A partition with no statistics
Run a real systemNeeds Docker, Python
Problem
Statistics are collected per table, and in PostgreSQL per partition. A partition loaded a minute ago has none until it is analyzed, either by hand or when auto-analyze notices enough changes (0.1 × rows + 50 by default). Until then the planner works from a placeholder, and the newest data, usually the most queried, is planned blind.
Create a partitioned table, load a new partition, and compare plans before and after ANALYZE on that partition only. Run EXPLAIN for a filter on the new partition's date, joined to a small dimension. Note whether the join method changes. Also read n_mod_since_analyze in pg_stat_user_tables and compute when auto-analyze would trigger using the rule 0.1 × n + 50.
The question. What does the planner estimate for a partition it has never analyzed, and does the plan change after ANALYZE?
Predict
Expect the row estimate to be a placeholder (a fraction of the table by default constants) before ANALYZE and close to the real count after.
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
a-partition-with-no-statistics.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 """A partition with no statistics. A PostgreSQL table partitioned by day, with statistics on the old partitions. A new day's partition is loaded and queried (filter on its date, joined to a dimension) before and after ANALYZE on that partition only: the row estimate and the join method. Also reads n_mod_since_analyze and computes when auto-analyze would fire (0.1 × n + 50). Needs the PostgreSQL service from the compose file (port 55702). """ import argparse, json, re import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55702/postgres", help="Connection string.") p.add_argument("--rows-per-day", type=int, default=200_000, help="Rows per day partition.") 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 events, stores") c.execute("CREATE TABLE stores (id int PRIMARY KEY, name text)"); c.execute("INSERT INTO stores SELECT g, 's' || g FROM generate_series(1, 1000) g") c.execute("CREATE TABLE events (day date, store_id int, amount numeric) PARTITION BY RANGE (day)") for d in range(1, 6): c.execute(f"CREATE TABLE events_{d} PARTITION OF events FOR VALUES FROM ('2025-03-0{d}') TO ('2025-03-0{d + 1}')") for d in range(1, 5): c.execute(f"INSERT INTO events_{d} SELECT DATE '2025-03-0{d}', 1 + g % 1000, g % 97 FROM generate_series(1, {args.rows_per_day}) g") c.execute("ANALYZE stores"); [c.execute(f"ANALYZE events_{d}") for d in range(1, 5)] c.execute("ALTER TABLE events_5 SET (autovacuum_enabled = off)") # keep auto-analyze from racing the lab c.execute(f"INSERT INTO events_5 SELECT DATE '2025-03-05', 1 + g % 1000, g % 97 FROM generate_series(1, {args.rows_per_day}) g") Q = "SELECT s.name, sum(e.amount) FROM events e JOIN stores s ON s.id = e.store_id WHERE e.day = DATE '2025-03-05' GROUP BY s.name" def plan(): txt = "\n".join(r[0] for r in c.execute("EXPLAIN ANALYZE " + Q).fetchall()) scan = re.search(r"Seq Scan on events_5 e\s+\(cost=[^)]*rows=(\d+)[^)]*\) \(actual time=[^)]*rows=(\d+)", txt) join = "hash join" if "Hash Join" in txt else "nested loop" if "Nested Loop" in txt else "merge join" if "Merge Join" in txt else "?" t = float(re.search(r"Execution Time: ([\d.]+)", txt).group(1)) return int(scan.group(1)), int(scan.group(2)), join, t c.execute("SELECT pg_stat_force_next_flush()"); c.execute("SELECT 1"); import time; time.sleep(1) mod = c.execute("SELECT n_mod_since_analyze, n_live_tup FROM pg_stat_user_tables WHERE relname = 'events_5'").fetchone() rows = [] e, a, j, t = plan(); rows.append({"state": "new partition, never analyzed", "estimate": e, "actual": a, "join": j, "ms": t}) c.execute("ANALYZE events_5") e, a, j, t = plan(); rows.append({"state": "after ANALYZE events_5", "estimate": e, "actual": a, "join": j, "ms": t}) summary = {"n_mod_since_analyze": mod[0], "trigger_at": 0.1 * args.rows_per_day + 50, "note": "threshold = 0.1 × reltuples + 50; reltuples is unknown (-1) before the first analyze"} for r in rows: print(f"{r['state']:<32} estimate {r['estimate']:>8,} actual {r['actual']:>8,} join: {r['join']:<12} {r['ms']:.1f} ms") print(f"n_mod_since_analyze = {mod[0]:,}; auto-analyze fires after 0.1 × n + 50 = {summary['trigger_at']:,.0f} changes") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"plans": 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
a-partition-with-no-statistics.compose.yml # PostgreSQL for the a-partition-with-no-statistics 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: ["55702: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 a-partition-with-no-statistics.compose.yml up -d --wait uv run a-partition-with-no-statistics.py uv run a-partition-with-no-statistics.py --help # every knob docker compose -f a-partition-with-no-statistics.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55702/postgres | Connection string. |
| --rows-per-day200,000 | Rows per day partition. |
Our run
The new partition, before and after ANALYZE
| State | Estimate | Actual | Join | Time (ms) |
|---|---|---|---|---|
| new partition, never analyzed | 649 | 200K | hash join | 90.7 |
| after ANALYZE events_5 | 200K | 200K | hash join | 55.7 |
new partition, never analyzed estimate 649 actual 200,000 join: hash join 90.7 ms
after ANALYZE events_5 estimate 200,000 actual 200,000 join: hash join 55.7 ms
n_mod_since_analyze = 200,000; auto-analyze fires after 0.1 × n + 50 = 20,050 changesWhat we found
Before ANALYZE the planner estimated 649 rows for a partition holding 200K; afterwards, 200K. The join method did not change here (hash join both times), because the dimension is small enough that a hash join wins even for a few hundred rows. The partition showed 200K modifications since analyze, past the auto-analyze threshold of 20.1K, so auto-analyze would have caught it on its next pass; this lab turned it off to look at the gap.
Read it
The gap between loading and analyzing is a window in which the most recent data is planned on a guess. Here the guess was off by orders of magnitude and the plan survived it; with a larger dimension, or a second join, the same error flips the join method. A loading job that analyzes the partition it just wrote closes the window instead of waiting for the background process.