Skip to content

Read columns with the same name by their own types from CSV result files - #1066

Merged
laughingman7743 merged 4 commits into
masterfrom
fix/1051-duplicate-column-types
Oct 4, 2026
Merged

laughingman7743 merged 4 commits into
masterfrom
fix/1051-duplicate-column-types

Conversation

@laughingman7743

@laughingman7743 laughingman7743 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

WHAT

Columns with the same name and different types now convert by their own types when PandasCursor and ArrowCursor read the S3 CSV result file, as they do through GetQueryResults.

  • Arrow. When the result has columns with the same name, _read_csv() reads the columns under their positions ("0", "1", ...), keys column_types by those positions, and renames the columns back after reading. The header row is skipped with skip_rows_after_names=1, which skips a parsed row; skip_rows counts physical lines and splits a quoted name that contains a newline. The varbinary NULL handling now matches binary columns by position, so a varchar column named like a varbinary column keeps its "" for NULL. Results with unique names are read as before.
  • pandas. pandas copies the dtype of a column to the columns it renames (x → x.1, x.2) that have no dtype of their own, in both the C and python engines, even when names is given with header=0. When the names repeat and every read_csv option given to execute() is one that does not change how pandas reads the header row (_LABELED_HEADER_READ_CSV_OPTIONS: e.g. usecols, index_col, converters, parse_dates, na_values, nrows), _read_csv_header_as_labels() passes the labels as names with header=None and skips the header row: one parsed row for the C engine, and the physical lines of the header for the python engine, which skips lines.
  • The public column_types and dtypes properties keep their name keys.

WHY

Closes #1051.

SELECT 1 AS x, 'a1' AS x, CAST('12:34:56' AS TIME) AS x failed on the S3 result file path:

  • PandasCursor: OperationalError: Unable to parse string "12:34:56.000". The Int64 dtype of the first x was applied to x.2.
  • ArrowCursor: ValueError: time data '1' does not match format '%H:%M:%S.%f'. The column_types entry of the last x was applied to all three.

Measured on Athena on 2026-10-04: Athena accepts a newline in a column name and writes it in the CSV header ("a\nb","a\nb"), so the header can span several lines.

Not changed:

  • With any other read_csv option given to execute() (e.g. dtype, names, sep, comment, encoding, escapechar), the header row is read as on master, so columns with the same name and different types can still fail there, as before.
  • .txt results and custom quoting or dialects keep the existing keys, as in Keep columns with the same name, and key CSV column types by the reader's column labels #1050.
  • PolarsCursor and the managed (GetQueryResults) path were already correct. Checked on Athena for PolarsCursor with the issue's query and the newline-named columns, with and without chunksize=1; the managed path through the extended test_duplicate_column_names[managed] tests.

