Skip to content

Resolve DST-ambiguous and nonexistent local times when casting timestamps to a named timezone - #11038

Merged
Jefffrey merged 5 commits into
apache:mainfrom
pydantic:fix-dst-timezone-cast
Sep 29, 2026
Merged

Jefffrey merged 5 commits into
apache:mainfrom
pydantic:fix-dst-timezone-cast

Conversation

@adriangb

@adriangb adriangb commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

What a user hits today

Cast a naive timestamp to a named timezone. Pick a wall clock reading that the zone skips or repeats. The cast fails.

use arrow_array::TimestampSecondArray;
use arrow_cast::{cast_with_options, CastOptions};
use arrow_schema::{DataType, TimeUnit};

// 1_710_037_800 is 2024-03-10T02:30:00, held as a naive wall clock reading.
// America/New_York skips that hour: the clocks go forward at 02:00.
let array = TimestampSecondArray::from(vec![1_710_037_800]);
let to = DataType::Timestamp(TimeUnit::Second, Some("America/New_York".into()));

let strict = CastOptions { safe: false, ..Default::default() };
let lenient = CastOptions { safe: true, ..Default::default() };

cast_with_options(&array, &to, &strict);
cast_with_options(&array, &to, &lenient);

Today:

strict  -> Err("Cast error: Cannot cast timezone to different timezone")
lenient -> Ok, null_count = 1

After this PR:

strict  -> Ok, value = 1710055800   (2024-03-10T07:30:00Z, i.e. 03:30 EDT)
lenient -> Ok, null_count = 0

The safe: true result is the dangerous one. A whole column of DST-boundary readings turns to NULL, and nothing reports it.

How this reaches a SQL user

A DataFusion user reaches the same kernel through ordinary SQL. This is how the bug was found:

SELECT '2024-03-10 02:30:00'::timestamp AT TIME ZONE 'America/New_York';
-- Cast error: Cannot cast timezone to different timezone

The engine plans that as Timestamp(_, None) to Timestamp(_, Some("America/New_York")), so the error text above comes straight from this kernel. See apache/datafusion#25084. An upcoming type-coercion change inserts the same cast automatically for timestamptz - timestamp, so the failure gets more common, not less.

Why the kernel fails

adjust_timestamp_to_timezone reads each value as a wall clock time in the target zone. In an IANA timezone that reading is not always one instant:

  • A "fall back" transition repeats an hour, so the reading is ambiguous.
  • A "spring forward" transition skips an hour, so the reading is nonexistent.

The kernel resolved the offset with offset_from_local_datetime(..).single(). That is None for both cases, so both cases fail.

What changes are included in this PR?

A new private helper, resolve_local_offset, decides the two cases instead of rejecting them. It follows PostgreSQL and DuckDB:

  • An ambiguous reading takes the later instant. This is the offset in effect after the transition. 2024-11-03T01:30:00 in America/New_York becomes 01:30-05:00 (EST).
  • A nonexistent reading moves forward by the length of the gap. 2024-03-10T02:30:00 in America/New_York becomes 03:30-04:00 (EDT).

adjust_timestamp_to_timezone calls the helper in place of .single(). That is the whole behavioural change.

The forward shift needs the offset in effect before the transition. The code recovers it with one probe: it asks for the offset 24 hours earlier and takes the earliest result. offset_from_utc_datetime is not a valid shortcut, because it returns the post-transition offset. That is wrong for a southern hemisphere gap such as Australia/Sydney 2024-10-06T02:30:00.

Two properties of the timezone database make the probe safe:

  • No two transitions in any of the 597 zones are closer than 167 hours. So the probe lands on the correct side of the transition. The smallest interval is America/Boa_Vista in 2000.
  • No local gap is longer than 24 hours. So the probe lands outside the gap at all. Seven zones sit exactly at 24 hours — the dateline changes, such as Pacific/Apia in 2011. The next largest gap is 10 hours.

If the probe still fails, the helper returns None and the caller keeps the old error or NULL. With current tzdata that branch is unreachable.

Nothing else changes. Unit conversion, safe handling and the error message stay as they were. A fixed-offset timezone has no transitions, so it is unaffected.

Are these changes tested?

Yes. Six new tests sit next to the existing test_cast_timestamp_with_timezone_* tests in arrow-cast/src/cast/mod.rs:

  • test_cast_timestamp_to_named_timezone_dst — America/New_York, with an unambiguous, an ambiguous and a nonexistent reading plus a null, under safe: false.
  • test_cast_timestamp_to_named_timezone_dst_safe — the same input under safe: true, which no longer produces nulls.
  • test_cast_timestamp_to_named_timezone_dst_southern_hemisphere — Australia/Sydney, where the transitions run the other way round.
  • test_cast_timestamp_to_named_timezone_dst_nanosecond and ..._dst_changing_unit — the resolution composes with the unit paths.
  • test_cast_timestamp_to_fixed_offset_timezone_unaffected — +08:00 on the same readings is unchanged.

Expected values come from chrono, built from the wall clock reading and the expected offset. They are not hardcoded. Revert the one-line kernel change and the five DST tests fail, while the fixed-offset test still passes.

Validation

The change was checked against 97,162 cases. These cover every transition of every IANA zone in 1890–1995 and 2023–2025: 15,871 in a gap and 14,370 ambiguous. There were zero disagreements with an independent implementation on the same tzdata.

Offsets resolve per row. One array that straddles both 2024 America/New_York transitions yields three distinct offsets, and every element matches PostgreSQL 17.11.

One unrelated discrepancy came up. chrono-tz 0.10.4 bundles tzdata 2025b, which puts the Europe/Chisinau and Europe/Tiraspol transitions an hour away from PostgreSQL, ICU and CPython. That reproduces on main without this PR. It needs a chrono-tz bump, not a change here.

A note on the test manifest

arrow-cast had no way to name an IANA zone in its own tests. Tz parses IANA names only when arrow-array is built with chrono-tz, and arrow-cast never enabled it.

