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 join shrinks what the join must process; filtering after it makes the join handle every row first. Optimizers 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 window function, 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
Note that modern optimizers often rewrite A into B. Expected: results are identical; the operator that sees the most rows dominates the time.
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
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)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
| Knob · default | What it changes |
|---|---|
| --orders20,000,000 | Orders. |
Our run
Three ways to the same answer
| Query | Time | Rows out of the join | Largest operator output |
|---|---|---|---|
| A: join, then filter | 20.1 ms | 3.98M | 3.98M |
| B: filter, then join | 20.4 ms | 3.98M | 3.98M |
| C: filter after a window | 72.5 ms | 20M | 20M |
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: yesWhat 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 UDFs, and materialization boundaries. Where the row count peaks in the plan is where the time goes.