Skip to content
About
Main content

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 can do with them. NOT IN follows : one 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 , so every partition is scanned. A 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

With NULLs in the subquery column, how many rows does NOT IN return, where NOT EXISTS returns 50,500?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    four-pairs-of-equal-queries.py61 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    four-pairs-of-equal-queries.compose.yml13 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55705/postgres Connection string.

Our run

Four pairs

Four pairs
PairQueryTime (ms)AnswerPartitions scanned
1. anti-joinNOT IN, NULLs present15.70—
1. anti-joinNOT EXISTS, NULLs present19.250.5K—
1. anti-joinNOT IN, column NOT NULL16.850.5K—
2. partition keyextract(year FROM ts) = 202411216.5M36
2. partition keyts >= '2024-01-01' AND ts < '2025-01-01'5016.5M12
3. CTECTE twice, MATERIALIZED29.810K—
3. CTECTE twice, NOT MATERIALIZED14.410K—
Reference runApple M2 · 8 cores · 8 GB
Output · uv run four-pairs-of-equal-queries.py
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 10000
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

What we found

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