This PR enables it on the dev-dependency only: arrow-array = { workspace = true, features = ["chrono-tz"] } under [dev-dependencies]. The new tests then run in every arrow-cast CI job. No public feature appears, and nothing changes for downstream crates.

One test needed an update. test_cast_string_to_timestamp_invalid_tz asserted the exact error tail only offset based timezones supported without chrono-tz feature, which is now failed to parse timezone. It asserts the stable prefix Parser error: Invalid timezone \"ZZTOP\": instead.

Local runs that pass: cargo test -p arrow-cast (default and --all-features, debug and release), cargo test -p arrow --features chrono-tz,prettyprint --test array_cast --test timezone, clippy with -D warnings on the CI feature combinations, and cargo doc --all-features with -D warnings.

Field research: PostgreSQL 17 and DuckDB

The resolution policy is the one arbitrary decision in this change, so it needs more than one reference. Both engines were measured directly: PostgreSQL 17.11 in postgres:17, and the DuckDB 1.5.2 CLI with the ICU extension. All times below are UTC.

Case PostgreSQL 17.11 DuckDB 1.5.2 This PR
America/New_York 2024-11-03 01:30 — ambiguous 2024-11-03 06:30:00 2024-11-03 06:30:00 2024-11-03 06:30:00
America/Havana 2024-11-03 00:00 — ambiguous local midnight 2024-11-03 05:00:00 2024-11-03 05:00:00 2024-11-03 05:00:00
America/New_York 2024-03-10 02:30 — gap 2024-03-10 07:30:00 2024-03-10 07:30:00 2024-03-10 07:30:00
America/Sao_Paulo 2018-11-04 00:00 — local midnight does not exist 2018-11-04 03:00:00 2018-11-04 03:00:00 2018-11-04 03:00:00
Australia/Sydney 2024-10-06 02:30 — southern hemisphere gap 2024-10-05 16:30:00 2024-10-05 16:30:00 2024-10-05 16:30:00
Australia/Lord_Howe 2024-10-06 02:15 — 30 minute DST step 2024-10-05 15:45:00 2024-10-05 15:45:00 2024-10-05 15:45:00
Pacific/Chatham 2024-09-29 03:00 — +12:45 / +13:45 2024-09-28 14:15:00 2024-09-28 14:15:00 2024-09-28 14:15:00

The two ambiguous rows carry the argument. A gap has only one sensible answer, so it discriminates nothing. An ambiguous reading has two real answers, and the choice between them is the policy.

2024-11-03 01:30 in New York is either 05:30Z (EDT) or 06:30Z (EST). Both engines give 06:30Z. Havana's ambiguous midnight is either 04:00Z (CDT) or 05:00Z (CST). Both engines give 05:00Z. In both cases the answer is the later instant, which is what this PR does.

Spark takes the earlier instant

Spark 4.2.0 was measured on the same cases. CAST(TIMESTAMP_NTZ ... AS TIMESTAMP), to_utc_timestamp, convert_timezone and a string cast all agree, with ANSI mode on and off. Spark matches every gap row above. It takes the earlier instant for every ambiguous reading: New York 01:30 gives 05:30Z, Havana midnight gives 04:00Z, and Australia/Sydney 2024-04-07 02:30 gives 2024-04-06 15:30Z. Spark follows Java here: the cast calls convertTz, which uses LocalDateTime.atZone (ZonedDateTime.of). This PR keeps the PostgreSQL and DuckDB rule.

The case neither engine can arbitrate

A fixed-offset string such as '+05:30' has no agreed reading, so it is recorded here rather than hidden. Measured:

Spelling PostgreSQL 17.11 Convention
SET TimeZone='+05:30' then '2024-01-01 12:00:00'::timestamptz 17:30Z POSIX, west-positive
'2024-01-01 12:00:00'::timestamp AT TIME ZONE '+05:30' 17:30Z POSIX, west-positive
timestamptz '2024-01-01 12:00:00 +05:30' 06:30Z ISO, east-positive

PostgreSQL agrees with arrow-rs when the offset sits inside the literal. It disagrees when the offset acts as a zone name. DuckDB 1.5.2 rejects the zone-name spelling outright: Not implemented Error: Unknown TimeZone '+05:30'.

So neither engine settles it. This PR does not change that behaviour. It is tracked downstream in apache/datafusion#25170.

Are there any user-facing changes?

Yes. A cast that previously raised an error, or returned NULL under safe: true, now returns the instants described above. There is no API change.

This PR does not close DataFusion #25084 on its own

There are two independent paths to a DST-boundary failure. This PR fixes one of them:

-- cast kernel, fixed here
SELECT '2024-03-10 02:30:00'::timestamp AT TIME ZONE 'America/New_York';

