Skip to content

fix: read timezone-naive timestamps in datafusion.execution.time_zone when implicitly converted to timezone-aware - #25211

Draft
adriangb wants to merge 6 commits into
apache:mainfrom
pydantic:session-time-zone-implicit-casts
Draft

fix: read timezone-naive timestamps in datafusion.execution.time_zone when implicitly converted to timezone-aware#25211
adriangb wants to merge 6 commits into
apache:mainfrom
pydantic:session-time-zone-implicit-casts

Conversation

@adriangb

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

  • Closes issue 13212.

Rationale for this change

A Timestamp(unit, Some(tz)) value is an instant; tz is only a display label
attached to it. A Timestamp(unit, None) value is a wall clock reading with no
instant attached. Converting the second into the first has to pick a zone to
read the wall clock in, and that choice decides which instant you get.

PostgreSQL and DuckDB both read it in the session time zone at every
implicit conversion. DataFusion reads it in whatever zone happens to label the
other operand, because type coercion emits a plain
CAST(naive AS Timestamp(u, Some(tz))) and arrow's cast reads the naive value
in tz. So with datafusion.execution.time_zone set, DataFusion answers a
query differently from both of them:

SET TIME ZONE = '+08';
-- PostgreSQL 17 and DuckDB 1.5.2: 08:00:00
-- DataFusion before this PR:      00:00:00
SELECT '2024-11-01T00:00:00+00:00'::timestamptz - '2024-11-01T00:00:00'::timestamp;

The same disagreement shows up wherever a naive timestamp meets an aware one:
comparisons, -, IN, BETWEEN, CASE, UNION, coalesce,
greatest/least, nullif, the array functions, date_bin's origin, VALUES
and INSERT.

What changes are included in this PR?

Wherever the planner or the analyzer inserts a cast from a (possibly nested)
naive timestamp to an aware type whose zone differs from
datafusion.execution.time_zone, a two step cast is emitted instead of one:

CAST(ts AS Timestamp(ns, "America/New_York"))
-- becomes, with datafusion.execution.time_zone = '+08:00'
CAST(CAST(ts AS Timestamp(ns, "+08:00")) AS Timestamp(ns, "America/New_York"))

The inner cast reads the wall clock in the session zone; the outer one only
relabels, since an aware -> aware cast preserves the instant. The coerced
type is therefore unchanged and only the instant differs. Nested naive
timestamps (List, LargeList, FixedSizeList, Struct, Map, Dictionary)
are re-zoned leaf by leaf.

Explicit conversions keep arrow's semantics untouched, which is also what
PostgreSQL and DuckDB do for their explicit forms: AT TIME ZONE, arrow_cast,
the DataFrame API's cast_to/cast, and Substrait casts. SQL
CAST(x AS TIMESTAMPTZ) already targeted the session time zone.

The distinction is exact by construction, not by inspection: the two step form
is built at the point the cast is inserted, never by looking for inserted casts
afterwards. A cast is only ever split by the code that created it, and an
expression that already has the target type is never cast at all, so a cast the
user wrote can never be mistaken for one DataFusion inserted. The three library
functions that insert a cast into a whole plan rather than into one expression
each gained a session-time-zone aware sibling for that reason.

The commits are:

  1. Timestamp(u, Some(tz)) - Timestamp(u, None) at equal units is the one
    mixed pair arrow can subtract directly, so BinaryTypeCoercer inserted no
    cast at all and the naive operand was read as UTC — disagreeing with = on
    the same values and with every other unit pairing. It now coerces like the
    rest.
  2. The cast_to_with_session_time_zone helper in datafusion-expr, plus a
    sibling for each of the three library functions that build a whole plan of
    casts rather than one cast: coerce_plan_expr_for_schema, cast_subquery
    and LogicalPlanBuilder::values/values_with_schema. Each sibling takes the
    session time zone and calls the helper at the exact point it inserts a cast;
    the existing functions become thin wrappers passing None, so their
    signatures and behaviour are unchanged.
  3. Every cast the analyzer inserts routed through it, opted into with the new
    TypeCoercionRewriter::with_session_time_zone. ExprSimplifier::coerce
    passes the session zone too, so SessionContext::create_physical_expr
    behaves like SQL. The two optimizer rules that build a rewriter over an
    already-coerced plan deliberately do not. A scalar function argument that is
    already a literal was folded to the coerced type outright rather than wrapped
    in a cast, which read its wall clock in the coerced type's own zone; it now
    keeps the split cast when there is one, and is folded as before when there is
    not.
  4. The SQL planner's own insertion points: INSERT, UPDATE ... SET and
    VALUES. LogicalPlanBuilder's existing API is unchanged.
  5. Tests.
  6. Docs.

datafusion.execution.time_zone is unset by default, and with no session time
zone every plan is byte-identical to today's.

