Skip to content

Resolve DST-ambiguous and nonexistent local times in string_to_datetime - #11054

Merged
Jefffrey merged 6 commits into
apache:mainfrom
pydantic:fix-dst-string-to-datetime
Nov 1, 2026
Merged

Jefffrey merged 6 commits into
apache:mainfrom
pydantic:fix-dst-string-to-datetime

Conversation

@adriangb

@adriangb adriangb commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

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.

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:

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:

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. #11038 fixed the cast kernel, so the first line below now works and the other two still fail:

SELECT '2024-03-10 02:30:00'::timestamp AT TIME ZONE 'America/New_York';  -- cast kernel: fixed by #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 #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 #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 #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 #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 #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 #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 #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 #11038 this closes DataFusion #25084

Neither PR closes it alone. #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) 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

@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. The highest-risk part of this stack is the change of resolve_local_offset from &Tz to <T: TimeZone>, so I attacked that first. It is behaviour-preserving, and I verified that empirically rather than by reading. Four findings follow, in priority order.

1. The generalisation does not change the original cast call site — verified byte for byte

This was the item that could regress #11038 silently. I ran the identical battery of cast-path readings on both branches and compared the raw output:

  • 15 readings in one array that straddles both 2024 America/New_York transitions.
  • 15 readings across Australia/Sydney, Australia/Lord_Howe, Pacific/Chatham, America/Sao_Paulo, America/Havana, Europe/Lisbon, Europe/Dublin, Asia/Tehran, Pacific/Apia, America/Santiago and Asia/Kolkata.
  • 13 readings inside the four 24-hour dateline gaps.

43 rows, and the two files are identical. The cast path on this branch is bit-for-bit the cast path on #11038.

The refactor moves .fix() from inside the function to the call site. Option::map(|o| o.fix()) and then ?, versus ? and then .fix(), are the same operation in the same order. The Single, Ambiguous and None arms are otherwise unchanged. So the empirical result matches the reading of the diff.

One note on the new wrapper. resolve_local_datetime builds its result with tz.from_utc_datetime(&(*local - offset.fix())), not with from_local_datetime. That is deliberate and necessary — from_local_datetime would reject the two cases this function exists to resolve — but it is not obvious. A one-line comment at that call helps the next reader.

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

This PR repeats #11038's justification: no two transitions are closer than 167 hours. That figure is correct, and I reproduce it. But it is not the property that makes the probe resolvable.

Two separate invariants are at work:

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

Seven zones have a local gap of exactly 24.00 hours — the dateline changes: Pacific/Apia, Pacific/Fakaofo, Pacific/Kiritimati, Pacific/Kanton, Pacific/Enderbury, Pacific/Kwajalein and Kwajalein. The next largest gap in any zone is 10.00 hours. For a reading at the last second of a 24-hour gap the probe lands one second before it. The real margin is one second.

The code is correct today. I tested the first instant, the midpoint and the last second of all four surviving dateline gaps through the cast path, and all 13 readings match PostgreSQL 17.11 exactly. But the doc comment in local_time.rs now carries this claim for the whole crate, so please state both invariants there.

3. The give-up branch is unreachable, and this PR widens who depends on that

resolve_local_offset returns None only when the probe itself lands in a gap. Both invariants above exclude that, so the branch is dead code with real tzdata. I could not construct an input that reaches it.

This PR raises the stakes, because the branch now has four callers rather than one. I read all four. All handle None correctly:

Caller None behaviour
adjust_timestamp_to_timezone, safe: false CastError("Cannot cast timezone to different timezone")
adjust_timestamp_to_timezone, safe: true null
string_to_datetime date-only site err("error computing timezone offset")
string_to_datetime naive-datetime site err("error computing timezone offset")
string_to_datetime named-zone site err("error computing timezone offset")

The three parser sites return a ParseError, which the string cast then turns into a null under safe: true and propagates under safe: false. I confirmed both modes still behave that way, and that genuinely bad input is unaffected:

