Fixing Timezones in Rails 2
In a Rails 2 application, there are several settings that affect how timezones are written, parsed, and escaped. When the database isn’t in UTC, this can lead to discrepancies in the values that are stored and retrieved from the database.
For background reading, take a look at the “Easier Timezones” article introducing timezone support in Rails.
The Default: UTC
The default generated configuration assumes that the database is running in UTC,
and all times are handled accordingly. From the generated environment.rb
:
# Set Time.zone default to the specified zone and make Active Record
# auto-convert to this zone. # Run "rake -D time" for a list of tasks for
# finding time zone names.
config.time_zone = 'UTC'
Assigning a value to config.time_zone
also sets these additional ActiveRecord
settings:
ActiveRecord::Base.default_timezone = :utc
ActiveRecord::Base.time_zone_aware_attributes = true
Fortunately, if you do nothing else, this will write valid data to and from the
database. But there’s a problem: what if your server, or your testing
environment, or your local script/console
aren’t also in UTC?
Whoops. The actual SQL generated will use Time.now.to_s(:db)
, which is local
time:
There’s some discussion and a proposed patch here, as well as an bug regarding this issue which had unfortunately been marked as invalid.
To produce the desired behavior sans patching, you can use Time.zone.now
in
place of Time.now
:
Non-UTC Databases
The fun begins when the database isn’t in UTC, and time data should be stored in the local timezone. There are certainly problems with this approach–especially when it comes to daylight savings transitions–but it’s sometimes a legacy requirement and can’t be changed.
ActiveRecord’s quoting code handles times by calling #to_s(:db)
. For bare
Time
objects, this merely strips the zone offset information, regardless of
whether or not the Time
is in UTC or local time.
Furthermore, when config.time_zone
is set, timestamp attributes are not bare
Time
objects, they’re actually wrapped as ActiveSupport::TimeWithZone
. The
#to_s(:db)
method always converts the underlying time to UTC:
Unfortunately, this means most time values are written to the database in UTC.
Setting config.time_zone
doesn’t affect this, either, as it only determines
the timezone that is used for display purposes, not how the times are serialized
and read from the database.
Additional Time Settings
To go further in depth, there are four settings that collectively determine how times are handled in Rails 2.
ENV[“TZ”]
The TZ
environment variable defines which timezone Time.now
will use in its
return value. This overrides the system’s default timezone. On a system running
in Mountain time:
Time.zone
In ruby, the regular Time
class is restricted to either system time
(ENV["TZ"]
) and UTC. As introduced in Rails 2.1, Time.zone
sets the
display timezone, but does not otherwise affect how times are parsed or
serialized.
ActiveRecord::Base.default_timezone
This setting determines how a timestamp is to be interpreted when parsed from the
database. By default, when config.time_zone
is set in environment.rb
,
ActiveRecord::Base.default_timezone
is set to :utc
. This means the times are
parsed directly as if they were UTC. When manually set to :local
, times are
parsed using the local timezone (ENV["TZ"]
) instead.
Changing the setting to :local
gets us part of the way: times are now read
according to the server timezone. But, as they’re still written in UTC, the
round-trip is incorrect.
ActiveRecord::Base.time_zone_aware_attributes
Time-zone aware attributes are also enabled by default when setting
config.time_zone
. When enabled, times read from the database are automatically
wrapped with ActiveSupport::TimeWithZone
, both when writing the attribute and
when accessing the attribute. This is designed for use in conjuction with
request-local Time.zone
settings, and is enabled by default.
A Solution
The singular point in ActiveRecord that causes timezone code to fail is in the
quoted_date
method in the ActiveRecord::ConnectionAdapters::Quoting
module.
It calls to_s(:db)
directly, always, which ends up forcing UTC in many cases
even when it’s not appropriate.
In Rails 3, this has been fixed with more intelligent behavior:
Jetlag
Jetlag started as a suite of specs to explore the range of time-related behavior in Rails, finally resulting in the backported patch and a rails plugin. You can browse the source on github, or install it as a plugin:
script/plugin install https://github.com/zerowidth/jetlag.git
If your database is not in UTC, set ENV["TZ"]
explicitly in your
config/environment.rb
to match the database’s timezone, and also add the
following line after config.time_zone = 'UTC'
:
What about Rails 3?
Rails 3 works fine with non-UTC databases, as long as ENV["TZ"]
matches the
database’s timezone, and and ActiveRecord::Base.default_timezone
is set to
:local
. This is verified via the specs in the
rails3 branch of jetlag.