747 words, 4 min read

When building Elixir applications with the Ash Framework, it's a best practice to store all datetimes in UTC in your database. However, users typically interact with your application in their local timezone, so you need a reliable way to convert their input into UTC—and show it back in their local time.

This post shows how to handle that conversion cleanly and reusably in Ash actions using a custom change module.

Why store in UTC?

Storing all datetimes in UTC avoids timezone ambiguity and makes it easier to compare, sort, and query times consistently—especially if your users are spread across multiple regions. PostgreSQL and Ash support UTC datetime types (:utc_datetime), and your application should convert values at the boundaries: when receiving input or displaying output.

Define UTC datetime attributes

First, define the attributes on your Ash resource using :utc_datetime:

attributes do
  attribute :starts_at, :utc_datetime
  attribute :ends_at, :utc_datetime
end

Add user-local input arguments

In your Ash action, accept user-local datetime input as strings, NaiveDateTime, or DateTime structs:

actions do
  create :schedule do
    argument :starts_at_local, :string
    argument :ends_at_local, :naive_datetime

    change {MyApp.Changes.ConvertLocalDatetimeToUtc, arg_key: :starts_at_local, field: :starts_at}
    change {MyApp.Changes.ConvertLocalDatetimeToUtc, arg_key: :ends_at_local, field: :ends_at}
  end
end

Reusable change module

Ash's change DSL expects a module implementing Ash.Resource.Change. Here's a reusable implementation you can drop into your project:

defmodule MyApp.Changes.ConvertLocalDatetimeToUtc do
  @moduledoc """
  Converts a local datetime argument (string, NaiveDateTime, or DateTime)
  to UTC and assigns it to an Ash attribute. Gracefully handles `nil` values.

  Options:
    - `:arg_key` — the action argument holding the local datetime value
    - `:field` — the resource attribute to assign the UTC value to
    - `:timezone_key` — the context key for the user's timezone (default: `:user_timezone`)
  """

  use Ash.Resource.Change

  def init(opts) do
    opts = Keyword.put_new(opts, :timezone_key, :user_timezone)
    {:ok, opts}
  end

  def change(changeset, opts, _context) do
    arg_key = opts[:arg_key]
    field = opts[:field]
    timezone_key = opts[:timezone_key]
    timezone = Ash.Changeset.get_context(changeset, timezone_key) || "UTC"

    case Ash.Changeset.fetch_argument(changeset, arg_key) do
      {:ok, nil} ->
        changeset

      {:ok, value} ->
        case do_convert_to_utc(value, timezone) do
          {:ok, utc_dt} ->
            Ash.Changeset.force_change_attribute(changeset, field, utc_dt)

          _ ->
            Ash.Changeset.add_error(
              changeset,
              "#{arg_key} is invalid or could not be converted to UTC"
            )
        end

      :error ->
        changeset
    end
  end

  defp do_convert_to_utc(nil, _), do: {:ok, nil}

  defp do_convert_to_utc(%DateTime{} = dt, _timezone) do
    DateTime.shift_zone(dt, "Etc/UTC")
  end

  defp do_convert_to_utc(%NaiveDateTime{} = naive, timezone) do
    with {:ok, dt_local} <- DateTime.from_naive(naive, timezone),
         {:ok, dt_utc} <- DateTime.shift_zone(dt_local, "Etc/UTC") do
      {:ok, dt_utc}
    end
  end

  defp do_convert_to_utc(value, timezone) when is_binary(value) do
    with {:ok, naive} <- NaiveDateTime.from_iso8601(value),
         {:ok, dt_local} <- DateTime.from_naive(naive, timezone),
         {:ok, dt_utc} <- DateTime.shift_zone(dt_local, "Etc/UTC") do
      {:ok, dt_utc}
    end
  end

  defp do_convert_to_utc(_, _), do: {:error, :invalid}
end

Note: DateTime.shift_zone/2 requires a timezone database. Add {:tzdata, "~> 1.1"} to your mix.exs dependencies and configure it:

config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase

Setting the timezone in context

To apply the conversion correctly, the user's timezone should be passed in the Ash changeset context.

Ash 3.x (domain-based):

AshPhoenix.Form.for_action(MyApp.Event, :schedule,
  domain: MyApp.Domain,
  as: :event,
  context: %{user_timezone: "Europe/Brussels"}
)

Ash 2.x (api-based):

AshPhoenix.Form.for_action(MyApp.Event, :schedule,
  api: MyApp.Api,
  as: :event,
  context: %{user_timezone: "Europe/Brussels"}
)

Summary

  • Store all datetime attributes in UTC.
  • Accept user-local input via custom arguments.
  • Use a reusable Ash.Resource.Change module to convert values to UTC.
  • Pass the user's timezone via context on the changeset.
  • Handle strings, NaiveDateTime, DateTime, and nil values gracefully.

This pattern keeps your Ash resources clean and your time handling robust across different timezones.