We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Ecto's :utc_datetime silently drops microseconds
almirsarajcic
:utc_datetime stores second precision. Hand it a DateTime.utc_now/0 value — which carries microseconds — and Ecto.Type.cast/2 truncates it with no warning, no error, nothing in the return value hinting anything was lost:
now = DateTime.utc_now()
now.microsecond
# => {286883, 6}
Ecto.Type.cast(:utc_datetime, now)
# => {:ok, ~U[2026-08-29 10:07:29Z]}
Ecto.Type.cast(:utc_datetime_usec, now)
# => {:ok, ~U[2026-08-29 10:07:29.286883Z]}
Same story through a changeset, which is the path most code actually takes:
defmodule MyApp.Event do
use Ecto.Schema
schema "events" do
field :second_at, :utc_datetime
field :usec_at, :utc_datetime_usec
end
end
changeset =
Ecto.Changeset.cast(%MyApp.Event{}, %{second_at: now, usec_at: now}, [:second_at, :usec_at])
changeset.changes
# => %{second_at: ~U[2026-08-29 10:07:29Z], usec_at: ~U[2026-08-29 10:07:29.286883Z]}
changeset.changes.second_at == now
# => false
changeset.changes.usec_at == now
# => true
cast/4 never raises or warns about the truncated field — it just quietly returns a different DateTime than the one you passed in.
Why the split exists
:utc_datetime maps to a timestamp column at second precision; :utc_datetime_usec maps to one at microsecond precision. The Ecto type and the column type have to agree, and cast/2 enforces that agreement by rounding down to whatever precision the type declares — silently, because truncation is a valid, expected cast, not an error.
Where it does raise is a level lower, in dump/2, and only if you hand it a value that skipped cast/2 entirely (a struct field set directly and persisted without going through a changeset):
Ecto.Type.dump(:utc_datetime, now)
# raises ArgumentError:
# :utc_datetime expects microseconds to be empty, got: ~U[2026-08-29 10:07:29.286883Z]
#
# Use `DateTime.truncate(utc_datetime, :second)` (available in Elixir v1.6+) to remove microseconds.
So the two layers disagree: cast is silent, dump is loud — but dump only ever sees a full-precision value if something bypassed casting first.
The practical gotchas
Be explicit at the boundary. If a field is genuinely second precision, truncate before comparing or storing so the intent is visible in the code:
DateTime.truncate(now, :second)
# => ~U[2026-08-29 10:07:29Z]
Ecto.Type.cast(:utc_datetime, DateTime.truncate(now, :second)) == {:ok, DateTime.truncate(now, :second)}
# => true
Generators inherit whatever timestamp_type you configured. mix phx.gen.schema and mix phx.gen.context default inserted_at/updated_at to :naive_datetime unless config.exs sets:
config :my_app,
generators: [timestamp_type: :utc_datetime, binary_id: true]
That setting only buys you :utc_datetime (second precision), not :utc_datetime_usec. If you need microsecond ordering on your timestamps, the field has to be declared as :utc_datetime_usec explicitly.
Changing precision is a migration on both sides. The column and the schema field have to move together — modify :inserted_at, :utc_datetime_usec in a migration, and the schema’s field :inserted_at, :utc_datetime_usec to match. Do only one and you get a type mismatch instead of the precision you wanted.
This is why assert value == reloaded_value fails in tests. Build a value with DateTime.utc_now/0, insert it through a :utc_datetime field, reload it, and compare against the original — the comparison fails because the reloaded value has been rounded down to the second, not because anything is broken. Truncate the expected value the same way before asserting, or use :utc_datetime_usec where sub-second precision matters — for a dedup key or an ordering tiebreaker between rows created in the same second, it usually does.
copied to clipboard