Estimate versus actual on a correlated filter
Run a real systemNeeds Docker, Python
Problem
A planner 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 selectivity is that of the stronger filter alone, and the product underestimates it by the selectivity of the weaker one, often by orders of magnitude. Extended statistics 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 statistics, and which extended statistic fixes it?
Predict
Expect the estimate to be tens of times too small. After CREATE STATISTICS s (dependencies) the estimate should move close to the truth.
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
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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 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 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
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55701/postgres | Connection string. |
| --rows1,000,000 | Rows. |
Our run
Estimated against actual rows
| Statistics | Filter | Estimate | Actual | Actual ÷ estimate |
|---|---|---|---|---|
| per-column only | BETWEEN | 13 | 10K | 769× |
| per-column only | equality | 13 | 1.43K | 110× |
| dependencies | BETWEEN | 14 | 10K | 714× |
| dependencies | equality | 1.42K | 1.43K | 1× |
| mcv (statistics target 1000) | BETWEEN | 9.99K | 10K | 1× |
| mcv (statistics target 1000) | equality | 1.51K | 1.43K | 0.944× |
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.9xWhat 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.