Database Partitioning Cheat Sheet
Covers PostgreSQL range, list, and hash partitioning strategies, partition management commands, and when partitioning improves query performance.
Range Partitioning
Split a table into partitions based on a continuous value range, ideal for time-series data.
-- Declarative range partitioning by date (PostgreSQL 10+)CREATE TABLE orders ( id SERIAL, order_date DATE NOT NULL, customer_id INT, amount NUMERIC) PARTITION BY RANGE (order_date);CREATE TABLE orders_2024_q1 PARTITION OF orders FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');CREATE TABLE orders_2024_q2 PARTITION OF orders FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');-- The planner automatically routes this to orders_2024_q1SELECT * FROM orders WHERE order_date = '2024-02-15';
Hash & List Partitioning
Distribute rows evenly with HASH, or split by discrete categories with LIST.
-- Hash partitioning: evenly distributes rows with no natural rangeCREATE TABLE users ( id INT NOT NULL, email TEXT) PARTITION BY HASH (id);CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);-- List partitioning: split by known discrete valuesCREATE TABLE sales ( id SERIAL, region TEXT NOT NULL, amount NUMERIC) PARTITION BY LIST (region);CREATE TABLE sales_us PARTITION OF sales FOR VALUES IN ('US', 'CA');CREATE TABLE sales_eu PARTITION OF sales FOR VALUES IN ('DE', 'FR', 'UK');
Partitioning Strategies
The main ways to split a large table and when to use each.
- Range- Splits rows by a continuous range of values (dates, IDs). Ideal for time-series data and rolling retention windows.
- List- Splits rows by explicit discrete values (region, status, tenant). Best when categories are known ahead of time.
- Hash- Distributes rows evenly across partitions using a hash of the key. Use when there's no natural range/list boundary and you need balanced write load.
- Composite (sub-partitioning)- Combines two strategies, e.g. RANGE by date then HASH by tenant_id, for finer-grained partition pruning.
- Partition pruning- The query planner skips scanning partitions that cannot contain matching rows based on the WHERE clause and partition key.
- Horizontal vs. vertical partitioning- Horizontal partitioning splits rows across tables; vertical partitioning splits columns into separate tables — a distinct technique.
- Sharding vs. partitioning- Partitioning splits data within a single database instance; sharding distributes partitions across multiple database servers/nodes.
Partition Maintenance
Common operations for adding, retiring, and inspecting partitions.
-- Detach a partition without a long-lived table lockALTER TABLE orders DETACH PARTITION orders_2024_q1;-- Attach a new partition for the next quarterALTER TABLE orders ATTACH PARTITION orders_2024_q3 FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');-- Instantly drop old data (no row-by-row DELETE, minimal WAL)DROP TABLE orders_2023_q4;-- Catch-all partition for values that don't match any defined range/listCREATE TABLE orders_default PARTITION OF orders DEFAULT;-- Inspect existing partitions of a tableSELECT relname FROM pg_class WHERE relispartition AND relname LIKE 'orders_%';
Indexes on Partitioned Tables
Global-looking indexes are actually per-partition local indexes managed as one logical object.
-- Creating an index on the parent creates a matching local index on every-- existing (and future) partition automaticallyCREATE INDEX idx_orders_customer ON orders (customer_id);-- Unique constraints must include the partition key -- Postgres cannot-- enforce global uniqueness across partitionsALTER TABLE orders ADD CONSTRAINT orders_pk PRIMARY KEY (id, order_date);-- Build an index on a single partition first (fast, no long lock on parent),-- then attach it to the parent's index definitionCREATE INDEX CONCURRENTLY idx_orders_2024_q3_customer ON orders_2024_q3 (customer_id);ALTER INDEX idx_orders_customer ATTACH PARTITION idx_orders_2024_q3_customer;
Composite (Multi-Level) Partitioning
Partition by date, then partition each date range again by tenant hash.
CREATE TABLE events ( id BIGSERIAL, tenant_id INT NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, payload JSONB) PARTITION BY RANGE (occurred_at);CREATE TABLE events_2024_q3 PARTITION OF events FOR VALUES FROM ('2024-07-01') TO ('2024-10-01') PARTITION BY HASH (tenant_id);CREATE TABLE events_2024_q3_p0 PARTITION OF events_2024_q3 FOR VALUES WITH (MODULUS 4, REMAINDER 0);CREATE TABLE events_2024_q3_p1 PARTITION OF events_2024_q3 FOR VALUES WITH (MODULUS 4, REMAINDER 1);-- ... p2, p3-- A query filtering on both occurred_at and tenant_id prunes to exactly one leaf
Automating Partition Rollover with pg_partman
Avoid hand-writing CREATE/DETACH statements for every new time window.
CREATE EXTENSION IF NOT EXISTS pg_partman;-- Register the table for automatic monthly partition managementSELECT partman.create_parent( p_parent_table => 'public.orders', p_control => 'order_date', p_interval => 'monthly', p_premake => 3 -- pre-create 3 future partitions);-- Configure retention: automatically detach (and optionally drop) old partitionsUPDATE partman.part_configSET retention = '12 months', retention_keep_table = falseWHERE parent_table = 'public.orders';-- Run via pg_cron or an external scheduler:SELECT partman.run_maintenance('public.orders');
Partitioning Gotchas & Limitations
Constraints that trip people up when moving an existing table to partitioned.
- No global unique/PK without the partition key- A UNIQUE or PRIMARY KEY constraint on a partitioned table must include the partitioning column(s), since Postgres enforces uniqueness per-partition
- Foreign keys referencing a partitioned table- Supported since PG12, but a partitioned table cannot itself be the referencing side of a composite FK easily in older versions -- check your version's docs
- ATTACH validates existing data- ATTACHing a table as a partition scans it to verify rows satisfy the partition bound unless a matching CHECK constraint already proves it, avoiding the scan
- Cross-partition queries can't use a single index scan- A query without a partition-key predicate must scan every partition (or its local index) and merge results, which can be slower than one big table for non-pruned queries
- Default partition blocks new ranges- If a DEFAULT partition exists and holds rows that would belong to a new range, Postgres refuses to ATTACH that range until the conflicting rows are moved out
- Migrating an existing large table- Requires creating a new partitioned table and backfilling (INSERT ... SELECT in batches) since ALTER TABLE cannot convert a table to partitioned in place
Monitoring Partition Size & Pruning
Verify partitions are balanced and that queries are actually being pruned.
-- Size of each partition, largest firstSELECT relname AS partition, pg_size_pretty(pg_total_relation_size(relid)) AS sizeFROM pg_catalog.pg_statio_user_tablesWHERE relname LIKE 'orders_%'ORDER BY pg_total_relation_size(relid) DESC;-- Confirm the planner is pruning: only matching partitions should appearEXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders WHERE order_date = '2024-08-01';-- Look for "Subplans Removed" or a plan touching only orders_2024_q3-- Row counts per partition (useful for spotting skewed hash distribution)SELECT relname, n_live_tup FROM pg_stat_user_tablesWHERE relname LIKE 'orders_%' ORDER BY n_live_tup DESC;
Choose the partition key to match your most selective and most frequent WHERE clause — a partitioning scheme that doesn't align with real query patterns adds maintenance overhead without improving performance, since the planner can't prune partitions it can't rule out.