str  safe=false ["2024-03-10 02:30:00", "2024-11-03 01:30:00"] -> [Some(1710055800), Some(1730615400)]
str  safe=true  ["2024-03-10 02:30:00", "2024-11-03 01:30:00"] -> [Some(1710055800), Some(1730615400)]
str  safe=false ["not a timestamp"]  -> Parser error: ... error parsing date
str  safe=true  ["not a timestamp"]  -> [None]

4. The known divergence deserves the same prominence it has in #11038

arrow-array/src/types.rs:349 is untouched. I confirmed that: git diff against the base commit shows this branch changes nothing under arrow-array/, and the function still reads Ambiguous(dt1, _) and None => None. Measured in America/New_York:

2024-11-03 01:30:00 Result
arrow_array::types::from_naive_datetime 1730611800 = 05:30Z, the earlier instant
arrow_cast::local_time::resolve_local_offset 1730615400 = 06:30Z, the later instant

2024-03-10 02:30:00 gives None from the first and 1710055800 from the second.

#11038 carries a full table and a paragraph on why the two must be reconciled. This PR mentions it in one parenthesis inside "What changes are included". I recommend the fuller disclosure here too. This is the PR that creates the single shared helper, so it is the PR that makes the surviving divergence look deliberate.


What I checked and found correct

Both paths now share one policy — measured, not assumed. I ran 5,012 boundary readings across 226 zones through the cast path and the string path in the same run. Every DST transition of every zone from 2020 to 2025, sampled at the start and the midpoint of each gap and each repeated interval: 2,506 in a gap, 2,506 ambiguous.

  • The cast path and the string path agree on all 5,012 readings.
  • 0 unresolved readings under safe: true.

That agreement is the central claim of this PR, and it holds across the whole tzdb, not just the tested zones.

Against PostgreSQL. 3,908 of those readings use a zone PostgreSQL 17.11 accepts. 16 disagree, on both paths equally. All 16 are Europe/Chisinau and its alias Europe/Tiraspol. That is the chrono-tz tzdata skew #11038 discloses, and it predates both PRs:

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. Please name Europe/Tiraspol alongside Europe/Chisinau.

Per-row resolution on the string path. One StringArray that straddles both 2024 America/New_York transitions produces three distinct offsets, and every element matches PostgreSQL:

String 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-11-03 01:30:00 (ambiguous) 1730615400 1730615400
2024-11-03 02:30:00 1730619000 1730619000
2024-12-15 12:00:00 1734282000 1734282000

Utf8, LargeUtf8 and Utf8View produce identical results on that array.

The four hard zones the PR names. All verified against PostgreSQL independently:

Zone Reading arrow and PostgreSQL
Australia/Lord_Howe 2024-10-06 02:15 (30-minute step) 1728143100
Pacific/Chatham 2024-09-29 03:00 (+12:45 / +13:45) 1727532900
Australia/Sydney 2024-10-06 02:30 (southern gap) 1728145800
America/Sao_Paulo 2018-11-04 (midnight does not exist) 1541300400
America/Havana 2024-11-03 (midnight happens twice) 1730610000

The site-3 argument holds. I confirmed the PostgreSQL claim directly. 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. So PostgreSQL makes no distinction between the two spellings, and neither does this PR.

No call site is missed. After this PR the only remaining local-time resolution in the tree outside local_time.rs is arrow-array/src/types.rs:349, which is the known divergence. arrow-array/src/timezone.rs only forwards to chrono.

Suite. cargo test -p arrow-cast passes: 395 lib tests and 13 doc tests. The doc-test count rises from 12 to 13, which is the new string_to_datetime example.

The fixed-offset caveat is over-broad in the body. I measured it. PostgreSQL uses POSIX west-positive signs only when '+05:30' acts as a zone name (SET TimeZone, or AT TIME ZONE), giving 17:30Z. With the offset inside the literal, timestamptz '2024-01-01 12:00:00 +05:30' gives 06:30Z, which agrees with arrow-rs. DuckDB 1.5.2 rejects the zone-name spelling outright. Please say "as a zone name".

