Handling time zones is hard in any environment I worked with. Part of the problem is that time zones are political construct rather than geographical one, and as such - they change whenever authorities make such decision. This is happening more often than you think.

To map local times between different time zones, our computers need an up to date database. This is generally provided by tzdata package (or similar) on Linux systems.

While this is fine for use of programs that by definition can rely on OS-level time zone data, many programs or programming environments decided to handle things differently. This includes Elixir - a multi platform environment.

PostgreSQL

Part of the confusion, and also source of many difficult to debug bugs, is quite unfortunate SQL types system.

By default, when you use Ecto, your database migrations will use SQL’s timestamp type. If you dare to check documentation on PostgreSQL site, you learn that timestamp is in fact “timestamp without time zone”.

What does it mean? In short: this data type will be interpreted differently, depending on the time zone settings of clients that connect to PostgreSQL server.

Ecto uses Postgrex, a database connection driver for Elxir, that is unlike many other PostgreSQL drivers out there. Instead of relying on a text protocol, it uses - more secure and performant - binary protocol.

A side effect of using binary protocol, is the fact that all timestamps are interpreted as UTC, because client’s time zone is always set to UTC.

Knowing the above, one could assume that in such case your dates in database are always kept in UTC. Right?

Wrong. While this will be true when you connect to PostgreSQL from your Elixir application, as soon as you connect using different client (psql, Ruby driver etc), the problems will start creeping in. SQL queries like:

SELECT * FROM events WHERE events.start_date < CURRENT_TIMESTAMP;

will have different meaning when you are in Europe/Warsaw time zone, and very much different when you are in PDT (California, USA), because of the 9 hour difference. If you have a reporting or maintenance script that you run against this database, depending on your client locale settings, you will miss more or less events from the query, or include unwanted ones in results. It would only be correct if your client’s settings were UTC.

How can you fix this issue? Use timestamptz data type in PostgreSQL, which is a shortcut to timestamp with time zone.

Now, the naming here is again super confusing. timestamp with time zone does not mean that the entries in the database carry over any time zone information. This type assumes that timestamp is in UTC time zone.

In my humble opinion, it’s safer to make your Elixir/Ecto application use timestamptz data type, instead of default timestamps. To do it, you should write your migrations in this form:

create table(:events) do
  add :title, :string
  add :start_date, :timestamptz
  add :end_date, :timestamptz

  timestamps(type: :timestamptz)
end

This will ensure, that whenever you run the SQL query from psql or from Elixir - your results will look the same.

Time zones handling in Elixir

Historically, Elixir (and Erlang) did not have any time zone handling, nor built in types to store this information. When Ecto was first developed, the authors came up with own data types, such as Ecto.DateTime that maps data type from database to custom type in Ecto schema, and your Elixir code.

Don’t use these types anymore. Elixir has now built-in DateTime, that you can use instead.

There is also NaiveDateTime. The difference between these two, is that “naive” version does not have the time_zone field, which means it won’t carry over any zone information.

When your database always keeps the timestamps in UTC time zone, I think it makes most sense to use the same assumption in your Ecto schemas:

schema "events" do
  ...
  field :starts_at, :utc_datetime
  field :ends_at, :utc_datetime
  timestamps(type: :utc_datetime)
end

By doing so, you can assume that all timestamps in your Ecto schemas, are always in UTC time zone. The types of values in these fields will be always DateTime with time_zone fixed to UTC, whenever you write or read it.

So what happens when you need to display the timestamp from the database to your web application users, in their local time zones?

You need some extra Elixir packages, I am afraid. Elixir does not come with many calendar functions, it only defines appropriate data structures. Detailed time zone manipulation functions are implemented by external libraries (Timex or Calendar).

The one that seems to be my choice these days, is simply called Calendar. You will need to add it in deps:

defp deps do
  [  {:calendar, "~> 0.17.2"},  ]
end

and also start it’s OTP application:

def application do
  [applications: [:calendar]]
end

Behind the scenes, Calendar relies on tzdata package, which provides database of time zones. Moreover, it periodically checks for changes in time zones, and updates it’s local database to reflect those changes. Pretty sweet.

Phoenix and the web users

When I want to display timestamps converted to local time zone for my users, I tend to stick to creating a boundary between my Elixir code and HTML / form data. The assumption is that anything in controllers and below, will have dates in UTC for simplicity. Whatever the user sees and sends back to the server - may be in their local time zone.

Simple view helper to display timestamps in user’s local time zone could be as follows:

def format_timestamp(nil) do
  nil
end

def format_timestamp(timestamp, time_zone) do
  timestamp
  |> shift_zone!("Europe/Warsaw")
  |> Calendar.Strftime.strftime!("%d/%m/%Y %H:%M")
end

defp shift_zone!(nil, time_zone) do
  nil
end

defp shift_zone!(timestamp, time_zone) do
  timestamp
  |> Calendar.DateTime.shift_zone!(time_zone)
end