-- string parser, NOT fixed here (see #11039)
SELECT '2024-03-10 02:30:00' AT TIME ZONE 'America/New_York';
SET datafusion.execution.time_zone = 'America/New_York';
SELECT '2024-03-10 02:30:00'::timestamptz;
-- Parser error: Error parsing timestamp from '2024-03-10 02:30:00': error computing timezone offset

To a user those are the same query, and the last spelling is the most natural one. Merge this alone and an explicit ::timestamp in the middle of an expression makes a query start to work. That is harder to explain than the current uniform failure. #11039 must land with this PR or close behind it, and must use the same policy so the two paths cannot drift.

This PR is sufficient for apache/datafusion#10308, which is entirely on the cast path.

Follow-up: arrow-array resolves ambiguity the opposite way

After this PR, arrow resolves an ambiguous local time two different ways in two crates:

Ambiguous In a gap
arrow-cast::resolve_local_offset (this PR) the later instant offset probed 24 hours earlier
arrow-array::types::from_naive_datetime (types.rs:347) the earlier instant (Ambiguous(dt1, _)) None

That function is deliberately untouched here.

This matters downstream. DataFusion calls the arrow-array form directly with a Some(tz) in datafusion/functions/src/datetime/date_part.rs (date_to_scalar, four call sites). It builds local-midnight bounds for the date_part(YEAR, col) = <year> rewrite. Where that local midnight is ambiguous, the earlier instant becomes an upper bound, so valid rows can drop out. The behaviour predates this PR, but this PR widens the gap between the two policies.

The two want reconciling, or the difference wants documenting on both functions. Changing from_naive_datetime affects every caller of arrow-array, so it deserves its own review rather than a ride along with a cast fix.

Follow-up: a loud error becomes a quiet non-identity

to_local_time(ts) AT TIME ZONE tz is a documented DataFusion idiom. Across an ambiguous hour it is no longer the identity, and it no longer says so. Before this PR the second leg raised Cannot cast timezone to different timezone:

orig                       local                 roundtrip                  identity
2021-10-31T02:00:00+02:00  2021-10-31T02:00:00   2021-10-31T02:00:00+01:00  false
2021-10-31T02:30:00+02:00  2021-10-31T02:30:00   2021-10-31T02:30:00+01:00  false
2021-10-31T02:00:00+01:00  2021-10-31T02:00:00   2021-10-31T02:00:00+01:00  true

PostgreSQL behaves the same way. t = ((t AT TIME ZONE 'Europe/Brussels') AT TIME ZONE 'Europe/Brussels') is f for those same two instants. So this is the intended trade, not an argument against the change. But it is a real behaviour change, and downstream docs want to say so.

🤖 Generated with Claude Code

@adriangb

adriangb commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

cc @Jefffrey @Omega359

…amps to a named timezone

Casting `Timestamp(_, None)` to `Timestamp(_, Some(tz))` reads each value as a
wall clock time in `tz`. In an IANA timezone that reading does not always
identify a unique instant:

* When the clocks go back ("fall back") the hour repeats and the reading is
  ambiguous.
* When the clocks go forward ("spring forward") an hour is skipped and the
  reading never occurs.

`adjust_timestamp_to_timezone` used `LocalResult::single()`, which is `None` for
both cases, so values on either kind of DST boundary failed with "Cannot cast
timezone to different timezone" under `CastOptions { safe: false }` and silently
became NULL under `safe: true`.

Resolve them instead, matching PostgreSQL 17 and DuckDB 1.5:

* Ambiguous readings take the *later* instant, i.e. the offset in effect after
  the transition. `2024-11-03T01:30:00` in `America/New_York` becomes
  `01:30-05:00` (EST).
* Nonexistent readings are shifted forward by the length of the gap, which is
  the same as reading them with the offset in effect *before* the transition.
  `2024-03-10T02:30:00` in `America/New_York` becomes `03:30-04:00` (EDT).

The pre-transition offset is recovered by probing the same lookup 24 hours
earlier and taking the earliest result; the timezone database contains no two
transitions within 24 hours of each other, so the probe always lands on the
other side of the transition. `offset_from_utc_datetime` is not usable as a
shortcut here: it returns the post-transition offset, which is wrong for
southern hemisphere gaps such as `Australia/Sydney` `2024-10-06T02:30:00`.

Fixed-offset timezones have no transitions and are unaffected. Unit conversion,
`safe` handling and the error message are unchanged.

Tests live in `arrow-cast`, which now enables `arrow-array/chrono-tz` as a
dev-dependency so its own tests can use IANA timezone names. The one existing
assertion on the "invalid timezone" parser message is relaxed to its common
prefix, since the tail of that message depends on whether `chrono-tz` is on.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@adriangb
adriangb force-pushed the fix-dst-timezone-cast branch from ad46575 to 6458760 Compare September 9, 2026 14:25
adriangb added a commit to pydantic/datafusion that referenced this pull request Sep 10, 2026
`to_local_time(t) AT TIME ZONE 'zone'` currently raises
"Cannot cast timezone to different timezone" across an ambiguous hour, so the
round trip cannot silently return a different instant. apache/arrow-rs#11038
removes that error, and once DataFusion picks it up the round trip moves by an
hour with no error and no warning.

PostgreSQL behaves the same way, so this is the intended trade rather than a
regression -- but today the error is what stops users writing that round trip,
so the docs need to say so before the behaviour changes.

Co-Authored-By: Claude Opus 5 <[email protected]>
@adriangb

Copy link
Copy Markdown
Contributor Author

Self-review QA pass — this is our own PR, so treat this as our own adversarial check, not an independent review.

No blocking defect. I re-measured every claim. Four findings, in priority order.

1. The stated safety argument for the 24-hour probe is not the load-bearing one

The PR justifies the probe with a transition-spacing figure: no two transitions in 597 zones are closer than 167 hours. That figure is correct. I reproduce it exactly (America/Boa_Vista 2000-10-15, 167.00 h). But it is not the property that makes the probe resolvable.

Two separate invariants are at work here:

  • Transition spacing above 24 hours. This makes the probed offset the correct pre-transition offset. Margin: 167 hours. The PR states this one.
  • Maximum local gap at or below 24 hours. This makes the probe land outside the gap at all. The PR does not state this one, and it has no margin.

Seven zones have a local gap of exactly 24.00 hours. These are the dateline changes:

Zone Transition (UTC) Local gap
Pacific/Kwajalein 1993-08-21T12:00Z 24.00 h
Kwajalein 1993-08-21T12:00Z 24.00 h
Pacific/Kiritimati 1994-12-31T10:00Z 24.00 h
Pacific/Kanton 1994-12-31T11:00Z 24.00 h
Pacific/Enderbury 1994-12-31T11:00Z 24.00 h
Pacific/Apia 2011-12-30T10:00Z 24.00 h
Pacific/Fakaofo 2011-12-30T11:00Z 24.00 h

The next largest gap is 10.00 h (Antarctica/Macquarie). So the distribution is bimodal, and the code sits exactly on the upper mode.

For a reading at the last second of such a gap the probe lands one second before the gap starts. The real margin is one second, not 167 hours. The code is correct today. It stops being correct the day tzdb gains a gap above 24 hours.

I tested all four surviving dateline zones at the first instant of the gap, the middle and the last second. All 13 readings match PostgreSQL 17.11 exactly:

Pacific/Apia       2011-12-30 00:00:00 -> 1325239200   (PG 1325239200)
Pacific/Apia       2011-12-30 00:00:01 -> 1325239201   (PG 1325239201)
Pacific/Apia       2011-12-30 12:00:00 -> 1325282400   (PG 1325282400)
Pacific/Apia       2011-12-30 23:59:59 -> 1325325599   (PG 1325325599)
Pacific/Kiritimati 1994-12-31 00:00:00 -> 788868000    (PG 788868000)
Pacific/Kwajalein  1993-08-21 23:59:59 -> 746020799    (PG 746020799)
Pacific/Fakaofo    2011-12-30 23:59:59 -> 1325329199   (PG 1325329199)
...

Please state the gap-length invariant next to the spacing invariant, both in the PR body and in the doc comment on resolve_local_offset. A future reader who bumps chrono-tz needs to know which of the two numbers to re-check.

2. The give-up branch is unreachable, so its callers are untested

LocalResult::None from the probe reaches .earliest(), which returns None. I could not construct any input that reaches it. Both invariants above exclude it:

  • No gap exceeds 24 hours, so the probe never lands in another gap.
  • No fall-back transition sits within 24 hours before a gap, so the probe never lands in an ambiguous interval either.

So the branch is dead code with real tzdata. I read the callers instead of testing them, and both are correct:

  • safe: false — adjust returns None, try_unary raises CastError("Cannot cast timezone to different timezone"). The whole array fails, as before.
  • safe: true — unary_opt writes a null. As before.

The ? in adjust covers three separate None sources: as_datetime, resolve_local_offset and from_naive_datetime. All three collapse into the same message. That is pre-existing and fine, but it means the new None source has no distinct diagnostic.

I confirmed the two options stay in step on real DST input:

cast  safe=false [Some(1710055800), Some(1730615400)]
cast  safe=true  [Some(1710055800), Some(1730615400)]

3. The fixed-offset claim is over-broad

The PR says PostgreSQL reads a fixed-offset string such as '+05:30' with the opposite sign. That is true for one spelling only. I measured both on PostgreSQL 17.11:

Spelling Result Sign convention
SET TimeZone='+05:30' then '2024-01-01 12:00:00'::timestamptz 17:30Z POSIX, west-positive
'2024-01-01 12:00:00'::timestamp AT TIME ZONE '+05:30' 17:30Z POSIX, west-positive
timestamptz '2024-01-01 12:00:00 +05:30' 06:30Z ISO, east-positive

So PostgreSQL agrees with arrow-rs when the offset sits inside the literal, and disagrees when the offset acts as a zone name. DuckDB 1.5.2 rejects the zone-name spelling outright (Not implemented Error: Unknown TimeZone '+05:30'). Please say "as a zone name" in the PR body. The current wording reads as though PostgreSQL always disagrees.

4. The dev-dependency removes the no-chrono-tz coverage of arrow-cast

arrow-array = { workspace = true, features = ["chrono-tz"] } under [dev-dependencies] unifies across the graph. So cargo test -p arrow-cast now always builds Tz with IANA support, and arrow-cast has no test that exercises the offset-only Tz. That is exactly why test_cast_string_to_timestamp_invalid_tz needed the relaxed assertion.

The relaxed assertion is the right call. I raise this only so the lost coverage is on the record.


What I checked and found correct

The per-row claim holds. This was my main concern, because a once-per-array offset gives silently wrong values rather than a loud error. I built one 15-row array that straddles both 2024 America/New_York transitions, and compared every element against PostgreSQL 17.11:

Wall clock reading arrow PostgreSQL 17.11
2024-01-15 12:00:00 1705338000 1705338000
2024-03-10 01:30:00 1710052200 1710052200
2024-03-10 02:30:00 (gap) 1710055800 1710055800
2024-03-10 03:30:00 1710055800 1710055800
2024-06-15 12:00:00 1718467200 1718467200
2024-11-03 00:30:00 1730608200 1730608200
2024-11-03 01:30:00 (ambiguous) 1730615400 1730615400
2024-11-03 02:30:00 1730619000 1730619000
2024-12-15 12:00:00 1734282000 1734282000

Three distinct offsets appear in one array. A single offset per array cannot produce this.

A broad independent sweep agrees. I generated every DST transition of every zone from 2020 to 2025, then sampled the start and the midpoint of each gap and each repeated interval. That gives 5,012 readings across 226 zones: 2,506 in a gap, 2,506 ambiguous. Results:

  • 0 unresolved readings under safe: true.
  • 3,908 of them use a zone PostgreSQL 17.11 accepts. 16 disagree.
  • All 16 are Europe/Chisinau and its alias Europe/Tiraspol.

That is the chrono-tz tzdata skew the PR already discloses. I confirmed it predates this PR. On the base commit, with no change applied:

main            Europe/Chisinau 2024-03-31 03:30 -> 1711845000
PostgreSQL 17.11                                -> 1711848600
DuckDB 1.5.2                                    -> 1711848600

PostgreSQL and DuckDB agree with each other, so chrono-tz is the outlier. It puts the Moldova spring transition at 02:00 local; the other two put it at 03:00 local. Non-boundary readings agree everywhere. Please add Europe/Tiraspol to the disclosure — it carries the same skew and the PR names only Europe/Chisinau.

The known divergence stays untouched and disclosed. git diff against the base commit shows this branch changes nothing under arrow-array/. arrow-array/src/types.rs:349 still reads Ambiguous(dt1, _) and None => None. Measured side by side in America/New_York:

2024-11-03 01:30:00 Result
from_naive_datetime 1730611800 = 05:30Z, the earlier instant
this PR's cast 1730615400 = 06:30Z, the later instant

Opposite, as the PR states. The disclosure in the body is accurate and prominent.

Test coverage. The six new tests cover the southern-hemisphere gap (Australia/Sydney), both safe modes, the nanosecond path, a unit change and a fixed offset. Australia/Lord_Howe, Pacific/Chatham, America/Sao_Paulo and America/Havana are not in this PR's tests, but #11054 adds them. I verified all four against PostgreSQL by hand and they pass on this branch too.

Suite. cargo test -p arrow-cast passes on this branch.

The comment gave only one reason the probe is sound: that no two transitions
are closer than 24 hours. That is the wrong invariant for the question it was
answering. It explains why the offset the probe finds is the *correct* one, but
not why the probe lands somewhere *resolvable*.

What makes the probe resolvable is a different property: no local gap is longer
than 24 hours. Seven zones have a gap of exactly 24 hours -- the dateline
changes, such as Pacific/Apia in 2011 and Pacific/Kiritimati in 1994. At the
last second of one of those, the probe lands one second before the gap starts.
So the real margin is one second, not the 167 hours the old comment implied.

Verified against tzdata directly: over 598 zones and 20470 forward transitions
between 1900 and 2040, zero gaps exceed 24 hours, seven are exactly 24 hours,
and the next largest is 10 hours.

Comment only, no behaviour change.

Co-Authored-By: Claude Opus 5 <[email protected]>

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this overall makes sense to me. some points:

  • i wonder how Spark does this? do they follow whats stated for Java in the original issue?
  • personally i dont see this as a breaking change, as its quite an edge case and timezones in general are finicky; hopefully this doesnt affect users who rely on existing behaviour, since we dont really have a way to configure this. happy to have another opinion on this
    • i suppose we shot ourselves in the foot by making CastOptions completely public, meaning its harder to add new options 🤔

Comment thread arrow-cast/src/cast/mod.rs Resolved
Comment thread arrow-cast/src/cast/mod.rs Resolved
}

