Table Partitioning in PostgreSQL: Range, List & Hash Partitions
Learn PostgreSQL table partitioning — splitting one logical table into smaller physical pieces by range, list, or hash — and how partition pruning speeds up queries.
Table Partitioning in PostgreSQL
Partitioning splits one large logical table into smaller physical pieces ("partitions"), while every query still targets it as if it were a single table. It's how huge tables (hundreds of millions of rows) stay manageable and fast.
Range Partitioning — By a Value Range
The most common case: partitioning a huge log/events table by date.
CREATE TABLE events (
id BIGSERIAL,
event_type TEXT,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2024_01 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE events_2024_02 PARTITION OF events
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
Inserts automatically route to the correct partition based on created_at — your application just inserts into events and never has to know which partition a row landed in.
INSERT INTO events (event_type, created_at) VALUES ('login', '2024-01-15');
-- automatically stored in events_2024_01
List Partitioning — By an Explicit Set of Values
CREATE TABLE orders (
id BIGSERIAL,
region TEXT,
total NUMERIC
) PARTITION BY LIST (region);
CREATE TABLE orders_asia PARTITION OF orders FOR VALUES IN ('India', 'China', 'Japan');
CREATE TABLE orders_europe PARTITION OF orders FOR VALUES IN ('Germany', 'France');
Hash Partitioning — Even Distribution With No Natural Grouping
When there's no meaningful range or list to split on, distribute rows evenly by hashing a column.
CREATE TABLE users (
id BIGSERIAL,
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);
CREATE TABLE users_p2 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE users_p3 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 3);
Partition Pruning — Why This Speeds Things Up
When a query's WHERE clause constrains the partition key, PostgreSQL skips reading partitions that couldn't possibly contain a match:
EXPLAIN SELECT * FROM events WHERE created_at >= '2024-02-01' AND created_at < '2024-02-15';
-- only scans events_2024_02 — every other monthly partition is skipped entirely
Dropping Old Data Instantly
The biggest operational win: deleting a whole month of old data becomes an instant metadata operation instead of a slow row-by-row DELETE.
DROP TABLE events_2023_01;
-- instantly removes an entire month's data, versus:
-- DELETE FROM events WHERE created_at >= '2023-01-01' AND created_at < '2023-02-01'; (slow on huge tables)
Common Errors & Troubleshooting
ERROR: no partition of relation "events" found for row— you tried to insert a row whose partition key value doesn't fall into any defined partition range; add a partition covering that range (or aDEFAULTpartition to catch anything unmatched).- Query not pruning partitions despite a
WHEREon the partition key — check theWHEREclause uses the partition key directly, without wrapping it in a function (likeDATE(created_at)) — that can prevent the planner from pruning, the same trap covered in the Query Optimization lesson. - Unique constraints failing unexpectedly on a partitioned table — a
PRIMARY KEY/UNIQUEconstraint on a partitioned table must include the partition key as part of the key — PostgreSQL can't otherwise guarantee uniqueness across separate partitions.
Key Takeaways
- Partitioning is invisible to most queries — you insert/select against the parent table, and PostgreSQL routes rows automatically.
- Partition pruning means a well-targeted query only scans the relevant partitions, not the entire table.
- Dropping an entire partition is near-instant — the standard way to purge old time-series data at scale.