adriangb and others added 3 commits October 18, 2026 09:50
No behaviour change. The string parser needs the same DST resolution policy as
the cast kernel, so `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 is generalised from `&Tz` to any `chrono::TimeZone`, because
`string_to_datetime` is generic over its target timezone. It now returns
`T::Offset`, so the one existing caller, `adjust_timestamp_to_timezone`, calls
`.fix()` itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…to_datetime`

These tests fail on this commit and pass after the next one. Each expectation
was taken from PostgreSQL 17.11.

They cover the three `string_to_datetime` call sites (a date-only string, a
naive datetime, and a datetime with a trailing IANA zone name), each with an
ambiguous and a nonexistent reading, through both `string_to_datetime` and the
`Utf8`/`LargeUtf8`/`Utf8View` -> `Timestamp(_, Some(tz))` cast.

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

`test_string_to_timestamp_nanos_unaffected_by_dst` passes already. It is a
control: UTC has no transitions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…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]>
@adriangb
adriangb force-pushed the fix-dst-string-to-datetime branch from 289f19a to b9c4018 Compare October 18, 2026 14:51

@adriangb adriangb left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewer guide: the commits are now split into refactor, then failing tests, then fix. Only the comments marked Behaviour change point at code that changes what users see. Everything else is a move, a test, or docs.

Comment thread arrow-cast/src/parse.rs
return timezone
.from_local_datetime(&datetime)
.single()
return resolve_local_datetime(timezone, &datetime)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behaviour change (1/3): date-only input. This was timezone.from_local_datetime(&datetime).single(), which gave None (so an error) when local midnight is ambiguous or skipped, for example America/Sao_Paulo on 2018-11-04 or America/Havana on 2024-11-03. Now it resolves to the later instant, or shifts forward past the gap.

Comment thread arrow-cast/src/parse.rs
return timezone
.from_local_datetime(&datetime)
.single()
return resolve_local_datetime(timezone, &datetime)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behaviour change (2/3): naive datetime read in the target timezone. This is the Utf8 -> Timestamp(_, Some(tz)) cast path. It was .single() before, so for example 2024-03-10 02:30:00 in America/New_York became an error, or NULL with safe: true. Now it gives 03:30 EDT.

Comment thread arrow-cast/src/parse.rs
let parsed = parsed_tz
.from_local_datetime(&datetime)
.single()
let parsed = resolve_local_datetime(&parsed_tz, &datetime)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Behaviour change (3/3): datetime with a trailing IANA zone name, for example 2024-11-03 01:30:00 America/New_York. This was .single() before. It now uses the same policy as the other two call sites, which is what PostgreSQL does.