/// Returns the offset to use when interpreting `local` as a wall clock reading
/// in `tz`, or `None` if it cannot be resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps we should explicitly add a note here about how None is expected to be a rare case, which shouldnt be possible with the current timezone database

@alamb

alamb commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

@adriangb will you have time to address @Jefffrey 's comments above so we can merge this PR?

@adriangb

Copy link
Copy Markdown
Contributor Author

@Jefffrey thanks for the review. I applied both suggestions and added the note about None in 63df2e0.

Regarding Spark: indeed it does what Java does. I ran Spark 4.2.0 locally on the same cases as the table in the PR description. CAST(TIMESTAMP_NTZ ... AS TIMESTAMP), to_utc_timestamp, convert_timezone and a string cast all give the same result, with ANSI mode on and off. All times are UTC:

Case Spark 4.2.0 PostgreSQL 17 / DuckDB / this PR
America/New_York 2024-11-03 01:30 — ambiguous 05:30 (earlier) 06:30 (later)
America/Havana 2024-11-03 00:00 — ambiguous 04:00 (earlier) 05:00 (later)
Australia/Sydney 2024-04-07 02:30 — ambiguous 2024-04-06 15:30 (earlier) 2024-04-06 16:30 (later)
America/New_York 2024-03-10 02:30 — gap 07:30 07:30
America/Sao_Paulo 2018-11-04 00:00 — gap 03:00 03:00
Australia/Sydney 2024-10-06 02:30 — gap 2024-10-05 16:30 2024-10-05 16:30
Australia/Lord_Howe 2024-10-06 02:15 — gap 2024-10-05 15:45 2024-10-05 15:45
Pacific/Chatham 2024-09-29 03:00 — gap 2024-09-28 14:15 2024-09-28 14:15