What is the testing strategy for this PR?

  • Unit tests for the helper in
    datafusion/expr/src/type_coercion/session_time_zone.rs: each nested type and
    the explicit-cast and no-session-zone no-ops.
  • Snapshot tests in datafusion/optimizer/src/analyzer/type_coercion.rs pinning
    the analyzed plan for subtraction, = ANY (<subquery>), UNION, CASE and a
    coerced function argument — each paired with the plan produced with no session
    time zone, which must stay exactly what DataFusion produced before. A UNION
    branch and a subquery projection that are already an explicit cast to the
    common type are pinned too, and must not be split.
  • Plan snapshots in datafusion/sql/tests/sql_integration.rs for a VALUES
    list under a session time zone: the naive cell carries the two step cast and
    the AT TIME ZONE cell is untouched.
  • A section in datafusion/sqllogictest/test_files/datetime/timestamps.slt
    covering every site end to end, with each expectation being the instant
    PostgreSQL and DuckDB return. It also pins the plans, so that the two casts are
    visible and provably survive simplify_expressions; the default behaviour with
    no session time zone; a named session zone across a DST boundary (EST and
    EDT); and AT TIME ZONE / arrow_cast, which must be unaffected — including
    an AT TIME ZONE at the top of a UNION branch, a subquery projection, a
    VALUES cell and a view.
  • test_create_physical_expr_timestamp_subtraction_uses_session_timezone in
    datafusion/core/tests/expr_api/mod.rs for the create_physical_expr path.
  • The full sqllogictest suite (516 files), datafusion-substrait and
    datafusion-proto all pass, the latter two confirming that plans carrying the
    nested casts round-trip.

One pre-existing expectation was wrong and is corrected:
date_bin('1 day', TIMESTAMPTZ '2022-01-01 20:10:00Z', TIMESTAMP '2020-01-01')
under +07 was pinned at 2022-01-01T07:00:00+07:00, i.e. the naive origin read
as UTC. PostgreSQL and DuckDB both return 2022-01-02T00:00:00+07:00, which is
also what the TIMESTAMPTZ-origin case immediately above it already expected.

Are there any user-facing changes?

Yes, and they are documented in the 56.0.0 upgrade guide.

New public API (no breaking changes):

  • datafusion_expr::type_coercion::session_time_zone::cast_to_with_session_time_zone
  • datafusion_expr::expr_rewriter::coerce_plan_expr_for_schema_with_session_time_zone
  • datafusion_expr::expr_schema::cast_subquery_with_session_time_zone
  • datafusion_expr::LogicalPlanBuilder::values_with_session_time_zone
  • TypeCoercionRewriter::with_session_time_zone. Applying the session time zone
    is opt in: TypeCoercionRewriter::new alone coerces exactly as it did before,
    so callers who coerce expressions themselves and want to match SQL planning
    need to call it.

🤖 Generated with Claude Code

adriangb and others added 6 commits September 11, 2026 16:32
…nits

`Timestamp(u, Some(tz)) - Timestamp(u, None)` at *equal* units is the one
mixed timezone-aware/naive pair that arrow can subtract directly, so
`BinaryTypeCoercer` inserted no cast at all and arrow subtracted the raw
values, reading the naive operand as UTC.

Every other unit pairing falls through to
`temporal_coercion_strict_timezone` and reads the naive operand in the
aware operand's zone, which is what comparisons (`=`, `<`, ...) already
do. Coerce the equal-unit case the same way so that all unit pairings
agree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A `Timestamp(unit, Some(tz))` is an instant and `tz` only labels it for
display; a `Timestamp(unit, None)` is a wall clock reading. Converting the
second to the first has to pick a zone to read the wall clock in, and arrow
uses the target type's zone. PostgreSQL and DuckDB instead read it in the
session time zone at every *implicit* conversion, so DataFusion currently
returns a different instant than either of them whenever coercion inserts
such a cast.

Add the two helpers the planner and analyzer will use to insert those casts,
emitting `CAST(CAST(ts AS <session zone>) AS <target zone>)` when the target
zone differs from `datafusion.execution.time_zone`: the inner cast reads the
wall clock in the session zone and the outer one only relabels, so the
coerced type is unchanged and only the instant differs. Nested naive
timestamps (`List`, `LargeList`, `FixedSizeList`, `Struct`, `Map`,
`Dictionary`) are re-zoned leaf by leaf.

Explicit conversions are deliberately not routed through these helpers:
`AT TIME ZONE`, `arrow_cast`, the DataFrame API and Substrait all keep
arrow's semantics, as they do in PostgreSQL and DuckDB.

With `datafusion.execution.time_zone` unset (the default) both helpers
degrade to a plain `cast_to` and plans are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Type coercion inserts `CAST(naive AS Timestamp(u, Some(tz)))` wherever a
timezone-naive timestamp meets a timezone-aware one, and arrow reads the
naive wall clock in `tz` — the zone that happens to label the *other*
operand. PostgreSQL and DuckDB read it in the session time zone instead, so
DataFusion answers a query like `tstz - ts` with a different instant than
either of them.

