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
Draft
Conversation
…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>
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Rationale for this change
A
Timestamp(unit, Some(tz))value is an instant;tzis only a display labelattached to it. A
Timestamp(unit, None)value is a wall clock reading with noinstant 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 valuein
tz. So withdatafusion.execution.time_zoneset, DataFusion answers aquery differently from both of them:
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,VALUESand
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: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. SQLCAST(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:
Timestamp(u, Some(tz)) - Timestamp(u, None)at equal units is the onemixed pair arrow can subtract directly, so
BinaryTypeCoercerinserted nocast at all and the naive operand was read as UTC — disagreeing with
=onthe same values and with every other unit pairing. It now coerces like the
rest.
cast_to_with_session_time_zonehelper indatafusion-expr, plus asibling for each of the three library functions that build a whole plan of
casts rather than one cast:
coerce_plan_expr_for_schema,cast_subqueryand
LogicalPlanBuilder::values/values_with_schema. Each sibling takes thesession time zone and calls the helper at the exact point it inserts a cast;
the existing functions become thin wrappers passing
None, so theirsignatures and behaviour are unchanged.
TypeCoercionRewriter::with_session_time_zone.ExprSimplifier::coercepasses the session zone too, so
SessionContext::create_physical_exprbehaves 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.
INSERT,UPDATE ... SETandVALUES.LogicalPlanBuilder's existing API is unchanged.datafusion.execution.time_zoneis unset by default, and with no session timezone every plan is byte-identical to today's.
What is the testing strategy for this PR?
datafusion/expr/src/type_coercion/session_time_zone.rs: each nested type andthe explicit-cast and no-session-zone no-ops.
datafusion/optimizer/src/analyzer/type_coercion.rspinningthe analyzed plan for subtraction,
= ANY (<subquery>),UNION,CASEand acoerced function argument — each paired with the plan produced with no session
time zone, which must stay exactly what DataFusion produced before. A
UNIONbranch and a subquery projection that are already an explicit cast to the
common type are pinned too, and must not be split.
datafusion/sql/tests/sql_integration.rsfor aVALUESlist under a session time zone: the naive cell carries the two step cast and
the
AT TIME ZONEcell is untouched.datafusion/sqllogictest/test_files/datetime/timestamps.sltcovering 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 withno session time zone; a named session zone across a DST boundary (EST and
EDT); and
AT TIME ZONE/arrow_cast, which must be unaffected — includingan
AT TIME ZONEat the top of aUNIONbranch, a subquery projection, aVALUEScell and a view.test_create_physical_expr_timestamp_subtraction_uses_session_timezoneindatafusion/core/tests/expr_api/mod.rsfor thecreate_physical_exprpath.datafusion-substraitanddatafusion-protoall pass, the latter two confirming that plans carrying thenested 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
+07was pinned at2022-01-01T07:00:00+07:00, i.e. the naive origin readas UTC. PostgreSQL and DuckDB both return
2022-01-02T00:00:00+07:00, which isalso 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.
datafusion.execution.time_zoneset, a timezone-naive timestamp is readin that zone wherever DataFusion implicitly converts it to a timezone-aware
one. The default is unset, which keeps the previous behaviour.
Timestamp(u, Some(tz)) - Timestamp(u, None)at equal units now reads thenaive operand in the aware operand's zone even with no session time zone,
rather than as UTC.
daylight saving gap or fold currently errors with arrow's
Cannot cast timezone to different timezoneinstead of resolving to aninstant. This is a pre-existing arrow limitation that a named session time zone
now reaches in more places; it is fixed by
Resolve DST-ambiguous and nonexistent local times when casting timestamps to a named timezone arrow-rs#11038 and
Resolve DST-ambiguous and nonexistent local times in
string_to_datetimearrow-rs#11054, tracked on the DataFusion sideat Cast from
Timestamp(_, None)to a named timezone errors on DST boundaries #25084. Fixed offsets areunaffected.
New public API (no breaking changes):
datafusion_expr::type_coercion::session_time_zone::cast_to_with_session_time_zonedatafusion_expr::expr_rewriter::coerce_plan_expr_for_schema_with_session_time_zonedatafusion_expr::expr_schema::cast_subquery_with_session_time_zonedatafusion_expr::LogicalPlanBuilder::values_with_session_time_zoneTypeCoercionRewriter::with_session_time_zone. Applying the session time zoneis opt in:
TypeCoercionRewriter::newalone 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