zerowidth positive lookahead

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?

Post.all(:conditions =>
      [ "created_at > ? AND created_at < ?",
        Time.now - 1.hour,
        Time.now
      ])

Whoops. The actual SQL generated will use Time.now.to_s(:db), which is local time:

# local timezone is -0600, MDT
    now = Time.now # => Wed Apr 20 09:37:47 -0600 2011
    now.to_s(:db) # => "2011-04-20 09:37:47"
    # but the correct time is
    now.utc.to_s(:db) # => "2011-04-20 15:37:47"

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:

Post.all(:conditions =>
      [ "created_at > ? AND created_at < ?",
        Time.zone.now - 1.hour,
        Time.zone.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.

now = Time.now # => Wed Apr 20 09:37:47 -0600 2011
    now.to_s(:db) # => "2011-04-20 09:37:47"
    now.utc.to_s(:db) # => "2011-04-20 15:37:47"

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:

now = Time.now # => Wed Apr 20 09:37:47 -0600 2011
    Time.zone = "Mountain Time (US & Canada)"
    in_zone = now.in_time_zone # => Wed, 20 Apr 2011 09:37:47 MDT -06:00
    in_zone.class # => ActiveSupport::TimeWithZone
    in_zone.to_s(:db) # => "2011-04-20 15:37:47"
    # same as:
    now.utc.to_s(:db) # => "2011-04-20 15:37:47"

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:

ENV["TZ"] # => nil
    Time.now # => 2011-04-11 09:45:56 -0600
    ENV["TZ"] = "US/Eastern"
    Time.now # => 2011-04-11 11:46:03 -0400

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.

def quoted_date(value)
      value.to_s(:db)
    end

In Rails 3, this has been fixed with more intelligent behavior:

def quoted_date(value)
      if value.acts_like?(:time)
        zone_conversion_method = ActiveRecord::Base.default_timezone == :utc ? :getutc : :getlocal
        value.respond_to?(zone_conversion_method) ? value.send(zone_conversion_method) : value
      else
        value
      end.to_s(:db)
    end

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':

config.active_record.default_timezone = :local

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.