Skip to content
About
Main content

Estimate versus actual on a correlated filter

Run a real systemNeeds Docker, Python

Problem

A estimates how many rows a filter keeps, and every later choice (join order, join method, memory) is built on that number. For a filter on two columns it usually multiplies their selectivities, which assumes the columns are independent. When one column determines the other, the true is that of the stronger filter alone, and the product underestimates it by the selectivity of the weaker one, often by orders of magnitude. tell the planner about the dependency.

Create a table where one column determines another, and see the independence assumption fail. Use PostgreSQL: 1 million people whose postcode implies their city. Look at rows= (estimate) and actual rows= for city_id = 5 AND postcode BETWEEN 5000 AND 5006. Then run CREATE STATISTICS s (dependencies) ON city_id, postcode FROM people; ANALYZE people; and repeat. Write down the error ratio before and after. The script also runs an equality filter, and replaces the dependency statistic with an MCV list over both columns.

The question. How far off is the planner's row estimate with per-column , and which extended statistic fixes it?


Predict

After CREATE STATISTICS (dependencies), how far off is the estimate for the BETWEEN filter on the correlated columns?

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.

    estimate-versus-actual-on-a-correlated.py49 linesDownload
    View the code
    estimate-versus-actual-on-a-correlated.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
    """Estimate against actual on a correlated filter.
    
    A PostgreSQL table where postcode implies city. EXPLAIN ANALYZE of a filter
    on both columns shows the planner's row estimate (it multiplies the two
    selectivities as if they were independent) against the actual rows; then
    CREATE STATISTICS (dependencies) and the same query again; then the same
    with an equality filter, and with an MCV statistic over both columns.
    Needs the PostgreSQL service from the compose file (port 55701).
    """
    import argparse, json, re
    import psycopg
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55701/postgres", help="Connection string.")
    p.add_argument("--rows", type=int, default=1_000_000, help="Rows.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    c = psycopg.connect(args.dsn, autocommit=True)
    c.execute("DROP TABLE IF EXISTS people")
    c.execute(f"""CREATE TABLE people AS SELECT g AS id, (g % 100) AS city_id, (g % 100) * 1000 + (g % 7) AS postcode
                  FROM generate_series(1, {args.rows}) g""")                # postcode implies city
    c.execute("ANALYZE people")
    Q = "SELECT * FROM people WHERE city_id = 5 AND postcode BETWEEN 5000 AND 5006"
    EQ = "SELECT * FROM people WHERE city_id = 5 AND postcode = 5003"
    def est(q=Q):
        plan = "\n".join(r[0] for r in c.execute("EXPLAIN ANALYZE " + q).fetchall())
        m = re.search(r"rows=(\d+) width=\d+\) \(actual time=[\d.]+\.\.[\d.]+ rows=([\d.]+)", plan)
        return int(m.group(1)), float(m.group(2))
    rows = []
    def record(stats):
        for filt, q in (("BETWEEN", Q), ("equality", EQ)):
            e, a = est(q); rows.append({"statistics": stats, "filter": filt, "estimate": e, "actual": a, "error_x": a / e})
    record("per-column only")
    c.execute("CREATE STATISTICS s (dependencies) ON city_id, postcode FROM people"); c.execute("ANALYZE people"); record("dependencies")
    c.execute("DROP STATISTICS s"); c.execute("ALTER TABLE people ALTER postcode SET STATISTICS 1000")
    c.execute("CREATE STATISTICS s2 (mcv) ON city_id, postcode FROM people"); c.execute("ANALYZE people"); record("mcv (statistics target 1000)")
    for r in rows: print(f"{r['statistics']:<30} {r['filter']:<9} estimate {r['estimate']:>6,}   actual {r['actual']:>8,.0f}   actual / estimate {r['error_x']:.1f}x")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"estimates": rows}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    estimate-versus-actual-on-a-correlated.compose.yml13 linesDownload
    View the code
    estimate-versus-actual-on-a-correlated.compose.yml
    # PostgreSQL for the estimate-versus-actual-on-a-correlated 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: ["55701: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 estimate-versus-actual-on-a-correlated.compose.yml up -d --wait
    uv run estimate-versus-actual-on-a-correlated.py
    uv run estimate-versus-actual-on-a-correlated.py --help     # every knob
    docker compose -f estimate-versus-actual-on-a-correlated.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--dsnpostgresql://postgres:lab@localhost:55701/postgres Connection string.
--rows1,000,000 Rows.

Our run

Estimated against actual rows

Estimated against actual rows
StatisticsFilterEstimateActualActual ÷ estimate
per-column onlyBETWEEN1310K769×
per-column onlyequality131.43K110×
dependenciesBETWEEN1410K714×
dependenciesequality1.42K1.43K1×
mcv (statistics target 1000)BETWEEN9.99K10K1×
mcv (statistics target 1000)equality1.51K1.43K0.944×
Reference runApple M2 · 8 cores · 8 GB
Output · uv run estimate-versus-actual-on-a-correlated.py
per-column only                BETWEEN   estimate     13   actual   10,000   actual / estimate 769.2x
per-column only                equality  estimate     13   actual    1,428   actual / estimate 109.8x
dependencies                   BETWEEN   estimate     14   actual   10,000   actual / estimate 714.3x
dependencies                   equality  estimate  1,422   actual    1,428   actual / estimate 1.0x
mcv (statistics target 1000)   BETWEEN   estimate  9,993   actual   10,000   actual / estimate 1.0x
mcv (statistics target 1000)   equality  estimate  1,513   actual    1,428   actual / estimate 0.9x
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

With per-column statistics the planner estimated 13 rows for 10K actual, 769× too few: worse than "tens of times". The dependency statistic did not help this query at all (14 rows): PostgreSQL applies functional dependencies only to equality filters, and this one is a range. On the equality form it worked, from 110× off to 1×. An MCV statistic over both columns fixed the range filter too: 9.99K estimated against 10K.


Read it

The independence assumption fails silently and in one direction: correlated filters are underestimated, so plans built on them choose nested loops and small memory grants for what is in fact a large result. Extended statistics are the fix, but each kind covers a different filter shape, and you only know which one works by comparing the estimate with the actual on the query you care about.