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/2requires a timezone database. Add{:tzdata, "~> 1.1"}to yourmix.exsdependencies 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.Changemodule to convert values to UTC. - Pass the user's timezone via
contexton the changeset. - Handle strings,
NaiveDateTime,DateTime, andnilvalues gracefully.
This pattern keeps your Ash resources clean and your time handling robust across different timezones.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.