Repository navigation
Convert the timestamp text of chunked Polars CSV reads in the scan - #1234
Conversation
| } | ||
| df = _to_datetimes(pl.read_csv(source, **kwargs), timestamp_dtypes) | ||
| schema_overrides, timestamp_dtypes = self._get_csv_schema_overrides(has_header) | ||
| strict = not kwargs.get("ignore_errors", False) |
There was a problem hiding this comment.
Self-review round one (behavior and implementation): FINDINGS, repaired
Base 9e2bf6eebfa96f7a1f747aae51b30637b1bd8296, head 9fb2b4a6fca154e7f493f2e2e6ec709cf5a3f349.
Covered: _to_datetimes() (DataFrame/LazyFrame, missing columns, strict), _get_csv_schema_overrides() (header, schema/schema_overrides/with_column_names, custom converters with non-Datetime dtypes), _read_csv() (result file and GetQueryResults rows), _iter_csv_chunks()/_scan_csv_chunks() (new_columns, ignore_errors, error wrapping into OperationalError), callers (only these two readers; AsyncPolarsCursor uses the same result set), fetch methods over chunks (unchanged; they read the converted DataFrames), and the tests.
Finding (pre-existing on master, exposed by this PR): with ignore_errors=True, pl.read_csv() reads a timestamp with more fractional digits than the time unit as null instead of raising ComputeError (Polars 1.39.0 and 2.0.0: "2020-01-02 03:04:05.123456789012" → None), so the whole read never reached the text fallback and returned null, while the chunked read now truncates it. That contradicts #1191's expected behavior and #1212 contract item 8.
Repair in 2a2a61c: with ignore_errors, _read_csv() reads the timestamp columns as text at once and converts them with strict=False. test_read_csv_ignore_errors now covers both readers; both cases fail on master 9e2bf6e. Unit tests pass on Polars 2.0.0 and 1.39.0 (82 passed each).
No other findings.
Athena writes up to 12 fractional digits for timestamp columns, and Polars does not parse more than nine into a Datetime. A whole PolarsCursor CSV read catches the resulting ComputeError and reads the data again with those columns as text, truncating them with _to_datetimes(). The chunked reader passed the same Datetime dtypes to scan_csv() without that fallback, so the read raised OperationalError. A retry cannot work for chunks, because a value that fails to parse can be in a later chunk, after the earlier chunks have been returned. The chunked reader now reads the timestamp columns as text and applies _to_datetimes() to the scan_csv() LazyFrame, so the conversion runs in the query plan. _to_datetimes() takes a LazyFrame as well as a DataFrame, selecting the columns with cs.by_name(require_all=False) so that a LazyFrame need not resolve its schema, and takes strict, which the readers set from ignore_errors so that text that is not a timestamp becomes null as Polars reads it. _get_timestamp_dtypes() becomes _get_csv_schema_overrides(), which also returns the schema_overrides with those columns as String. With schema given to execute(), Polars ignores schema_overrides, so the columns are not read as text, as with schema_overrides and with_column_names. The whole read keeps parsing natively, because reading the columns as text raised its peak RSS by about 35-40% in local measurements. Fixes #1191 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With ignore_errors given to execute(), Polars reads a timestamp with more than nine fractional digits as null instead of raising ComputeError, so the whole read never fell back to reading the timestamp columns as text and returned null for such values, while a chunked read now truncates them. With ignore_errors, the whole read now reads those columns as text at once and converts them without strict, so both reads return the truncated values, and null for text that is not a timestamp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Polars parses timestamps with up to nine fractional digits into a Datetime of any time unit, truncating them, and fails only on more, so the docstrings no longer say that it fails on more digits than the time unit holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2a2a61c to
3276a3f
Compare
|
|
||
| Athena writes up to 12 fractional digits, which Polars does not parse into a | ||
| Datetime whose time unit holds fewer. | ||
| Athena writes up to 12 fractional digits, and Polars does not parse more than |
There was a problem hiding this comment.
Self-review round two (claims, callers, operations): FINDINGS, repaired
Base 9e2bf6eebfa96f7a1f747aae51b30637b1bd8296, head 7d5584ef (round one's head after rewording), repaired at 3276a3f472bd3996c154f42f2142c0c6d2eb74a2.
Claims checked (PR body, docstrings, comments, commit messages, issue premise):
- "Polars does not parse more fractional digits than the time unit" (docstrings, PR body "such as
TIMESTAMP(9)", commit messages, Chunked PolarsCursor CSV reads fail on timestamps with more fractional digits than the time unit #1191's title): false. On Polars 1.39.0 and 2.0.0,read_csv/scan_csvwithDatetime(\"us\")or(\"ns\")parse 3, 6, and 9 digits (9 truncated to us) and raiseComputeErroronly at 12; the livetest_temporal_types_chunkedfailure on master namests_p12, notts_p9. Repaired: docstrings in 3276a3f, commit messages rewritten (same trees; force-pushed with lease), PR body corrected to "more than nine (TIMESTAMP(10)to(12))". - "The whole read is unchanged": stale after round one's repair; PR body now says it is unchanged without
ignore_errorsand describes theignore_errorspath, including its peak-RSS implication. scan_csv()has nocolumnsparameter (1.39.0, 2.0.0): confirmed by signature.- Polars ignores
schema_overrideswhen givenschema: confirmed (schemaofStringwins overschema_overridesInt64). - Fixed-length cut correct for years 1-9999: Athena writes four-digit years in range (Define and enforce the contract for Athena temporal types across cursors #1212 facts); outside is out of scope (contract item 3).
- Benchmark table: re-measured at 9fb2b4a (same read path as the head), local, noisy machine; ranges stated as measured. Per-batch numbers and the whole-read RSS figures are cited from Read the timestamp columns of chunked Polars CSV results as text #1194.
Callers: _scan_csv_chunks()/_get_csv_schema_overrides() are private with no callers outside result_set.py; AsyncPolarsCursor uses the same result set (aio tests pass). No AWS request count changes: the same single result file read per query; the live test adds one SELECT.
Evidence at 3276a3f: just lint passed; pytest -n 2 tests/pyathena/polars tests/pyathena/aio/polars with AWS (Py 3.15, Polars 2.0.0): 219 passed, 1 xfailed (#1214); unit tests on Py 3.13 + Polars 1.39.0: 82 passed. Not run: SQLAlchemy suites and other cursors (untouched).
| text = cs.by_name(name, require_all=False).as_expr() | ||
| exprs.append( | ||
| pl.when(text != "") | ||
| .then(text.str.slice(0, _TIMESTAMP_TEXT_LENGTHS[time_unit])) |
There was a problem hiding this comment.
Independent review (relayed): Codex CLI 0.163.0, model gpt-6-astra, sandbox read-only, reasoning effort high, session 01a14803-ee7c-7a33-9d78-0d8bb2f56ec4. Static review of git diff 9e2bf6eebfa96f7a1f747aae51b30637b1bd8296..3276a3f472bd3996c154f42f2142c0c6d2eb74a2 in a detached snapshot (unchanged afterwards); no PR text, commit messages, or prior findings given. It ran small Polars probes under /tmp on 1.39.0 and 2.0.0; no repository tests, AWS, or GitHub access.
Covered (reviewer): whole-read native parsing and fallback; GetQueryResults CSV serialization; lazy chunk conversion and exception wrapping; NULLs and empty/header-only results; duplicate names and renaming; reader kwargs; typing, docstrings, and regression assertions. Valid timestamps with 0-12 fractional digits matched between whole and chunked reads across years and time units. Result: FINDINGS (2, both P2).
Finding 1 (P2), here: "Truncation hides malformed timestamp suffixes: 2020-01-02 03:04:05.123456garbage is cut to a valid timestamp before validation, so strict and ignore_errors accept it."
Author verdict: rejected. The text of a timestamp column comes from Athena, which writes only YYYY-MM-DD HH:MM:SS[.f{1,12}] for years 1-9999 (#1212 facts); a suffix after the fraction cannot occur. The cut predates this PR (master's whole-read fallback), and dropping it was #1201, closed not planned.
| return {} | ||
| dtypes = self._csv_dtypes | ||
| return { | ||
| if not has_header or self._kwargs.keys() & { |
There was a problem hiding this comment.
Independent review (relayed), finding 2 (P2), here: "Eligibility ignores the effective has_header kwarg: with execute(has_header=False, skip_rows=1, ignore_errors=True) on a .csv result whose timestamp column is named column_1 (Polars 1.39) / column_0 (2.0), the native whole read returns null for a 12-digit value while this change returns a truncated datetime, violating the headerless exception."
Author verdict: rejected. _get_csv_schema_overrides() only replaces the Datetime entries of the schema_overrides that the readers already pass with String, and _to_datetimes() converts the same names. With has_header=False, Polars applies those overrides by its default names, so the columns read as text are exactly the ones it would otherwise parse as Datetime, and a column the frame does not have is skipped. In the scenario, the truncated value is what #1212 contract items 2 and 8 require; the null was the ignore_errors defect fixed in 7d5584e. Honoring the execute() has_header here would make that case raise OperationalError (without ignore_errors) or return null instead. The docstring's "Without one" refers to the has_header argument, which comes from the result file type.
No repair, so no follow-up review is needed.
WHAT
A chunked PolarsCursor read of a CSV result now reads the timestamp columns as text and converts them in the scan's query plan. Fractions finer than the Datetime time unit are truncated, as a whole read already does when parsing fails. A whole read with
ignore_errorsnow does the same instead of returning null._scan_csv_chunks()applies_to_datetimes()to thepl.scan_csv()LazyFrame beforecollect_batches(). It used to pass the Datetime dtypes topl.scan_csv(), so chunked reads raisedOperationalErroron a timestamp with more than nine fractional digits (Athena'sTIMESTAMP(10)toTIMESTAMP(12)). Polars parses up to nine digits into a Datetime of any time unit, truncating them, and fails on more (Polars 1.39.0 and 2.0.0)._to_datetimes()accepts a LazyFrame as well as a DataFrame. It selects each column withcs.by_name(name, require_all=False)instead of checkingdf.columns, so a missing column is still skipped and a LazyFrame doesn't have to resolve its schema._to_datetimes()takesstrict, which both readers set fromignore_errors, so text that is not a timestamp is read as null whenignore_errors=True, as Polars reads it._get_timestamp_dtypes()is renamed to_get_csv_schema_overrides(). It also returns theschema_overrideswith the timestamp columns set toString; the chunked read and the whole read's fallback both use it.execute()is givenschema, the same as withschema_overridesandwith_column_names. Polars ignoresschema_overrideswhen givenschema, so the columns take that schema's types.ignore_errors, the whole read is unchanged: it parses the columns as Datetime and reads the data again with them as text only onComputeError.ignore_errorsgiven toexecute(), the whole read reads the timestamp columns as text at once. Polars reads a timestamp with more than nine fractional digits as null withignore_errors, raising noComputeError, so the whole read returned null where the chunked read now returns the truncated value.This replaces Draft PR #1194, which was written against an older master. The chunked read is implemented as #1194 did (the design was agreed there). This PR is rebuilt on the current master, adds the whole read with
ignore_errors, and covers the chunked read in the #1213 temporal conformance test.WHY
Fixes #1191 (part of #1212, contract items 1, 2, and 8).
Retrying on
ComputeError, as the whole read does, cannot work for chunks: a value that fails to parse can be in a later chunk, after the rows of earlier chunks have been returned.The whole read keeps parsing natively. Reading the columns as text would hold the text column and the converted one at the same time. Measured for #1194, text reading made whole reads 2-4.5x faster but raised their peak RSS by about 35-40%.
Years outside 1 to 9999 are out of scope (#1212, contract item 3). The fixed-length truncation in
_to_datetimes()assumes four-digit years, so it is correct for every year in range.Chunked reads of a local 5-million-row CSV file (one timestamp column and one string column, 179-194 MB,
pl.Datetime(us),chunk_size=100_000, each run in a fresh process, 3 runs each with 3 and 6 fractional digits), measured at 9fb2b4a:The peak RSS of a chunked read grows with the text. Polars 1.39.0 parses the whole file before
collect_batches()returns the first batch, so on 1.39.0 the growth covers the whole file. For #1194, converting each batch instead measured slower than converting in the plan on both versions (1.39.0: 0.24–0.39 s, 554–592 MiB; 2.0.0: 0.25–0.28 s, 478–508 MiB).Behavior change: with
ignore_errors=Truegiven toexecute(), whole and chunked reads now truncate a timestamp with more than nine fractional digits instead of returning null; text that is not a timestamp is still null. A whole read withignore_errorsholds the text column and the converted one at once, so its peak RSS grows as measured above for text reading.TEST
Tested commit: 3276a3f (the chunked read benchmark at 9fb2b4a, whose read path is the same).
just lint: passed.uv run --env-file .env pytest -n 2 tests/pyathena/polars tests/pyathena/aio/polars(Python 3.15, Polars 2.0.0, with AWS): 219 passed, 1 xfailed (test_temporal_types[unload], UNLOAD timestamps outside 1677-2262 are read as wrong values #1214).pytest tests/pyathena/polars/test_result_set.py tests/pyathena/polars/test_converter.pyon Python 3.13 with Polars 1.39.0: 82 passed.test_temporal_types_chunkedreads the Add temporal type tests for each synchronous cursor #1213 temporal query withchunksize=1and checks the dtypes and values against the contract, includingTIMESTAMP(9)andTIMESTAMP(12). On master (9e2bf6e) it fails withcould not parse `"2020-01-02 03:04:05.123456789012"` as dtype `datetime[μs]` at column 'ts_p12'.test_read_csv_truncates_timestampscovers both readers, withnew_columns,schema, andwith_column_names. Its_iter_csv_chunkscases with no options and withnew_columnsfail on master.scan_csv()does not takecolumns, so that case covers only the whole read.test_read_csv_ignore_errorscovers both readers withignore_errors=True, a 12-digit timestamp, and text that is not a timestamp; both cases fail on master.test_to_datetimescovers a DataFrame and a LazyFrame, including a column the frame does not have.test_to_datetimes_not_strictcoversstrict=False.just test sqla/sqla-async(no SQLAlchemy changes) and the other cursors' tests (no changes outside the Polars result set and converter).🤖 Generated with Claude Code