Skip to content
About
Main content

A partition with no statistics

Run a real systemNeeds Docker, Python

Problem

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 notices enough changes (0.1 × rows + 50 by default). Until then the 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

Before ANALYZE, how many rows does the planner estimate for a new partition holding 200,000?

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.

    a-partition-with-no-statistics.py56 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    a-partition-with-no-statistics.compose.yml13 linesDownload
    View 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: 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 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
Knobs
Knob · defaultWhat 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

The new partition, before and after ANALYZE
StateEstimateActualJoinTime (ms)
new partition, never analyzed649200Khash join90.7
after ANALYZE events_5200K200Khash join55.7
Reference runApple M2 · 8 cores · 8 GB
Output · uv run a-partition-with-no-statistics.py
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 changes
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

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