Route every cast the analyzer inserts through
`cast_to_with_session_time_zone`, so that with
`datafusion.execution.time_zone` set the naive side is read in the session
zone and then relabelled to the coerced type. The coerced types are
unchanged; only the instant differs.

A scalar function argument that is already a literal was folded to the
coerced type outright rather than wrapped in a cast, which read its wall
clock in the coerced type's own zone; it now keeps the split cast when there
is one, and is folded as before when there is not.

`coerce_plan_expr_for_schema` (UNION branch alignment, view output coercion)
and `cast_subquery` build a whole projection rather than one cast, so their
casts are split afterwards — only at the top level of each projected
expression, since anything deeper was written by the user. The projection
keeps the schema it already had so that splitting a cast never renames a
column.

`TypeCoercionRewriter::new` alone still coerces the way it always did;
`with_session_time_zone` opts in, and `TypeCoercion` passes the session zone
for SQL. `ExprSimplifier::coerce` passes it too so that
`SessionContext::create_physical_expr` matches SQL planning. The two
optimizer rules that build a rewriter over an already coerced plan
deliberately do not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…NSERT

`INSERT`, `UPDATE ... SET` and `VALUES` cast their cells to the target
column's type in the SQL planner rather than in type coercion, so they did
not pick up the session time zone with the analyzer's casts. Inserting a
timezone-naive timestamp into a timezone-aware column read the wall clock in
the *column's* zone; PostgreSQL and DuckDB read it in the session zone.

`INSERT`/`UPDATE` build each cast one expression at a time, so they call
`cast_to_with_session_time_zone` directly. `VALUES` goes through
`LogicalPlanBuilder::values`, which casts every cell to the column's common
type and takes no configuration; its public API is left alone and the SQL
planner splits the top level cast of each cell afterwards instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
End to end coverage of every site where DataFusion converts a
timezone-naive timestamp to a timezone-aware one on the user's behalf:
comparisons, `IN`, `BETWEEN`, `CASE`, subtraction, subqueries, joins,
`UNION ALL`, `coalesce`, `least`/`greatest`, `nullif`, `nvl2`, the array
functions, `VALUES` and `INSERT`. Each expectation is the instant
PostgreSQL and DuckDB return.

Also pinned: the plans, so that the two casts are visible and provably
survive `simplify_expressions` rather than collapsing back into the single
cast that reads the wall clock in the wrong zone; the default, where with no
session time zone the naive operand is still read in the aware operand's
zone; and `AT TIME ZONE` and `arrow_cast`, which keep arrow's semantics
whatever the session time zone is.

`date_bin('1 day', TIMESTAMPTZ '2022-01-01 20:10:00Z', TIMESTAMP '2020-01-01')`
under `+07` was pinned at `2022-01-01T07:00:00+07:00`, which is neither what
PostgreSQL nor what DuckDB returns: the naive origin was read as UTC rather
than in the session time zone. It now returns `2022-01-02T00:00:00+07:00`,
matching both and matching the TIMESTAMPTZ-origin case just above it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`datafusion.execution.time_zone`'s description only mentioned `now`, which
undersold it: it is now also the zone a timezone-naive timestamp's wall clock
is read in wherever DataFusion converts one to a timezone-aware timestamp on
the user's behalf.

The upgrade guide covers the three behaviour changes a 55.0.0 user can see —
the session time zone applying to implicit conversions, equal-unit
`aware - naive` subtraction now coercing like every other unit pairing, and
the arrow limitation that makes a DST gap or fold error under a named session
time zone — plus the new `TypeCoercionRewriter::with_session_time_zone`,
which callers who coerce expressions themselves need in order to match SQL
planning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation sql SQL Planner logical-expr Logical plan and expressions optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) common Related to common crate labels Sep 11, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.49881% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.95%. Comparing base (f8cc678) to head (61cb5db).

Files with missing lines Patch % Lines
datafusion/optimizer/src/analyzer/type_coercion.rs 87.05% 24 Missing and 38 partials ⚠️
...fusion/expr/src/type_coercion/session_time_zone.rs 93.98% 8 Missing and 5 partials ⚠️
datafusion/expr/src/expr_rewriter/mod.rs 87.09% 2 Missing and 2 partials ⚠️
datafusion/expr-common/src/type_coercion/binary.rs 76.92% 2 Missing and 1 partial ⚠️
datafusion/sql/src/statement.rs 90.90% 3 Missing ⚠️
datafusion/expr/src/expr_schema.rs 89.47% 2 Missing ⚠️
datafusion/expr/src/logical_plan/builder.rs 96.55% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25211      +/-   ##
==========================================
+ Coverage   81.93%   81.95%   +0.01%     
==========================================
  Files        1133     1134       +1     
  Lines      423529   424243     +714     
  Branches   423529   424243     +714     
==========================================
+ Hits       347028   347689     +661     
- Misses      55910    55954      +44     
- Partials    20591    20600       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions optimizer Optimizer rules sql SQL Planner sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants