shackleton 0.0.2__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ name: Python Test
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ push:
8
+ branches:
9
+ - main
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: '3.12'
19
+ enable-cache: true
20
+ - run: uv tool install branthebuilder
21
+ - run: branb test
@@ -0,0 +1,15 @@
1
+ name: Python Package Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ release:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v5
13
+ - run: uv build && uv publish
14
+ env:
15
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
@@ -127,4 +127,5 @@ dmypy.json
127
127
  .idea
128
128
  .vscode
129
129
  dask-worker-space/
130
- draft*.ipynb
130
+ draft*.ipynb
131
+ .claude/
@@ -0,0 +1,37 @@
1
+ # Performance
2
+
3
+ ## Running Benchmarks
4
+
5
+ ```bash
6
+ uv run bench.py
7
+ uv run bench.py --rows 5000000
8
+ ```
9
+
10
+ Default: 1M rows, 5 columns (int, float, str, date, bool).
11
+
12
+ ## Metrics
13
+
14
+ All measured in rows/second.
15
+
16
+ | Operation | Baseline (1M rows) |
17
+ |-----------|-------------------|
18
+ | Single-file extend | ~33M rows/s |
19
+ | Extend with sorted merge (id_col) | ~49M rows/s |
20
+ | Partitioned write (26 partitions) | ~11M rows/s |
21
+ | Hash-partitioned write (64 buckets) | ~7M rows/s |
22
+ | Full read (collect) | ~131M rows/s |
23
+ | Lazy read + filter | ~170M rows/s |
24
+ | Single partition read | ~18M rows/s |
25
+ | Replace records (10k upserts) | ~107k rows/s |
26
+
27
+ ## Known Bottlenecks
28
+
29
+ - `replace_records` requires a full merge — O(n) in total rows, not O(k) in replacement size
30
+ - Hash-partitioned writes scale sub-linearly with bucket count due to per-partition file overhead
31
+ - Partition reads are limited by parquet file open latency when partitions are small
32
+
33
+ ## What to Watch
34
+
35
+ - Polars version upgrades can significantly affect parquet I/O throughput
36
+ - Row group size interacts with partition count — too many small files hurts read performance
37
+ - The GIL-releasing path through polars I/O makes thread-pool scaling important for parallel reads
@@ -0,0 +1,302 @@
1
+ Metadata-Version: 2.4
2
+ Name: shackleton
3
+ Version: 1.1.0
4
+ Summary: Polars-based persistent tabular store backed by Parquet or Arrow IPC files
5
+ Project-URL: Homepage, https://github.com/endremborza/shackleton
6
+ Author-email: Endre Márk Borza <endremborza@gmail.com>
7
+ License: Copyright 2023 Endre Márk Borza
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14
+ License-File: LICENSE
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: polars
17
+ Description-Content-Type: text/markdown
18
+
19
+ # shackleton
20
+
21
+ [![pypi](https://img.shields.io/pypi/v/shackleton.svg)](https://pypi.org/project/shackleton/)
22
+
23
+ Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
24
+
25
+ - **Partitioned writes** — by column value or xxhash bucket
26
+ - **Sorted merge** — maintain sorted order on extend via `id_col`
27
+ - **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
28
+ - **Schema evolution** — diagonal concat handles mismatched columns transparently
29
+ - **Thread-safe** — per-directory stdlib locks; polars I/O releases the GIL
30
+
31
+ Single dependency: `polars`.
32
+
33
+ ---
34
+
35
+ ## Install
36
+
37
+ ```
38
+ pip install shackleton
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from pathlib import Path
47
+ import polars as pl
48
+ from shackleton import TableRepo
49
+
50
+ repo = TableRepo(Path("/tmp/mydata"))
51
+
52
+ # Write
53
+ repo.extend(pl.DataFrame({"id": [1, 2, 3], "value": [10.0, 20.0, 30.0]}))
54
+ repo.extend(pl.DataFrame({"id": [4, 5], "value": [40.0, 50.0]}))
55
+
56
+ # Read
57
+ df = repo.get_full_df() # pl.DataFrame — all rows
58
+ lf = repo.get_full_lf() # pl.LazyFrame — push down filters yourself
59
+
60
+ # Overwrite everything
61
+ repo.replace_all(pl.DataFrame({"id": [99], "value": [0.0]}))
62
+
63
+ # Delete all files
64
+ repo.purge()
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Column partitioning
70
+
71
+ Data is split into hive-style directories (`col=val/data.parquet`).
72
+
73
+ ```python
74
+ repo = TableRepo(Path("/tmp/events"), partition_cols=["region", "env"])
75
+
76
+ repo.extend(pl.DataFrame({
77
+ "region": ["us", "us", "eu"],
78
+ "env": ["prod", "dev", "prod"],
79
+ "count": [100, 50, 80],
80
+ }))
81
+
82
+ # Read only us/prod — no full scan
83
+ df = repo.get_partition_df({"region": "us", "env": "prod"})
84
+
85
+ # Wildcard: all eu partitions
86
+ df_eu = repo.get_partition_df({"region": "eu"})
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Hash partitioning
92
+
93
+ When partition values have unbounded cardinality, hash into fixed buckets instead.
94
+ Uses polars' native xxhash — vectorised, GIL-free.
95
+
96
+ ```python
97
+ from shackleton import HashPartitioner
98
+
99
+ hp = HashPartitioner(col="user_id", num_groups=128)
100
+ repo = TableRepo(Path("/tmp/users"), partition_cols=hp)
101
+
102
+ repo.extend(pl.DataFrame({
103
+ "user_id": ["alice", "bob", "carol", "dave"],
104
+ "score": [0.9, 0.7, 0.8, 0.6],
105
+ }))
106
+
107
+ df = repo.get_full_df()
108
+ # The hash key column is stripped — only original columns are stored
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Sorted merge with `id_col`
114
+
115
+ When `id_col` is set, each extend keeps the file sorted by that column and
116
+ `replace_records` does an upsert: matching IDs are updated, new IDs appended.
117
+
118
+ ```python
119
+ repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
120
+
121
+ repo.extend(pl.DataFrame({
122
+ "ticker": ["AAPL", "GOOG", "MSFT"],
123
+ "price": [180.0, 140.0, 380.0],
124
+ }))
125
+
126
+ # Update GOOG, add TSLA
127
+ repo.replace_records(pl.DataFrame({
128
+ "ticker": ["GOOG", "TSLA"],
129
+ "price": [145.0, 220.0],
130
+ }))
131
+
132
+ repo.get_full_df().sort("ticker")
133
+ # ┌────────┬───────┐
134
+ # │ ticker ┆ price │
135
+ # ╞════════╪═══════╡
136
+ # │ AAPL ┆ 180.0 │
137
+ # │ GOOG ┆ 145.0 │
138
+ # │ MSFT ┆ 380.0 │
139
+ # │ TSLA ┆ 220.0 │
140
+ # └────────┴───────┘
141
+ ```
142
+
143
+ ---
144
+
145
+ ## File size limits with `max_records`
146
+
147
+ Cap each file at N rows. Useful when downstream tools (Spark, DuckDB) benefit
148
+ from many smaller files, or when partial reads matter.
149
+
150
+ ```python
151
+ repo = TableRepo(Path("/tmp/logs"), max_records=100_000)
152
+
153
+ for batch in my_log_stream():
154
+ repo.extend(batch)
155
+ # Creates file-00000000000000000000.parquet, file-00000000000000000001.parquet, …
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Streaming ingestion with batch writers
161
+
162
+ For row-at-a-time or small-DataFrame pipelines: accumulate in memory, flush in
163
+ batches.
164
+
165
+ ```python
166
+ # Dict record writer — for APIs / scrapers
167
+ with repo.get_extending_record_writer(batch_size=50_000) as writer:
168
+ for event in event_stream():
169
+ writer.add_to_batch({"ts": event.ts, "value": event.value})
170
+ # Final partial batch is flushed on context exit
171
+
172
+ # DataFrame writer — for chunked ETL
173
+ with repo.get_extending_df_writer(batch_size=500_000) as writer:
174
+ for chunk_df in chunked_source():
175
+ writer.add_to_batch(chunk_df)
176
+
177
+ # Replacing variants — upsert on each flush
178
+ with repo.get_replacing_record_writer(batch_size=10_000) as writer:
179
+ for record in updates():
180
+ writer.add_to_batch(record)
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Schema evolution
186
+
187
+ Columns added in later writes fill missing values with `null` in earlier files.
188
+ No migration step needed.
189
+
190
+ ```python
191
+ repo = TableRepo(Path("/tmp/evolving"))
192
+ repo.extend(pl.DataFrame({"a": [1, 2], "b": [3, 4]}))
193
+ repo.extend(pl.DataFrame({"a": [5], "c": [6]})) # new column c
194
+
195
+ df = repo.get_full_df()
196
+ # shape: (3, 3) — columns a, b, c — nulls where data didn't exist
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Arrow IPC format
202
+
203
+ Slightly faster reads for local workloads that don't need Parquet compatibility.
204
+
205
+ ```python
206
+ repo = TableRepo(Path("/tmp/fast"), ipc=True) # writes .arrow files
207
+ repo.extend(df)
208
+ df = repo.get_full_df()
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Lazy reads and predicate pushdown
214
+
215
+ `get_full_lf()` and `get_partition_lf()` return `pl.LazyFrame`. Polars will
216
+ push predicates and column selections into the scan.
217
+
218
+ ```python
219
+ result = (
220
+ repo.get_full_lf()
221
+ .filter(pl.col("region") == "us")
222
+ .select(["ticker", "price"])
223
+ .sort("price", descending=True)
224
+ .limit(10)
225
+ .collect()
226
+ )
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Parallel partition processing with `map_partitions`
232
+
233
+ Apply a function to each partition's collected DataFrame in parallel. All files
234
+ within a partition are combined before your function is called, so it always
235
+ receives one complete DataFrame per partition.
236
+
237
+ ```python
238
+ repo = TableRepo(Path("/tmp/events"), partition_cols=["region"])
239
+ repo.extend(pl.DataFrame({
240
+ "region": ["us", "us", "eu", "eu", "ap"],
241
+ "revenue": [100.0, 200.0, 150.0, 50.0, 300.0],
242
+ }))
243
+
244
+ # Sequential (workers=None, default)
245
+ totals = repo.map_partitions(lambda df: df["revenue"].sum())
246
+ # e.g. [300.0, 200.0, 300.0] — one value per partition
247
+
248
+ # Parallel with auto-detected thread count
249
+ totals = repo.map_partitions(lambda df: df["revenue"].sum(), workers=0)
250
+
251
+ # Parallel with fixed thread pool
252
+ totals = repo.map_partitions(lambda df: df["revenue"].sum(), workers=4)
253
+ ```
254
+
255
+ The `workers` parameter controls parallelism:
256
+
257
+ | `workers` | Behaviour |
258
+ |---|---|
259
+ | `None` | Sequential — no thread pool |
260
+ | `0` | Thread pool sized to `os.cpu_count()` |
261
+ | `> 0` | Thread pool of exactly that size |
262
+
263
+ Because polars I/O releases the GIL, threads overlap on I/O with no contention.
264
+
265
+ The function can return any type; results preserve partition order.
266
+
267
+ ```python
268
+ # Compute per-partition summary stats
269
+ def summarise(df: pl.DataFrame) -> dict:
270
+ return {"n": len(df), "mean": df["revenue"].mean()}
271
+
272
+ stats = repo.map_partitions(summarise, workers=0)
273
+ # [{"n": 2, "mean": 150.0}, {"n": 2, "mean": 100.0}, {"n": 1, "mean": 300.0}]
274
+
275
+ # Collect into a single DataFrame
276
+ import polars as pl
277
+ result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
278
+ ```
279
+
280
+ ---
281
+
282
+ ## Performance
283
+
284
+ Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
285
+ Run `uv run bench.py` to reproduce.
286
+
287
+ | Scenario | Throughput |
288
+ |---|---|
289
+ | extend — single file | ~33M rows/s |
290
+ | extend — sorted merge (`id_col`) | ~49M rows/s |
291
+ | extend — 26 column partitions | ~11M rows/s |
292
+ | extend — 64 hash partitions | ~7M rows/s |
293
+ | extend — `max_records=10k` | ~7M rows/s |
294
+ | extend — IPC format | ~23M rows/s |
295
+ | read full (collect) | ~131M rows/s |
296
+ | read full (lazy + filter) | ~170M rows/s |
297
+ | read single partition (1 of 26) | ~18M rows/s |
298
+ | read full — IPC format | ~102M rows/s |
299
+ | replace_records (10k upserts) | ~107k rows/s |
300
+
301
+ `replace_records` does a full read-merge-rewrite, so throughput scales with
302
+ existing file size, not batch size. Use partitioned repos to bound the merge scope.
@@ -0,0 +1,284 @@
1
+ # shackleton
2
+
3
+ [![pypi](https://img.shields.io/pypi/v/shackleton.svg)](https://pypi.org/project/shackleton/)
4
+
5
+ Persistent tabular store backed by Parquet or Arrow IPC, built on [polars](https://pola.rs).
6
+
7
+ - **Partitioned writes** — by column value or xxhash bucket
8
+ - **Sorted merge** — maintain sorted order on extend via `id_col`
9
+ - **Lazy reads** — returns `pl.LazyFrame` so polars can push down predicates
10
+ - **Schema evolution** — diagonal concat handles mismatched columns transparently
11
+ - **Thread-safe** — per-directory stdlib locks; polars I/O releases the GIL
12
+
13
+ Single dependency: `polars`.
14
+
15
+ ---
16
+
17
+ ## Install
18
+
19
+ ```
20
+ pip install shackleton
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from pathlib import Path
29
+ import polars as pl
30
+ from shackleton import TableRepo
31
+
32
+ repo = TableRepo(Path("/tmp/mydata"))
33
+
34
+ # Write
35
+ repo.extend(pl.DataFrame({"id": [1, 2, 3], "value": [10.0, 20.0, 30.0]}))
36
+ repo.extend(pl.DataFrame({"id": [4, 5], "value": [40.0, 50.0]}))
37
+
38
+ # Read
39
+ df = repo.get_full_df() # pl.DataFrame — all rows
40
+ lf = repo.get_full_lf() # pl.LazyFrame — push down filters yourself
41
+
42
+ # Overwrite everything
43
+ repo.replace_all(pl.DataFrame({"id": [99], "value": [0.0]}))
44
+
45
+ # Delete all files
46
+ repo.purge()
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Column partitioning
52
+
53
+ Data is split into hive-style directories (`col=val/data.parquet`).
54
+
55
+ ```python
56
+ repo = TableRepo(Path("/tmp/events"), partition_cols=["region", "env"])
57
+
58
+ repo.extend(pl.DataFrame({
59
+ "region": ["us", "us", "eu"],
60
+ "env": ["prod", "dev", "prod"],
61
+ "count": [100, 50, 80],
62
+ }))
63
+
64
+ # Read only us/prod — no full scan
65
+ df = repo.get_partition_df({"region": "us", "env": "prod"})
66
+
67
+ # Wildcard: all eu partitions
68
+ df_eu = repo.get_partition_df({"region": "eu"})
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Hash partitioning
74
+
75
+ When partition values have unbounded cardinality, hash into fixed buckets instead.
76
+ Uses polars' native xxhash — vectorised, GIL-free.
77
+
78
+ ```python
79
+ from shackleton import HashPartitioner
80
+
81
+ hp = HashPartitioner(col="user_id", num_groups=128)
82
+ repo = TableRepo(Path("/tmp/users"), partition_cols=hp)
83
+
84
+ repo.extend(pl.DataFrame({
85
+ "user_id": ["alice", "bob", "carol", "dave"],
86
+ "score": [0.9, 0.7, 0.8, 0.6],
87
+ }))
88
+
89
+ df = repo.get_full_df()
90
+ # The hash key column is stripped — only original columns are stored
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Sorted merge with `id_col`
96
+
97
+ When `id_col` is set, each extend keeps the file sorted by that column and
98
+ `replace_records` does an upsert: matching IDs are updated, new IDs appended.
99
+
100
+ ```python
101
+ repo = TableRepo(Path("/tmp/prices"), id_col="ticker")
102
+
103
+ repo.extend(pl.DataFrame({
104
+ "ticker": ["AAPL", "GOOG", "MSFT"],
105
+ "price": [180.0, 140.0, 380.0],
106
+ }))
107
+
108
+ # Update GOOG, add TSLA
109
+ repo.replace_records(pl.DataFrame({
110
+ "ticker": ["GOOG", "TSLA"],
111
+ "price": [145.0, 220.0],
112
+ }))
113
+
114
+ repo.get_full_df().sort("ticker")
115
+ # ┌────────┬───────┐
116
+ # │ ticker ┆ price │
117
+ # ╞════════╪═══════╡
118
+ # │ AAPL ┆ 180.0 │
119
+ # │ GOOG ┆ 145.0 │
120
+ # │ MSFT ┆ 380.0 │
121
+ # │ TSLA ┆ 220.0 │
122
+ # └────────┴───────┘
123
+ ```
124
+
125
+ ---
126
+
127
+ ## File size limits with `max_records`
128
+
129
+ Cap each file at N rows. Useful when downstream tools (Spark, DuckDB) benefit
130
+ from many smaller files, or when partial reads matter.
131
+
132
+ ```python
133
+ repo = TableRepo(Path("/tmp/logs"), max_records=100_000)
134
+
135
+ for batch in my_log_stream():
136
+ repo.extend(batch)
137
+ # Creates file-00000000000000000000.parquet, file-00000000000000000001.parquet, …
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Streaming ingestion with batch writers
143
+
144
+ For row-at-a-time or small-DataFrame pipelines: accumulate in memory, flush in
145
+ batches.
146
+
147
+ ```python
148
+ # Dict record writer — for APIs / scrapers
149
+ with repo.get_extending_record_writer(batch_size=50_000) as writer:
150
+ for event in event_stream():
151
+ writer.add_to_batch({"ts": event.ts, "value": event.value})
152
+ # Final partial batch is flushed on context exit
153
+
154
+ # DataFrame writer — for chunked ETL
155
+ with repo.get_extending_df_writer(batch_size=500_000) as writer:
156
+ for chunk_df in chunked_source():
157
+ writer.add_to_batch(chunk_df)
158
+
159
+ # Replacing variants — upsert on each flush
160
+ with repo.get_replacing_record_writer(batch_size=10_000) as writer:
161
+ for record in updates():
162
+ writer.add_to_batch(record)
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Schema evolution
168
+
169
+ Columns added in later writes fill missing values with `null` in earlier files.
170
+ No migration step needed.
171
+
172
+ ```python
173
+ repo = TableRepo(Path("/tmp/evolving"))
174
+ repo.extend(pl.DataFrame({"a": [1, 2], "b": [3, 4]}))
175
+ repo.extend(pl.DataFrame({"a": [5], "c": [6]})) # new column c
176
+
177
+ df = repo.get_full_df()
178
+ # shape: (3, 3) — columns a, b, c — nulls where data didn't exist
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Arrow IPC format
184
+
185
+ Slightly faster reads for local workloads that don't need Parquet compatibility.
186
+
187
+ ```python
188
+ repo = TableRepo(Path("/tmp/fast"), ipc=True) # writes .arrow files
189
+ repo.extend(df)
190
+ df = repo.get_full_df()
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Lazy reads and predicate pushdown
196
+
197
+ `get_full_lf()` and `get_partition_lf()` return `pl.LazyFrame`. Polars will
198
+ push predicates and column selections into the scan.
199
+
200
+ ```python
201
+ result = (
202
+ repo.get_full_lf()
203
+ .filter(pl.col("region") == "us")
204
+ .select(["ticker", "price"])
205
+ .sort("price", descending=True)
206
+ .limit(10)
207
+ .collect()
208
+ )
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Parallel partition processing with `map_partitions`
214
+
215
+ Apply a function to each partition's collected DataFrame in parallel. All files
216
+ within a partition are combined before your function is called, so it always
217
+ receives one complete DataFrame per partition.
218
+
219
+ ```python
220
+ repo = TableRepo(Path("/tmp/events"), partition_cols=["region"])
221
+ repo.extend(pl.DataFrame({
222
+ "region": ["us", "us", "eu", "eu", "ap"],
223
+ "revenue": [100.0, 200.0, 150.0, 50.0, 300.0],
224
+ }))
225
+
226
+ # Sequential (workers=None, default)
227
+ totals = repo.map_partitions(lambda df: df["revenue"].sum())
228
+ # e.g. [300.0, 200.0, 300.0] — one value per partition
229
+
230
+ # Parallel with auto-detected thread count
231
+ totals = repo.map_partitions(lambda df: df["revenue"].sum(), workers=0)
232
+
233
+ # Parallel with fixed thread pool
234
+ totals = repo.map_partitions(lambda df: df["revenue"].sum(), workers=4)
235
+ ```
236
+
237
+ The `workers` parameter controls parallelism:
238
+
239
+ | `workers` | Behaviour |
240
+ |---|---|
241
+ | `None` | Sequential — no thread pool |
242
+ | `0` | Thread pool sized to `os.cpu_count()` |
243
+ | `> 0` | Thread pool of exactly that size |
244
+
245
+ Because polars I/O releases the GIL, threads overlap on I/O with no contention.
246
+
247
+ The function can return any type; results preserve partition order.
248
+
249
+ ```python
250
+ # Compute per-partition summary stats
251
+ def summarise(df: pl.DataFrame) -> dict:
252
+ return {"n": len(df), "mean": df["revenue"].mean()}
253
+
254
+ stats = repo.map_partitions(summarise, workers=0)
255
+ # [{"n": 2, "mean": 150.0}, {"n": 2, "mean": 100.0}, {"n": 1, "mean": 300.0}]
256
+
257
+ # Collect into a single DataFrame
258
+ import polars as pl
259
+ result = pl.DataFrame(repo.map_partitions(summarise, workers=0))
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Performance
265
+
266
+ Benchmarked on a single core, NVMe SSD, 1 million rows (4 columns: i64, f64, str, str).
267
+ Run `uv run bench.py` to reproduce.
268
+
269
+ | Scenario | Throughput |
270
+ |---|---|
271
+ | extend — single file | ~33M rows/s |
272
+ | extend — sorted merge (`id_col`) | ~49M rows/s |
273
+ | extend — 26 column partitions | ~11M rows/s |
274
+ | extend — 64 hash partitions | ~7M rows/s |
275
+ | extend — `max_records=10k` | ~7M rows/s |
276
+ | extend — IPC format | ~23M rows/s |
277
+ | read full (collect) | ~131M rows/s |
278
+ | read full (lazy + filter) | ~170M rows/s |
279
+ | read single partition (1 of 26) | ~18M rows/s |
280
+ | read full — IPC format | ~102M rows/s |
281
+ | replace_records (10k upserts) | ~107k rows/s |
282
+
283
+ `replace_records` does a full read-merge-rewrite, so throughput scales with
284
+ existing file size, not batch size. Use partitioned repos to bound the merge scope.