Ecto's :utc_datetime silently drops microseconds

almirsarajcic

almirsarajcic

49 minutes ago

0 comments

: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.

Ecto.Schema primitive types

Comments (0)

Sign in with GitHub to join the discussion