What's the time Mr Matsumoto?

An adaptation of a presentation I gave on writing robust code across timezones in Ruby/Rails. You can see the original presentation here.

Some things I've learned from writing cross-timezone code in Ruby. Thinking across timezones is hard; UTC isn't enough.

Let's establish some common ground. Computers generally deal with time in UTC (Coordinated Universal Time). This is great if you're dealing with other computers that are also using UTC, but when dealing with the real world it isn't super useful. UTC represents a datum timezone at 0° longitude, and isn't adjusted for DST (daylight savings time), so if you want to do something relative to a real time like 11am in Sydney you're going to have to deal with timezones.

Real timezones are messy, but they're necessary if you want to represent times in the real world. For example, UTC is fine for tasks like "I want to run this job once an hour". However, what if you want t to run a job at 02:00. First, 02:00 in which timezone? Does it need to be adjusted for DST? Does it matter if it runs early/late? What if 02:00 doesn't exist on that day in that timezone?

What tools do we have in Ruby?

Note that the core Ruby library does not include a TimeZone class.

> time = Time.now
=> 2023-04-06 16:25:08.108773 +0100

> time.zone
=> "BST"

> require 'date'
> date = Date.today
=> #<Date: 2023-04-06 ((2460041j,0s,0n),+0s,2299161j)>

Enter ActiveSupport

ActiveSupport in Rails provides us with some extensions to time which give us both a way of dealing explicitly with timezones (via ActiveSupport::TimeZone) and associating them with times (via ActiveSupport::TimeWithZone).

> require 'active_support'
> require 'active_support/time'
> Time.zone
=> nil
> Time.zone = Time.find_zone!("UTC")
> Time.zone
=> #<ActiveSupport::TimeZone:0x0000000105f456b8
     @name="UTC",
     @tzinfo=#<TZInfo::DataTimezone: Etc/UTC>,
     @utc_offset=nil>
> Time.zone.now
=> Thu, 06 Apr 2023 15:32:55.127999000 UTC +00:00
> Time.zone.now + 1.day + 1.hour
=> Fri, 07 Apr 2023 16:33:05.759982000 UTC +00:00
> Time.zone.now.in_time_zone("Australia/Sydney")
=> Fri, 07 Apr 2023 01:34:12.413260000 AEST +10:00

When using ActiveSupport we can configure the default timezone (Rails will do this for you):

> require 'active_support'
> require 'active_support/time'
> Time.zone
=> nil

> Time.zone = Time.find_zone!("UTC")
> Time.zone.class
=> ActiveSupport::TimeZone

> Time.zone.now
=> Thu, 06 Apr 2023 15:32:55.127999000 UTC +00:00

> Time.zone.now.class
=> ActiveSupport::TimeWithZone

A note on timezones

Where does the computer's notion of timezones come from? On linux-like systems, they're stored in /usr/share/zoneinfo/:

> tree /usr/share/zoneinfo/
/usr/share/zoneinfo/
├── +VERSION
├── Africa
│   ├── Abidjan
│   ├── Accra
│   ├── Addis_Ababa
│   ├── Algiers
│   ├── Asmara
│   ├── Asmera
│   ├── Bamako
│   ├── Bangui
│   ├── Banjul
│   ├── Bissau
│   ├── Blantyre

What do these tell us?

> zdump -v /usr/share/zoneinfo/Europe/London | tail -n 6
/usr/share/zoneinfo/Europe/London  Sun Mar 29 00:59:59 2037 UTC = Sun Mar 29 00:59:59 2037 GMT isdst=0
/usr/share/zoneinfo/Europe/London  Sun Mar 29 01:00:00 2037 UTC = Sun Mar 29 02:00:00 2037 BST isdst=1
/usr/share/zoneinfo/Europe/London  Sun Oct 25 00:59:59 2037 UTC = Sun Oct 25 01:59:59 2037 BST isdst=1
/usr/share/zoneinfo/Europe/London  Sun Oct 25 01:00:00 2037 UTC = Sun Oct 25 01:00:00 2037 GMT isdst=0
/usr/share/zoneinfo/Europe/London  Mon Jan 18 03:14:07 2038 UTC = Mon Jan 18 03:14:07 2038 GMT isdst=0
/usr/share/zoneinfo/Europe/London  Tue Jan 19 03:14:07 2038 UTC = Tue Jan 19 03:14:07 2038 GMT isdst=0

