Repository navigation
Parse temporal text with fromisoformat and convert PandasCursor time columns after reading (GH-1308) - #1312
Conversation
…columns after reading (GH-1308) The timestamp, date, time, and time zone converters parsed with datetime.strptime(), at about 3.5 microseconds a value. fromisoformat() parses Athena's text, truncating digits beyond microseconds, about 50 times faster. This speeds up the row output of every cursor with temporal columns. PandasCursor left time columns to parse_dates, for which pandas cannot infer a format and falls back to dateutil for each value. They are now read as text and converted after reading, as the other temporal columns are. This also fixes a parse_dates that does not name a time column, which raised AttributeError, and index_col naming a time column, which raised KeyError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| if varchar_value is None: | ||
| return None | ||
| return _parse_datetime(varchar_value) | ||
| return datetime.fromisoformat(varchar_value) |
There was a problem hiding this comment.
Self-review round one (implementation behavior): CLEAN
Base 0a58be36ff188fc1dc9541d1d28e5383500a8110, head 3bdd65d9e0d61628da7e3341c970c2238c481f90.
Covered:
- Behavior and failure paths:
- Callers of the changed functions:
_DEFAULT_CONVERTERS(Cursor, DictCursor, S3FSCursor, and the nested values ofTypedValueConverterinpyathena/parser.py, which does not catch converter errors); the Arrow, Polars, and pandas mappings; and_to_utc_timestamps(). fromisoformat()on Python 3.11 and 3.14 for 0 to 12 fractional digits and years 1 and 9999. Results equal the old truncation.- Text outside Athena's formats (
Tseparator, basic forms, offsets) is now accepted; noted under "Behavior change" in the PR description.
- Callers of the changed functions:
- Data boundaries,
_to_times()(pyathena/pandas/result_set.py:454):- NULL from the C engine (NaN, with
future.infer_stringon and off), from pyarrow strings (pd.NA), and as an empty string becomes None. - Header-only and all-NULL
timecolumns give the same dtype and values as master for whole, chunked, andengine="pyarrow"reads.
- NULL from the C engine (NaN, with
- Options:
dtype={},converters={},usecols,keep_default_na,chunksize, andengine="pyarrow"give the same values as master.parse_dates=[]andparse_dates=["ts"]failed on master withAttributeError; a time column left out ofparse_datesnow stays text with the c, python, and pyarrow engines.index_col="t"failed on master withKeyError; the index now holdstimeobjects._get_fetch_types()leaves out thetimecolumns, whose values are alreadytimeobjects.
- Tests: with the source changes reverted, the 7 new or changed cases fail; the updated
_types_frameexpectation pins the raw text read.
Pre-existing and out of scope: since 36b23f0, the values.dtype.kind == "M" branches of _to_timestamps() and _to_utc_timestamps() (pyathena/pandas/result_set.py:432, :496) look unreachable, because the temporal converters are empty whenever parse_dates, dtype, or converters is given. _to_times() does not add such a branch. This PR leaves them unchanged.
There was a problem hiding this comment.
Round one follow-up on the repair 9ebd8ff3 (affected scope): CLEAN
git range-diff scope: _to_times() accepts parsed datetime values, and _trunc_date() calls _replace_by_label().
- Column that pandas parsed:
_to_times()keeps master's.dt.time.astype(object).where(notna, None), so NULL is None as before. - Text column that pandas did not parse: it is reassigned unchanged;
parse_dates=[]andparse_dates=["ts"]still give text with the c, python, and pyarrow engines. - Index level:
_replace_by_label()rebuilds it, as for the other temporal columns. - Labels that are absent or shared:
usecolsthat leaves the column out is skipped.- A duplicate label, for which
df[name]is a DataFrame, is skipped by_replace_by_label()'s Series check. Duplicate CSV names get distinct labels from_key_csv_columns_by_labels().
- Tests:
test_time_index[*-pandas_parses]fails with only the repair reverted, and passes on the head.
There was a problem hiding this comment.
Round one follow-up on the repair f5029cfe (independent-review repairs, affected scope): CLEAN
_to_times(): pyarrow'stime64values come out ofto_numpy(dtype=object)asdatetime.timeand are kept.datetimeis not a subclass oftime, so no other value type passes through. The order is unchanged: text, then time, then None for NaN andpd.NA._trunc_date(): it skips only the labels in_csv_temporal_converters, which holdtimeobjects. Withparse_dates,dtype, orconvertersgiven, the mapping is empty, so pandas-parsed columns and index levels are still converted.- Re-checked: the edge matrix (options, engines,
usecols,index_col, chunks) and thedtype=Nonematrix with TIME(0) and TIME(3), whole and withindex_col. Results match master, except for the intended fixes.
With parse_dates, dtype, or converters given to execute(), pandas parses a time column that index_col names into the index, which _trunc_date() skipped. It now converts the column or index level through _replace_by_label(), as the after-read conversion does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| # A NULL is None, as with the GetQueryResults fallback and the other types. | ||
| truncated = df.loc[:, self._time_columns].apply( | ||
| lambda r: r.dt.time.astype(object).where(r.notna(), None) | ||
| _replace_by_label( |
There was a problem hiding this comment.
Self-review round two (claims, callers, evidence): FINDINGS, repaired
Base 0a58be36ff188fc1dc9541d1d28e5383500a8110, reviewed head 3bdd65d9e0d61628da7e3341c970c2238c481f90, repaired head 9ebd8ff35c115d431af91572e9972729444690a1.
Claims checked: the PR description, the commit message, the docs/pandas.md paragraph, and the changed docstrings.
Findings:
- The PandasCursor table did not belong to the published head. Its times and peak RSS came from a vectorized
_to_times()that was later reverted. Repair: re-measured on9ebd8ff3, with 5 runs alternating with master on the same interpreter. Peak RSS is reported as measured: whole-read medians 732 → 790 MiB, chunked 611 → 631 MiB, with the run-to-run ranges and the phase sampling that points to allocator retention. I tried blocks of 10,000 values, which lowered the traced peak of the conversion from 97 to 54 MiB but not the read's peak RSS, so the simple code stays. - Overclaim in "the fetch methods of every cursor".
PandasCursor'sdatetime64columns and UNLOAD results never call these functions. Repair: the WHY section and the release notes now name the actual callers. - The docs claim that "
timecolumns that pandas parses becometimeobjects" did not hold for an index level. Withindex_col="tm"anddtype={},_trunc_date()skipped the parsed index, which stayeddatetime64with today's date. Repair:_trunc_date()goes through_replace_by_label(), as the after-read conversion does.test_time_index[*-pandas_parses]covers this and fails with only the repair reverted.
Also checked:
fromisoformat()on Python 3.12, 3.13, and 3.15, in addition to 3.11 and 3.14: same results for 12 fractional digits.- The removed private helpers
_parse_datetime()and_parse_time()were added in 8b083aa (v4.0.0) and have no other callers in the repository. - The micro-benchmark numbers were re-measured with the head's functions.
Re-run on 9ebd8ff3: just lint; 572 offline tests; and the Athena temporal subset of every cursor (219 passed, 7 skipped).
There was a problem hiding this comment.
Round two follow-up on the repair f5029cfe: CLEAN after correcting claims
- Correction: in this round's first pass, I wrote that the
kind == "M"branches looked unreachable. That was wrong:dtype=Nonereaches them (see the independent review thread). - Docstring check: the new
_TemporalConvertersentence says that a column pyarrow already parsed withdtype=None"gets the same dtype". Checked for each type:timestamp:datetime64goes toas_unit("us").timestamp with time zone: converted to UTC.date: pyarrow'sdateobjects take the text path of_to_timestamps().time:timeobjects are kept.
- PR description: TEST now names
f5029cfeand the addedtest_temporal_options_nonecase. The measurements stay attributed to9ebd8ff3, with a timing check onf5029cfe.
…GH-1308) With dtype=None given to execute(), PyAthena's dtype mapping is replaced, and pyarrow parses time text without a fraction into time objects, which _to_times() turned into None. It now keeps them. _trunc_date() also skips the time columns that PyAthena converted after reading, which it rebuilt again when they were index levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| return values.dt.time.astype(object).where(values.notna(), None) | ||
| return pd.Series( | ||
| [ | ||
| _to_time(v) if isinstance(v, str) else v if isinstance(v, time) else None |
There was a problem hiding this comment.
Independent review (relayed): Codex CLI
- Reviewer:
codex exec -s read-only, Codex v0.163.0, modelgpt-6-astra, reasoning effort max, session01a154d0-2a67-7a33-b37a-3398e2e70d46. - Scope: base
0a58be36ff188fc1dc9541d1d28e5383500a8110, head9ebd8ff35c115d431af91572e9972729444690a1, a detached snapshot. - Prompt: no PR number, description, commit messages, or prior findings.
- Static review only: no edits, builds, tests, or GitHub access. The snapshot stayed clean.
- Covered:
- the shared converters through Cursor/DictCursor/S3FSCursor, nested typed conversion, Arrow/Polars/pandas, and the sync, threaded async, and aio callers;
- NULL and empty strings, truncation, time zones, exception types, and column names;
- whole and chunked reads, managed results, the three CSV engines,
future.infer_string, option overrides, indexes,usecols, duplicate names, and the pandas fetch methods; - tests, docs, conventions, and allocation costs.
- No regression found in the scalar
fromisoformat()substitutions for Athena's formats.
Result: FINDINGS (2 introduced by the diff)
1. P2. Times that pyarrow already parsed silently become NULL (pyathena/pandas/result_set.py, _to_times(); line 472 at the reviewed head).
- Scenario:
engine="pyarrow"anddtype=None(which keeps_TemporalConverterbut replaces the string dtype mapping), no chunksize, a result above the size threshold, and a TIME(0) column with03:04:05. pyarrow infers time values, and the comprehension turned every non-string into None. At base, the column went throughparse_datesand the datetime-to-time conversion. - Verified: reproduced: master gives
time(3, 4, 5), the reviewed head gaveNonein the DataFrame, infetchone(), and in the index withindex_col. TIME with a fraction was not affected. - Repair
f5029cfe:_to_times()keepstimevalues.test_temporal_options_nonenow has a TIME(0) column; itsdtype-pyarrowcase fails with the repair reverted and passes on the head.- The
_TemporalConverterdocstring now names the actual case for already parsed columns,dtype=None.
Pre-existing observation (P3 docs), folded into the same repair: the _TemporalConverter docstring said that a dtype given to execute() leads to already parsed columns, but a non-None dtype disables these converters.
Correction to my round-two record: I wrote that the kind == "M" branches of _to_timestamps() and _to_utc_timestamps() looked unreachable. They are reachable with dtype=None, as this finding shows.
There was a problem hiding this comment.
Independent follow-up (relayed): Codex CLI. CLEAN
- Reviewer:
codex exec -s read-only, modelgpt-6-astra, session01a154dc-c629-72f0-abc5-5a3dab21b681. - Scope: the repair only,
9ebd8ff35c115d431af91572e9972729444690a1..f5029cfefee44133cfddda1ccc9b9940c48c9191, base unchanged. Detached snapshot atf5029cfe, which stayed clean. - Static review only: no tests, builds, edits, or GitHub access.
- Covered:
- NULL handling;
- the c, python, and pyarrow engines, and
future.infer_stringon and off; - whole and chunked reads,
index_colincluding MultiIndex levels,usecols, and the fetch methods; - the changed docstrings and the test's distinctness.
Result:
_to_times()keeps thetimevalues that pyarrow parsed._trunc_date()skips the labels that_TemporalConverterconverted and still converts the time columns and index levels that pandas parsed.- The docstrings are accurate.
- The amended
test_temporal_options_noneprotects a distinct contract (TIME(0) withengine="pyarrow",dtype=None) and would receiveNonewithout the repair.
| The same DataFrame. | ||
| """ | ||
| for time_col in self._time_columns: | ||
| if time_col in self._csv_temporal_converters: |
There was a problem hiding this comment.
Independent review (relayed), finding 2. P3. A converted time index is rebuilt again
- Location:
pyathena/pandas/result_set.py,_trunc_date(); line 1630 at the reviewed head. - Scenario: with
index_col=["tm", "id"],_TemporalConverterconvertstmand rebuilds the MultiIndex._trunc_date()then called_replace_by_label()fortmagain. Its callback returned the values unchanged, but the helper still materialized every level and calledMultiIndex.from_arrays(). - Basis: static, not benchmarked by the reviewer.
- Verified: by reading the code.
_trunc_date()runs after the after-read conversion, and a convertedtimecolumn holdstimeobjects, never datetimes. - Repair
f5029cfe:_trunc_date()skips the labels in_csv_temporal_converters. There is no added test, because the effect is cost, not values.test_time_index(default anddtype={}, whole and chunked) still passes.
Pre-existing, not changed: malformed scalar text such as 25:00:00 raises ValueError without a column name in row conversion (pyathena/result_set.py:1113, and the Arrow and Polars equivalents). Only DataError gets the column name. This was the same with strptime(), so it is left out of this PR.
Validation on f5029cfe:
just lint- 572 offline tests
- the Athena temporal subset of every cursor: 219 passed, 7 skipped
- a 1M-row temporal PandasCursor whole read: 3.85–3.95 s, unchanged
WHAT
Closes #1308.
pyathena/converter.py: thetimestamp,date,time,timestamp with time zone, andtime with time zoneconverters parse withdatetime.fromisoformat(),date.fromisoformat(), andtime.fromisoformat()instead ofdatetime.strptime(). On Python 3.11 to 3.15,fromisoformat()reads Athena's text with 0 to 12 fractional digits and truncates digits beyond microseconds, as before. The private_parse_datetime()and_parse_time()helpers, added in 4.0.0, are removed.PandasCursorreadstimecolumns of CSV results as text and converts them totimeobjects after reading, as it already does fortimestamp,date, andtimestamp with time zonecolumns. Before, pandas parsed them throughparse_dates. pandas cannot infer a format for time text, so it fell back todateutilfor each value. Withparse_dates,dtype, orconvertersgiven toexecute(), pandas still parses them, and PyAthena converts the parsed values totimeobjects as before._trunc_date(), which converts those parsed values, goes through_replace_by_label()and skips columns that pandas did not parse. This fixes three cases:parse_datesthat does not name atimecolumn raisedAttributeError: Can only use .dt accessor with datetimelike values. The column now stays as pandas read it.index_colnaming atimecolumn raisedKeyError. With the default options, the index now holdstimeobjects.index_colnaming atimecolumn andparse_dates,dtype, orconvertersgiven, the parsed index level also becomestimeobjects.docs/pandas.md: the paragraph onparse_dates,dtype, andconvertersnow includestimecolumns.Behavior change
fromisoformat()accepts more ISO 8601 forms than the oldstrptime()formats, such as aTseparator, basic forms like20240102, and a trailing offset. Athena never writes these for these types, so only text from other sources is affected: such text now converts instead of raisingValueError.WHY
#1308 reported the cost of the temporal conversion in the 4.2.0 benchmark. Profiling locally showed one cause for each section.
Cursor,DictCursor, andS3FSCursor, and of the async cursors, including nested values with type hints. They also convert thetimestamp with time zone,time, andtime with time zonevalues thatArrowCursorandPolarsCursorkeep as text, andPandasCursor'stime with time zonevalues.strptime()took 2.6 to 4.3 µs a value; the new code takes 0.1 to 1.1 µs. Values that pandas, pyarrow, or Polars parse themselves, such asPandasCursor'sdatetime64columns and UNLOAD results, do not use these functions.cProfile, in a 1-million-row read thetimecolumn took 33.6 s of 37.6 s, almost all indateutil. Thetimestamp,date, andtimestamp with time zoneconversions took 1.25 s.Local measurements: Apple Silicon, Python 3.13, pandas 3.0.6, master
0a58be36against9ebd8ff3of this PR, on the same interpreter. The later commitf5029cfeonly keepstimevalues that pyarrow parsed and skips converted columns in_trunc_date(). On it, the 1M-row temporal whole read took 3.85–3.95 s in 3 runs.Per-value conversion, the minimum of 5 repeats:
timestamp2024-01-02 03:04:05.123datetimestamp with time zone,UTC/Asia/Tokyotimestamp with time zone,+09:00timetime with time zoneThe data has the benchmark's
temporalshape: three flat columns plusevent_time,event_date, threetimestamp with time zonecolumns (UTC,Asia/Tokyo,+09:00), andevent_clock(time(3)).Row conversion of 300,000 rows, the median of 5 repeats in one process:
CursorandS3FSCursorArrowCursorandPolarsCursorPandasCursorreads of 1 million rows, the medians of 5 runs alternating between master and this PR:chunksize=100000, temporalThe peak RSS of the temporal whole read varied from run to run: 678–853 MiB on master and 740–864 MiB with this PR. A second set of 5 runs gave medians of 709 and 806 MiB. RSS sampled at each conversion step points to allocator retention, not to memory that stays allocated:
read_csv(), master's RSS was sometimes much lower, once 352 MiB against about 610 MiB with this PR.I tried two other
timeconversions:_to_timestamps()was no lower in peak RSS and slower: 0.20 s against 0.13–0.16 s per 1 million values.This PR keeps the simple per-value conversion. The script that measures the reads also holds the CSV text in memory, which is about 300 MiB of each figure, as in the flat row.
The benchmark itself (the
temporalshape at the large scale onr7g.2xlarge) was not rerun.Release notes
timestamp,date,time,timestamp with time zone, andtime with time zonevalues in the rows ofCursor,DictCursor,S3FSCursor, and their async variants, of thetimestamp with time zone,time, andtime with time zonevalues in the rows ofArrowCursorandPolarsCursor, and oftimecolumns inPandasCursor's CSV reads (Reduce the cost of the temporal conversion in row output and PandasCursor CSV reads #1308).PandasCursorno longer fails whenparse_datesleaves out atimecolumn, or whenindex_colnames atimecolumn (Reduce the cost of the temporal conversion in row output and PandasCursor CSV reads #1308).TEST
Tested commit: f5029cf, except where noted.
just lint: passed.uv run --env-file .env pytest -n 4 tests/pyathena/test_converter.py tests/pyathena/pandas/test_result_set.py tests/pyathena/pandas/test_converter.py tests/pyathena/s3fs/test_result_set.py tests/pyathena/polars/test_result_set.py tests/pyathena/arrow/test_result_set.py tests/pyathena/test_result_set.py tests/pyathena/test_parser.py: 572 passed.Against Athena:
uv run --env-file .env pytest -n 4 -k "temporal or time_zone or date or time" tests/pyathena, excluding thefilesystem,sqlalchemy,aio/sqlalchemy, andsparkdirectories: 219 passed, 7 skipped. This covers the temporal corpus tests of every sync and async cursor.On 3bdd65d, the first commit:
just docs lint: 0 errors. The docs did not change after it.just benchmark test: 256 passed, 1 skipped.New and changed tests in
tests/pyathena/pandas/test_result_set.py:test_time_index, with the default options and withdtype={}timecase oftest_temporal_conversion_errortimecolumn intest_temporal_options_read_by_pandastest_temporal_options_none: withdtype=None, pyarrow parses it intotimevalues, which must be keptWith the source changes reverted, their cases that check the changed behavior fail with
KeyError, without aDataError, or withAttributeError. With only the second commit reverted, the twodtype={}cases oftest_time_indexfail. With only the third commit reverted,test_temporal_options_none[dtype-pyarrow]fails.Updated the raw-read expectation of
test_read_csv_with_pyarrow_matches_pandas[types-*]: with the default options, thetimecolumn is read as pyarrow text before the conversion.Not run locally: the full
just test pyathenaand the SQLAlchemy suites. The AWS CI runs them when this PR is marked Ready.🤖 Generated with Claude Code