Repository navigation
Raise DataError for time zone values without one instant - #1224
Conversation
Athena writes a TIMESTAMP WITH TIME ZONE value with a zone name as the local time in that zone. The converter returned a naive datetime for a zone name unknown to Python's time zone database and silently chose fold=0 for a local time that a DST transition repeats, although Athena writes two instants as the same text there. The converter now raises DataError for an unknown zone name, an ambiguous local time, and a local time that a DST transition skips, which Athena never writes and which therefore means that Python's rules differ from Athena's. The message suggests converting the value to UTC in SQL. Require tzdata>=2024.2 (IANA 2024b), which knows every zone name that Athena accepts. Closes #1220 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| value = _parse_datetime(datetime_).replace(tzinfo=_parse_time_zone(tz)) | ||
| # PEP 495: fold=0 and fold=1 give different offsets only at a transition. At a | ||
| # repeated local time, fold=0 is the earlier instant; at a skipped one, the later. | ||
| if value.utcoffset() != value.replace(fold=1).utcoffset(): |
There was a problem hiding this comment.
Self-review round one (behavior and implementation)
Base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, head 6b072428e15b7413b849d13fdfcc4582945a9cc4; repair head 0ebd096b.
Covered:
- Callers of
_to_datetime_with_tz: the default, pandas, Arrow, and Polars converter maps, and type-hinted nested values (DataError propagates, checked). - Exception handlers on fetch paths:
result_set.py:455,s3fs/result_set.py:210,polars/result_set.py:124, and PandasCursor's whole-read wrapping intoOperationalError(documented in the PR, unwrapped in Name the column in DataError for time zone values in every cursor #1217). - NULL and empty text,
UTC, offsets (+14:00,-14:00), years 1 and 9999 including ambiguous and skipped times in 9999 (noOverflowErrorfromtimestamp()in the error branch). _parse_time_zonecaching (failures are not cached); unknown and unreadable names.- Tests: expected instants around transitions are independent of the implementation; the 11 new converter cases fail on master.
Result: FINDINGS (two minor issues), repaired in 0ebd096b:
- The docstring said a DST transition, but the check covers any change of the zone's UTC offset; reworded.
test_to_datetime_with_tz_unknown_zone_namecleared the cache although failures are not cached; removed.
Out of scope: the column name in the message (#1217), vectorized pandas checks (#1219), and async cursors, which share the converter and the result sets and were not run.
There was a problem hiding this comment.
Repair record (extra /code-review and /simplify passes, by the author; head cabcb4e3)
Applied:
0ebd096b..cabcb4e3: bindfolded = value.replace(fold=1)once.- Return early for a fixed-offset
timezone, which has no transitions. Offsets now cost +3% per value instead of +9%. - Merge the ambiguous and skipped local time tests into one parametrized test.
- Drop the
cache_clear()calls, which no longer guard anything because failed lookups are not cached. - Import
DataErrorfrompyathena.error.
Rejected or deferred:
- A stale system tz database (searched before the
tzdatapackage) can turn a valid value intoDataError. This is intended over a silently wrong instant, and the limitation is documented in Document the temporal type contract and gate the release on it #1221. - PandasCursor whole reads wrap the error in
OperationalError(Name the column in DataError for time zone values in every cursor #1217). - Not caching failed lookups costs only on the error path, which aborts the read.
- No cache can leak from a mock that always raises.
- The
UTCand-04:00cases are kept on purpose: they show that the repeated local time converts when the zone has no transition. Their docstring now says so. - Comparing
utcoffset()directly would need anOptionalworkaround for mypy.
Validation at cabcb4e3: UV_PYTHON=3.13 just lint passed, and tests/pyathena/test_converter.py passed (190). Both self-review perspectives applied to the repair: the change of behavior is limited to the early return for datetime.timezone (ZoneInfo is not a subclass), and the PR body's performance and test claims were updated.
| except (ZoneInfoNotFoundError, ValueError, OSError) as e: | ||
| raise DataError( | ||
| f"Unknown time zone {value!r}. " | ||
| "Update the tzdata package or the system time zone database." |
There was a problem hiding this comment.
Self-review round two (claims, callers, and operations)
Base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, head 0ebd096bdd (full pass over the PR body, commit messages, docstrings, and the issue premises they repeat).
Claims checked:
- Before/after table: master's naive fallback with one warning and the
fold=0result were reproduced; the new messages are taken from the code. - Per-cursor exception classes: measured on live Athena with this branch; Cursor raises in
execute()(the tests assert it, including managed results). - Type-hinted arrays, maps, and rows raise
DataError(checked for each). tzdata2024.2 is IANA 2024b (tzdata.IANA_VERSION);uv lock --checkpasses with uv 0.12.5 and 0.13.1.- Benchmark: same interpreter (Python 3.15), master's module loaded from
origin/master. - Docs: no page describes the naive fallback;
docs/s3fs.md(awaredatetime) anddocs/pandas.mdstay correct.
Callers and operations:
- SQLAlchemy receives the DB API
DataError. - Installations pinning
tzdata<2024.2will fail to resolve; this is in the release note. - No AWS requests are added.
- A false
DataErroris possible when Python's tz rules have a transition that Athena's do not; that skew limitation is documented under Document the temporal type contract and gate the release on it #1221.
Result: FINDINGS, PR body corrected:
- "Every cursor" was wrong; SparkCursor does not convert values, so the claim is narrowed.
- The claim that named zones come only from queries is limited to what was measured (Iceberg reads back as
UTC; Hive has no time zone type). - The tzdata 2024b coverage claim is stated as the Trino zone-index comparison it rests on.
- The test record names the repair head.
No code changes in this round.
A fixed offset has no transitions, so values with one return before the fold comparison and keep master's per-value cost. Merge the repeated and skipped local time tests into one parametrized test, and drop cache resets that no longer guard anything because failed lookups are not cached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| return _parse_datetime(datetime_).replace(tzinfo=_parse_time_zone(tz)) | ||
| zone = _parse_time_zone(tz) | ||
| value = _parse_datetime(datetime_).replace(tzinfo=zone) | ||
| if isinstance(zone, timezone): |
There was a problem hiding this comment.
Independent review (relayed result from Codex CLI, codex exec -s read-only, model gpt-6-astra, session 01a1470d-dcec-76f0-ac96-44fd17980d97)
- Base:
b2d7aa24b00a204e9e150952c708e3bb1126f9e2. Head:cabcb4e3. - Snapshot: a detached worktree of the head. It was unchanged afterwards, and so was the PR worktree.
- The prompt contained the intended behavior and the conventions only: no PR number, description, commit messages, or prior findings.
Reviewer's coverage:
- The shared converter: unknown zones, repeated and skipped local times, transition boundaries, offsets,
UTC, and NULL or empty values. Fold handling was checked against PEP 495. - Parser and typed nested conversions; synchronous and asynchronous result sets; the pandas, Arrow, Polars, and S3FS callers.
- The changed unit and integration tests, independent expected values, and the wiring of the real Athena fixtures.
- The
tzdata>=2024.2floor and the consistency of the lock file (revision 3, resolved 2024.2).
Result: CLEAN. No actionable findings against the stated behavior, including the accepted PandasCursor wrapping and the absence of the column name.
Limits: static review only. No builds, tests, or AWS execution.
WHAT
The shared converter for
timestamp with time zoneraisesDataErrorwhen the text does not identify one instant, instead of returning a naive or arbitrarily resolved datetime. This applies to the fetch methods of every cursor that converts values (all but SparkCursor), to values inside type-hinted arrays, maps, and rows, and to PandasCursor, which uses the same converter for these columns. The asynchronous cursors share the converter.datetime, one warning per nameDataError: Unknown time zone 'X'. Update the tzdata package or the system time zone database.2020-11-01 01:30:00.000 America/New_York)datetimewithfold=0(the earlier instant)DataError: '...' is ambiguous: the local time occurs twice in America/New_York. Convert the value to UTC in SQL, for example with AT TIME ZONE 'UTC'.2020-03-08 02:30:00.000 America/New_York)datetimewithfold=0DataError: '...' does not exist: the local time is skipped in America/New_York. ...UTC, other zone namesThe dependency
tzdatanow has the floor>=2024.2(IANA 2024b).Exception classes per cursor for the ambiguous value, measured on live Athena:
execute(), which converts the first page), S3FSCursor, ArrowCursor and PolarsCursor fetch methods, and PandasCursor chunked reads:DataError.OperationalErrorcaused by theDataError, because the reader wraps read failures. Name the column in DataError for time zone values in every cursor #1217 unwraps it and adds the column name to every temporalDataError.Arrow and Polars tables keep these columns as text, so only their fetch methods are affected.
Release note (4.2.0, breaking): values of
timestamp with time zonewith an unknown zone name, or with a local time that a DST transition repeats or skips, raiseDataErrorinstead of returning a naive datetime or the earlier instant. Convert such values to UTC in SQL (AT TIME ZONE 'UTC') or readto_unixtime().tzdata>=2024.2is required.WHY
Closes #1220. Part of #1212. The decision and live measurements are in #1220 (comment):
UTC, and Hive tables have no time zone type, so named zones come from the query (at_timezone(),AT TIME ZONE,with_timezone()), where converting them gives the exact instant.America/Coyhaique, which Athena rejects.TEST
Tested at 6b07242 (Python 3.15, pandas 3.0.6, pyarrow 26.0.0, Polars 2.0.0). The later commits change only
pyathena/converter.py(docstring, early return for fixed offsets) andtests/pyathena/test_converter.py; at cabcb4e, lint andtests/pyathena/test_converter.py(190 passed) passed again:UV_PYTHON=3.13 just lint: passed.uv run --env-file .env pytest -n 4 tests/pyathena/test_converter.py tests/pyathena/test_cursor.py tests/pyathena/s3fs/test_cursor.py tests/pyathena/arrow/test_cursor.py tests/pyathena/polars/test_cursor.py tests/pyathena/pandas/test_cursor.py: 741 passed, 1 skipped (s3fs/test_cursor.py:253, an unconditional skip on master). This run preceded the type-hinted array test;tests/pyathena/test_converter.pypassed afterwards (190 passed)._to_datetime_with_tzat cabcb4e (same interpreter, 200,000 calls, best of 7):UTC3.71 → 4.09 µs (+10%),America/New_York3.76 → 4.17 µs (+11%),+05:303.71 → 3.82 µs (+3%; fixed offsets skip the transition check). Make PandasCursor's temporal dtypes and fetched types depend only on the Athena type #1219 replaces PandasCursor's per-value conversion with a vectorized one.uv.lockchanges only thetzdataspecifier;uv lock --checkpasses with uv 0.12.5 (lock revision 3) and 0.13.1.Not run: async cursors (they share the result sets and converter) and the SQLAlchemy suites. The full AWS suite runs in CI when the PR is marked Ready.
🤖 Generated with Claude Code