I have also hacked together a mechanism for my users to be able to specify time zone when they add timestamps on the forms. In our example, Event has start and end dates. My users can specify time zone information using this amended version of standard datetime_select, that accepts the same arguments and behaves similar - yet has an extra dropdown for time zone (that can be replaced with hidden field). The code for the helper:

def date_time_and_zone_select(form, field, opts \\ []) do
  time_zone = Keyword.get(opts, :time_zone) || "Etc/UTC"

  value = Keyword.get(opts, :value, Phoenix.HTML.Form.input_value(form, field) || Keyword.get(opts, :default))
          |> shift_zone!(time_zone)


  default_builder = fn b ->
    ~e"""
    <%= b.(:year, []) %> / <%= b.(:month, []) %> / <%= b.(:day, []) %>
    —
    <%= b.(:hour, []) %> : <%= b.(:minute, []) %>
    <%= b.(:time_zone, []) %>
    """
  end

  builder = Keyword.get(opts, :builder) || default_builder

  builder.(datetime_builder(form, field, date_value(value), time_value(value), time_zone, opts))
end

@months [
  {"January", "1"},
  {"February", "2"},
  {"March", "3"},
  {"April", "4"},
  {"May", "5"},
  {"June", "6"},
  {"July", "7"},
  {"August", "8"},
  {"September", "9"},
  {"October", "10"},
  {"November", "11"},
  {"December", "12"},
]

map = &Enum.map(&1, fn i ->
  pre = if i < 9, do: "0"
  {"#{pre}#{i}", i}
end)

@days   map.(1..31)
@hours  map.(0..23)
@minsec map.(0..59)

defp datetime_builder(form, field, date, time, time_zone, parent) do
  id   = Keyword.get(parent, :id, input_id(form, field))
  name = Keyword.get(parent, :name, input_name(form, field))

  fn
    :year, opts when date != nil ->
      {year, _, _}  = :erlang.date()
      {value, opts} = datetime_options(:year, year-5..year+5, id, name, parent, date, opts)
      select(:datetime, :year, value, opts)
    :month, opts when date != nil ->
      {value, opts} = datetime_options(:month, @months, id, name, parent, date, opts)
      select(:datetime, :month, value, opts)
    :day, opts when date != nil ->
      {value, opts} = datetime_options(:day, @days, id, name, parent, date, opts)
      select(:datetime, :day, value, opts)
    :hour, opts when time != nil ->
      {value, opts} = datetime_options(:hour, @hours, id, name, parent, time, opts)
      select(:datetime, :hour, value, opts)
    :minute, opts when time != nil ->
      {value, opts} = datetime_options(:minute, @minsec, id, name, parent, time, opts)
      select(:datetime, :minute, value, opts)
    :second, opts when time != nil ->
      {value, opts} = datetime_options(:second, @minsec, id, name, parent, time, opts)
      select(:datetime, :second, value, opts)
    :time_zone, opts ->
      {value, opts} = timezone_options(:time_zone, parent[:zones_list] || Tzdata.zone_list(), id, name, time_zone, opts)

      if parent[:hide_time_zone] == true do
        hidden_input(:datetime, :time_zone, Keyword.merge(opts, [value: time_zone]))
      else
        select(:datetime, :time_zone, value, opts)
      end
  end
end

defp timezone_options(type, values, id, name, time_zone, opts) do
  suff = Atom.to_string(type)
  {value, opts} = Keyword.pop(opts, :options, values)

  {value,
    opts
    |> Keyword.put_new(:id, id <> "_" <> suff)
    |> Keyword.put_new(:name, name <> "[" <> suff <> "]")
    |> Keyword.put_new(:value, time_zone)}
end


defp datetime_options(type, values, id, name, parent, datetime, opts) do
  opts = Keyword.merge Keyword.get(parent, type, []), opts
  suff = Atom.to_string(type)

  {value, opts} = Keyword.pop(opts, :options, values)

  {value,
    opts
    |> Keyword.put_new(:id, id <> "_" <> suff)
    |> Keyword.put_new(:name, name <> "[" <> suff <> "]")
    |> Keyword.put_new(:value, Map.get(datetime, type))}
end

defp time_value(%{"hour" => hour, "minute" => min} = map),
  do: %{hour: hour, minute: min, second: Map.get(map, "second", 0)}
defp time_value(%{hour: hour, minute: min} = map),
  do: %{hour: hour, minute: min, second: Map.get(map, :second, 0)}

defp time_value(nil),
  do: %{hour: nil, minute: nil, second: nil}
defp time_value(other),
  do: raise(ArgumentError, "unrecognized time #{inspect other}")

defp date_value(%{"year" => year, "month" => month, "day" => day}),
  do: %{year: year, month: month, day: day}
defp date_value(%{year: year, month: month, day: day}),
  do: %{year: year, month: month, day: day}

defp date_value({{year, month, day}, _}),
  do: %{year: year, month: month, day: day}
defp date_value({year, month, day}),
  do: %{year: year, month: month, day: day}

