PostgreSQL Table Partitioning — Range, List, Hash, and Pruning
Fast recall
Quick revision
Core answer
DETACH PARTITION CONCURRENTLY and DROP TABLE. Below ~50–100M rows the overhead rarely pays for itself; above 500M–1B rows it almost always does.Whether you pick the right partitioning strategy (range for time-series, list for tenants, hash for write distribution), understand partition pruning requires the partition key in WHERE, and know the operational tooling — pg_partman, DETACH PARTITION CONCURRENTLY, composite partitioning.
Partitioning a 50 GB table that would run fine on a plain B-Tree, or choosing hash partitioning and then being surprised that selective reads do not prune anything.
Show EXPLAIN output proving pruning, mention pg_partman and TimescaleDB for automated chunk management, describe the migration story for moving an existing large table to partitioning via logical replication, and note the 'partition key must be in unique constraints' rule.
Interview-ready guide
Interview answer
Declarative partitioning splits a big table into child tables while keeping one logical name. The single thing that justifies it is partition pruning — when a query filters on the partition key, the planner eliminates partitions that cannot contain matching rows before reading anything.
Three strategies
- RANGE on a timestamp is the common case — monthly partitions on
created_atfor events, orders, sensor readings - LIST on a discrete value when each tenant or region should live in its own partition
- HASH when you want write distribution but do not need selective read pruning
The operational win
- Archiving a month becomes a DETACH PARTITION plus DROP, which is milliseconds, instead of a DELETE that writes per-row WAL
- Retention policies become a scheduled DETACH rather than a mass DELETE
Two rules you must not break
- Unique constraints and primary keys must include the partition key, because PostgreSQL cannot enforce uniqueness across partitions without it
- Every query that matters should include the partition key in WHERE — otherwise the planner scans every partition, and that is slower than an unpartitioned table
When to partition
- Below around 50 to 100 million rows, don't — the overhead isn't worth it
- Above around 500 million to a billion rows, almost always
- In between, partition only if queries naturally filter by time or tenant and you archive data regularly
Tooling
- pg_partman automates creating future partitions, enforcing retention, and dropping old ones on a schedule
- TimescaleDB is a specialised extension for time-series workloads with hypertables and continuous aggregates
- For migrating an existing 500 GB table, logical replication or pg_partman's
partition_data_procgives you seconds of downtime instead of hours
PostgreSQL Table Partitioning — Range, List, Hash, and Pruning
Direct Answer
PostgreSQL declarative partitioning splits a parent table into physical child tables keyed by a partition column. RANGE partitions on time or numeric ranges, LIST on discrete values, HASH distributes rows evenly. The decisive benefit is partition pruning: when the query filters on the partition key, the planner scans only relevant partitions. Archiving is instant via DETACH PARTITION CONCURRENTLY and DROP TABLE. Below ~50–100M rows the overhead rarely pays for itself; above 500M–1B rows it almost always does.
Code Example
-- 1. RANGE partitioning on a time column — the dominant pattern
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
event_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
payload JSONB,
PRIMARY KEY (created_at, id) -- partition key MUST be in unique constraints
) PARTITION BY RANGE (created_at);
-- 2. Monthly partitions
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- Default partition catches out-of-range rows instead of erroring (PG 11+)
CREATE TABLE events_default PARTITION OF events DEFAULT;
-- 3. Index on the parent — cascades to all current and future partitions (PG 11+)
CREATE INDEX idx_events_user_created ON events (user_id, created_at DESC);
-- 4. Partition pruning in EXPLAIN
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2026-02-10' AND created_at < '2026-02-20'
AND user_id = 42;
-- Append
-- -> Index Scan using events_2026_02_user_id_created_at_idx on events_2026_02
-- Index Cond: (user_id = 42 AND created_at >= ... AND created_at < ...)
-- (Only events_2026_02 is scanned — pruning worked)
-- 5. Without the partition key, every partition is scanned
EXPLAIN
SELECT count(*) FROM events WHERE user_id = 42;
-- Append scans events_2026_01, events_2026_02, events_2026_03, events_default
-- Slower than the equivalent query on a non-partitioned table
-- 6. Instant archive: detach + drop
ALTER TABLE events DETACH PARTITION events_2025_01 CONCURRENTLY;
-- events_2025_01 is now a standalone table; export or drop without affecting parent
DROP TABLE events_2025_01;
-- 7. LIST partitioning — tenant-scoped multi-tenant table
CREATE TABLE documents (
id BIGSERIAL, tenant_id INT NOT NULL, body JSONB,
PRIMARY KEY (tenant_id, id)
) PARTITION BY LIST (tenant_id);
CREATE TABLE documents_tenant_1 PARTITION OF documents FOR VALUES IN (1);
CREATE TABLE documents_tenant_2 PARTITION OF documents FOR VALUES IN (2);
-- 8. HASH partitioning — distributes rows evenly, no pruning for selective reads
CREATE TABLE jobs (
id BIGSERIAL, user_id BIGINT NOT NULL, body JSONB,
PRIMARY KEY (user_id, id)
) PARTITION BY HASH (user_id);
CREATE TABLE jobs_0 PARTITION OF jobs FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE jobs_1 PARTITION OF jobs FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE jobs_2 PARTITION OF jobs FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE jobs_3 PARTITION OF jobs FOR VALUES WITH (MODULUS 4, REMAINDER 3);
-- 9. pg_partman — automate creation, retention, and cleanup
CREATE EXTENSION pg_partman;
SELECT partman.create_parent(
p_parent_table => 'public.events',
p_control => 'created_at',
p_type => 'native',
p_interval => 'monthly',
p_premake => 4
);
UPDATE partman.part_config
SET retention = '12 months',
retention_keep_table = false
WHERE parent_table = 'public.events';
-- Scheduled maintenance (pg_cron or external scheduler)
-- SELECT partman.run_maintenance();
Common Mistakes & Pitfalls
Partitioning a 50 GB table that would run fine on a plain B-Tree, or choosing hash partitioning and then being surprised that selective reads do not prune anything.
Check your understanding
Answer these without looking back at the article.
- What is partition pruning and what conditions must be true for it to happen?
- How would you migrate an existing 500 GB table to range partitioning with minimal downtime?
- Why must unique constraints on a partitioned table include the partition key?
- How does TimescaleDB's hypertable differ from native declarative partitioning?
Related interview prep categories
1Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.