Postgres · partitioning for locality and cheap bulk removal
When a single tenant's rows dominate a table, or a log needs monthly retention, one big heap makes every cleanup touch everything. Partitioning splits the table so per-tenant work stays local and removing a slice becomes a metadata operation, not a million-row DELETE.
DELETE FROM claims WHERE …DROP TABLE claim_events_2026_01;01 The shape of the pain
Picture a claims table: a couple hundred million rows, spread across thousands of projects. A plain table in Postgres is one file, called the heap, and rows land in it roughly in the order they were inserted. Yours arrive interleaved, so one project's rows end up scattered the length of the file. Most days it behaves. The trouble starts with the per-project maintenance you have to run.
You run a dedup anti-join for project 42, deleting rows that a newer claim supersedes. You autovacuum. Later, project 42 wraps up and you want its data gone. Every one of these jobs cares about a single project. Every one of them, on a plain heap table, drags across all of it: the anti-join and the vacuum walk rows and indexes that belong to thousands of other projects, and the archive is a giant DELETE that leaves dead tuples behind for vacuum to chase.
The waste is structural. Your data has a natural slice, one project, but the storage has no idea that slice exists. Every logical operation on one slice pays for the whole table.
02 Why the big table fights back
Deleting a million rows in Postgres does not free a million rows. It marks each one dead, a dead tuple, writes a WAL record (the write-ahead log, Postgres' crash-safety journal) for each, leaves every matching index entry pointing at a corpse, and hands the cleanup to vacuum. Until vacuum runs and the pages are reused, the table keeps the same size on disk. You paid full price to remove data and the file did not shrink.
Indexes help you find project 42's rows, but they do nothing for the second problem: a vacuum or an anti-join scoped to one project still has to reason about a structure shared with everyone else. There is no physical boundary around a project, so there is nothing the engine can skip.
Removing rows is expensive. Removing a whole partition is nearly free, because it was already a separate thing.
03 What partitioning actually gives you
Declare a table PARTITION BY some key and Postgres stops storing rows in one heap. Each row goes to a child table, a partition, chosen by that key. The parent becomes a routing rule with no storage of its own. Two things fall out of that, and they are the whole point.
First, pruning: when your query restricts the partition key, the planner proves which partitions can possibly match and skips the rest before reading a single page. Second, whole-partition operations: a partition is a real table, so you can DETACH or DROP it as a catalog change, instantly, without touching the rows inside.
Those map exactly onto the two jobs from the start. Locality comes from choosing a key that puts one logical slice in one partition, so pruning localizes the work. Cheap removal comes from choosing a key where a slice you retire is a whole partition, so you drop it instead of deleting it. Different jobs, different keys.
04 The three ways to split
Postgres gives you three partitioning methods, and choosing between them is not a matter of taste. It follows from the shape of the column you split on, and from whether the slice you want to drop lines up with a single partition.
RANGE splits on contiguous, ordered ranges: a timestamp, an id band, a price bracket. A row lands in the partition whose range contains its value. This is the one for anything ordered with a retention story, because the slice you retire, last month, is a contiguous range you can drop whole.
LIST splits on an explicit set of discrete values: a region, a status, a tier, or one specific heavy tenant pinned to its own partition. A row routes by exact membership. Reach for it when the column is a small set of meaningful categories, or when you want to isolate one value so that dropping exactly that slice is a single DROP.
HASH splits on modulus and remainder over the hash of the key, with no order and no categories, just even spread. A row routes by its hash. This is the locality play: it co-locates one key's rows without you enumerating anything, at the price of not pruning ranges and not dropping a single logical value, because a bucket holds many.
Two differences decide most designs: what predicates let the planner prune, and whether a slice you retire is exactly one partition. Click a type below and read down its column.
= < > BETWEEN= IN= onlyPruning columns are the predicate classes each method can prune on. HASH can only prune equality, which is why it buys locality but not range queries or single-value drops.
05 Strategy A · locality
For per-tenant locality, hash-partition on the owner key. Every row for a given project_id hashes to the same bucket, so a project's entire history lives inside one partition. Eight buckets spreads the load and keeps any single partition a manageable slice of the whole.
-- the partition key must be part of the primary key CREATE TABLE claims ( project_id bigint NOT NULL, profile_id bigint NOT NULL, -- ... claim columns ... PRIMARY KEY (project_id, profile_id) ) PARTITION BY HASH (project_id); CREATE TABLE claims_p0 PARTITION OF claims FOR VALUES WITH (MODULUS 8, REMAINDER 0); -- ... claims_p1 .. claims_p7 ...
Now the payoff. The dedup anti-join for project 42 runs entirely inside that project's one partition, because every row it compares shares the same project_id. Autovacuum works one leaf at a time, so a project's dead tuples and index churn stay isolated from the other seven. The cache stays warm on the buckets you are actually touching.
06 The live knob
Here is pruning you can operate. Pick an operation, then drag the project id. The eight boxes are the hash partitions of claims. Lit means the operation touches it; dimmed means the planner proved it cannot match and skipped it before reading a page.
The lesson is in one contrast. Every operation that names project_id collapses to a single partition. The one operation that does not name it lights all eight. The predicate is what lets the planner prune, and pruning is the entire locality win.
The bucket a project lands in is shown illustratively as project_id % 8; the real server uses Postgres' hash function, but the behavior, one project to one partition, is exactly this.
Name the partition key in your predicate and eight partitions become one. Forget to, and you scan them all.
07 Strategy B · retention
An append-only log wants a different key. Partition claim_events by range on its timestamp, one partition per month, plus a DEFAULT to catch anything that has no month partition yet. Now retention is not a query at all. Scroll through what a month's life looks like.
Each month of events is its own child table. The parent routes a row by its at timestamp. Seven months here, and a DEFAULT at the bottom.
Writes for July go straight to claim_events_2026_07. That is the only hot partition. Every insert, index update, and the vacuum that follows stay confined to it.
An event for a month you have not created a partition for still inserts, into DEFAULT, instead of erroring. It is the safety net that keeps writes flowing while you provision months ahead.
January ages out. You DETACH then DROP the month, a catalog change that unlinks the file. No scan, no dead tuples, no vacuum. The hot partition never even notices.
CREATE TABLE claim_events ( id bigint GENERATED ALWAYS AS IDENTITY, at timestamptz NOT NULL, -- ... event columns ... PRIMARY KEY (id, at) -- at joins the PK: it is the partition key ) PARTITION BY RANGE (at); CREATE TABLE claim_events_2026_07 PARTITION OF claim_events FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); CREATE TABLE claim_events_default PARTITION OF claim_events DEFAULT; -- retention: instant, metadata-only ALTER TABLE claim_events DETACH PARTITION claim_events_2026_01; DROP TABLE claim_events_2026_01;
The live table never bloats, because the writes and the deletes never share a partition. July grows; January leaves. The hot path and the retention path are physically separate slices, so neither one ever waits on the other.
08 The mechanism behind the win
Both of these get rid of a month of events. They are not the same size of operation, not even close. Flip between them and read what each one actually asks the database to do.
Cost bars are qualitative, not benchmarked. The point is the shape: DELETE is work proportional to rows, DROP is a fixed catalog change.
This is why the partition key on the log is chosen so a retention slice is exactly one partition. When the thing you retire lines up with a partition boundary, removal stops being data work and becomes bookkeeping. That is the cheap bulk removal the whole design was reaching for.
09 What bit us
Partitioning is mostly boring once it works. Getting there means walking through a short list of rules the docs state but you only truly learn by hitting them. Here are the four we hit, with the error and the fix.
10 When to reach for it
Partitioning earns its keep in exactly the two cases we started with. One logical slice grows large enough that per-slice maintenance drags on everyone: hash it out so the work localizes. Or a table accumulates and needs bulk removal on a schedule: range it by that dimension so retiring a slice is a DROP. If neither is true, a well-indexed heap is simpler and usually faster, and every partition you add is another object to plan, vacuum, and reason about.
Match the key to the job. Hash on the owner for locality. Range on time for retention. List when the column is a handful of categories, or when one slice has to be droppable on its own. Keep the partition key in the primary key, set storage params on the leaves, keep a DEFAULT so writes never stall, and validate the constraints your version allows. Get those right and the giant DELETE, and the whole-table vacuum behind it, simply stop being part of your week.
Your data already had a natural boundary, one project, one month. A plain heap ignores it, so every operation pays for the whole table. Name that boundary as the partition key and the planner prunes to it, and retiring it costs a catalog change instead of a scan.
Pick the key that matches the job, keep it in the primary key, and the two expensive problems, per-tenant maintenance and bulk removal, quietly become cheap.