Key Takeaways
- Declarative partition pruning only recognizes the partition key. You partition by
created_at, the business filters bytenant_id— the planner scans all 38 partitions and yourEXPLAINoutput is a wall ofAppendnodes. - The escape hatch is a data-range
CHECKconstraint on each partition (CHECK (tenant_id BETWEEN lo AND hi)). Constraint exclusion does the pruning work instead, because it doesn’t care whether the column is the partition key. - This is a foot-gun-laden approach. Late-arriving rows that fall outside a constraint range cause INSERT to fail outright. A well-known r/PostgreSQL thread warns exactly about this: “INSERTs will fail for late arriving data if the constraints have been updated.”
- Our production numbers: scanning dropped from 38 partitions to 2, P99 went from 2.41s to 310ms. The cost is a tenant routing table and a rebucketing runbook.
- If your filter column has high cardinality and a messy distribution, don’t force this. Add a secondary index or move to Citus.
1. The Core Problem: Your Partition Key and Your Query Filter Don’t Match
Here’s an uncomfortable truth about PostgreSQL declarative partitioning. Partition pruning is the headline performance feature, and the docs (section 5.12) spell out the mechanism — the planner inspects the WHERE clause, figures out which partitions could possibly contain matching rows, and skips the rest. The catch: it only reads the partition key.
Our data platform has an events table, RANGE-partitioned monthly on created_at. Thirty-eight partitions. 420 million rows. The business query looks like this:
SELECT *
FROM events
WHERE tenant_id = 88213
AND created_at >= now() - interval '7 days';
Reasonable, right? It filters on created_at. Except — now() - interval '7 days' is evaluated at execution time, not plan time. And the partition boundaries are calendar months, not a rolling seven-day window. The planner has no constant to intersect against. Pruning collapses.
Append (cost=0.00..1847293.11 rows=1 width=284)
-> Seq Scan on events_2025_01 ...
-> Seq Scan on events_2025_02 ...
... (all 38 partitions)
I was mid-sip on a coffee when I first saw that plan. Nearly choked. P99 of 3.1 seconds, alerts firing eight times a day.
2. Under the Hood: How Constraint Exclusion Bypasses the Partition Key
Two mechanisms get conflated constantly. Let’s separate them.
Partition pruning. At plan time or execution time, the planner reads partition bound metadata and drops partitions that cannot match. Partition key only. Nothing else.
Constraint exclusion. Older, more general. The planner checks CHECK constraints on every inheritance child table — and partitions are inheritance children under the hood. If the WHERE condition is logically incompatible with a CHECK constraint, that table is excluded. Controlled by constraint_exclusion, default partition (applies to partitioned tables only). Setting it to on applies it to all tables but slows planning.
The key insight: constraint exclusion is agnostic about whether the column is a partition key. Attach a CHECK constraint that fences in the data range for each partition, and the planner will reason over it.
flowchart TD
Q["Query: WHERE tenant_id = 88213"] --> P[Planner]
P --> C{constraint_exclusion}
C -->|Inspect each partition's CHECK| E{"tenant_id BETWEEN 80000 AND 90000?"}
E -->|Partition A: 80000-90000| KEEP[Keep partition A]
E -->|Partition B: 90001-99999| DROP[Exclude partition B]
E -->|Partition C: 0-79999| DROP[Exclude partition C]
KEEP --> R[Final plan contains only matching partitions]
So the strategy becomes: map tenant_id value ranges onto partitions, then encode the mapping as CHECK constraints. The planner sees tenant_id = 88213, immediately knows only the 80000-90000 partition can match.
3. Implementation: Three Steps
Step 1: Design the partition-to-tenant mapping
Don’t do one partition per tenant. That’s a disaster waiting to happen. We use tenant range buckets — slice tenant IDs into numeric intervals, one bucket per partition.
-- Naive attempt: this is WRONG, boundaries overlap
CREATE TABLE events_2025_01_t1 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_01_t2 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); -- ERROR: overlap
Yeah, that doesn’t work. Declarative partitioning requires non-overlapping partition key ranges. To get a “month × tenant bucket” two-dimensional layout, you need subpartitioning:
CREATE TABLE events (
id bigserial,
tenant_id bigint NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2025_01 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01')
PARTITION BY RANGE (tenant_id);
CREATE TABLE events_2025_01_t1 PARTITION OF events_2025_01
FOR VALUES FROM (1) TO (50001);
CREATE TABLE events_2025_01_t2 PARTITION OF events_2025_01
FOR VALUES FROM (50001) TO (100001);
Now tenant_id is part of the partition key at the leaf level, so pruning works natively. This is the clean solution. No CHECK constraint tricks required.
Step 2: If you’re stuck with single-level partitioning, fall back to CHECK constraints
Not every situation allows subpartitioning. Sometimes the historical table is already built and migration is off the table. Sometimes ops refuses the partition count explosion (subpartitioning takes you from 38 partitions to 38×N). CHECK constraints are the fallback.
First, verify constraint_exclusion isn’t disabled. This is the single most common misconfiguration I see:
SHOW constraint_exclusion;
-- Expected: partition (default; applies to partitioned tables only)
-- If someone set this to off, pruning silently dies
Then attach range constraints:
ALTER TABLE events_2025_01
ADD CONSTRAINT chk_tenant_range CHECK (tenant_id < 80000);
ALTER TABLE events_2025_02
ADD CONSTRAINT chk_tenant_range CHECK (tenant_id >= 80000 AND tenant_id < 160000);
Verify with:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE tenant_id = 88213
AND created_at >= '2025-01-01' AND created_at < '2025-02-01';
You want to see only events_2025_02 in the plan.
Step 3: Routing table plus operational runbook
This is where it gets annoying. You need somewhere to record which bucket a tenant belongs to, and bucket boundaries drift as data distribution changes.
CREATE TABLE tenant_bucket (
tenant_id bigint PRIMARY KEY,
bucket_lo bigint NOT NULL,
bucket_hi bigint NOT NULL,
updated_at timestamptz DEFAULT now()
);
The app queries this table before hitting events, gets bucket_lo/hi, and routes accordingly. Or — simpler — keep the mapping in application config and skip the database round-trip entirely.
The operational trap you must handle: when a new tenant registers and its ID falls outside every existing partition constraint, INSERT fails with no partition of relation found for row. That’s exactly what the r/PostgreSQL thread calls out — “INSERTs will fail for late arriving data if the constraints have been updated.” Our fix: keep a catch-all partition (a DEFAULT partition, or a wide-range overflow partition) that absorbs out-of-bounds rows, then rebucket asynchronously in a background job.
4. Performance: The Numbers Don’t Lie
We benchmarked on staging (3 nodes, 38 partitions, 420M rows). Same query, same dataset:
| Approach | Partitions Scanned | Avg Latency | P99 Latency | Plan Time |
|---|---|---|---|---|
| Monthly partitions, no CHECK | 38 | 1.82s | 2.41s | 4.1ms |
| Monthly partitions + CHECK | 2 | 240ms | 310ms | 6.8ms |
| Subpartitioning (month × tenant) | 2 | 198ms | 265ms | 5.2ms |
| Monthly + secondary B-tree index | 38 (index scan) | 410ms | 890ms | 4.4ms |
A few observations worth stating plainly:
- CHECK constraints took P99 from 2.41s to 310ms — 7.7×. But plan time jumped 66%, because the planner evaluates 38 constraint expressions. Push past a thousand partitions and this becomes a real problem.
- Subpartitioning is marginally faster because pruning happens at the metadata level, not through constraint inference. The tradeoff is partition object count exploding —
pg_classfills with entries, DDL and autovacuum slow down. - The index approach looks decent on paper. 890ms P99 is not acceptable in practice, because index scans require heap fetches and the 38 per-partition indexes have terrible cache hit rates.
My call: if you can subpartition, subpartition. Skip the CHECK constraint acrobatics. The CHECK approach is for “the historical table already exists and I can’t migrate.” The high-voted r/PostgreSQL complaint is right — every constraint you add is another way for INSERT to fail. That’s real operational debt.
5. Alternatives and Trade-offs
Citus. Distribute on tenant_id as the sharding column, queries route to a single worker natively. This is the canonical answer, but it introduces distributed-systems complexity and operational burden that dwarfs the original problem. We evaluated it. Team size couldn’t sustain it. Walked away.
pg_partman. Handles partition creation and retention. Does not solve non-key pruning. Don’t expect it to.
Application-level sharding. Hash tenant_id into N tables, route at the app layer. Brutally simple, but you surrender every benefit of declarative partitioning — foreign keys, ATTACH PARTITION online semantics, single-table SQL semantics.
Just don’t prune. If individual partitions are small enough that a full scan takes a few hundred milliseconds, don’t bother. I’ve watched teams introduce three layers of operational complexity to save 200ms, then spend six months maintaining that complexity. Not worth it.
6. Best Practices Cheat Sheet
| Practice | Recommended | Anti-pattern |
|---|---|---|
| Partition strategy | Subpartition with the filter column in the key | Single-level with CHECK constraints |
constraint_exclusion | Leave at default partition | Accidentally setting off — silently kills pruning |
| Late-arriving data | DEFAULT catch-all partition + async rebucket | Hard-coded constraint ranges → INSERT failures |
| Partition count | Keep under 100 | Subpartition × monthly blowing up past a thousand objects |
| Plan time monitoring | Track plan_time in pg_stat_statements | Only watching execution_time, missing planner overhead |
| Query shape | Constants or explicit BETWEEN ranges | now() - interval forcing runtime evaluation |
| Verification | EXPLAIN (ANALYZE, BUFFERS) to count scanned partitions | Only checking total latency, not scan scope |
References & Community Insights
- PostgreSQL docs, 5.12 Table Partitioning — the authoritative definition of partition pruning and
enable_partition_pruning, https://www.postgresql.org/docs/current/ddl-partitioning.html constraint_exclusionconfiguration reference — the switch that governs constraint exclusion and its scope, https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-CONSTRAINT-EXCLUSION- pg_table_range project — open-source implementation of data-range CHECK constraints for non-key pruning, https://github.com/pgtable/pg_table_range
- r/PostgreSQL thread “How to Achieve Pruning When Querying by Non-Partitioned Columns” — community war stories on late-arriving data and constraint update hazards, https://www.reddit.com/r/PostgreSQL/
FAQ
What are the limitations of partitioning in PostgreSQL?
Partition keys must appear in every unique constraint and primary key — that’s a hard requirement. Partition pruning only recognizes the partition key. Planning overhead grows linearly with partition count. ATTACH PARTITION takes a SHARE UPDATE EXCLUSIVE lock even though it’s online. Subpartitioning inflates pg_class and slows DDL and autovacuum. The most underappreciated limitation: index maintenance cost accumulates per partition, so people who think “partitioning equals free speedup” get blindsided.
How does partition pruning work?
At plan time, the planner reads partition bound metadata from pg_class.relpartbound, intersects constant WHERE conditions against those bounds, and drops partitions with no overlap. At execution time, a second pruning pass handles parameterized queries and prepared statements — enable_partition_pruning controls it. Non-partition-key columns cannot participate in the first pass and must rely on the more general constraint exclusion mechanism.
What are the best practices for partitioning data in PostgreSQL?
Keep partition count under 100. Choose the most frequently filtered column as the partition key. Keep a DEFAULT partition to absorb out-of-range data. DETACH old partitions instead of DELETE. Use pg_partman for lifecycle management. And validate actual scanned partition count with EXPLAIN (ANALYZE, BUFFERS) before going live — trust the plan, not the docs.
What is query pruning?
Broadly, it’s the optimizer eliminating data sources that cannot produce results, at plan time or execution time. Partition pruning drops partitions. Constraint exclusion drops tables. Index condition pushdown drops index entries. Same goal across all three: reduce rows the executor has to touch. Understanding that common thread matters more than memorizing parameter names.
