Four pairs of equal queries
Run a real systemNeeds Docker, Python
Problem
Two queries that look equivalent can differ in what they return or in what the planner can do with them. NOT IN follows three-valued logic: one NULL in the subquery makes every comparison unknown, and the query returns nothing; NOT EXISTS does not have that trap. A function wrapped around a partition key hides the key from partition pruning, so every partition is scanned. A CTE referenced twice can be computed once and stored (materialized) or inlined into each reference, and either can be cheaper.
Build a table customers (100,000 rows) and refunds (50,000 rows, with some NULL customer_id). Time NOT IN against NOT EXISTS. Then set customer_id NOT NULL (clean the NULLs first) and time NOT IN again. Next, partition a table by month and compare WHERE extract(year FROM ts) = 2024 with a half-open range using EXPLAIN and see how many partitions are scanned. Last, test a CTE referenced twice with and without MATERIALIZED / NOT MATERIALIZED.
The question. For each pair, do the two forms return the same answer, and how differently do they run?
Predict
Expect large differences in the first two cases and a small one in the third.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
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
four-pairs-of-equal-queries.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Four pairs of equal queries. Pairs of PostgreSQL queries that return the same answer and can run very differently: NOT IN against NOT EXISTS (with NULLs in the subquery column, then after making it NOT NULL); a function on a partition key against a half-open range (partitions scanned); a CTE referenced twice, MATERIALIZED against NOT MATERIALIZED. Needs the PostgreSQL service from the compose file (port 55705). """ import argparse, json, re import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55705/postgres", help="Connection string.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() c = psycopg.connect(args.dsn, autocommit=True) def run(sql): txt = "\n".join(r[0] for r in c.execute("EXPLAIN (ANALYZE) " + sql).fetchall()) return float(re.search(r"Execution Time: ([\d.]+)", txt).group(1)), txt def answer(sql): return c.execute(sql).fetchone()[0] c.execute("DROP TABLE IF EXISTS customers, refunds, sales CASCADE") c.execute("CREATE TABLE customers AS SELECT g AS id FROM generate_series(1, 100000) g") c.execute("CREATE TABLE refunds AS SELECT CASE WHEN g % 100 = 0 THEN NULL ELSE g * 2 END AS customer_id FROM generate_series(1, 50000) g") c.execute("ANALYZE customers, refunds") rows = [] q_in = "SELECT count(*) FROM customers WHERE id NOT IN (SELECT customer_id FROM refunds)" q_ex = "SELECT count(*) FROM customers c WHERE NOT EXISTS (SELECT 1 FROM refunds r WHERE r.customer_id = c.id)" for label, q in (("NOT IN, NULLs present", q_in), ("NOT EXISTS, NULLs present", q_ex)): t, _ = run(q); rows.append({"pair": "1. anti-join", "query": label, "ms": t, "answer": answer(q), "partitions": None}) c.execute("DELETE FROM refunds WHERE customer_id IS NULL"); c.execute("ALTER TABLE refunds ALTER customer_id SET NOT NULL"); c.execute("ANALYZE refunds") t, _ = run(q_in); rows.append({"pair": "1. anti-join", "query": "NOT IN, column NOT NULL", "ms": t, "answer": answer(q_in), "partitions": None}) c.execute("CREATE TABLE sales (ts timestamp, amount int) PARTITION BY RANGE (ts)") for y in (2023, 2024, 2025): for m in range(1, 13): nm = (y + (m == 12), m % 12 + 1) c.execute(f"CREATE TABLE sales_{y}_{m:02d} PARTITION OF sales FOR VALUES FROM ('{y}-{m:02d}-01') TO ('{nm[0]}-{nm[1]:02d}-01')") c.execute("INSERT INTO sales SELECT TIMESTAMP '2023-01-01' + (g % (3 * 365 * 24)) * INTERVAL '1 hour', g % 100 FROM generate_series(1, 1000000) g"); c.execute("ANALYZE sales") for label, q in (("extract(year FROM ts) = 2024", "SELECT sum(amount) FROM sales WHERE extract(year FROM ts) = 2024"), ("ts >= '2024-01-01' AND ts < '2025-01-01'", "SELECT sum(amount) FROM sales WHERE ts >= '2024-01-01' AND ts < '2025-01-01'")): t, txt = run(q); parts = len(re.findall(r"Scan on sales_\d+_\d+", txt)) rows.append({"pair": "2. partition key", "query": label, "ms": t, "answer": answer(q), "partitions": parts}) for label, mat in (("CTE twice, MATERIALIZED", "MATERIALIZED"), ("CTE twice, NOT MATERIALIZED", "NOT MATERIALIZED")): q = f"""WITH big AS {mat} (SELECT id, id % 10 AS g FROM customers) SELECT count(*) FROM big a JOIN big b ON a.id = b.id WHERE a.g = 3""" t, _ = run(q); rows.append({"pair": "3. CTE", "query": label, "ms": t, "answer": answer(q), "partitions": None}) for r in rows: print(f"{r['pair']:<17} {r['query']:<42} {r['ms']:9.1f} ms answer {r['answer']}" + (f" partitions scanned {r['partitions']}" if r["partitions"] is not None else "")) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"queries": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
four-pairs-of-equal-queries.compose.yml # PostgreSQL for the four-pairs-of-equal-queries lab. services: postgres: image: postgres:17.6 command: ["postgres", "-c", "shared_buffers=256MB", "-c", "max_parallel_workers_per_gather=0"] environment: POSTGRES_PASSWORD: lab ports: ["55705:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f four-pairs-of-equal-queries.compose.yml up -d --wait uv run four-pairs-of-equal-queries.py uv run four-pairs-of-equal-queries.py --help # every knob docker compose -f four-pairs-of-equal-queries.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55705/postgres | Connection string. |
Our run
Four pairs
| Pair | Query | Time (ms) | Answer | Partitions scanned |
|---|---|---|---|---|
| 1. anti-join | NOT IN, NULLs present | 15.7 | 0 | — |
| 1. anti-join | NOT EXISTS, NULLs present | 19.2 | 50.5K | — |
| 1. anti-join | NOT IN, column NOT NULL | 16.8 | 50.5K | — |
| 2. partition key | extract(year FROM ts) = 2024 | 112 | 16.5M | 36 |
| 2. partition key | ts >= '2024-01-01' AND ts < '2025-01-01' | 50 | 16.5M | 12 |
| 3. CTE | CTE twice, MATERIALIZED | 29.8 | 10K | — |
| 3. CTE | CTE twice, NOT MATERIALIZED | 14.4 | 10K | — |
1. anti-join NOT IN, NULLs present 15.7 ms answer 0
1. anti-join NOT EXISTS, NULLs present 19.2 ms answer 50500
1. anti-join NOT IN, column NOT NULL 16.8 ms answer 50500
2. partition key extract(year FROM ts) = 2024 111.8 ms answer 16522448 partitions scanned 36
2. partition key ts >= '2024-01-01' AND ts < '2025-01-01' 50.0 ms answer 16522448 partitions scanned 12
3. CTE CTE twice, MATERIALIZED 29.8 ms answer 10000
3. CTE CTE twice, NOT MATERIALIZED 14.4 ms answer 10000What we found
The anti-join pair differed in its answer, not its speed: with NULLs present, NOT IN returned 0 rows and NOT EXISTS 50.5K, in similar times (15.7 and 19.2 ms); with the column NOT NULL, NOT IN returned 50.5K. The partition pair scanned 36 partitions with extract and 12 with the range, in 112 against 50 ms. The CTE pair returned the same count in 29.8 ms materialized and 14.4 ms inlined.
Read it
Equal-looking queries are equal only under conditions the syntax does not show: no NULLs, a bare partition key, a CTE cheap enough to repeat. The NULL case is the dangerous one, because it changes the answer without an error. Write anti-joins as NOT EXISTS, filter partition keys with half-open ranges on the bare column, and decide CTE materialization by measuring.