Pushdown and projection: count the bytes
Measure your machineNeeds Python with DuckDB
Problem
Two optimizations decide how many bytes a Parquet scan reads. Filter pushdown hands the filter to the scan, which uses row-group statistics to skip groups and evaluates the filter before rows leave the reader. Projection pushdown reads only the columns the query uses, and in a columnar file each column is stored separately, so reading one column costs roughly that column's share of the bytes. Both depend on the engine recognizing the filter and the columns; an expression around the column can hide it.
With DuckDB, run SELECT sum(qty) FROM 'orders.parquet' WHERE store_id = 17 on two files of 10 million orders: random order and sorted by store_id. Compare the rows scanned and the time. Then select all columns and one column from the file, which has a wide text column. Then wrap the filter in a function (WHERE store_id + 0 = 17) and check whether the scan still received the filter.
The question. How much does a sorted file save a filtered query, how much does reading one column save, and does an expression around the column stop the pushdown?
Predict
Expect: a large drop in time for the sorted file, and a drop close to the column's share of bytes for the one-column query.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
pushdown-and-projection-count-the-bytes.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb", "numpy", "pyarrow"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Pushdown and projection: count the bytes. DuckDB reads two Parquet files of 10 million orders (random order and sorted by store_id, 100,000 rows per row group, with a wide text column). Times SELECT sum(qty) WHERE store_id = 17 on each, with the rows the scan returned (EXPLAIN ANALYZE); then all columns against one column; then the filter wrapped in an expression (store_id + 0 = 17), to see whether the scan still receives it. """ import argparse, json, os, re, tempfile, time import duckdb, numpy as np, pyarrow as pa, pyarrow.parquet as pq p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=10_000_000, help="Rows.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() d = tempfile.mkdtemp(); rng = np.random.default_rng(args.seed) store = rng.integers(1, 401, args.n); qty = rng.integers(1, 10, args.n) note = pa.array(np.char.add("note-", rng.integers(0, 10**12, args.n).astype(str))) # the wide text column for name, order in (("random", np.arange(args.n)), ("sorted", np.argsort(store, kind="stable"))): pq.write_table(pa.table({"store_id": store[order], "qty": qty[order], "note": note.take(pa.array(order))}), os.path.join(d, f"{name}.parquet"), row_group_size=100_000) c = duckdb.connect(); c.execute("SET threads = 4") def run(sql, reps=5): c.execute(sql).to_arrow_table(); ts = [] for _ in range(reps): t = time.perf_counter(); c.execute(sql).to_arrow_table(); ts.append(time.perf_counter() - t) # materialize every row read prof = c.execute("EXPLAIN ANALYZE " + sql).fetchall()[0][1] scanned = sum(int(x.replace(",", "")) for x in re.findall(r"(?:TABLE_SCAN|PARQUET_SCAN|READ_PARQUET)[\s\S]*?([\d,]+) [Rr]ows", prof)[:1]) if prof else None return min(ts) * 1000, scanned, prof rows = [] for name in ("random", "sorted"): f = os.path.join(d, f"{name}.parquet") for label, sql in (("sum(qty) WHERE store_id = 17", f"SELECT sum(qty) FROM '{f}' WHERE store_id = 17"), ("same, filter as store_id + 0 = 17", f"SELECT sum(qty) FROM '{f}' WHERE store_id + 0 = 17"), ("SELECT * (all columns)", f"SELECT * FROM '{f}'"), ("SELECT qty (one column)", f"SELECT qty FROM '{f}'")): ms, scanned, prof = run(sql) pushed = "yes" if re.search(r"Filters:\s*store_id\s*=\s*17", prof) else "no" rows.append({"file": name, "query": label, "ms": ms, "rows_scanned": scanned, "filter_in_scan": pushed if "WHERE" in sql else None}) sizes = {c_: pq.ParquetFile(os.path.join(d, "random.parquet")).metadata.row_group(0).column(i).total_compressed_size for i, c_ in enumerate(["store_id", "qty", "note"])} summary = {"qty_share_pct": sizes["qty"] / sum(sizes.values()) * 100, "duckdb": duckdb.__version__} for r in rows: print(f"{r['file']:<7} {r['query']:<36} {r['ms']:8.1f} ms rows from scan {r['rows_scanned']}" + (f" filter pushed into scan: {r['filter_in_scan']}" if r["filter_in_scan"] else "")) print(f"qty is {summary['qty_share_pct']:.1f}% of the file's bytes") for f in os.listdir(d): os.remove(os.path.join(d, f)) if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"queries": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run pushdown-and-projection-count-the-bytes.py uv run pushdown-and-projection-count-the-bytes.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n10,000,000 | Rows. |
| --seed0 | Random seed. |
Our run
Queries on two Parquet files
| File | Query | Time (ms) | Rows from scan | Filter in scan |
|---|---|---|---|---|
| random | sum(qty) WHERE store_id = 17 | 10.9 | 24.9K | yes |
| random | same, filter as store_id + 0 = 17 | 10.7 | 24.9K | yes |
| random | SELECT * (all columns) | 431 | 10M | — |
| random | SELECT qty (one column) | 31 | 10M | — |
| sorted | sum(qty) WHERE store_id = 17 | 0.774 | 24.9K | yes |
| sorted | same, filter as store_id + 0 = 17 | 0.768 | 24.9K | yes |
| sorted | SELECT * (all columns) | 423 | 10M | — |
| sorted | SELECT qty (one column) | 30.5 | 10M | — |
random sum(qty) WHERE store_id = 17 10.9 ms rows from scan 24870 filter pushed into scan: yes
random same, filter as store_id + 0 = 17 10.7 ms rows from scan 24870 filter pushed into scan: yes
random SELECT * (all columns) 431.4 ms rows from scan 10000000
random SELECT qty (one column) 31.0 ms rows from scan 10000000
sorted sum(qty) WHERE store_id = 17 0.8 ms rows from scan 24870 filter pushed into scan: yes
sorted same, filter as store_id + 0 = 17 0.8 ms rows from scan 24870 filter pushed into scan: yes
sorted SELECT * (all columns) 423.3 ms rows from scan 10000000
sorted SELECT qty (one column) 30.5 ms rows from scan 10000000
qty is 3.6% of the file's bytesWhat we found
The filtered sum took 10.9 ms on the random file and 0.774 ms on the sorted one: the same matching rows, but the sorted file let the scan skip almost every row group. Reading one column took 31 ms against 431 ms for all columns; qty is 3.58% of the file's bytes, and the time fell by somewhat less than that share because decoding and materializing rows has its own cost. Wrapping the filter as store_id + 0 = 17 did not stop the pushdown: DuckDB simplified the expression and the scan still received the filter (yes), with the same time.
Read it
The bytes a query reads are decided by layout (which groups can be skipped) and by projection (which columns are needed), and the engine's job is to notice both. Modern optimizers see through trivial expressions; a real function call, a cast that changes the type, or a UDF will still hide the column from the scan, so check the plan rather than the SQL.