If you’re curious about the last two lines, Google the "Epochalypse" (yes, that’s a thing). If you're using a language like Go you'll see that it represents datetimes as an int64 to avoid this problem

By default TZInfo in Ruby actually uses the tzinfo-data gem as its source. You can force it to use zoneinfo with TZInfo::DataSource.set(:zoneinfo)

> require "tzinfo"
> london = TZInfo::Timezone.get("Europe/London")
=> #<TZInfo::DataTimezone: Europe/London>
> london.canonical_identifier
=> "Europe/London"
> london.observed_utc_offset
=> 3600
> london.now
=> 2023-04-07 22:25:16.077745 +0100

# Also available on an ActiveSupport::TimeZone:

> london = Time.find_zone!("Europe/London")
> london.tzinfo
=> #<TZInfo::DataTimezone: Europe/London>

What about ActiveRecord?

There are actually two types of timestamp column you can create with Rails (in Postgres, at least):

> rails generate model widget \
  title:string utc_timestamp:timestamp tz_timestamp:timestamptz

---

class CreateWidgets < ActiveRecord::Migration[7.0]
  def change
    create_table :widgets do |t|
      t.string :title
      t.timestamp :utc_timestamp
      t.timestamptz :tz_timestamp

      t.timestamps
    end
  end
end

The difference is that, in the database, one is stored with timezone information:

|    Column     |              Type              |
+---------------+--------------------------------+
| id            | bigint                         |
| title         | character varying              |
| utc_timestamp | timestamp without time zone    |
| tz_timestamp  | timestamp with time zone       |
| created_at    | timestamp(6) without time zone |
| updated_at    | timestamp(6) without time zone |

By default this will use the timezone set in the database:

rails_test_development=> SELECT * FROM widgets;
-[ RECORD 1 ]-+------------------------------
id            | 1
title         | my_widget
utc_timestamp | 2023-04-08 20:07:34.545792
tz_timestamp  | 2023-04-08 21:07:34.545792+01

rails_test_development=> SHOW TIMEZONE;
-[ RECORD 1 ]-----------
TimeZone | Europe/London

This doesn't actually matter in Rails since it will convert everything to UTC (or your configured Rails timezone) on models:

now = Time.find_zone!("Australia/Sydney").now

Widget.create(
  title: "my_widget",
  tz_timestamp: now,
  utc_timestamp: now,
)

#<Widget:0x0000000106d14780
 id: 1,
 title: "my_widget",
 utc_timestamp: Sat, 08 Apr 2023 20:07:34.545792000 UTC +00:00,
 tz_timestamp: Sat, 08 Apr 2023 20:07:34.545792000 UTC +00:00,
 created_at: Sat, 08 Apr 2023 20:07:57.445968000 UTC +00:00,
 updated_at: Sat, 08 Apr 2023 20:07:57.445968000 UTC +00:00>

We can see this in the SQL executed:

Widget.create(
  title: "my_widget",
  tz_timestamp: Time.find_zone!("Europe/London").now,
  utc_timestamp: Time.find_zone!("Australia/Sydney").now,
)

INSERT INTO "widgets" ("title", "utc_timestamp", "tz_timestamp", "created_at", "updated_at")
VALUES ($1, $2, $3, $4, $5) RETURNING "id"
[
  ["title", "my_widget"],
  ["utc_timestamp", "2023-04-27 15:25:04.389264"],
  ["tz_timestamp", "2023-04-27 15:25:04.382907"],
  ["created_at", "2023-04-27 15:25:04.392253"],
  ["updated_at", "2023-04-27 15:25:04.392253"]
]

The simplest thing is to store everything in the database as a timestamp and let Rails take care of the UTC conversion.

Things to avoid

Manual UTC offsetting

Especially don’t do this:

> Time.find_zone!("Europe/London").utc_offset
=> 0

> Date.today
=> Fri, 07 Apr 2023

> Timecop.freeze("01 November 2023") do
    Time.find_zone!("Europe/London").utc_offset
  end
=> 0

This is equivalent to the constant:

> Time.find_zone!("Europe/London").tzinfo.base_utc_offset
=> 0

If you have to do this, use observed_utc_offset:

> Time.find_zone!("Europe/London").tzinfo.observed_utc_offset
=> 3600

> Date.today
=> Fri, 07 Apr 2023

This will return different values depending on the current DST offset:

