Skip to content
About
Main content

Same answer, different cost

Measure your machineNeeds Python with DuckDB

Problem

A query's cost is set by how many rows flow through each operator, and the order of operators decides that. Filtering before a shrinks what the join must process; filtering after it makes the join handle every row first. move filters below joins on their own (predicate pushdown), so two differently written queries usually run the same plan; a construct the optimizer cannot see through, such as a , pins the filter where it was written.

Use DuckDB with 1 million customers and 20 million orders. Write the same report two ways: (A) join orders to customers, then filter amount > 400 and group by tier; (B) filter orders first in a subquery, then join. Then defeat the optimizer by placing the join under a window function, so the filter cannot move below it (C). Record time and the row counts at each operator from EXPLAIN ANALYZE.

The question. Do the two ways of writing the report cost the same, and what happens when the filter cannot be pushed down?


Predict

In which form does DuckDB fail to push the filter below the join?

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.

    same-answer-different-cost.py49 linesDownload
    View the code
    same-answer-different-cost.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Same answer, different cost.
    
    DuckDB, 1 million customers and 20 million orders. The same report written
    two ways: (A) join, then filter amount > 400, then group by tier; (B) filter
    orders in a subquery first, then join. Then (C) the join placed under a window
    function, which the optimizer cannot push the filter past. Times each and reads the row count each
    operator produced (EXPLAIN ANALYZE), to find where the rows peak.
    """
    import argparse, json, re, time
    import duckdb
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--orders", type=int, default=20_000_000, help="Orders.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    c = duckdb.connect(); c.execute("SELECT setseed(0.42)")
    c.execute("CREATE TABLE customers AS SELECT i AS customer_id, i % 5 AS tier FROM range(1, 1000001) t(i)")
    c.execute(f"""CREATE TABLE orders AS SELECT i AS order_id, 1 + (random() * 999999)::INT AS customer_id, (random() * 500)::INT AS amount
                  FROM range(0, {args.orders}) t(i)""")
    Q = {"A: join, then filter": "SELECT tier, sum(amount) FROM orders o JOIN customers c USING (customer_id) WHERE amount > 400 GROUP BY tier ORDER BY tier",
         "B: filter, then join": "SELECT tier, sum(amount) FROM (SELECT * FROM orders WHERE amount > 400) o JOIN customers c USING (customer_id) GROUP BY tier ORDER BY tier",
         "C: filter after a window": """SELECT tier, sum(amount) FROM (SELECT o.amount, c.tier, row_number() OVER () AS rn
                                           FROM orders o JOIN customers c USING (customer_id)) j
                                           WHERE amount > 400 GROUP BY tier ORDER BY tier"""}                      # a filter cannot move below the window
    rows, answers = [], {}
    for label, q in Q.items():
        answers[label] = c.execute(q).fetchall()
        ts = []
        for _ in range(3):
            t = time.perf_counter(); c.execute(q).fetchall(); ts.append(time.perf_counter() - t)
        prof = c.execute("EXPLAIN ANALYZE " + q).fetchall()[0][1]
        join_in = [int(x.replace(",", "")) for x in re.findall(r"HASH_JOIN[\s\S]*?([\d,]+)\s+[Rr]ows", prof)]
        counts = [int(x.replace(",", "")) for x in re.findall(r"([\d,]+)\s+[Rr]ows", prof)]
        rows.append({"query": label, "seconds": min(ts), "join_output_rows": join_in[0] if join_in else None, "peak_rows": max(counts) if counts else None})
    summary = {"same_answer": "yes" if len({str(v) for v in answers.values()}) == 1 else "no"}
    for r in rows: print(f"{r['query']:<22} {r['seconds']:6.3f} s   rows out of the join {r['join_output_rows']:>12,}   largest operator output {r['peak_rows']:>12,}")
    print("all three return the same answer:", summary["same_answer"])
    
    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 same-answer-different-cost.py
    uv run same-answer-different-cost.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--orders20,000,000 Orders.

Our run

Three ways to the same answer

Three ways to the same answer
QueryTimeRows out of the joinLargest operator output
A: join, then filter20.1 ms3.98M3.98M
B: filter, then join20.4 ms3.98M3.98M
C: filter after a window72.5 ms20M20M
Reference runApple M2 · 8 cores · 8 GB
Output · uv run same-answer-different-cost.py
A: join, then filter    0.020 s   rows out of the join    3,979,324   largest operator output    3,979,324
B: filter, then join    0.020 s   rows out of the join    3,979,324   largest operator output    3,979,324
C: filter after a window  0.073 s   rows out of the join   20,000,000   largest operator output   20,000,000
all three return the same answer: yes
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20

What we found

A and B ran the same plan: the optimizer pushed the filter below the join in A, so both joined 3.98M rows, in 20.1 ms and 20.4 ms. With the filter held above a window function, the join produced 20M rows and the query took 72.5 ms. All three returned the same answer (yes). The row count peaked at the join in every plan.


Read it

Write the query clearly and let the optimizer place the filter, but know what stops it: window functions, some , and materialization boundaries. Where the row count peaks in the plan is where the time goes.