Verifying Partition Pruning with EXPLAIN ANALYZE
This guide confirms, with certainty, which partitions a given query reads — in PostgreSQL and MySQL, for literal and parameterised forms — and turns that check into a test that fails when a code change breaks pruning. It applies the diagnostics introduced in Partition Pruning & Query Planning within the wider Database Partitioning Fundamentals & Architecture section.
Prerequisites
Step 1 — Capture the statement the application really sends
The plan depends on the statement form, so the first job is to stop guessing at it. pg_stat_statements normalises literals into placeholders, which is exactly the form you want for the parameterised check:
SELECT queryid,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(mean_plan_time::numeric, 2) AS plan_ms,
query
FROM pg_stat_statements
WHERE query ILIKE '%from events%'
ORDER BY mean_exec_time * calls DESC
LIMIT 5;
queryid | calls | mean_ms | plan_ms | query
-----------+--------+---------+---------+------------------------------------------------
-84213... | 412039 | 1893.4 | 14.21 | SELECT count(*) FROM events WHERE occurred_at >= $1 AND occurred_at < $2
Operational note: mean_plan_time is populated when pg_stat_statements.track_planning is on. A plan time above one millisecond on a simple query is itself a pruning signal — it means the planner is considering many children.
DBA tip: Sort by mean_exec_time * calls rather than by mean alone. A 40 ms query called two million times a day costs far more than a 4-second report run twice.
Step 2 — Check the literal form first
Start with the simplest case, because if this does not prune, nothing else will:
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*)
FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';
A healthy plan names exactly one child and has no Append node at all, because a single surviving partition needs no appending:
Aggregate (actual time=41.882..41.883 rows=1 loops=1)
Buffers: shared hit=1204 read=98311
-> Seq Scan on events_2026_08 events (actual time=0.019..29.114 rows=31428104 loops=1)
Filter: ((occurred_at >= '2026-08-01 00:00:00+00') AND (occurred_at < '2026-09-01 00:00:00+00'))
Buffers: shared hit=1204 read=98311
Operational note: Include BUFFERS. Buffer counts are the honest measure of work done — a plan that reads 98,000 blocks is reading one month; a plan reading 3.4 million blocks is reading the table regardless of what the row counts suggest.
SRE tip: Run this against a database with production-like statistics. A staging copy with a thousand rows per partition will prune identically but tells you nothing about whether the plan choice survives real cardinalities.
Step 3 — Check the parameterised form, which is what production runs
This is the step that finds real bugs. Prepare the statement and force the generic plan:
PREPARE ev_count(timestamptz, timestamptz) AS
SELECT count(*) FROM events WHERE occurred_at >= $1 AND occurred_at < $2;
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, COSTS OFF)
EXECUTE ev_count('2026-08-01', '2026-09-01');
Aggregate (actual time=42.914..42.915 rows=1 loops=1)
-> Append (actual time=0.028..39.774 rows=31428104 loops=1)
Subplans Removed: 35
-> Seq Scan on events_2026_08 events_1 (actual time=0.027..37.001 rows=31428104 loops=1)
Filter: ((occurred_at >= $1) AND (occurred_at < $2))
Planning Time: 13.884 ms
Execution Time: 42.955 ms
Subplans Removed: 35 is the proof. Note also Planning Time: 13.884 ms — pruning worked, and planning still cost thirteen milliseconds because all 36 children were considered before 35 were dropped.
Operational note: Reset with SET plan_cache_mode = auto; when you are done. Leaving a session pinned to generic plans is harmless; leaving it in a connection-pooled application is not, because the setting outlives the request on a reused connection.
DBA tip: If Subplans Removed is absent and every child shows real rows, compare the generic plan against the custom plan by setting force_custom_plan instead. A large difference is the case for pinning that workload to custom plans.
Step 4 — Verify in MySQL with the partitions column
MySQL reports pruning directly, which makes the check shorter:
EXPLAIN SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p202608
type: range
possible_keys: idx_occurred_at
key: idx_occurred_at
rows: 31428104
A comma-separated list of every partition name in that column is the failure signal. For the parameterised case, MySQL prunes when the statement executes with bound values, so use the prepared-statement form to be sure:
PREPARE ev FROM 'SELECT count(*) FROM events WHERE occurred_at >= ? AND occurred_at < ?';
SET @a = '2026-08-01', @b = '2026-09-01';
EXPLAIN EXECUTE ev USING @a, @b\G
Operational note: MySQL’s EXPLAIN does not execute the statement, so unlike PostgreSQL’s EXPLAIN ANALYZE it is always safe to run against writes.
SRE tip: EXPLAIN ANALYZE exists in MySQL 8.0.18+ and reports actual timings, but the partitions column from plain EXPLAIN is the pruning check — the analyze form is for cardinality misestimates.
Step 5 — Turn the check into a test
A plan verified by hand stays verified until the next ORM upgrade. Encode the expectation instead:
# tests/test_pruning.py — fails when a query stops pruning
import re
import pytest
QUERIES = [
("monthly_count",
"SELECT count(*) FROM events WHERE occurred_at >= %s AND occurred_at < %s",
("2026-08-01", "2026-09-01"),
1), # expected partitions touched
("tenant_recent",
"SELECT id FROM events WHERE tenant_id = %s "
"AND occurred_at >= %s AND occurred_at < %s LIMIT 50",
(8842, "2026-08-01", "2026-09-01"),
1),
]
CHILD_RE = re.compile(r"(?:Seq Scan|Index Scan|Bitmap Heap Scan)[^\n]*on (events_\d{4}_\d{2})")
def partitions_touched(cur, sql, params):
cur.execute("EXPLAIN (ANALYZE, COSTS OFF, FORMAT TEXT) " + sql, params)
plan = "\n".join(row[0] for row in cur.fetchall())
touched = {
m.group(1)
for line, m in ((l, CHILD_RE.search(l)) for l in plan.splitlines())
if m and "never executed" not in line
}
return touched
@pytest.mark.parametrize("name,sql,params,expected", QUERIES)
def test_query_prunes(db_cursor, name, sql, params, expected):
touched = partitions_touched(db_cursor, sql, params)
assert len(touched) == expected, (
f"{name} touched {len(touched)} partitions ({sorted(touched)}), expected {expected}"
)
Operational note: The regular expression above deliberately ignores children annotated never executed, so the test passes for both plan-time and runtime pruning — the two forms are equally acceptable outcomes.
SRE tip: Run the same assertion as a scheduled job against production once a day, reading the top statements from pg_stat_statements. It catches pruning regressions introduced by data growth and statistics changes, which no pull request can predict.
Verification
Confirm the whole chain works by deliberately breaking it. Add a cast to the predicate and re-run the test:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM events WHERE occurred_at::date = '2026-08-15';
Expected output — every child scanned, which is precisely what the test must catch:
Aggregate (actual time=4128.771..4128.772 rows=1 loops=1)
-> Append (actual time=0.031..4009.114 rows=1044118 loops=1)
-> Seq Scan on events_2024_09 events_1 (actual time=0.030..88.204 rows=0 loops=1)
Filter: ((occurred_at)::date = '2026-08-15'::date)
-> Seq Scan on events_2024_10 events_2 (actual time=0.021..91.113 rows=0 loops=1)
... 34 more children, all executed ...
The test should fail with monthly_count touched 36 partitions, expected 1. If it passes, the assertion is not reading the plan correctly — fix the test before trusting it.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
Plan verified in psql but production is slow |
the manual check used literals and a custom plan; production uses a bound parameter and a generic plan | verify with plan_cache_mode = force_generic_plan, or capture and PREPARE the exact normalised statement from pg_stat_statements |
| Pruning test passes in CI, fails in production | CI database has too few partitions for the plan shape to differ, or different statistics | seed CI with the same number of partitions as production (rows can stay tiny) and refresh statistics after seeding |
EXPLAIN ANALYZE on a write statement changes data |
ANALYZE executes the statement rather than simulating it |
wrap in BEGIN; ... ROLLBACK; and expect the locks and WAL to be real; never run it against a write path during peak |
FAQ
Is EXPLAIN ANALYZE safe to run in production?
EXPLAIN ANALYZE executes the statement, so it is safe for SELECT and unsafe for anything that writes unless you wrap it in a transaction you roll back. For write statements use BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK; and be aware that the work is really performed — locks are taken and WAL is written before the rollback.
Why does EXPLAIN show partitions that were never executed?
That is runtime pruning working as designed. The plan was built before the parameter value was known, so every child appears in it, and execution skipped the ones that could not match. A child annotated (never executed) read no data. What you do not want to see is children with real row counts and actual times, which means no pruning happened at all.
How do I check pruning for the SQL my ORM actually sends?
Capture the statement from the database rather than from the application: enable log_statement or read pg_stat_statements, take the exact text including the parameter placeholders, then PREPARE it and run EXPLAIN with plan_cache_mode set to force_generic_plan. Retyping the query with literals in psql tests a different planner path and is the most common reason a pruning bug survives review.
Related
- Partition Pruning & Query Planning — the parent topic covering how pruning decides what to skip and what it costs
- Fixing Queries That Defeat Partition Pruning — what to do once this guide has found a query that scans everything
- Using Django ORM with PostgreSQL Partitioned Tables — enforcing the partition key at the ORM layer so the plan never regresses