So Spark agrees on every gap and disagrees on every ambiguous reading. The source confirms it: the cast calls convertTz, which calls LocalDateTime.atZone. That is ZonedDateTime.of, which takes the earlier offset in an overlap and shifts forward in a gap. Spark never raises an error or returns NULL for these readings.

So the SQL engines split on the ambiguous case: PostgreSQL and DuckDB take the later instant, Spark takes the earlier one. But, as far as I was able to tell, no Spark-compatible DataFusion implementer reaches this code. Comet handles the naive-to-zoned cast in its own code (cast.rs), which takes the earlier instant. That code runs before Comet falls back to arrow's cast. In datafusion-spark, to_utc_timestamp, from_utc_timestamp and spark_cast do not call it either. And compared with Spark, readings in a gap go from an error or NULL to Spark's answer. Ambiguous readings did not give Spark's answer before this PR either. So this PR does not make anything worse for Spark users, because no Spark-compatible engine reaches this kernel for this cast.

@adriangb

Copy link
Copy Markdown
Contributor Author

@alamb I think this can be merged now 😄

@Jefffrey Jefffrey added the bug label Sep 29, 2026
@Jefffrey
Jefffrey merged commit 2166741 into apache:main Sep 29, 2026
42 of 43 checks passed
@Jefffrey

Copy link
Copy Markdown
Contributor

thanks @adriangb

@adriangb
adriangb deleted the fix-dst-timezone-cast branch September 29, 2026 10:55
@adriangb

Copy link
Copy Markdown
Contributor Author

thank you for reviewing!

