Selective reads on wide tables
We compared Delta Arrow Reader (DAR), delta-rs, DuckDB, Polars, and single-machine Spark on queries that return about 900 rows from Delta tables containing about 60 million rows. The 416-column tables return 69 selected columns; the 90-column tables return 71.
Matching rows are either grouped together or spread out within row groups. Each layout has a snapshot with and without deletion vectors (DVs), which mark deleted rows without rewriting data files. These combinations give eight cases. The inputs use public TPC-H lineitem data at scale factor 10, extended with synthetic numeric columns. These are custom scans, not standard TPC-H queries or a TPC-H score.
Query an initialized table
The README chart shows the first complete query after table initialization. All five readers use this boundary: query planning, reading, and consuming every result are timed. Process startup, Python imports, Spark session/JVM startup, and table initialization are excluded. No query result cache is used.
DAR was rerun from merged commit
cd3064e
with its default automatic warmup and partial-page policy. The other four
readers retain their original five samples per case. The same input hashes,
queries, hardware, CPU and memory limits, MinIO binary, and network proxy
settings were verified before the update. This comparison combines the new
DAR measurements with the earlier competitor measurements.
Every value is the median of five independent processes, in seconds; lower is faster. Each process initializes its table once and runs two queries. Only its first query contributes to this table. Every case passed exact value checks before timing began.
All readers had four physical CPU cores and an 8 GiB memory limit. Storage used 200 ms request latency (+/-20 ms jitter) and a shared 150 Mbps bandwidth limit. These conditions emulate a network connection, not a particular cloud service.
| Columns / matches | DAR | delta-rs | DuckDB | Polars | Spark |
|---|---|---|---|---|---|
| 416, grouped | 1.730 | 10.385 | 10.847 | 10.241 | 10.302 |
| 416, scattered | 6.254 | 10.526 | 10.953 | 10.311 | 13.963 |
| 416, grouped + DV | 2.001 | 69.937 | 12.130 | 64.594 | 12.900 |
| 416, scattered + DV | 6.512 | 70.488 | 12.113 | 65.004 | 15.288 |
| 90, grouped | 1.689 | 22.101 | 22.449 | 21.988 | 10.931 |
| 90, scattered | 9.975 | 22.594 | 23.014 | 22.175 | 25.534 |
| 90, grouped + DV | 1.979 | 177.713 | 23.719 | 166.096 | 14.071 |
| 90, scattered + DV | 10.274 | 178.820 | 24.169 | 166.000 | 26.840 |
First-query quartiles
Values are median [p25, p75] in seconds. The bracketed interval covers
the middle half of the five independent samples.
| Columns / matches | DAR | delta-rs | DuckDB | Polars | Spark |
|---|---|---|---|---|---|
| 416, grouped | 1.730 [1.728, 1.737] | 10.385 [10.379, 10.388] | 10.847 [10.845, 10.847] | 10.241 [10.238, 10.244] | 10.302 [10.286, 10.424] |
| 416, scattered | 6.254 [6.254, 6.261] | 10.526 [10.523, 10.526] | 10.953 [10.949, 10.953] | 10.311 [10.310, 10.312] | 13.963 [13.962, 13.981] |
| 416, grouped + DV | 2.001 [1.995, 2.044] | 69.937 [69.937, 69.939] | 12.130 [12.126, 12.133] | 64.594 [64.403, 64.658] | 12.900 [12.839, 12.933] |
| 416, scattered + DV | 6.512 [6.487, 6.513] | 70.488 [70.483, 70.491] | 12.113 [12.103, 12.121] | 65.004 [64.860, 65.014] | 15.288 [15.272, 15.329] |
| 90, grouped | 1.689 [1.682, 1.694] | 22.101 [22.101, 22.107] | 22.449 [22.392, 22.462] | 21.988 [21.987, 21.989] | 10.931 [10.868, 10.960] |
| 90, scattered | 9.975 [9.931, 9.995] | 22.594 [22.585, 22.596] | 23.014 [23.013, 23.019] | 22.175 [22.163, 22.195] | 25.534 [25.531, 25.571] |
| 90, grouped + DV | 1.979 [1.944, 2.019] | 177.713 [177.700, 177.732] | 23.719 [23.708, 23.726] | 166.096 [165.950, 166.493] | 14.071 [14.055, 14.121] |
| 90, scattered + DV | 10.274 [10.267, 10.364] | 178.820 [178.792, 178.921] | 24.169 [24.122, 24.199] | 166.000 [165.995, 166.237] | 26.840 [26.749, 26.948] |
Initialization and repeated queries
Initialization is paid once per loaded table. DAR's default initialization prepares metadata and samples the connection before deciding whether smaller reads should help. That work is included in the initialization figures below. The query chart describes applications that keep a table object and query it. Use the combined figures below to include its first-use cost.
Table initialization
Values are median [p25, p75] in seconds. Engine startup is excluded.
Each reader uses its native table initialization API.
| Columns / matches | DAR | delta-rs | DuckDB | Polars | Spark |
|---|---|---|---|---|---|
| 416, grouped | 4.024 [4.021, 4.026] | 1.213 [1.213, 1.215] | 1.199 [1.199, 1.201] | 1.214 [1.212, 1.214] | 6.012 [6.006, 6.053] |
| 416, scattered | 4.094 [4.093, 4.097] | 1.258 [1.257, 1.258] | 1.247 [1.247, 1.249] | 1.261 [1.260, 1.263] | 5.868 [5.861, 5.926] |
| 416, grouped + DV | 4.429 [4.426, 4.440] | 1.651 [1.650, 1.651] | 1.629 [1.629, 1.629] | 1.650 [1.649, 1.652] | 6.243 [6.224, 6.273] |
| 416, scattered + DV | 4.480 [4.478, 4.485] | 1.603 [1.603, 1.603] | 1.586 [1.582, 1.590] | 1.604 [1.603, 1.605] | 6.042 [6.039, 6.077] |
| 90, grouped | 3.711 [3.706, 3.716] | 0.870 [0.868, 0.870] | 0.867 [0.866, 0.868] | 0.872 [0.871, 0.874] | 5.497 [5.473, 5.513] |
| 90, scattered | 4.101 [3.977, 4.112] | 0.884 [0.884, 0.884] | 0.882 [0.878, 0.883] | 0.886 [0.886, 0.889] | 5.708 [5.693, 5.736] |
| 90, grouped + DV | 3.804 [3.775, 3.816] | 0.908 [0.908, 0.911] | 0.908 [0.905, 0.909] | 0.913 [0.910, 0.914] | 5.766 [5.734, 5.767] |
| 90, scattered + DV | 3.705 [3.695, 3.989] | 0.917 [0.917, 0.920] | 0.915 [0.913, 0.916] | 0.919 [0.916, 0.920] | 5.615 [5.591, 5.780] |
Initialization plus the first query
Values are median [p25, p75] in seconds. These are measured from the
same sessions as the chart, with table initialization included.
| Columns / matches | DAR | delta-rs | DuckDB | Polars | Spark |
|---|---|---|---|---|---|
| 416, grouped | 5.754 [5.753, 5.758] | 11.596 [11.595, 11.601] | 12.047 [12.045, 12.049] | 11.454 [11.449, 11.461] | 16.308 [16.299, 16.545] |
| 416, scattered | 10.348 [10.318, 10.378] | 11.784 [11.783, 11.785] | 12.200 [12.196, 12.201] | 11.571 [11.571, 11.573] | 19.842 [19.815, 19.888] |
| 416, grouped + DV | 6.436 [6.431, 6.470] | 71.587 [71.587, 71.590] | 13.758 [13.757, 13.762] | 66.244 [66.051, 66.310] | 19.110 [19.090, 19.180] |
| 416, scattered + DV | 10.991 [10.977, 10.992] | 72.094 [72.086, 72.096] | 13.699 [13.693, 13.701] | 66.606 [66.462, 66.621] | 21.370 [21.364, 21.371] |
| 90, grouped | 5.393 [5.376, 5.410] | 22.973 [22.968, 22.977] | 23.315 [23.260, 23.329] | 22.861 [22.859, 22.863] | 16.428 [16.381, 16.434] |
| 90, scattered | 14.032 [13.971, 14.087] | 23.478 [23.470, 23.479] | 23.897 [23.891, 23.897] | 23.060 [23.052, 23.077] | 31.264 [31.249, 31.270] |
| 90, grouped + DV | 5.782 [5.741, 5.854] | 178.621 [178.613, 178.643] | 24.624 [24.616, 24.630] | 167.006 [166.863, 167.403] | 19.837 [19.704, 19.964] |
| 90, scattered + DV | 14.059 [13.966, 14.256] | 179.738 [179.709, 179.837] | 25.082 [25.037, 25.115] | 166.921 [166.914, 167.152] | 32.529 [32.356, 32.563] |
Second query on the same table object
Values are median [p25, p75] in seconds. Query results are not cached.
The two queries within a process are not independent samples.
| Columns / matches | DAR | delta-rs | DuckDB | Polars | Spark |
|---|---|---|---|---|---|
| 416, grouped | 1.739 [1.729, 1.746] | 9.896 [9.895, 9.897] | 10.825 [10.817, 10.826] | 10.224 [10.223, 10.224] | 7.501 [7.496, 7.526] |
| 416, scattered | 6.247 [6.245, 6.273] | 10.149 [10.143, 10.156] | 10.923 [10.915, 10.925] | 10.297 [10.296, 10.309] | 11.176 [11.164, 11.190] |
| 416, grouped + DV | 1.997 [1.991, 1.998] | 67.778 [67.767, 67.792] | 12.110 [12.108, 12.111] | 64.460 [64.259, 64.729] | 9.345 [9.327, 9.352] |
| 416, scattered + DV | 6.455 [6.442, 6.505] | 68.345 [68.335, 68.354] | 12.075 [12.074, 12.077] | 64.966 [64.862, 65.234] | 11.795 [11.777, 11.813] |
| 90, grouped | 1.704 [1.684, 1.705] | 21.670 [21.669, 21.670] | 22.460 [22.424, 22.477] | 22.140 [22.139, 22.197] | 8.407 [8.356, 8.437] |
| 90, scattered | 9.956 [9.950, 9.977] | 22.122 [22.116, 22.122] | 23.010 [23.002, 23.027] | 22.109 [22.108, 22.111] | 23.020 [23.005, 23.033] |
| 90, grouped + DV | 2.060 [1.994, 2.071] | 175.209 [175.197, 175.251] | 23.684 [23.672, 23.684] | 165.890 [165.332, 166.131] | 10.852 [10.840, 10.871] |
| 90, scattered + DV | 10.243 [10.239, 10.260] | 176.236 [176.236, 176.252] | 24.198 [24.175, 24.200] | 166.514 [166.413, 166.594] | 23.670 [23.614, 23.672] |
Spark consumes the full result through DataFrame.toArrow(), so it has no
comparable streaming first-batch timestamp. The raw timings
retain initialization, both query clocks, cleanup, and process measurements.
Blank fields mean unavailable, not zero.
Requests and downloaded data
The following values come from separate diagnostic runs with request tracing enabled. Tracing is disabled during timing. Each cell shows response-body MiB / request count for initialization and both queries together, including Delta logs, listings, Parquet, deletion vectors, and DAR's connection sampling. One MiB is 1,048,576 bytes. The byte count is what the proxy accepted for delivery to the reader, including bytes from interrupted responses.
| Columns / matches | DAR | delta-rs | DuckDB | Polars | Spark |
|---|---|---|---|---|---|
| 416, grouped | 41.37 / 5077 | 331.73 / 86 | 339.06 / 76 | 346.00 / 84 | 136.59 / 887 |
| 416, scattered | 212.76 / 28191 | 339.10 / 88 | 341.20 / 76 | 348.17 / 84 | 345.79 / 247 |
| 416, grouped + DV | 47.90 / 5092 | 2261.01 / 96 | 352.57 / 90 | 2262.44 / 616 | 143.38 / 991 |
| 416, scattered + DV | 220.82 / 27782 | 2276.34 / 96 | 354.70 / 90 | 2277.77 / 616 | 352.58 / 351 |
| 90, grouped | 35.14 / 5505 | 742.82 / 102 | 756.19 / 78 | 766.23 / 136 | 147.48 / 1065 |
| 90, scattered | 435.38 / 34717 | 761.89 / 102 | 761.29 / 102 | 771.57 / 136 | 765.26 / 281 |
| 90, grouped + DV | 37.38 / 5517 | 5836.14 / 210 | 758.09 / 94 | 5837.30 / 978 | 148.54 / 1189 |
| 90, scattered + DV | 340.85 / 44492 | 5876.37 / 210 | 763.18 / 118 | 5877.53 / 978 | 766.32 / 405 |
File statistics identify five of 130 Parquet files for the 416-column query and six of 60 for the 90-column query. The predicates and matching file sets are unchanged by DVs. Whole-session diagnostics can touch additional files during initialization, so their file counts also include preparation work.
DAR can request parts of eligible pages when the page layout and measured connection cost make that useful. Fewer downloaded bytes can come with more requests; latency, bandwidth, and concurrency determine whether that trade helps. The I/O CSV separates traffic by object type. DAR chooses ranges using its connection measurements, so its request pattern can vary between invocations. These records do not provide comparable decoded-page counters for all five readers.
Tables and queries
Each base table contains all 59,986,052 SF10 source rows. Parquet divides a file into row groups, then divides each group's column data into pages. In the grouped layout, matches sit close together within each row group. The scattered layout reorders rows within each group, preserving values and file/group membership. A DV snapshot shares its base table's Parquet bytes and deletes one of the 895 matching rows.
| Table / matches | Files | Stored columns | Output columns | Parquet GiB | Matching rows |
|---|---|---|---|---|---|
| 416 columns, grouped | 130 | 416 | 69 | 65.89 | 895 |
| 416 columns, scattered | 130 | 416 | 69 | 66.09 | 895 |
| 416 columns, grouped + DV | 130 | 416 | 69 | 65.89 | 894 |
| 416 columns, scattered + DV | 130 | 416 | 69 | 66.09 | 894 |
| 90 columns, grouped | 60 | 90 | 71 | 30.04 | 895 |
| 90 columns, scattered | 60 | 90 | 71 | 30.23 | 895 |
| 90 columns, grouped + DV | 60 | 90 | 71 | 30.04 | 894 |
| 90 columns, scattered + DV | 60 | 90 | 71 | 30.23 | 894 |
Both queries apply this WHERE filter (the predicate) and read all selected
columns and matching rows, without COUNT or LIMIT:
The 416-column query returns five source columns and 64 nullable numeric
payloads. The 90-column query also returns l_suppkey and l_quantity.
The provenance data contains the full SQL,
projection lists, fixture identities, file sizes, and writer settings.
The writer targets 512 MiB files with Zstd level 3, plain encoding, and no dictionaries. Row groups contain at most 131,072 rows: four groups per file for the 416-column table and eight for the 90-column table. Page settings are 20,000 rows, 1 MiB, and 1,024-row write batches. Checking the row limit at batch boundaries can produce pages with 20,480 rows.
Test conditions
| Setting | Recorded value |
|---|---|
| Host | AMD Ryzen 7 8845HS, Linux 6.19.14-200.fc43.x86_64 |
| Reader CPUs | Logical CPUs 0-3 and 8-11, the two SMT siblings of four physical cores |
| Reader memory | 8 GiB for the whole process tree, no swap |
| Storage | Dedicated local MinIO behind the same transport proxy for every reader |
| Transport | 200 ms request latency with deterministic +/-20 ms jitter; one shared, progressively paced 150 Mbps response-body budget; seed q2-full-network-v1 |
| Caches | Fresh reader process/client per invocation; MinIO and OS caches retained, no flushes |
| Sampling | Five independent processes per reader and case; two sequential queries per process |
| DAR source | Merged main at cd3064e; automatic warmup and partial-page reads use public defaults |
| Other readers | delta-rs 1.6.6, DuckDB 1.5.5, Polars 1.44.2, Spark 4.1.1 with Delta Lake 4.3.1 |
The reader allocation uses both SMT siblings of four physical cores. MinIO
has CPUs 4 and 5 and 4 GiB; the observer uses CPU 6 and the proxy CPU 7.
Spark runs with local[8] and a 4 GiB JVM heap within the common memory limit.
Other runner settings retain the original benchmark's fixed resource budget;
using DAR's default read policy does not mean every runner setting is a library
default. The measured DAR build includes changes after the 0.6.2 release.
The scattered 416-column cases use a separate storage warmup. The other six use their exact validation run as storage warmup. The update preserves this per-case choice. Storage warmup happens in a separate process and is distinct from the table initialization measured in every invocation.
The transport is emulated. Hardware, cache state, query selectivity, file encoding, and storage conditions can change the results. These timings do not predict performance on a particular S3 service or every Delta table.
Data and reproduction
The current comparison contains 200 timed invocations: 40 new DAR sessions and 160 retained competitor sessions. No samples were removed or replaced within a case's five-run set.
- Current timings: every displayed sample, including separate initialization and first- and second-query clocks.
- Current I/O: complete-session diagnostic traffic.
- Current provenance: build and input identities, exact-value certificates, source-record hashes, and export checksums.
The original report and its 400 timing samples, summary, I/O, and provenance remain unchanged. They include the older DAR build and measurements that open a fresh table for each query. Those opening times are not used in the current chart.
The evidence release
contains the original campaign archive. The default-policy update is a separate
archive, selective-read-defaults-cd3064e.tar.gz, with its raw runs, reference
results, exact exports, diagnostics, frozen sources, and replay script.
Bulk tables and installed engines are excluded.
The reproduction guide explains archive verification and the pinned fixture and reader setup.