defp date_value(nil),
  do: %{year: nil, month: nil, day: nil}
defp date_value(other),
  do: raise(ArgumentError, "unrecognized date #{inspect other}")

end

This results in form sending an extra parameter when is being submitted, in timestamp fields. Instead of

%{"year" => "2017", "month" => "5", "day" => "1", "hour" => "12", "minute" => "30"}

server receives from form submit extra time_zone parameter:

%{"year" => "2017", "month" => "5", "day" => "1", "hour" => "12", "minute" => "30", "time_zone" => "Europe/Warsaw"}

I created a simple plug, that recursively walks through all the parameters, detects those custom 6-element maps with timestamps, and replaces them with Elixir’s native DateTime, properly shifted to UTC time zone. The complete code is:

defmodule ShiftToUtc do
  @behaviour Plug

  def init([]), do: []

  import Plug.Conn

  def call(%Plug.Conn{} = conn, []) do
    new_params = conn.params |> shift_to_utc!()

    %{conn | params: new_params}
  end

  defp shift_to_utc!(%{__struct__: mod} = struct) when is_atom(mod) do
    struct
  end

  defp shift_to_utc!(%{"year" => year, "month" => month, "day" => day, "hour" => hour, "minute" => minute, "time_zone" => time_zone} = map) do
    {year, _} = Integer.parse(year)
    {month, _} = Integer.parse(month)
    {day, _} = Integer.parse(day)
    {hour, _} = Integer.parse(hour)
    {minute, _} = Integer.parse(minute)

    second = case Map.get(map, "second", 0) do
      0 -> 0
      string ->
        {integer, _} = Integer.parse(string)
        integer
    end

    {{year, month, day}, {hour, minute, second}}
    |> Calendar.DateTime.from_erl!(time_zone)
    |> Calendar.DateTime.shift_zone!("UTC")
  end

  defp shift_to_utc!(%{} = param) do
    Enum.reduce(param, %{}, fn({k, v}, acc) ->
      Map.put(acc, k, shift_to_utc!(v))
    end)
  end

  defp shift_to_utc!(param) when is_list(param) do
    Enum.map(param, &shift_to_utc!/1)
  end

  defp shift_to_utc!(param) do
    param
  end
end

To use it, add it to the controllers you want, or to the browser pipeline in your router.ex.

The alternative approach would be to write a custom Ecto type, that’d shift time zone before persisting record to database. An example of such custom Ecto type, kindly provided by Michał Muskała is presented below:

defmodule ZonedDateTime do
  @behaviour Ecto.Type

  def type, do: :utc_datetime

  def cast({"time_zone" => time_zone} = map) do
    with {:ok, naive} <- Ecto.Type.cast(:naive_datetime, map),
         {:ok, dt} <- Calendar.DateTime.from_naive(time_zone) do
      {:ok, Calendar.DateTime.shift_zone!(dt, "Etc/UTC")}
    else
      _ -> :error
    end
  end
  def cast(value), do: Ecto.Type.cast(:utc_datetime, value)

  def dump(value), do: Ecto.Type.dump(:utc_datetime, value)

  def load(value), do: Ecto.Type.load(:utc_datetime, value)
end

Timestamps with special needs

While the approach above works for me and my users, you may have slightly different needs. Most notable case, in my opinion, is when you want to preserve the time zone information that the user specified.

For example, you may want to save the selected time zone in events table. How can you approach this?

While Elixir’s types would allow doing that, PostgreSQL does not by default. The general approach would be to store extra start_date_time_zone column in your events table.

Luckily, we don’t have to do it manually. There’s Calecto library out there, that provides a way to use :calendar_datetime primitive type in your migrations. Behind the scenes, it creates a compound PostgreSQL type, that stores information about timestamp and time zone. Whenever you save information to database that contains timestamp with zone information, the same timestamp with the same zone information will be returned later, when you use the schema to read it.

This is especially useful when you want to ensure that Event’s start_date will not change in local time zone, despite time zone changes. This may matter if your users are in Turkey or Venezuela, or the dates are generally far in the future.

The other special case are hours of day. PostgreSQL has dedicated type of time (with and without timzeone - of course!) to store information like opening hours of shops etc. When you store that information in UTC in database, you may find yourself in surprising situation if your users live in countries that use daylight saving time. In such cases you must preserve the zone information, and ensure you display the information correctly.

Summary

There is a lot of confusion about which libraries and data types to use when you write Elixir applications. The simplest approach that works for me is to:

  • use timestamptz in PostgreSQL
  • use :utc_datetime in Ecto schemas, which maps to Elixir’s native DateTime with zone set to UTC
  • convert the timestamps to local zone when displaying to user
  • write custom date/time/zone select to replace the standard one that comes with Phoenix
  • use custom plug to detect form parameters that carry over zone information, and shift them to UTC before passing on to controller/changeset

Post by Hubert Łępicki

Hubert is partner at AmberBit. Rails, Elixir and functional programming are his areas of expertise.