Repository navigation
Read the timestamp columns of chunked Polars CSV results as text - #1194
laughingman7743 wants to merge 4 commits into
Conversation
Athena writes up to 12 fractional digits for timestamp columns. A whole PolarsCursor CSV read caught the ComputeError from parsing them into a Datetime with a shorter time unit and read 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 the rows of the chunks already returned would be read again. Both readers now always read the timestamp columns as text and convert them with _to_datetimes(), the chunked reader for each chunk. Reading 5 million rows locally, this was faster than parsing them as Datetime with Polars 1.39.0, 1.44.2, and 2.0.0, for whole and chunked reads, and returned the same values and dtypes, including with time zones. _get_timestamp_dtypes() becomes _get_csv_schema_overrides(), which also returns the schema_overrides with those columns as String. Fixes #1191 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Polars ignores schema_overrides when it is given a schema, so with a schema given to execute() the timestamp columns have the types of that schema, not String. The readers converted them with _to_datetimes() anyway, turning a String column into a Datetime one and failing on a Datetime column. As with schema_overrides, the columns are now not read as text when execute() is given a schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| dtypes = self._csv_dtypes | ||
| return { | ||
| if not has_header or self._kwargs.keys() & { | ||
| "schema", |
There was a problem hiding this comment.
Self-review round one (implementation behavior): FINDINGS, repaired.
Scope: merge-base 06224494788043a4887afcb112a96c94bbcb02f1..bc95db9a6441caf4e112754b16c0631106583d16; pyathena/polars/result_set.py (_get_csv_schema_overrides(), _read_csv(), _iter_csv_chunks(), _scan_csv_chunks()), tests/pyathena/polars/test_result_set.py, tests/pyathena/polars/test_cursor.py. Callers traced: the sync and async PolarsCursor share AthenaPolarsResultSet; the S3 result file and the managed GetQueryResults rows both reach _read_csv().
Finding: with schema given to execute(), Polars ignores schema_overrides (checked with read_csv() and scan_csv()), so a timestamp column keeps the dtype from that schema. bc95db9 still converted it with _to_datetimes(): a String column became Datetime("us"), and a Datetime("ms") column failed with OperationalError, for both readers. On master both kept the schema's dtype; the old fallback ran only after a ComputeError.
Repair (f5023e8): schema joins schema_overrides and with_column_names as a reason not to read the columns as text. test_read_csv_truncates_timestamps gains a schema case per reader; both fail without the repair.
Checked without findings: errors from _to_datetimes() are inside each reader's try and become OperationalError; NULL as an empty field or ""; columns, new_columns (renamed after the conversion), .txt results without a header, and tz-aware or ns Datetime dtypes from a custom converter.
Validation: just lint; pytest tests/pyathena/polars/test_result_set.py tests/pyathena/polars/test_converter.py: 76 passed. The AWS Polars suites ran at bc95db9 (209 passed) and are rerun before Ready.
There was a problem hiding this comment.
Round one, follow-up after the design change (80424b2): CLEAN for the changed scope (git range-diff-equivalent: f5023e8..80424b2 touches _read_csv(), _scan_csv_chunks(), _to_datetimes(), and their tests).
Round two measured that reading the timestamp columns of a whole read as text raised its peak RSS by about 35-40%. After consulting Codex and claude-fable-5-1, the maintainer chose to keep the whole read as on master (native parse, text re-read only on ComputeError) and to convert the chunked read's text columns in the scan_csv() query plan.
Checked: the whole read matches master except that a ComputeError with schema given re-raises without the futile re-read (the outcome is the same OperationalError); a conversion error in a chunk surfaces from collect_batches() inside the try as OperationalError; cs.by_name(require_all=False) matches names literally (not as a regex), skips a column the frame lacks without resolving a LazyFrame's schema, and keeps the column order; collect_batches() keeps maintain_order=True; a header-only file yields no batches as before; new_columns is applied after the conversion.
Validation at 80424b2: just lint; Polars 2.0.0 with AWS tests/pyathena/polars tests/pyathena/aio/polars: 214 passed; unit tests on Polars 1.39.0 and 1.44.2: 79 passed each.
Reading the timestamp columns of a whole read as text was faster than parsing them, but held the text and the converted column at once: reading 5 million rows locally, the peak RSS of a whole read grew by about 35-40% with Polars 1.39.0 and 2.0.0. The whole read again parses them natively and reads the data again with them as text only when parsing fails, as before #1191. The chunked read converts the text columns in the scan's query plan instead of converting each batch. _to_datetimes() takes a LazyFrame as well as a DataFrame and selects the columns with cs.by_name(require_all=False), so it skips a column the frame does not have without resolving the schema of a LazyFrame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| # Renamed after reading, so that Polars matches the types to the header. | ||
| read_kwargs.pop("new_columns", None) | ||
| lazy_df = pl.scan_csv(location, **read_kwargs) | ||
| lazy_df = _to_datetimes(pl.scan_csv(location, **read_kwargs), timestamp_dtypes) |
There was a problem hiding this comment.
Self-review round two (claims, compatibility, operational effects): FINDINGS, repaired.
Scope: merge-base 06224494788043a4887afcb112a96c94bbcb02f1..80424b2eb029ce5a731dd41137b8c6b68155f3c9 (full pass of the PR body, commit messages, docstrings, comments, and docs/polars.md).
Findings:
- Operational: the PR body claimed only a speed-up for reading timestamps as text. Measuring peak RSS (fresh process per run, 5M rows, ~180-190 MB) showed whole reads grew by ~35-40% (1.39.0: 545-577 → 756-798 MiB; 2.0.0: 479-525 → 645-700 MiB). Repaired in 80424b2 by keeping the whole read as on master (maintainer decision after consulting Codex and claude-fable-5-1), and converting chunks in the query plan.
- Claim: the PR body said the text-reading conditions were unchanged; f5023e8 added
schema. Corrected in the PR body. - Compatibility (from the Codex consultation, verified on 1.39.0 and 2.0.0): with
ignore_errors=True, a chunked read used to return null for a value with excess digits; it now returns it truncated, and non-timestamp text raises. Documented in the PR body; Athena does not write such text for a timestamp column.
Claims verified: schema makes Polars ignore schema_overrides for read_csv() and scan_csv() (1.39.0, 1.44.2, 2.0.0); value/dtype equality of text conversion and native parsing (0/1/3/6 digits, years 0001-9999, NULL, us/ns/UTC/Asia/Tokyo; 1.39.0 and 2.0.0); Polars 1.39.0 parses the whole file before collect_batches() returns the first batch (first batch at 0.42 s, 530 MiB, equal to the total) while 2.0.0 streams (0.02 s, 163 MiB); the chunked timings and RSS in the PR table (3 runs each, bench4.py importing PyAthena's converter in both modes). docs/polars.md has no statement this change makes obsolete. The first commit's message (bc95db9) describes the superseded design; later commits record the change.
Limits: one data shape (one timestamp and one string column) on local macOS; with more columns the relative differences shrink. AWS validation is local; CI runs on Ready.
With ignore_errors given to execute(), Polars reads a value it cannot parse as null. The chunked CSV read now reads the timestamp columns as text, so text that is not a timestamp reached the strict conversion of _to_datetimes() and raised OperationalError instead. _to_datetimes() takes strict, which the readers set from ignore_errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| pl.scan_csv(location, **read_kwargs), | ||
| timestamp_dtypes, | ||
| # As Polars reads a value it cannot parse as null with ignore_errors. | ||
| strict=not read_kwargs.get("ignore_errors", False), |
There was a problem hiding this comment.
Independent review (relayed): FINDINGS.
Reviewer: Codex CLI 0.162.1, model gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a141e0-50a5-77d3-83a6-59fd6e533f3f. Static review of a detached snapshot at 80424b2eb029ce5a731dd41137b8c6b68155f3c9, diff from merge-base 06224494788043a4887afcb112a96c94bbcb02f1; the prompt held the intended behavior, conventions, and diff only (no PR text, commit messages, or prior findings). The reviewer ran in-memory Polars probes under /tmp with 1.39.0 and 2.0.0; no repository tests, builds, edits, AWS, or GitHub access. The snapshot remained unchanged.
Covered: whole-read parse and fallback; chunked lazy conversion and OperationalError translation; NULLs, empty/header-only results, duplicate names, projections, new_columns; schema, schema_overrides, with_column_names, null_values, ignore_errors; Polars 1.39.0/2.0.0 selectors, units, batch ordering, typing, and test assertions.
Finding (P2): _to_datetimes() stayed strict regardless of ignore_errors. With timestamp metadata and CSV t\n2020-01-02 03:04:05.123\ninvalid\n, a chunked read with ignore_errors=True used to return the datetime then NULL; at 80424b2 it raised InvalidOperationError → OperationalError, aborting iteration.
Verified and repaired in eee0346: _to_datetimes() takes strict, which _scan_csv_chunks() and the whole read's fallback set from ignore_errors. test_iter_csv_chunks_ignore_errors fails at 80424b2 and passes now; test_to_datetimes_not_strict covers the converter. Both self-review perspectives on the repair: strict only affects str.to_datetime (not the cast); the PR body's statement that non-timestamp text raises with ignore_errors was corrected. Validation at eee0346: just lint; Polars 2.0.0 with AWS tests/pyathena/polars tests/pyathena/aio/polars: 216 passed; unit tests on Polars 1.39.0: 81 passed. An independent follow-up review of the repair follows.
There was a problem hiding this comment.
Independent follow-up review (relayed): CLEAN.
Reviewer: Codex CLI 0.162.1, gpt-6-astra, reasoning effort max, codex exec -s read-only, session 01a141ed-b626-7582-bb88-e0bfd275efa1. Static review of a detached snapshot at eee03465f89838bcb6c128009f8065916b7e3ff2, covering 80424b2e..eee03465 with the overall diff from merge-base 06224494788043a4887afcb112a96c94bbcb02f1 available; in-memory Polars 1.39.0 / 2.0.0 probes under /tmp only. The snapshot remained unchanged.
Covered: _to_datetimes() and both callers, kwargs forwarding, whole-read fallback and lazy batching, cast/timezone, NULL and empty strings, fractional truncation, non-boolean ignore_errors values (Polars rejects them before the conversion), and the regression assertions.
Notes from the reviewer, not actionable: the new tests do not exercise the whole read's fallback with ignore_errors (with that option the native parse does not raise, so the fallback is not reached); whole reads with ignore_errors=True return null for excess fractional digits while chunked reads truncate them, which predates this change.
|
On hold for #1216 under the temporal contract in #1212; do not merge as is. Reading chunks as text routes signed years through The lazy conversion in the scan plan and the measurements here (whole reads kept native after +35-40% peak RSS for text reads) carry over to #1216. This PR will be reworked there to enforce the year range. |
|
Superseded by #1234, which rebuilds this change on the current master (after #1226), keeps the same chunked-read design, also reads timestamps of whole reads with |
WHAT
A chunked PolarsCursor read of a CSV result reads the timestamp columns as text and converts them in the scan's query plan, truncating fractions finer than the Datetime time unit, as a whole read already does when parsing them fails.
_scan_csv_chunks()applies_to_datetimes()to thepl.scan_csv()LazyFrame beforecollect_batches(). It previously passed the Datetime dtypes topl.scan_csv(), so a chunked read of a timestamp with more fractional digits than the time unit raisedOperationalError._to_datetimes()takes a LazyFrame as well as a DataFrame. It selects each column withcs.by_name(name, require_all=False)instead of checkingdf.columns, so a column the frame does not have is still skipped without resolving a LazyFrame's schema._get_timestamp_dtypes()becomes_get_csv_schema_overrides(), which also returns theschema_overrideswith the timestamp columns asString; the chunked read and the whole read's fallback use it.execute()is givenschema, as withschema_overridesandwith_column_names: Polars ignoresschema_overrideswhen given aschema, so the columns have that schema's types.ComputeError.WHY
Fixes #1191.
A retry 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 the earlier chunks have been returned.The whole read keeps parsing natively because reading the columns as text holds the text column and the converted one at once. Reading a local 5-million-row CSV file (a timestamp column and a string column, ~180-190 MB,
pl.Datetime(us), each run in a fresh process), text reading made the whole read 2-4.5x faster but raised its peak RSS by about 35-40% (Polars 1.39.0: 545-577 → 756-798 MiB; 2.0.0: 479-525 → 645-700 MiB). Converting in the query plan was chosen for chunks over converting each batch; it was faster than per-batch conversion in the measurements below.Chunked reads with this PR, same data,
chunk_size=100_000(seconds and peak RSS over 3 runs, 3 and 6 fractional digits):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 the growth there covers the whole file. Converting each batch instead measured 0.24–0.39 s / 554–592 MiB on 1.39.0 and 0.25–0.28 s / 478–508 MiB on 2.0.0.The converted values and dtypes equal those of the Datetime parse for Athena's timestamp text (0, 1, 3, and 6 fractional digits, years 0001 to 9999, NULL as an empty field or
""), withDatetime(us),Datetime("ns"),Datetime("us", "UTC"), andDatetime("ms", "Asia/Tokyo"), on Polars 1.39.0 and 2.0.0. Withignore_errors=Truegiven toexecute(), a chunked read now returns a value with more fractional digits than the time unit truncated instead of null; text that is not a timestamp is still read as null, as_to_datetimes()takesstrict, which the readers set fromignore_errors.TEST
Tested commit: eee0346.
just lint: passed.uv run --env-file .env pytest -n 4 tests/pyathena/polars tests/pyathena/aio/polars(Python 3.15, Polars 2.0.0, with AWS, including the managed result work group): 216 passed.pytest tests/pyathena/polars/test_result_set.py tests/pyathena/polars/test_converter.pyon Python 3.13 with Polars 1.39.0: 81 passed (79 with Polars 1.44.2 at 80424b2).test_read_csv_truncates_timestampscovers both readers, includingnew_columns,schema, andwith_column_names; its_iter_csv_chunkscases without options and withnew_columnsfail on 0622449 (master).scan_csv()does not takecolumns, so that case covers the whole read only.test_to_datetimescovers a DataFrame and a LazyFrame, including a column the frame does not have;test_to_datetimes_not_strictcoversstrict=False.test_iter_csv_chunks_ignore_errorsreads a chunk withignore_errors=Trueand text that is not a timestamp; it fails at 80424b2.test_iter_chunks_result_file_valuescompares the chunks ofRESULT_FILE_VALUES_QUERYread withchunksize=1with the whole result, including theTIMESTAMP(12)column.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