TEST

  • just lint: passed on af023a2.
  • uv run --env-file .env pytest -n 4 tests/pyathena/pandas tests/pyathena/arrow tests/pyathena/aio/pandas tests/pyathena/aio/arrow: 473 passed on 6af16a0's code before a comment-only edit in that commit.
  • After the round-one repair in 936a692, which changes only pyathena/arrow/result_set.py: pytest -n 4 tests/pyathena/arrow tests/pyathena/aio/arrow 119 passed.
  • After the independent-review repair in e1567d1 (pandas only): pytest -n 4 tests/pyathena/pandas tests/pyathena/aio/pandas 355 passed, 1 failed: test_result_set_file_system[pandas_cursor2-None], the UNLOAD (Parquet) case, which does not read through the changed CSV code. Its error output was not kept; it passed on a -n 1 rerun (3 passed), so its cause is unexplained.
  • TestPandasCursor::test_duplicate_column_names_header_options[escapechar|detected_delimiter] fail on 936a692 and pass on e1567d1.
  • After the follow-up repair in af023a2 (allow-list, pandas only): pytest -n 4 tests/pyathena/pandas/test_cursor.py tests/pyathena/aio/pandas -k "duplicate_column or binary or header_options or read_options" 59 passed. The full pandas suites were not rerun on af023a2; the change only narrows when the header is replaced.
  • Regression tests that fail on master (checked by reverting the source diff):
    • TestPandasCursor::test_duplicate_column_names[default] (the issue's int/varchar/time columns, C engine).
    • TestPandasCursor::test_duplicate_column_names_with_newline[c|python] (int/time/interval columns named a\nb).
    • TestArrowCursor::test_duplicate_column_names[default], test_duplicate_column_names_with_newline.
    • TestArrowCursor::test_binary_null_vs_empty (a varchar column named like the varbinary column).
  • Ad hoc on Athena: the issue's query with PandasCursor options {}, engine="python", chunksize=1 (both engines), usecols=["x.2", "x"], dtype={"x.1": str}, and index_col="x.1" all read the expected values.
  • Not run: SQLAlchemy suites (no dialect change) and the Polars test suite (Polars code unchanged).

🤖 Generated with Claude Code

laughingman7743 and others added 2 commits October 4, 2026 13:17
pyarrow applies a column_types entry to every column with its name, so the
Arrow cursor reads such columns under their positions and renames them after
reading; the binary columns are also matched by position. pandas copies the
dtype of a column to the columns it renames, such as x.1, so the pandas cursor
skips the header row and reads the columns under their labels when it builds
the dtypes.

Closes #1051

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ames

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
}
else:
column_names = names
column_types = self.column_types

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Self-review round one (behavior and implementation) — base 8c9c201ac53a06c2f54d7f282ec4de23b0fe449f, head 6af16a0dfc8c6810e634d8c4b1390435c101c354. Result: FINDINGS (1, repaired in 936a692).

Finding: for results with unique column names, _read_csv() built its own positional column_types dict instead of reading the public column_types property, so a subclass overriding that property would no longer affect reading, a change outside #1051.
Repair: unique names use self.column_types and the master read options exactly; only repeated names take the positional path. Rerun: pytest -n 4 tests/pyathena/arrow tests/pyathena/aio/arrow → 119 passed.

Covered without findings:

  • Positional column_names/column_types and rename_columns(names); zip(strict=True) lengths always match by construction.
  • skip_rows_after_names=1 skips a parsed row (verified locally on pyarrow 25.0.1 with a quoted newline in the header and small block sizes). A header-only file gives an empty table with the renamed columns.
  • .txt results with repeated names, varbinary NULL handling by position, and async (AsyncArrowCursor shares the result set).

]
self._time_columns = [label for label, d in columns if d[1] == "time"]

def _read_csv_header_as_labels(self, read_csv_kwargs: dict[str, Any], csv_engine: str) -> None:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Self-review round one (behavior and implementation) — base 8c9c201ac53a06c2f54d7f282ec4de23b0fe449f, head 6af16a0d…. pandas side: CLEAN.

Covered:

  • Inheritance is real in both engines (pandas 3.0.6): the C engine copies the dtype even with header=0 and names; the python engine copies it to non-date columns, which is why the newline test adds an interval column. No dtype value stands in for "no dtype" (object changes inference and date dtypes; None differs between engines).
  • Skipping the header: the C engine skips parsed rows, and the python engine skips physical lines. pandas opens text with newline="", so universal-newline counting matches for \n, \r, \r\n, and consecutive newlines (verified locally, also with chunksize).
  • Guards: runs only when labels is not None (standard parsing, not the pyarrow engine, labels matching the columns) and neither dtype nor names was given to execute(). User usecols, index_col, and chunksize work with the replaced header (checked on Athena). engine is popped from kwargs by the cursor, so csv_engine is the engine used.
  • Ordering: called after _configure_binary_csv_read(), whose _is_standard_csv_parsing() check requires header=0. BinaryCSVReader passes the header record through unchanged, and test_binary_dataframe_column_names[duplicate_names-c|python] passes.
  • Tests: the new and extended tests fail on master (checked by reverting the source diff).

]
self._time_columns = [label for label, d in columns if d[1] == "time"]

def _read_csv_header_as_labels(self, read_csv_kwargs: dict[str, Any], csv_engine: str) -> None:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Self-review round two (claims, callers, operations, evidence): base 8c9c201ac53a06c2f54d7f282ec4de23b0fe449f, head 936a6924722f375c75891302b6d87640ae6bf5be. Result: FINDINGS (PR description only, corrected). No code changes.

Claims checked:

  • "pandas copies the dtype ... in both engines, even when names is given with header=0": measured locally on pandas 3.0.6 (C: time after Int64; python: interval after Int64 failed with header=0 + names). The comment and docstring here state the same thing.
  • "skip_rows counts physical lines" (Arrow) and "the python engine skips lines": measured locally with a quoted \n header (pyarrow 25.0.1, pandas 3.0.6).
  • Athena allows newlines in column names and writes them in the header: measured on Athena ("a\nb","a\nb").
  • "Results with unique names are read as before": true after the round-one repair (Arrow uses self.column_types and the master options; pandas returns early).
  • "PolarsCursor ... already correct": was based only on the issue's table. Now measured on Athena with the issue's query and the newline-named int/time/interval columns, with and without chunksize=1.
  • Varbinary positional claim: test_binary_null_vs_empty fails on master and passes here.

Callers and operations: there are no API signature or default changes. Public column_types/dtypes/converters keep their keys. With PyAthena-built dtypes, renamed columns lose only an inherited dtype that was never theirs, and pandas no longer emits a "Both a converter and dtype" ParserWarning for those columns. There are no new S3 or Athena requests; pandas adds one in-memory header parse only when names repeat.

Corrections: the TEST section claimed 6af16a0 for a run that preceded a comment-only edit, and did not cover the repair head. It now lists the run per revision (473 on the pre-comment tree; 119 Arrow sync and async tests on 936a692). "Measured with this branch" became a dated Athena measurement, and the Polars evidence was added.
Deferred: .txt results and custom quoting/dialects keep the existing keys, the same scope as #1050.

…for other delimiters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
return
# pandas renames the names in a header row, and copies their dtypes, even with
# names given, so the header row is skipped instead.
read_csv_kwargs["names"] = self._resolve_csv_column_names(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent review (relayed): Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, -s read-only, session 01a10527-6805-7ce3-a2dd-fe2a929ab285. Base 8c9c201ac53a06c2f54d7f282ec4de23b0fe449f, head 936a6924722f375c75891302b6d87640ae6bf5be, run in a clean detached snapshot that was still unchanged afterwards. The prompt contained the literal diff and the intended behavior, and no PR text or prior findings. This was a static review: no tests, builds, or network.
Coverage: pandas header replacement for the C and python engines, multiline names, chunking, usecols/index_col/other overrides, BinaryCSVReader, storage_options, empty and header-only files; the Arrow positional path, .txt, binary NULL handling, and the unique-name path; tests and conventions. Result: FINDINGS (2). It found no Arrow or unique-name-path defect.

Finding 1 (P2), this line in 936a692: the replacement names came from _get_column_names() with default options, which ignore caller options such as escapechar. With engine="python", escapechar="\\" and SELECT CAST('01:02:03' AS TIME) AS "a\b", 1 AS "a\b", the dtype and date keys use the labels ab/ab.1, but the names were a\b/a\b.1, giving Missing column provided to 'parse_dates': 'ab'.
Verified: reproduced offline with pandas 3.0.6. Repaired in e1567d1: the names are resolved with _resolve_csv_column_names(names, read_csv_kwargs, pd.read_csv), the same resolution _get_csv_column_labels() uses for the keys.

Comment thread pyathena/pandas/result_set.py Outdated
self._kwargs.keys() & {"dtype", "names"}
or len(set(names)) == len(names)
# pandas detects another delimiter, as with sep=None, from the header row.
or (read_csv_kwargs.get("delimiter") or read_csv_kwargs.get("sep")) != ","

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Finding 2 (P2, relayed from the same Codex review): skipping the header removes pandas' delimiter-detection sample. With engine="python", sep=None and the header-only result of SELECT 1 AS x, 2 AS x WHERE false, the read raised Could not determine delimiter; master returns an empty DataFrame.
Verified: reproduced offline. Repaired in e1567d1: the header is replaced only when the delimiter is ,, so with sep=None or another delimiter pandas reads the header row as before.

Repair self-review (both perspectives), 936a6924..e1567d15, pandas only:

  • Round one: the names now match the keys by construction (same resolver and options). usecols still gives the full list ([0]). The resolver sees names=None and the engine actually used. Both new test cases (test_duplicate_column_names_header_options[escapechar|detected_delimiter]) fail on 936a692 and pass on e1567d1.
  • Round two: the PR description now states the comma-delimiter condition and the option-aware labels, and lists the runs per revision. pytest -n 4 tests/pyathena/pandas tests/pyathena/aio/pandas: 355 passed, 1 failed (test_result_set_file_system[pandas_cursor2-None], an UNLOAD/Parquet case outside the changed CSV path). That failure's output was not kept; a -n 1 rerun passed, and its cause is unexplained.

…as reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_PYARROW_READ_CSV_OPTIONS: ClassVar[frozenset[str]] = frozenset({"dtype", "parse_dates"})
# The pandas.read_csv() options given to execute() that do not change how pandas reads
# the header row, with which _read_csv_header_as_labels() replaces the header.
_LABELED_HEADER_READ_CSV_OPTIONS: ClassVar[frozenset[str]] = frozenset(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent follow-up review (relayed): Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, -s read-only.

Round 1, session 01a10539-de2c-7ee3-8038-4e4c63e5e6b8, reviewed 936a6924..e1567d15 on a clean snapshot at e1567d1. Result: FINDINGS (3):

  1. With sep=None and a populated result, the header was kept, so the dtype copy still failed. This is the same behavior as master.
  2. comment changed the header labels pandas reads (e.g. index_col="z" for "z#suffix"), but the replaced names did not.
  3. encoding changed the decoded header labels, but the replaced names did not.

Findings 2 and 3 were regressions from master for those options; I verified both offline. Repaired in af023a2: the header is replaced only when every option given to execute() is in the allow-list _LABELED_HEADER_READ_CSV_OPTIONS, which lists options that do not change how pandas reads the header row. Any other option (dtype, names, sep, comment, encoding, escapechar, ...) keeps master's header reading, including master's limitation for same-named columns with different types. This narrows the change rather than extending it.

Repair self-review (both perspectives):

  • Offline: comment + index_col, usecols + index_col, user converters, and no options all read as expected.
  • On Athena: pytest -n 4 tests/pyathena/pandas/test_cursor.py tests/pyathena/aio/pandas -k "duplicate_column or binary or header_options or read_options" gave 59 passed.
  • The PR description now states the allow-list condition and the master fallback.

Round 2, session 01a10547-5690-77e0-8675-ca22c9c3b059, reviewed e1567d15..af023a29 on a clean snapshot at af023a2. Covered: all 20 allow-listed options against names + header=None + skiprows for the C and python engines, empty and multiline-header results, guard order, and the fallback for excluded options. Result: CLEAN. This was a static review against pandas 3.0.6 Python sources; the compiled C tokenizer source was unavailable. No tests were run.

@laughingman7743
laughingman7743 marked this pull request as ready for review October 4, 2026 05:05
@laughingman7743
laughingman7743 merged commit a89a4f7 into master Oct 4, 2026
12 checks passed
@laughingman7743
laughingman7743 deleted the fix/1051-duplicate-column-types branch October 4, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PandasCursor and ArrowCursor fail on columns with the same name and different types in the S3 result file

1 participant