///
/// This is [`TimeZone::from_local_datetime`] with the two unresolvable
/// [`LocalResult`] cases decided rather than rejected.
pub(crate) fn resolve_local_datetime<T: TimeZone>(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

New in the fix commit. It only wraps resolve_local_offset (moved unchanged in the first commit) to return a DateTime<T>, which is the type the three parser call sites need.

let adjust = |o| {
let local = as_datetime::<T>(o)?;
let offset = resolve_local_offset(to_tz, &local)?;
let offset = resolve_local_offset(to_tz, &local)?.fix();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No behaviour change. resolve_local_offset now returns T::Offset because it is generic over the timezone type, so this caller calls .fix() itself. Before, the function called .fix() internally.

@adriangb

Copy link
Copy Markdown
Contributor Author

@kosiew any interest in reviewing this since we've worked on tz changes together recently?

@kosiew kosiew 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.

@adriangb,

Thanks for working on this. The shared DST resolution logic looks good, and the regression tests cover the important cases. I found no blocking issues, so I'm approving with a few suggestions about documentation, test duplication, and a possible optimization.

Comment thread arrow-cast/src/parse.rs
///
/// If a timestamp is ambiguous, for example as a result of daylight-savings time, an error
/// will be returned
/// A timestamp with no explicit offset is read as a wall clock time in the

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 change also affects the CSV and JSON readers, which call string_to_datetime directly. Timestamps in DST gaps or overlaps that previously failed will now be accepted. Could you mention this behavior in the rustdoc and PR description? A reader-level regression test would also be useful.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 684e943

Comment thread arrow-cast/src/parse.rs Outdated
fn test_string_to_timestamp_nanos_unaffected_by_dst() {
assert_eq!(
string_to_timestamp_nanos("2024-03-10 02:30:00").unwrap(),
1_710_038_000_000_000_000 - 200_000_000_000

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.

Nit: Could you write the expected value as 1_710_037_800 * 1_000_000_000 instead of subtracting two large numbers? That would make the timestamp easier to verify, as in the existing cast test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread arrow-cast/src/parse.rs
// and so do we: the zone is named more explicitly, but the wall clock reading
// is no less ambiguous for it.
#[test]
fn test_string_to_datetime_dst_named_zone_in_string() {

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.

Nit: Some named-zone cases are duplicated across the parser, cast, and integration tests. Could you remove the two Los Angeles rows already covered by test_parse_timezone and the separate test_cast_string_to_timezone_named_in_string_dst test? The remaining tests should still cover the distinct parser and cast behaviors.

Comment thread arrow-cast/src/local_time.rs Outdated
local: &NaiveDateTime,
) -> Option<DateTime<T>> {
let offset = resolve_local_offset(tz, local)?;
Some(tz.from_utc_datetime(&(*local - offset.fix())))

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.

The 24-hour probe assumes IANA timezone rules, but this helper also accepts other TimeZone implementations. Could you clarify in the documentation that a non-IANA timezone may return None or select an incorrect offset if its transitions do not satisfy those assumptions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Comment thread arrow-cast/src/local_time.rs Outdated
local: &NaiveDateTime,
) -> Option<DateTime<T>> {
let offset = resolve_local_offset(tz, local)?;
Some(tz.from_utc_datetime(&(*local - offset.fix())))

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.

Nit: from_utc_datetime performs another timezone lookup even though we already have the offset. Could you use local.checked_sub_offset(offset.fix()).map(|utc| DateTime::from_naive_utc_and_offset(utc, offset)) instead? This avoids the extra lookup and handles subtraction overflow without panicking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

…obe assumptions and reader impact

Also avoid a second timezone lookup in resolve_local_datetime and use
checked_sub_offset so overflow returns None instead of panicking.

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

Copy link
Copy Markdown
Contributor Author

Thanks @kosiew, addressed the review comments in 3b18577.

@Jefffrey could I ask you to take a look at this PR which has already had a round of review? Thanks!

@github-actions github-actions Bot added arrow-json arrow-json crate arrow-csv arrow-csv crate labels Oct 19, 2026

@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.

just some minor notes on documentation

Comment thread arrow-cast/src/parse.rs Outdated
/// reading (the skipped hour when the clocks go forward) is shifted forward by
/// the length of the gap.
///
/// These rules also apply to the CSV and JSON readers, which call this

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.

it feels odd to mention CSV and JSON here; it seems more appropriate to locate that in csv/json related code, as that is where users of csv/json would look for such documentation

///
/// Timezones with a fixed offset are never ambiguous and have no gaps.
///
/// The gap rule probes the offset 24 hours earlier. This is correct for the

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 section should be combined with the section above about None not being expected in practice as they describe the same thing

@alamb

alamb commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

@adriangb do you have time to address Jeffrey's comments? I can then merge it in

@adriangb

Copy link
Copy Markdown
Contributor Author

Done!

@Jefffrey
Jefffrey added this pull request to the merge queue Nov 1, 2026
@Jefffrey

Jefffrey commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

thanks all

@Jefffrey Jefffrey added the bug label Nov 1, 2026
Merged via the queue into apache:main with commit 89fc173 Nov 1, 2026
21 checks passed
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 arrow-core Core crates related to representing arrow data arrow-csv arrow-csv crate arrow-json arrow-json crate arrow-kernels Crates that transform arrow data (e.g. compute, cast) bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

string_to_datetime (and Utf8 → Timestamp(tz) casts) fail on DST-ambiguous and nonexistent local times

4 participants