# Ecto's :utc_datetime silently drops microseconds

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

```elixir
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:

```elixir
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):

```elixir
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:

```elixir
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:

```elixir
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](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types)


---

Created by: almirsarajcic
Date: August 31, 2026
URL: https://elixirdrops.net/d/jb2TzQGZ
