Skip to content
About
Main content

Pushdown and projection: count the bytes

Measure your machineNeeds Python with DuckDB

Problem

Two optimizations decide how many bytes a scan reads. Filter pushdown hands the filter to the scan, which uses row-group to skip groups and evaluates the filter before rows leave the reader. 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

Written as store_id + 0 = 17, does DuckDB still push the filter into the scan?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    pushdown-and-projection-count-the-bytes.py58 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--n10,000,000 Rows.
--seed0 Random seed.

Our run

Queries on two Parquet files

Queries on two Parquet files
FileQueryTime (ms)Rows from scanFilter in scan
randomsum(qty) WHERE store_id = 1710.924.9Kyes
randomsame, filter as store_id + 0 = 1710.724.9Kyes
randomSELECT * (all columns)43110M—
randomSELECT qty (one column)3110M—
sortedsum(qty) WHERE store_id = 170.77424.9Kyes
sortedsame, filter as store_id + 0 = 170.76824.9Kyes
sortedSELECT * (all columns)42310M—
sortedSELECT qty (one column)30.510M—
Reference runApple M2 · 8 cores · 8 GB
Output · uv run pushdown-and-projection-count-the-bytes.py
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 bytes
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · numpy 2.5.4 · pyarrow 26.0.0 · run 2026-10-20

What 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 . 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 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.