alamb added a commit to alamb/datafusion that referenced this pull request Oct 16, 2026
* `interleave` no longer repeats a shared data buffer for byte view arrays
  (apache/arrow-rs#11281), so the spill `compact_view_array` tests build
  the repeated-buffer array by hand
* Casting a naive timestamp to a named time zone now resolves DST-ambiguous
  and nonexistent local times like PostgreSQL instead of erroring
  (apache/arrow-rs#11038): update `timestamps_timezone.slt`

Co-Authored-By: Claude Fable 5.1 <[email protected]>
adriangb added a commit to pydantic/arrow-rs that referenced this pull request Oct 18, 2026
…ime`

`string_to_datetime` resolves a wall clock reading with `LocalResult::single()`,
which is `None` both when the reading is ambiguous (the repeated hour when the
clocks go back) and when it does not exist (the skipped hour when the clocks go
forward). There are three such call sites:

* a date-only string, which reads local midnight;
* a naive datetime with no trailing offset or zone;
* a datetime with a trailing IANA zone name, e.g.
  `'2024-03-10T02:30:00 America/New_York'`.

Every `Utf8`/`LargeUtf8`/`Utf8View` -> `Timestamp(_, Some(tz))` cast goes through
one of them, so such values failed with "error computing timezone offset" under
`CastOptions { safe: false }` and silently became NULL under `safe: true`.

This is the parser-side twin of apache#11038, which fixed the same bug in the cast
kernel's `Timestamp(_, None)` -> `Timestamp(_, Some(tz))` path. Rather than
restate the policy, apache#11038's `resolve_local_offset` moves out of `cast/mod.rs`
into a new private `local_time` module, generalised from `&Tz` to any
`chrono::TimeZone` (`string_to_datetime` is generic over its target timezone),
and both paths now call it. The policy is unchanged: an ambiguous reading takes
the *later* instant, and a nonexistent one is shifted forward by the length of
the gap, recovered by probing the same lookup 24 hours earlier. No two
transitions in the timezone database are closer than 167 hours, so that probe
always lands on the other side of the transition.

A zone named by the string itself is resolved the same way as one taken from the
target type. The zone is spelled out more explicitly, but the wall clock reading
is no less ambiguous for it, and PostgreSQL 17 makes no distinction between the
two spellings either.

Every expectation added here was taken from PostgreSQL 17.11, and covers each of
the three call sites for both an ambiguous and a nonexistent reading: a
whole-hour DST step (`America/New_York`, `America/Los_Angeles`), a thirty minute
step (`Australia/Lord_Howe`, where a gap shifts 02:15 to 02:45 rather than
03:15), a 45 minute offset (`Pacific/Chatham`), a southern hemisphere zone whose
gap falls in October (`Australia/Sydney`), and the two zones whose transitions
land on midnight and so are only reachable from the date-only site
(`America/Sao_Paulo` 2018-11-04, a local midnight that does not exist, and
`America/Havana` 2024-11-03, one that happens twice). Fixed offsets are
unaffected. An array straddling both of a zone's transitions confirms the offset
is resolved per row.

`arrow/tests/timezone.rs` asserted the old error for the two
`America/Los_Angeles` cases; those move from `test_parse_timezone_invalid` to
`test_parse_timezone` with the instants PostgreSQL produces.

Closes apache#11039

Co-Authored-By: Claude Opus 5 <[email protected]>
adriangb added a commit to pydantic/arrow-rs that referenced this pull request Oct 18, 2026
…ime`

`string_to_datetime` resolved a wall clock reading with `LocalResult::single()`,
which is `None` both when the reading is ambiguous (the repeated hour when the
clocks go back) and when it does not exist (the skipped hour when the clocks go
forward). So such values failed with "error computing timezone offset" under
`CastOptions { safe: false }` and silently became NULL under `safe: true`.

The three call sites (date-only, naive datetime, trailing IANA zone name) now go
through a new `resolve_local_datetime` wrapper around the shared
`resolve_local_offset`, so they apply the same policy as the cast kernel since
apache#11038: an ambiguous reading takes the
*later* instant, and a nonexistent one is shifted forward by the length of the
gap. This makes the tests from the previous commit pass.

Closes apache#11039

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ByteBaker pushed a commit to ByteBaker/arrow-rs that referenced this pull request Nov 1, 2026
…ime` (apache#11054)

# Which issue does this PR close?

- Closes apache#11039 and
apache/datafusion#25084

# Rationale for this change

## What a user hits today

Parse a timestamp string into a named timezone. Pick a wall clock
reading that the zone skips or repeats. The parse fails.

```rust
use arrow_array::timezone::Tz;
use arrow_cast::parse::string_to_datetime;

let tz: Tz = "America/New_York".parse().unwrap();

// 02:30 does not exist on 2024-03-10: the clocks go forward at 02:00.
string_to_datetime(&tz, "2024-03-10 02:30:00");
// 01:30 happens twice on 2024-11-03: the clocks go back at 02:00.
string_to_datetime(&tz, "2024-11-03 01:30:00");
```

Today:

```text
gap       -> Err("Parser error: Error parsing timestamp from '2024-03-10 02:30:00': error computing timezone offset")
ambiguous -> Err("Parser error: Error parsing timestamp from '2024-11-03 01:30:00': error computing timezone offset")
```

After this PR:

```text
gap       -> Ok("2024-03-10T07:30:00+00:00")
ambiguous -> Ok("2024-11-03T06:30:00+00:00")
```

Every `Utf8` / `LargeUtf8` / `Utf8View` cast to `Timestamp(_, Some(tz))`
goes through that function. Under `CastOptions { safe: true }` the error
becomes a **silent NULL** for the whole row.

## How this reaches a SQL user

A DataFusion user reaches the parser through ordinary SQL. apache#11038 fixed
the cast kernel, so the first line below now works and the other two
still fail:

```sql
SELECT '2024-03-10 02:30:00'::timestamp AT TIME ZONE 'America/New_York';  -- cast kernel: fixed by apache#11038
SELECT '2024-03-10 02:30:00'            AT TIME ZONE 'America/New_York';  -- parser: this PR
SET datafusion.execution.time_zone = 'America/New_York';
SELECT '2024-03-10 02:30:00'::timestamptz;                                -- parser: this PR
```

To a user those are the same query, and the last spelling is the most
natural one. With only apache#11038 merged, an explicit `::timestamp` in the
middle of an expression makes a query start to work. That is harder to
explain than the current uniform failure. So this PR completes apache#11038,
it is not an optional follow-up.

## Why the parser fails

`string_to_datetime` in `arrow-cast/src/parse.rs` resolves a wall clock
reading with `LocalResult::single()`. That is `None` in two distinct
cases:

* the reading is **ambiguous** — the repeated hour when the clocks go
back;
* the reading is **nonexistent** — the skipped hour when the clocks go
forward.

There are three such call sites, one per shape of input:

| # | Site | Example input |
| - | ---- | ------------- |
| 1 | date only, reads local midnight | `'2018-11-04'` |
| 2 | naive datetime, no trailing offset or zone | `'2024-03-10
02:30:00'` |
| 3 | datetime with a trailing IANA zone name | `'2024-03-10 02:30:00
America/New_York'` |

# What changes are included in this PR?

## Overview

One shared function now decides every wall clock reading in arrow-cast.
The three parser sites and the cast kernel all call it. The policy
itself does not change.

## Detail

The resolution policy is deliberately shared with apache#11038, not restated.
Two copies of this policy that drift apart is exactly the bug this PR
avoids. One such divergence already exists in the tree, and this PR
leaves it alone. See the follow-up section below.

So apache#11038's `resolve_local_offset` moves out of `cast/temporal.rs` into
a new private `arrow-cast/src/local_time.rs` module and becomes
`pub(crate)`. It generalises from `&Tz` to any `chrono::TimeZone`,
because `string_to_datetime` is generic over its target timezone. A thin
`resolve_local_datetime` wrapper returns a `DateTime<T>`, which is the
shape the three parser sites want. All four call sites now go through
the one function.

The policy is unchanged from apache#11038:

* An **ambiguous** reading takes the **later** instant. This is the
offset in effect after the transition.
* A **nonexistent** reading moves **forward** by the length of the gap.
The code recovers the pre-transition offset with one probe 24 hours
earlier, and takes the earliest result.

Two properties of the timezone database make the probe safe:

* No two transitions in any of the 597 zones are closer than **167
hours**. So the probe lands on the correct side of the transition.
* No local gap is longer than **24 hours**. So the probe lands outside
the gap at all.

Both were verified exhaustively for apache#11038. This PR does not re-derive
them.

## On site 3, the zone named in the string

Site 3 resolves against a zone the user spelled out, rather than one
taken from the target type. So it is fair to ask whether it deserves
stricter treatment. It gets the **same** policy, for two reasons:

1. A named zone makes the *zone* unambiguous. It does nothing to make
the *wall clock reading* unambiguous. `'2024-03-10 02:30:00
America/New_York'` is exactly as unresolvable as `'2024-03-10 02:30:00'`
read in `America/New_York`, and the user has no other spelling for what
they meant.
2. PostgreSQL 17 makes no distinction between the two spellings. `SET
TimeZone='America/New_York'; SELECT '2024-03-10 02:30:00'::timestamptz;`
and `SELECT timestamptz '2024-03-10 02:30:00 America/New_York';` both
return `2024-03-10 07:30:00+00`. This was measured, not assumed.

A different rule for site 3 would put the "same query, different
spelling, different outcome" split back inside a single function. That
split is what this PR exists to remove.

# Are these changes tested?

Yes. **Every expected instant in the new tests came from a real
PostgreSQL 17.11** (`postgres:17`). None were derived by hand. All three
call sites are covered for both an ambiguous and a nonexistent reading:

* `America/New_York` and `America/Los_Angeles` — a whole-hour DST step.
* `Australia/Lord_Howe` — a **thirty minute** step, so a gap shifts
`02:15` to `02:45`, not `03:15`. This rules out any implementation that
assumes a one-hour gap.
* `Pacific/Chatham` — `+12:45` / `+13:45`, an offset that is not a whole
number of hours on either side.
* `Australia/Sydney` — southern hemisphere, so the gap falls in October
and the repeated hour in April. This catches implementations that reach
for `offset_from_utc_datetime` as a shortcut, which returns the
post-transition offset and is wrong here.
* The date-only site, through the two zones whose transitions land on
midnight and are therefore unreachable from any datetime input:
* `America/Sao_Paulo` `2018-11-04` — local midnight **does not exist**,
because Brazil started DST at 00:00. Result: `2018-11-04T03:00:00Z`.
* `America/Havana` `2024-11-03` — local midnight **happens twice**,
because Cuba ends DST at 00:00. Result: the later instant,
`2024-11-03T05:00:00Z`.
* A fixed offset (`+05:30`), which has no transitions and must be
unaffected.
* `CastOptions { safe: true }` against `safe: false` at the array level.
The former produced a silent NULL and the latter an error. Both now
produce the resolved instant, and genuinely unparseable input still
nulls or errors as before.
* An array that straddles both of a zone's 2024 transitions, across
`StringArray`, `LargeStringArray` and `StringViewArray`. This confirms
the offset resolves **per row**, not once for the array.
* Sub-second precision survives the shift applied to a nonexistent
reading.

Counterfactually validated. Revert the three call sites and keep the
tests, and 9 of the new tests fail. The `safe: true` case fails with
`left: 0`, which is the silent-NULL mode.

```
cargo test -p arrow-cast            # and --all-features, --doc, --doc --no-default-features
cargo clippy -p arrow-cast --all-targets --all-features -- -D warnings
cargo test -p arrow --features chrono-tz
cargo test -p arrow-csv -p arrow-json -p arrow-array
cargo fmt --all
```

`arrow-cast` already gained `arrow-array/chrono-tz` as a dev-dependency
in apache#11038, so no manifest change is needed here.

# Field research: PostgreSQL 17 and DuckDB

The resolution policy is the one arbitrary decision in this stack, so it
needs more than one reference. Both engines were measured directly:
PostgreSQL 17.11 in `postgres:17`, and the DuckDB 1.5.2 CLI with the ICU
extension. All times below are UTC.

| Case | PostgreSQL 17.11 | DuckDB 1.5.2 | This PR |
| --- | --- | --- | --- |
| `America/New_York` `2024-11-03 01:30` — **ambiguous** | `2024-11-03
06:30:00` | `2024-11-03 06:30:00` | `2024-11-03 06:30:00` |
| `America/Havana` `2024-11-03 00:00` — **ambiguous** local midnight |
`2024-11-03 05:00:00` | `2024-11-03 05:00:00` | `2024-11-03 05:00:00` |
| `America/New_York` `2024-03-10 02:30` — gap | `2024-03-10 07:30:00` |
`2024-03-10 07:30:00` | `2024-03-10 07:30:00` |
| `America/Sao_Paulo` `2018-11-04 00:00` — local midnight does not exist
| `2018-11-04 03:00:00` | `2018-11-04 03:00:00` | `2018-11-04 03:00:00`
|
| `Australia/Sydney` `2024-10-06 02:30` — southern hemisphere gap |
`2024-10-05 16:30:00` | `2024-10-05 16:30:00` | `2024-10-05 16:30:00` |
| `Australia/Lord_Howe` `2024-10-06 02:15` — 30 minute DST step |
`2024-10-05 15:45:00` | `2024-10-05 15:45:00` | `2024-10-05 15:45:00` |
| `Pacific/Chatham` `2024-09-29 03:00` — +12:45 / +13:45 | `2024-09-28
14:15:00` | `2024-09-28 14:15:00` | `2024-09-28 14:15:00` |

**The two ambiguous rows carry the argument.** A gap has only one
sensible answer, so it discriminates nothing. An ambiguous reading has
two real answers, and the choice between them is the policy.

`2024-11-03 01:30` in New York is either `05:30Z` (EDT) or `06:30Z`
(EST). Both engines give `06:30Z`. Havana's ambiguous midnight is either
`04:00Z` (CDT) or `05:00Z` (CST). Both engines give `05:00Z`. In both
cases the answer is **the later instant**, which is what this PR does.

## The case neither engine can arbitrate

A fixed-offset string such as `'+05:30'` has no agreed reading, so it is
recorded here rather than hidden. Measured:

| Spelling | PostgreSQL 17.11 | Convention |
| --- | --- | --- |
| `SET TimeZone='+05:30'` then `'2024-01-01 12:00:00'::timestamptz` |
`17:30Z` | POSIX, west-positive |
| `'2024-01-01 12:00:00'::timestamp AT TIME ZONE '+05:30'` | `17:30Z` |
POSIX, west-positive |
| `timestamptz '2024-01-01 12:00:00 +05:30'` | `06:30Z` | ISO,
east-positive |

PostgreSQL agrees with arrow-rs when the offset sits inside the literal.
It disagrees when the offset acts as a zone name. DuckDB 1.5.2 rejects
the zone-name spelling outright: `Not implemented Error: Unknown
TimeZone '+05:30'`.

So neither engine settles it. This PR does not change that behaviour. It
is tracked downstream in
apache/datafusion#25170.

# Are there any user-facing changes?

Yes, and the change is intentional. A string that previously failed to
parse, or silently became NULL, now parses to the instant PostgreSQL and
DuckDB produce. There is no API change. `resolve_local_offset` stays
crate-private.

## Together with apache#11038 this closes DataFusion #25084

Neither PR closes it alone. apache#11038 covers the cast kernel. This PR
covers the parser. A user hits both through the same SQL, so both must
land.

## One existing test changes expectation

`arrow/tests/timezone.rs` asserted the old error for two
`America/Los_Angeles` cases. They move from
`test_parse_timezone_invalid` to `test_parse_timezone`:

| Input | Before | After (= PostgreSQL 17) |
| ----- | ------ | ----------------------- |
| `2023-03-12 02:05:06 America/Los_Angeles` | `error computing timezone
offset` | `2023-03-12T10:05:06+00:00` |
| `2023-11-05 01:30:06 America/Los_Angeles` | `error computing timezone
offset` | `2023-11-05T09:30:06+00:00` |

## Downstream

DataFusion has one matching expectation, at
`datafusion/sqllogictest/test_files/datetime/timestamps.slt:2360-2366`.
It asserts that `SELECT TIMESTAMPTZ '2023-03-12 02:00:00
America/Los_Angeles'` errors, and its own comment already notes `#
postgresql: accepts`. On the next arrow upgrade it becomes a `query P`
that returns `2023-03-12T10:00:00Z`, which is what PostgreSQL returns.
This PR includes no DataFusion change.

## Follow-up: `arrow-array` resolves ambiguity the opposite way

This PR creates one shared helper for arrow-cast, but one divergence
remains in the tree. It is deliberately untouched here:

| | Ambiguous | In a gap |
| --- | --- | --- |
| `arrow-cast::local_time::resolve_local_offset` | the **later** instant
| offset probed 24 hours earlier |
| `arrow-array::types::from_naive_datetime`
([`types.rs:347`](https://github.com/apache/arrow-rs/blob/main/arrow-array/src/types.rs#L347))
| the **earlier** instant (`Ambiguous(dt1, _)`) | `None` |

Measured in `America/New_York` for `2024-11-03 01:30:00`:
`from_naive_datetime` gives `05:30Z`, and this stack gives `06:30Z`.

This matters downstream. DataFusion calls the `arrow-array` form
directly with a `Some(tz)` in
`datafusion/functions/src/datetime/date_part.rs` (`date_to_scalar`, four
call sites). Where the local midnight it builds is ambiguous, the
earlier instant becomes an **upper** bound, so valid rows can drop out.
The behaviour predates this stack.

Changing `from_naive_datetime` affects every caller of `arrow-array`, so
it deserves its own review rather than a ride along with a parser fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-cast bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Casting Timestamp(_, None) to a named timezone fails on DST-ambiguous and nonexistent local times

3 participants