Skip to content

Normalize DateTime casts to UTC for :time and :time_usec - #4792

Merged
josevalim merged 1 commit into
elixir-ecto:masterfrom
youdie006:time-cast-utc
Sep 15, 2026
Merged

josevalim merged 1 commit into
elixir-ecto:masterfrom
youdie006:time-cast-utc

Conversation

@youdie006

Copy link
Copy Markdown
Contributor

#4775 normalized non-UTC %DateTime{} casts to the UTC instant for :date, :naive_datetime and :naive_datetime_usec. cast_time/1 has no %DateTime{} clause, so a DateTime falls through to the map clauses at lib/ecto/type.ex:1132/:1140 and a Time is rebuilt from the wall-clock fields.

On 2020-06-01 00:30:07.008+02:00 CEST:

cast(:date, dt)                = {:ok, ~D[2020-05-31]}          # UTC, since #4775
cast(:naive_datetime, dt)      = {:ok, ~N[2020-05-31 22:30:07]} # UTC, since #4775
cast(:time, dt)                = {:ok, ~T[00:30:07]}            # wall clock

So a schema casting that one value into a :date field and a :time field stores 2020-05-31 with 00:30:07 - neither the UTC instant nor the submitted local time. Before #4775 the pair was at least consistently local.

The new clause mirrors cast_date/1 (:1072) and cast_naive_datetime/1 (:1202) exactly. A %DateTime{} already in Etc/UTC short-circuits in cast_utc_datetime/1, so the existing time_usec cast from DateTime test is unaffected.

Same backward-incompatibility as #4775, and the same workaround: call DateTime.to_time/1 first if you want the wall time. Issue #4774 did not mention :time, which is presumably why it was missed.

Verification

Elixir 1.17.3 / OTP 27, matching the CI matrix row.

mix deps.unlock --check-unused, mix compile --warnings-as-errors, mix test - the three commands ci.yml runs - all exit 0: 97 doctests, 1492 tests, 0 failures, identical to a pristine checkout.

Both new assertions go into tests that already exist. Reverting the clause fails both of them; keeping the clause but also truncating to the second fails only the time_usec row, since :time truncates microseconds anyway.

Disclosure: prepared with AI assistance (Claude). I reviewed it, reproduced the split date/time pair against Ecto.Type directly, and verified the red-green tests myself.

elixir-ecto#4775 moved :date, :naive_datetime and :naive_datetime_usec onto the UTC
instant when given a non-UTC %DateTime{}, but cast_time/1 has no %DateTime{}
clause, so it falls through to the map clauses and rebuilds a Time from the
wall-clock fields.

That leaves a schema casting one %DateTime{} into a :date field and a :time
field with 2020-05-31 and 00:30:07, which is neither the UTC instant nor the
submitted local time. Route %DateTime{} through cast_utc_datetime/1 first, the
same way cast_date/1 and cast_naive_datetime/1 do.
@josevalim
josevalim merged commit 24f914a into elixir-ecto:master Sep 15, 2026
7 of 8 checks passed
@josevalim

Copy link
Copy Markdown
Member

💚 💙 💜 💛 ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants