Skip to content

Commit ac6a3a1

Browse files
Merge pull request #828 from pyathena-dev/fix/820-821-polars-floor
Require polars>=1.39.0 for chunked PolarsCursor reads
2 parents 9dd63eb + cc7135d commit ac6a3a1

8 files changed

Lines changed: 103 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Extra packages:
4848
| AioSQLAlchemy | `pip install PyAthena[AioSQLAlchemy]` | >=2.0.0 |
4949
| Pandas | `pip install PyAthena[Pandas]` | >=1.3.0 |
5050
| Arrow | `pip install PyAthena[Arrow]` | >=10.0.0 |
51-
| Polars | `pip install PyAthena[Polars]` | >=1.0.0 |
51+
| Polars | `pip install PyAthena[Polars]` | >=1.39.0 |
5252

5353
## Usage
5454

‎benchmarks/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,8 @@ API row conversion and native DataFrame/Table access appear as separate cases.
227227
Sampled RSS can miss short peaks, and the constructor suite's RSS includes its subsequent validation read.
228228
If the operating system denies access to per-thread CPU times, `thread_cpu_available` is false; thread counts and RSS are still recorded.
229229
Each trial process receives its own `POLARS_TEMP_DIR`, which the parent samples with RSS and removes after the trial.
230-
Polars lazy CSV scans of S3 objects download the whole object into a file cache in this directory before yielding batches; in the recorded runs, those files remained after the process exited.
231-
When the temporary directory is a tmpfs, as `/tmp` is on Amazon Linux 2023, this storage uses memory that RSS does not include.
230+
Polars before 1.39.0 downloaded the whole S3 object of a lazy CSV scan into a file cache in this directory before yielding batches and left the file after the process exited; PyAthena requires Polars 1.39.0 or later, whose scans do not write that cache.
231+
When the temporary directory is a tmpfs, as `/tmp` is on Amazon Linux 2023, files written there use memory that RSS does not include.
232232

233233
| Capability | Treatment |
234234
| --- | --- |

‎benchmarks/pyathena_bench/runner.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,10 @@ def directory_bytes(path: Path) -> int:
175175
def start_with_temp_dir(process: Any, temp_dir: Path) -> None:
176176
"""Start a spawned trial process with its own Polars temporary directory.
177177
178-
Polars downloads cloud objects for lazy scans into a file cache under
179-
``POLARS_TEMP_DIR`` and keeps the files after the process exits. A
180-
per-trial directory lets the parent measure and remove that storage.
178+
Polars before 1.39.0 downloads cloud objects for lazy CSV scans into a
179+
file cache under ``POLARS_TEMP_DIR`` and keeps the files after the process
180+
exits. A per-trial directory lets the parent measure and remove any
181+
storage Polars writes there.
181182
182183
Args:
183184
process: Unstarted multiprocessing process; it inherits the environment.

‎docs/introduction.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Extra packages:
3535
| AioSQLAlchemy | `pip install PyAthena[AioSQLAlchemy]` | >=2.0.0 |
3636
| Pandas | `pip install PyAthena[Pandas]` | >=1.3.0 |
3737
| Arrow | `pip install PyAthena[Arrow]` | >=10.0.0 |
38-
| Polars | `pip install PyAthena[Polars]` | >=1.0.0 |
38+
| Polars | `pip install PyAthena[Polars]` | >=1.39.0 |
3939

4040
(features)=
4141

‎docs/polars.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ This cursor downloads the CSV file after executing the query and loads it into a
1111
Performance is better than fetching data with Cursor.
1212

1313
PolarsCursor uses [Polars](https://pola.rs/) native reading capabilities (`pl.read_csv`, `pl.read_parquet`) and
14-
does not require PyArrow as a dependency. PyAthena's own S3FileSystem (fsspec compatible)
15-
is used for S3 access, so s3fs is also not required.
14+
does not require PyArrow as a dependency. CSV results read without the chunksize option use
15+
PyAthena's own S3FileSystem (fsspec compatible), and other reads use Polars' native S3 access,
16+
so s3fs is also not required.
1617

1718
You can use the PolarsCursor by specifying the `cursor_class`
1819
with the connect method or connection object.
@@ -310,8 +311,11 @@ for chunk in cursor.iter_chunks():
310311
print(f"Processed chunk with {chunk.height} rows")
311312
```
312313

313-
This method uses Polars' `scan_csv()` and `scan_parquet()` with `collect_batches()`
314-
for efficient lazy evaluation, minimizing memory usage when processing large datasets.
314+
This method uses Polars' `scan_csv()` and `scan_parquet()` with `collect_batches()`,
315+
which read the result from S3 without writing it to local storage.
316+
Memory usage depends on the size of each chunk and on how far Polars reads ahead, which `chunksize` does not limit.
317+
For CSV results, Polars limits the read-ahead by the number of CPU cores, so memory usage does not grow with the result size.
318+
If reading a chunk fails, iteration raises `OperationalError` instead of ending early.
315319

316320
The chunked iteration also works with the unload option:
317321

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ arrow = [
6565
"pyarrow>=22.0.0; python_version>='3.14'",
6666
]
6767
polars = [
68-
"polars>=1.0.0",
68+
"polars>=1.39.0",
6969
]
7070

7171
[dependency-groups]
@@ -77,7 +77,7 @@ dev = [
7777
"numpy>=2.3.0; python_version>='3.14'",
7878
"pyarrow>=10.0.0; python_version<'3.14'",
7979
"pyarrow>=22.0.0; python_version>='3.14'",
80-
"polars>=1.0.0",
80+
"polars>=1.39.0",
8181
"Jinja2>=3.1.0",
8282
"mypy>=0.900",
8383
"pytest>=3.5",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright 2026 The PyAthena authors
2+
#
3+
# Licensed under the MIT License.
4+
# See LICENSE or https://opensource.org/licenses/MIT.
5+
#
6+
# SPDX-License-Identifier: MIT
7+
8+
from unittest.mock import PropertyMock, patch
9+
10+
import polars as pl
11+
import pytest
12+
13+
from pyathena.error import OperationalError
14+
from pyathena.polars.result_set import AthenaPolarsResultSet
15+
16+
_ROWS_BEFORE_FAILURE = 300_000
17+
18+
19+
def _chunked_result_set() -> AthenaPolarsResultSet:
20+
"""Create a chunked result set without calling Athena.
21+
22+
Returns:
23+
An AthenaPolarsResultSet with only the attributes used by the chunk readers.
24+
"""
25+
result_set = AthenaPolarsResultSet.__new__(AthenaPolarsResultSet) # bypass __init__
26+
result_set._chunksize = 10_000
27+
result_set._kwargs = {}
28+
return result_set
29+
30+
31+
class TestAthenaPolarsResultSet:
32+
def test_iter_csv_chunks_raises_when_read_fails_partway(self, tmp_path):
33+
"""A CSV read that fails partway through the data raises instead of ending early."""
34+
path = tmp_path / "result.csv"
35+
path.write_text(
36+
"a\n" + "".join(f"{i}\n" for i in range(_ROWS_BEFORE_FAILURE)) + "not-a-number\n"
37+
)
38+
result_set = _chunked_result_set()
39+
with (
40+
patch.object(
41+
AthenaPolarsResultSet,
42+
"output_location",
43+
new_callable=PropertyMock,
44+
return_value=str(path),
45+
),
46+
patch.object(
47+
AthenaPolarsResultSet,
48+
"dtypes",
49+
new_callable=PropertyMock,
50+
return_value={"a": pl.Int64},
51+
),
52+
patch.object(
53+
AthenaPolarsResultSet,
54+
"_parquet_storage_options",
55+
new_callable=PropertyMock,
56+
return_value={},
57+
),
58+
patch.object(AthenaPolarsResultSet, "_is_csv_readable", return_value=True),
59+
pytest.raises(OperationalError, match="not-a-number"),
60+
):
61+
list(result_set._iter_csv_chunks())
62+
63+
def test_iter_parquet_chunks_raises_when_read_fails_partway(self, tmp_path):
64+
"""A Parquet read that fails partway through the data raises instead of ending early."""
65+
pl.DataFrame({"a": range(_ROWS_BEFORE_FAILURE)}).write_parquet(
66+
tmp_path / "0.parquet", row_group_size=10_000
67+
)
68+
valid = (tmp_path / "0.parquet").read_bytes()
69+
# Keep the footer magic but corrupt the metadata so the second file fails to read.
70+
(tmp_path / "1.parquet").write_bytes(valid[: len(valid) // 2] + b"\x00" * 64 + valid[-8:])
71+
result_set = _chunked_result_set()
72+
result_set._unload_location = f"{tmp_path}/"
73+
with (
74+
patch.object(
75+
AthenaPolarsResultSet,
76+
"_parquet_storage_options",
77+
new_callable=PropertyMock,
78+
return_value={},
79+
),
80+
patch.object(AthenaPolarsResultSet, "_prepare_parquet_location", return_value=True),
81+
pytest.raises(OperationalError),
82+
):
83+
list(result_set._iter_parquet_chunks())

‎uv.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)