> Timecop.freeze("01 November 2023") do
    Time.find_zone!("Europe/London").tzinfo.observed_utc_offset
  end
=> 0

.parse methods

These are far too permissive in what they consider valid:

> Date.parse("Monday 26rd September 2016")
=> #<Date: 2016-09-26 ((2457658j,0s,0n),+0s,2299161j)>

> Date.parse("fri1feb3bc4pm+5")
=> #<Date: -0002-02-01 ((1720359j,0s,0n),+0s,2299161j)>

> Date.parse("2O15-O2-01")
=> #<Date: 2022-09-15 ((2459838j,0s,0n),+0s,2299161j)>

> Date.parse("20189")
=> Tue, 07 Jul 2020

Use the iso8601 methods instead, or strptime for nonstandard formats:

> Date.iso8601("2022-01-01")
=> Sat, 01 Jan 2022

> Time.zone.iso8601("2022-01-01T12:00")
=> Sat, 01 Jan 2022 12:00:00.000000000 UTC +00:00

> Time.zone.now.iso8601
=> "2023-04-08T15:07:06Z"

> Time.zone.strptime("8 April 2023, 12:00", "%d %B %Y, %k:%M")
=> Sat, 08 Apr 2023 12:00:00.000000000 UTC +00:00

"Implicit" timezones, poor conversion

Interpreting a datetime without specifying the timezone may give unexpected results:

> Time.iso8601("2022-06-17T16:00").in_time_zone("Europe/London")
=> Fri, 17 Jun 2022 16:00:00.000000000 BST +01:00
> Time.zone.iso8601("2022-06-17T16:00").in_time_zone("Europe/London")
=> Fri, 17 Jun 2022 17:00:00.000000000 BST +01:00
> Time.zone.iso8601("2022-06-17T16:00")
=> Fri, 17 Jun 2022 16:00:00.000000000 UTC +00:00

# Specify the timezone as context
> Time.find_zone!("Europe/London").iso8601("2022-06-17T16:00")
=> Fri, 17 Jun 2022 16:00:00.000000000 BST +01:00

# Even "now" should have a timezone context
> Time.now
=> 2023-04-07 22:40:34.782764 +0100
> Time.local(2023, 1, 1).zone
=> "GMT"
> Time.zone.name
=> "UTC"

Don't pass around dates

Without a timezone context a date is meaningless:

> beginning_of_day = Time.find_zone!("Australia/Sydney").now.beginning_of_day
=> Fri, 07 Apr 2023 00:00:00.000000000 AEST +10:00
> date = beginning_of_day.to_date
=> Fri, 07 Apr 2023

# in some other code

> now_somewhere_else = Time.zone.iso8601(date.iso8601)
=> Fri, 07 Apr 2023 00:00:00.000000000 UTC +00:00
> beginning_of_day.to_i - now_somewhere_else.to_i
=> -36000

Best practices

Use strict methods

Avoid using methods that can silently do the wrong thing:

# don’t use this - returns nil
Time.find_zone("Foo/Bar")

# don’t use this - returns nil
ActiveSupport::TimeZone.new("Foo/Bar")

# raises TZInfo::InvalidTimezoneIdentifier
TZInfo::Timezone.get("Foo/Bar")

# raises ArgumentError
Time.find_zone!("Foo/Bar")

Using methods that raise errors should always be preferred, as if you aren't checking your return values you can end up with more insidious bugs:

> london = Time.find_zone("Europe/Londn")
=> nil
> Time.find_zone!("UTC").now.in_time_zone(london)
=> 2023-04-06 11:11:03.449456 UTC

# vs

> london = Time.find_zone!("Europe/Londn")
lib/active_support/core_ext/time/zones.rb:85:in `find_zone!':
Invalid Timezone: Europe/Londn (ArgumentError)

Use specific names

Prefer specific timezone names, which will also be enforced by using strict methods. Avoid using legacy names.

For example: prefer 'America/New_York' to 'US/Eastern' or 'EST5EDT'. This also applies when discussing/specifying timezones. Referring to 'Europe/London' as GMT or 'America/New_York' as EST is wrong about 50% of the year.

Test in multiple timezones

  • Don’t fall into the trap of Europe/London or UTC being the "one true timezone"
  • Test that time-sensitive code works correctly in multiple timezones
  • Make use of Timecop to account for DST and any other relevant changes over time
  • Make use of the internet or tzinfo to find out when DST happens