Skip to content

fix: resolve DST-ambiguous and nonexistent local times when casting naive timestamps to a named timezone - #25115

Closed
adriangb wants to merge 1 commit into
apache:mainfrom
pydantic:fix-dst-naive-timestamp-cast
Closed

fix: resolve DST-ambiguous and nonexistent local times when casting naive timestamps to a named timezone#25115
adriangb wants to merge 1 commit into
apache:mainfrom
pydantic:fix-dst-naive-timestamp-cast

Conversation

@adriangb

@adriangb adriangb commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Casting a timezone-naive timestamp to a named timezone fails whenever the wall clock value falls on a daylight saving boundary:

SET datafusion.execution.time_zone = 'America/New_York';

SELECT '2024-11-03T01:30:00'::timestamp::timestamptz;  -- ambiguous ("fall back" hour)
Arrow error: Cast error: Cannot cast timezone to different timezone

SELECT '2024-03-10T02:30:00'::timestamp::timestamptz;  -- nonexistent ("spring forward" hour)
Arrow error: Cast error: Cannot cast timezone to different timezone

TRY_CAST turns the same values into NULL. Unambiguous times and fixed-offset timezones are unaffected. It reproduces on real columns as well as literals.

arrow-cast resolves the offset with offset_from_local_datetime(..).single(), which is None for both LocalResult::Ambiguous and LocalResult::None. PostgreSQL 17.11 and DuckDB 1.5.2 both resolve these deterministically, and they agree with each other:

input (America/New_York) PostgreSQL / DuckDB DataFusion before DataFusion after
2024-11-03T01:30:00 (ambiguous) 2024-11-03 01:30:00-05 error 2024-11-03T01:30:00-05:00
2024-03-10T02:30:00 (nonexistent) 2024-03-10 03:30:00-04 error 2024-03-10T03:30:00-04:00
Reference behaviour (same SQL on PostgreSQL 17.11 and DuckDB 1.5.2)
SET TimeZone = 'America/New_York';
SELECT '2024-11-03T01:30:00'::timestamp::timestamptz AS ambiguous,
       '2024-03-10T02:30:00'::timestamp::timestamptz AS nonexistent;

PostgreSQL 17.11:

       ambiguous        |      nonexistent
------------------------+------------------------
 2024-11-03 01:30:00-05 | 2024-03-10 03:30:00-04

DuckDB 1.5.2:

┌──────────────────────────┬──────────────────────────┐
│        ambiguous         │       nonexistent        │
│ timestamp with time zone │ timestamp with time zone │
├──────────────────────────┼──────────────────────────┤
│ 2024-11-03 01:30:00-05   │ 2024-03-10 03:30:00-04   │
└──────────────────────────┴──────────────────────────┘

DataFusion with this PR (SET datafusion.execution.time_zone = 'America/New_York'):

+---------------------------+---------------------------+
| ambiguous                 | nonexistent               |
+---------------------------+---------------------------+
| 2024-11-03T01:30:00-05:00 | 2024-03-10T03:30:00-04:00 |
+---------------------------+---------------------------+

This matters beyond explicit casts: the fix for #13212 (see #25094) makes type coercion insert exactly this cast for timestamptz - timestamp, so without this change any such query under a named session timezone starts erroring on DST-boundary rows.

What changes are included in this PR?

  • New module datafusion_common::timezone_cast implementing the PostgreSQL/DuckDB convention:
    • ambiguous local times resolve to the later instant, i.e. the post-transition (standard) offset;
    • nonexistent local times shift forward by the size of the gap, which is the same as interpreting the wall clock reading with the pre-transition offset (recovered by probing the offset 24 hours earlier).
  • The two DataFusion cast entry points, ColumnarValue::cast_to (arrays, i.e. CastExpr) and ScalarValue::cast_to_with_options (scalars, i.e. constant folding), route the one type pair Timestamp(_, None) -> Timestamp(_, Some(tz)) through it. Everything else, including the unit conversion and CastOptions::safe handling, is still delegated to arrow's kernel, and the error message for a value that still cannot be resolved is unchanged.

I chose to do this in DataFusion rather than arrow-rs because which instant an ambiguous wall clock time maps to is a SQL-engine semantic choice (matching PostgreSQL/DuckDB) rather than something arrow's kernel should decide by default. If arrow-rs later grows a CastOptions knob for this, the module can shrink to a call into it.

What is the testing strategy for this PR?

  • 9 unit tests in datafusion/common/src/timezone_cast.rs: unambiguous, ambiguous and gap values for America/New_York and Australia/Sydney (southern hemisphere, opposite transition order), a fixed offset, null preservation, a unit-changing cast, and equality with arrow::compute::cast for values where arrow already succeeds.
  • New datafusion/sqllogictest/test_files/datetime/cast_timestamp_dst.slt: the reproductions from the issue as literals (constant-folded scalar path) and as table columns (array path), all four time units, TRY_CAST, NULL inputs, to_unixtime checks of the resolved instants, Australia/Sydney, and a fixed-offset +08:00 control showing unchanged behaviour. Expected epoch seconds were computed independently rather than copied from the runner.
  • The full unfiltered sqllogictest suite passes; no existing expectation changed. The existing statement error for TIMESTAMPTZ '2023-03-12 02:00:00 America/Los_Angeles' is untouched: that is the string parser, not the cast.

Are there any user-facing changes?

Yes, in the sense that casts which used to error (or return NULL under TRY_CAST) now return a value, following the PostgreSQL/DuckDB convention described above. No public API changes.

🤖 Generated with Claude Code

…aive timestamps to a named timezone

Casting a `Timestamp(_, None)` to a `Timestamp(_, Some(tz))` means reading a
wall clock time in `tz`. Around a daylight saving transition that reading is
not always a single instant: the hour repeated by a "fall back" transition is
ambiguous, and the hour skipped by a "spring forward" transition does not
exist. arrow's cast kernel resolves the offset with
`offset_from_local_datetime(..).single()`, which is `None` in both cases, so
these casts fail with `Cannot cast timezone to different timezone` (or produce
NULL under `TRY_CAST`).

PostgreSQL and DuckDB resolve both deterministically, and DataFusion now does
the same, in a new `datafusion_common::timezone_cast` module wired into the two
DataFusion cast entry points (`ColumnarValue::cast_to` and
`ScalarValue::cast_to_with_options`) for exactly that one pair of types:

  * ambiguous local times resolve to the later instant, i.e. the
    post-transition (standard) offset, so `2024-11-03T01:30:00` in
    `America/New_York` is `2024-11-03T01:30:00-05:00`;
  * nonexistent local times shift forward by the size of the gap, so
    `2024-03-10T02:30:00` in `America/New_York` is `2024-03-10T03:30:00-04:00`.

Everything else, including the unit conversion, is still delegated to arrow.

Closes apache#25084

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added logical-expr Logical plan and expressions sqllogictest SQL Logic Tests (.slt) common Related to common crate labels Sep 9, 2026
@adriangb

adriangb commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Closing this: the right place for the fix is arrow-rs, where adjust_timestamp_to_timezone lives. Fixing it there covers every arrow-rs consumer and also the DataFusion cast paths this PR did not intercept (schema adaptation, nested struct casts). arrow-rs PR to follow, I will link it from #25084.

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

Labels

common Related to common crate logical-expr Logical plan and expressions sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cast from Timestamp(_, None) to a named timezone errors on DST boundaries

1 participant