Module 14 — Advanced Partitioning: Pruning, Maintenance, and pg_partman
What this module covers: Module 7 introduced partitioning strategies. This module goes deeper into the mechanics that make partitioning actually work at scale: how the query planner prunes partitions (and what silently breaks it), declarative partitioning vs inheritance-based partitioning and when each applies, constraint exclusion, and the operational reality of maintaining hundreds of partitions over months and years using pg_partman.
Declarative Partitioning vs Inheritance-Based Partitioning
Postgres has two partitioning systems with very different operational profiles.
Inheritance-Based Partitioning (Pre-PG10)
Before PostgreSQL 10, partitioning was implemented via table inheritance and CHECK constraints:
sql
Problems with inheritance partitioning:
INSERT routing requires a trigger on every insert (trigger overhead on every write)
The trigger must be manually updated every time a new partition is added
Unique constraints cannot span partitions
Foreign keys to/from partitioned tables are not enforced
No automatic partition pruning in the query planner (requires constraint_exclusion = on)
Declarative Partitioning (PG10+)
Declarative partitioning is a first-class feature. The database handles routing, pruning, and constraint enforcement natively:
sql
Advantages of declarative over inheritance:
No trigger required for INSERT routing (handled by the executor natively)
Primary key and unique constraints work (must include partition key)
Foreign keys to partitioned tables are supported (PG12+)
Native partition pruning — no constraint_exclusion needed
ATTACH PARTITION / DETACH PARTITION for online maintenance
When to still use inheritance: very old databases still on PG9.x (rare), or when you need a child table to have additional columns beyond the parent (inheritance allows this, declarative does not).
Partition Pruning: How It Works and What Breaks It
Partition pruning is the planner optimization that skips scanning irrelevant partitions. It is the primary reason to partition: a query on timestamp > '2026-05-01' should only scan transactions_2026, not all historical partitions.
How Pruning Works
The planner examines the partition key predicate and eliminates partitions whose bounds cannot contain matching rows.
sql
What Silently Breaks Pruning
1. Type mismatch between predicate and partition key:
sql
2. Function wrapping the partition key:
sql
3. OR conditions across partition key:
sql
4. Partition key in a subquery:
sql
5. Runtime parameter (Postgres < 14):
sql
Postgres 11+ supports runtime partition pruning — even parameterized queries prune at execution time.
Verifying Pruning
sql
Constraint Exclusion (Legacy)
For inheritance-based partitioning, constraint_exclusion enables the planner to use CHECK constraints for pruning:
ini
With declarative partitioning, constraint_exclusion is irrelevant — native partition pruning handles it. Only enable constraint_exclusion = on if you are still using inheritance-based partitioning.
Partition Maintenance at Scale
The Problem: Partition Explosion
A table partitioned by week over 5 years has 260 partitions. By month, 60. Manually creating and managing these partitions is error-prone and operationally expensive.
The worst case: the table has no partition for the current date range. An INSERT fails with:
text
This is a write failure — all inserts are rejected until a partition covering the current time range is created.
pg_partman: Automated Partition Management
pg_partman is the standard extension for automated partition management. It:
Creates future partitions proactively on a schedule
Optionally detaches or drops old partitions based on retention policy
Maintains a premake buffer (creates N future partitions ahead of current time)
Works with pg_cron or external schedulers
sql
(p_type and other create_parent parameter names have shifted across major pg_partman releases — verify the exact signature against the pg_partman version you're actually running before copying this as-is.)
This creates:
All monthly partitions from 2024-01-01 to 4 months in the future
The partman.part_config entry tracking this table
Automated Maintenance with pg_cron
Module 15 (Extensions Ecosystem) covers pg_cron itself in depth — the scheduling snippet below is the pg_partman-specific application of it.
sql
run_maintenance_proc() checks all registered partition sets and:
Creates new future partitions up to p_premake ahead
Detaches or drops partitions older than the retention policy
Setting a Retention Policy
sql
retention_keep_table = true detaches old partitions — they become standalone tables, accessible for archival queries but no longer part of the partitioned set. This allows bulk exports to cold storage before dropping.
ATTACH PARTITION and DETACH PARTITION
Manual partition management for cases where pg_partman is not used:
sql
To make ATTACH PARTITION instant (skip the validation scan):
sql
sql
Partition-Local Indexes vs Global Indexes
Each partition has its own indexes. There are no global indexes spanning all partitions.
sql
Unique Constraints and Partitioning
Unique constraints on a partitioned table must include the partition key:
sql
For global uniqueness on a non-partition-key column (e.g., hash must be globally unique), the options are:
Maintain a separate unpartitioned lookup table: CREATE TABLE transaction_hashes (hash BYTEA PRIMARY KEY, transaction_id BIGINT)
Enforce uniqueness at the application layer
Use hash as part of the partition key (impractical for range-based partitioning)
Summary
Concept
Key Takeaway
Declarative vs inheritance
Use declarative (PG10+) for all new work. Inheritance is legacy.
Partition pruning
Works automatically with declarative partitioning. Breaks with function wrapping, type mismatch, or OR conditions on partition key.
Verifying pruning
EXPLAIN (VERBOSE) and look for "Partitions excluded". Always verify after partitioning.
pg_partman
Automate partition creation and retention. Run via pg_cron hourly.
ATTACH PARTITION
Use NOT VALID + VALIDATE + ATTACH sequence for instant attachment of pre-loaded partitions.
DETACH CONCURRENTLY
PG14+ — detach without blocking reads/writes.
Global unique constraints
Must include partition key. For true global uniqueness, use a separate lookup table.
Partition-local indexes
New partitions do not inherit parent's indexes automatically. pg_partman handles this.
Knowledge Check
A Senior DBA is investigating a performance regression in a PostgreSQL 10 application (the first release with declarative partitioning). A query against a PARTITION BY RANGE (timestamp) table, executed via a prepared statement with a timestamp parameter (e.g., PREPARE q AS SELECT * FROM transactions WHERE timestamp = $1;), is unexpectedly performing full table scans across all partitions. Which of the following is the MOST likely architectural reason for this behavior?
A DevOps engineer configures pg_partman for a mission-critical transactions table, partitioned monthly by timestamp. The requirement is to keep 12 months of active data online, but also retain older data for compliance audits in a separate, queryable archive without it being part of the active partitioned set. Which pg_partman configuration, combined with its maintenance process, achieves this goal most effectively?
A team needs to integrate a large, pre-existing transactions_legacy_2023 table (containing 2023 data) into a live, declaratively partitioned transactions table with minimal disruption to ongoing read and write operations. The transactions table is partitioned by timestamp. Which sequence of operations will achieve this with the least impact on the live system?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
-- Parent table (no data stored here)CREATETABLEtransactions( id BIGSERIAL, block_height BIGINT,timestamp TIMESTAMPTZ, amount NUMERIC(38,8));-- Child table inherits parent's columnsCREATETABLE transactions_2025 (CHECK(timestamp>='2025-01-01'ANDtimestamp<'2026-01-01')) INHERITS (transactions);CREATETABLE transactions_2026 (CHECK(timestamp>='2026-01-01'ANDtimestamp<'2027-01-01')) INHERITS (transactions);-- Route inserts via triggerCREATEORREPLACEFUNCTION insert_transaction_trigger()RETURNSTRIGGERAS $$
BEGINIF NEW.timestamp>='2026-01-01'THENINSERTINTO transactions_2026 VALUES(NEW.*); ELSIF NEW.timestamp>='2025-01-01'THENINSERTINTO transactions_2025 VALUES(NEW.*);ENDIF;RETURNNULL;END;$$ LANGUAGE plpgsql;CREATETRIGGER insert_transaction
BEFORE INSERTONtransactionsFOR EACH ROWEXECUTEFUNCTION insert_transaction_trigger();
-- Declarative range partitioning by timestampCREATETABLEtransactions( id BIGSERIAL, block_height BIGINTNOTNULL,timestamp TIMESTAMPTZ NOTNULL, amount NUMERIC(38,8))PARTITIONBY RANGE (timestamp);-- Partition definition — no triggers neededCREATETABLE transactions_2025
PARTITIONOFtransactionsFORVALUESFROM('2025-01-01')TO('2026-01-01');CREATETABLE transactions_2026
PARTITIONOFtransactionsFORVALUESFROM('2026-01-01')TO('2027-01-01');-- Inserts route automatically based on timestamp valueINSERTINTOtransactions(block_height,timestamp, amount)VALUES(18500000,'2026-05-17 10:00:00+00',1.5);-- Goes to transactions_2026 automatically
EXPLAINSELECT*FROMtransactionsWHEREtimestamp>='2026-05-01'ANDtimestamp<'2026-06-01';-- With pruning:-- Append-- -> Seq Scan on transactions_2026-- Filter: (timestamp >= '2026-05-01' AND timestamp < '2026-06-01')-- Without pruning (all partitions scanned):-- Append-- -> Seq Scan on transactions_2025-- -> Seq Scan on transactions_2026-- ...
-- Partition key is TIMESTAMPTZ-- Predicate uses a TEXT literal — implicit cast prevents pruningWHEREtimestamp>='2026-05-01'::text-- Correct: use the matching typeWHEREtimestamp>='2026-05-01'::timestamptz
-- or just let Postgres infer the correct type:WHEREtimestamp>='2026-05-01 00:00:00+00'
-- date_trunc wraps the partition key — pruning cannot see through the functionWHERE date_trunc('month',timestamp)='2026-05-01'-- Correct: range predicate on the raw columnWHEREtimestamp>='2026-05-01'ANDtimestamp<'2026-06-01'
-- Both conditions needed for pruning; OR prevents eliminationWHEREtimestamp>'2026-05-01'OR block_height =18500000-- Planner cannot prune — block_height condition could match any partition
-- The outer query's partition key is hidden from the plannerSELECT*FROMtransactionsWHERE id IN(SELECT id FROM other_table WHEREtimestamp>'2026-05-01')-- Planner does not prune — it doesn't know which partitions id values fall in
-- In PG < 14, pruning only happens at plan time, not execution time-- Parameterized queries may not prune at plan timePREPARE q ASSELECT*FROMtransactionsWHEREtimestamp= $1;EXECUTE q('2026-05-17');-- may not prune in PG < 14
-- Always verify pruning is working after adding partitioningEXPLAIN(ANALYZE, VERBOSE)SELECTcount(*)FROMtransactionsWHEREtimestamp>='2026-05-01'ANDtimestamp<'2026-06-01';-- Look for:-- "Partitions excluded: 23 out of 24"-- or check that only one child table appears in the plan-- Disable pruning to see the unoptimized plan (diagnostic only):SET enable_partition_pruning =off;EXPLAINSELECTcount(*)FROMtransactionsWHEREtimestamp>='2026-05-01';-- Shows all partitions being scannedSET enable_partition_pruning =on;
# postgresql.confconstraint_exclusion=partition # only apply to partitioned tables (default)# constraint_exclusion = on # apply to all tables (expensive)# constraint_exclusion = off # disable completely
ERROR: no partition of relation "transactions" found for row
DETAIL: Partition key of the failing row contains (timestamp) = (2027-01-05 00:00:00+00).
-- Install pg_cronCREATE EXTENSION pg_cron;-- Run partition maintenance every hourSELECT cron.schedule('partman-maintenance','0 * * * *',-- every hour at :00 $$SELECT partman.run_maintenance_proc()$$
);
-- Keep 12 months of data; automatically detach older partitionsUPDATE partman.part_config
SET retention ='12 months', retention_keep_table =true-- detach (keep as regular table), not dropWHERE parent_table ='public.transactions';-- To drop instead of detach:UPDATE partman.part_config
SET retention ='12 months', retention_keep_table =falseWHERE parent_table ='public.transactions';
-- Create a new partition and attach it (near-instant with declarative partitioning)CREATETABLE transactions_2027
PARTITIONOFtransactionsFORVALUESFROM('2027-01-01')TO('2028-01-01');-- Alternatively: create as a regular table, load data, then attachCREATETABLE transactions_archive_2023 (LIKEtransactions INCLUDING ALL);-- ... load data into transactions_archive_2023 ...-- ATTACH acquires ShareUpdateExclusiveLock — reads and writes continue-- It performs a full table scan to validate all rows satisfy the partition constraintALTERTABLEtransactions ATTACH PARTITION transactions_archive_2023
FORVALUESFROM('2023-01-01')TO('2024-01-01');
-- Step 1: add the CHECK constraint before attaching (instant, NOT VALID)ALTERTABLE transactions_archive_2023
ADDCONSTRAINT chk_2023_range
CHECK(timestamp>='2023-01-01'ANDtimestamp<'2024-01-01')NOT VALID;-- Step 2: validate the constraint (ShareUpdateExclusiveLock, reads/writes continue)ALTERTABLE transactions_archive_2023
VALIDATE CONSTRAINT chk_2023_range;-- Step 3: attach — now instant because constraint proves all rows are validALTERTABLEtransactions ATTACH PARTITION transactions_archive_2023
FORVALUESFROM('2023-01-01')TO('2024-01-01');
-- Detach a partition (near-instant in PG14+ with CONCURRENTLY)ALTERTABLEtransactions DETACH PARTITION transactions_2023 CONCURRENTLY;-- PG14+: uses a weaker lock, allows reads/writes during detach
-- Create an index on the parent — it creates on all existing partitionsCREATEINDEX idx_transactions_block_height
ONtransactions(block_height);-- This creates:-- idx_transactions_2025_block_height on transactions_2025-- idx_transactions_2026_block_height on transactions_2026-- ... one per partition-- New partitions created later do NOT automatically get the index-- You must create it explicitly on new partitions, OR use pg_partman-- which handles this automatically
-- This fails: hash uniqueness cannot be guaranteed globallyALTERTABLEtransactionsADDCONSTRAINT transactions_hash_unique UNIQUE(hash);-- ERROR: unique constraint on partitioned tables must include all partitioning columns-- This works: uniqueness within each (timestamp_month, hash) combinationALTERTABLEtransactionsADDCONSTRAINT transactions_hash_unique
UNIQUE(timestamp,hash);-- Each partition enforces uniqueness within its time range