dato search shopping envelope Pinterest youtube LinkedIn Facebook Twitter instagram search

Working with Time Zones in Elixir

Elixir has two different ways of representing date and time values: NaiveDateTime which does not have any information about the time zone, and DateTime that is time zone aware.

NaiveDateTime literals can be constructed using the ~N sigil, e.g.

iex> ~N[2020-01-22 23:00:00] # 11 pm on January 22, 2020
~N[2020-01-22 23:00:00]

iex> ~N[2020-03-29 02:30:00] # 2:30 am on March 29, 2020
~N[2020-03-29 02:30:00]

iex> ~N[2020-10-25 02:30:30] # 2:30 am on October 25, 2020
~N[2020-10-25 02:30:30]

How you interpret the three NaiveDateTime values above is up to you – is there a time zone implied, or do each of these values refer to the time displayed on a wall clock wherever the user happens to be? DateTime removes that uncertainty.

DateTimes can be constructed several different ways, eg. with the ~U sigil or with the from_iso8601 function:

iex> ~U[2020-01-22 23:00:00Z] # UTC
~U[2020-01-22 23:00:00Z]

iex> DateTime.from_iso8601("2020-01-22 23:00:00Z") # UTC
{:ok, ~U[2020-01-22 23:00:00Z], 0}

iex> DateTime.from_iso8601("2020-01-22 23:00:00+01:00") # +1 hour from UTC, e.q. CET
{:ok, ~U[2020-01-22 22:00:00-05:00], 3600}

iex> DateTime.from_iso8601("2020-01-22 23:00:00-05:00") # -5 hours from UTC, e.g. EST
{:ok, ~U[2020-01-23 04:00:00Z], -18000}

The ~U sigil only works with UTC. The from_iso8601 function allows you to specify a time zone offset, e.g. +01:00 if you are working with Central European Time (CET) or -05:00 for Eastern Standard Time (EST)

A NaiveDateTime can be cast to a DateTime using the from_naive function and providing a time zone:

iex> DateTime.from_naive(~N[2020-01-22 23:00:00], "Etc/UTC")
{:ok, ~U[2020-01-22 23:00:00Z]}

Out of the box this only works with UTC. If we try with other time zones, we get an error:

iex> DateTime.from_naive(~N[2020-01-22 23:00:00], "Europe/Copenhagen")
{:error, :utc_only_time_zone_database}

iex> DateTime.from_naive(~N[2020-01-22 23:00:00], "America/New_York")
{:error, :utc_only_time_zone_database}

To support other time zones we need to add a time zone database depencendy to our project, e.g. Tzdata:

defp deps do
  [  {:tzdata, "~> 1.0.2"},  ]

We can add a couple of lines to config.exs to help us save some typing in our code:

import Config
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase

This time the from_naive call succeeds with time zones other than UTC:

iex> DateTime.from_naive(~N[2020-01-22 23:00:00], "Europe/Copenhagen")
{:ok, #DateTime<2020-01-22 23:00:00+01:00 CET Europe/Copenhagen>}

iex> DateTime.from_naive(~N[2020-01-22 23:00:00], "America/New_York")
{:ok, #DateTime<2020-01-22 23:00:00-05:00 EST America/New_York>}

If your application works with dates and times, you will most likely want to use DateTime instead of NaiveDateTime in your code. To see why, let us take a look at the second of the NaiveDateTime examples we started out with:

iex> ndt = ~N[2020-03-29 02:30:00] # 2:30 am on March 29, 2020
~N[2020-03-29 02:30:00]

iex> DateTime.from_naive(ndt, "Etc/UTC")
{:ok, ~U[2020-03-29 02:30:00Z]}

iex> DateTime.from_naive(ndt, "Europe/Copenhagen")
{:gap, #DateTime<2020-03-29 01:59:59.999999+01:00 CET Europe/Copenhagen>,
 #DateTime<2020-03-29 03:00:00+02:00 CEST Europe/Copenhagen>}

Casting ~N[2020-03-29 02:30:00] to DateTime with UTC succeeds, but when we try the cast using the “Europe/Copenhagen” time zone we see something different. Instead of an {:ok, ...} tuple we get a tuple starting with :gap followed by two different DateTime values.

The problem is that daylight savings time in most of Europe begins on the night between March 28 and March 29, 2020. When the clock would normally change to 2:00 am it jumps straight to 3:00 am – there is no 2:30 am on March 29th in this time zone. The two DateTimes in the {:gap, ...} tuple are the last valid time before the clock jump (01:59:59.999999 Central European Time) and the first valid time after the jump (03:00:00 Central European Summer Time). It is up to us to decide how to complete the cast.

We see a similar issue with the third NaiveDateTime example:

iex> ndt = ~N[2020-10-25 02:30:30]
~N[2020-10-25 02:30:30]

iex> DateTime.from_naive(ndt, "Europe/Copenhagen")
{:ambiguous, #DateTime<2020-10-25 02:30:30+02:00 CEST Europe/Copenhagen>,
 #DateTime<2020-10-25 02:30:30+01:00 CET Europe/Copenhagen>}

As you have probably guessed, daylight savings time ends in Europe on the night between October 24 and October 25, 2020. The first time the wall clock reaches 3:00 am it jumps back to 2:00 am, so there are two possible interpretations of “2:30 am on October 25”:

  • 02:30:00 CEST (the first time the wall clock shows 2:30 am), and
  • 02:30:00 CET (the second time).

Again, it is up to us to handle this ambiguity.

Working with NaiveDateTime you might never realise that there is a problem. Using DateTime forces you to be explicit and can help you catch errors that might otherwise come back to haunt you later on.