Splitting tables with partitioning
Open in the interactive app: practice, quiz, mock interview →In one minute
- Partitioning splits one logical table into smaller physical pieces by a rule on a partition key, so each query, index, vacuum or delete touches only the pieces it needs.
- Horizontal partitioning splits rows (by range, hash or list of values); vertical partitioning splits columns, moving wide or rarely read columns elsewhere.
- Range partitions keep neighbouring keys together, which makes scans and time-based retention cheap but concentrates sequential writes on one piece. Hash partitions spread writes evenly but turn every range query into a scatter.
- Partitions can live inside one server (Postgres declarative partitioning) or be the unit of distribution across many (CockroachDB ranges, Bigtable tablets). Sharding, the next lesson, is the multi-server case.
- The problem shapes are "hot spots" and "finding things fast": the partition key decides both where load lands and how much of the data a query must look at.
How it works
Why split a table at all
A single 4 TB events table has one heap, one set of deep B-tree indexes, and one autovacuum job that takes hours to walk it. Split it into one partition per month and a query for last week opens one or two partitions, each partition's indexes fit in memory, and retention becomes DROP TABLE events_2025_09 instead of a billion-row DELETE. The Postgres manual puts it neatly: partitioning stands in for the upper levels of an index, so the heavily used parts are more likely to fit in memory (Postgres docs).
The rule that assigns a row to a partition is a pure function of the partition key. Every benefit and every problem of partitioning comes from how well that key matches the access pattern.
Horizontal strategies
Range partitioning gives each partition a contiguous interval of the key: [2025-09-01, 2025-10-01) or user_id from A to F. Range scans touch only overlapping partitions, and splitting or dropping by range is natural. The cost: a monotonically increasing key (a timestamp, an auto-increment ID) sends every insert to the last partition.
Hash partitioning assigns a row to hash(key) mod N. Writes spread evenly, but a range query must visit all N partitions, and changing N moves most rows, so systems fix N large up front or hash into splittable ranges of a hash space (see the consistent hashing lesson).
List partitioning maps explicit values to partitions, such as region IN ('eu-west', 'eu-central') for data residency.
Composite partitioning layers two rules, for example list by region, then range by month (sub-partitioning in Postgres).
Vertical partitioning
Vertical partitioning moves columns. Moving a 6 KB profile_json blob out of users into user_profiles keeps the hot table narrow, so more rows fit per 8 KB page. Columnar stores take this to the limit by storing every column separately.
Static versus dynamic partitions
In Postgres or MySQL you declare partitions, and a job or an extension like pg_partman creates next month's partition ahead of time. Distributed stores manage them for you: CockroachDB starts each table as one range, splits it when it passes range_max_bytes (512 MiB by default) or its load crosses a threshold, and merges neighbours smaller than range_min_bytes (128 MiB) back together (CockroachDB docs). Bigtable splits tablets at about 100 to 200 MB by default, and HBase does the same with regions, so the layout follows the real key distribution instead of a day-one guess (Bigtable paper). Managed stores also cap what one partition can absorb: a DynamoDB partition is designed for at most 3,000 read units and 1,000 write units per second, however much capacity the table has in total (AWS docs).
Local and global secondary indexes
A local secondary index covers one partition; a global one spans all partitions and is itself partitioned by the indexed column. Local indexes are cheap and drop with their partition, but a lookup by another column probes every partition. Global indexes answer it in one probe but make each write touch two partitions, needing a distributed transaction or an asynchronous index. Postgres has only local indexes, which is why a unique constraint on a partitioned table must include all the partition key columns (Postgres docs).
Down the stack
Layer 1The contract
A partitioned table promises the same SQL results as an unpartitioned one; what changes is cost. A query whose WHERE clause constrains the partition key with constants or parameters is promised to touch only matching partitions (pruning). A query that does not constrain it is promised correctness, not speed: it scans every partition. Uniqueness is only guaranteed within what one index covers, so global uniqueness on a non-key column is not part of the contract in Postgres. In a distributed store the contract adds routing: any node can accept any key and will forward it to the current owner, with a retry if the range moved mid-request.
Layer 2The mechanism
Postgres stores the partitioning scheme in pg_partitioned_table (strategy and key columns) and each child's bounds in pg_class.relpartbound. On INSERT, tuple routing builds a PartitionDispatch for the parent and binary-searches the sorted bounds to find the child, recursing for sub-partitions. Planning-time pruning compares WHERE clauses against the same bounds; execution-time pruning handles values known only at run time, such as prepared statement parameters or the inner side of a nested loop, and shows up in EXPLAIN ANALYZE as Subplans Removed: N.
CockroachDB treats the whole database as one sorted key-value map cut into ranges. Range descriptors (start key, end key, replica locations) live in a two-level index: meta1 points to meta2 ranges, which point to data ranges, meta1's location is gossiped, and every node caches meta2 entries (CockroachDB docs). Load-based splitting now measures CPU by default: with kv.allocator.load_based_rebalancing.objective set to cpu (the default), a range becomes a split candidate above kv.range_split.load_cpu_threshold (500 ms of CPU per second); the older kv.range_split.load_qps_threshold of 2,500 applies only when the objective is qps (CockroachDB settings). Either way, it samples per-key load and picks a split key that roughly halves it; the split only writes new descriptors, because the data is already sorted in place. A single dominant key cannot be split, since one key lives in exactly one range. If that key carries a moderate share, say 60%, CockroachDB can still isolate it in its own range, but at 99% there is nothing useful to split (CockroachDB docs).
Layer 3The machine
Each Postgres partition is a separate relation with its own heap files, visibility map and indexes. DROP TABLE on a partition writes a few catalog rows and unlinks files: milliseconds, and nearly no WAL. Deleting the same 200 million rows with DELETE writes a WAL record per tuple, several GB, and leaves dead tuples for vacuum. Partitioning also bounds index size: B-tree fan-out of a few hundred keeps depth at 3 or 4 levels either way, but a 10 GB per-partition index keeps its hot pages in shared buffers where a 900 GB one cannot, so a lookup costs about one random read instead of several.
The overheads show up in the lock manager and planner. A query that cannot prune takes an AccessShareLock on every partition and every index. Before PostgreSQL 18 each backend had only 16 fast-path lock slots, so a query over 50 partitions with 3 indexes each spilled into the shared lock table and contended on its 16 lock_manager LWLocks under concurrency. Postgres 18 sizes the fast-path arrays from max_locks_per_transaction, so the default of 64 gives 64 slots, up to a cap of 16,384 (Postgres commit). Note also that partitions pruned at executor start-up are still locked. Planning time also grows with the number of unpruned partitions, and each session loads metadata for every partition it touches into its own memory. The manual says the planner handles up to a few thousand partitions fairly well only if typical queries prune all but a few (Postgres docs), so thousands of tiny partitions are slower than dozens of reasonable ones.
Why it's built this way
- Memory is finite and random reads are slow, so smaller partitions keep each index small enough to stay hot.
- Deleting rows one by one costs WAL and vacuum work proportional to the rows, while dropping a file costs a constant, so retention is aligned with partition boundaries.
- Most queries filter on the same one or two columns, so choosing one of them as the partition key lets the planner skip most of the data with a cheap comparison against sorted bounds.
- Data distribution is unknown and changes over time, so distributed stores split and merge dynamically by size and load instead of trusting a fixed layout.
- Splitting by load needs per-key statistics, which are expensive, so they are sampled only after a range is already hot.
Diagram
flowchart TD
Q["INSERT / SELECT with key"] --> R{"Partition bounds (sorted)"}
R -->|"key < 2025-09-01"| P1["events_2025_08"]
R -->|"2025-09-01 to 2025-10-01"| P2["events_2025_09"]
R -->|"key >= 2025-10-01"| P3["events_2025_10 (hot)"]
P3 --> L{"QPS > threshold?"}
L -->|yes| S["Sample keys, split at load median"]
S --> P3a["range A"]
S --> P3b["range B"]
P1 --> D["Retention: DROP partition"]Worked example
A SaaS audit log takes 3,000 inserts per second, about 260 million rows a day at 300 bytes each, roughly 78 GB a day before indexes. Retention is 90 days, so the table holds about 7 TB.
Unpartitioned, the nightly retention job deletes 260 million rows: tens of GB of WAL for replicas to replay, then 78 GB of dead tuples for vacuum. Partitioned by day, retention is DROP TABLE audit_2025_07_18.
Queries are "events for tenant X in the last 24 hours". With daily range partitions on created_at, the planner opens one or two partitions of 78 GB each instead of 7 TB, and an index on (tenant_id, created_at) inside each partition is about 10 GB rather than 900 GB. Ninety daily partitions is a comfortable count; hourly partitions would give 2,160, and a dashboard query over 30 days would lock and plan 720 partitions.
The catch: all 3,000 inserts per second land in today's partition. One Postgres server absorbs that, but in a distributed store it puts every write on one range and one node. Prefixing the key with a hash bucket, (hash(tenant_id) % 16, created_at), trades 16-way scatter on reads for 16-way spread on writes.
In the wild
- GitLab, 2023: lock manager saturation on replicas. After active client-facing replicas went from 8 to 6, each took up to 60,000 queries per second, and several hours of severe slowdowns followed. Nearly 70% of heavyweight locks were taking the slow path through the 16
lock_managerLWLocks, because hot queries touched more than 16 relations: theprojectstable alone had 57 indexes. GitLab flagged partitions without pruning as the same risk and planned more replicas, fewer indexes, and lock contention as a saturation metric (GitLab). - Figma, 2023-2024: vertical first, then horizontal. Figma's database load grew almost 100x from 2020. It first moved groups of tables onto their own databases (vertical partitioning), but some tables still reached several terabytes and billions of rows, with vacuum trouble and RDS IOPS limits. It then built horizontal sharding on Postgres, with "colos" of tables sharing a shard key so joins within one key still work; the first table cut over in September 2023 with about 10 seconds of partial unavailability (Figma).
- Discord, 2017-2022: hot partitions in a message store. Messages were partitioned by channel and time bucket in Cassandra. A single busy channel concentrated reads on one partition and slowed quorum queries across the cluster. Discord put a routing layer in front that coalesces concurrent reads of the same partition into one query, then moved from 177 Cassandra nodes to 72 ScyllaDB nodes, with p99 historical reads falling from 40-125 ms to 15 ms (Discord).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Range | Range scans prune well, cheap retention, natural splits | Sequential keys create a hot last partition | Time series, ordered scans, retention by age |
| Hash | Even write spread, no hot tail | Range queries hit every partition; changing N moves data | Point lookups by ID, write-heavy, no range scans |
| List | Explicit placement, data residency | Skew if one value dominates; new values need new partitions | Small fixed set of categories or regions |
| Composite | Combines placement and pruning | More partitions, more planning and ops | Region residency plus time retention |
| Vertical | Narrow hot rows, fewer pages per lookup | Joins to reassemble; two writes per update | Wide rows with rarely read columns |
Pitfalls in production
- Queries that do not mention the partition key. A lookup by
order_idon a table partitioned bycreated_atscans every partition's index. Put the key in the query, add it to the API, or keep a small lookup table from ID to partition. - Too many partitions. Hourly partitions for a year are 8,760 relations; planning, locking and catalog caches slow down every query. Size partitions so a typical query touches a handful and the total stays in the hundreds.
- Forgetting to pre-create partitions. Inserts for a month with no partition fail, or fall into a
DEFAULTpartition. Attaching the right partition later scans the default one for rows that belong in it and fails if it finds any. Create partitions days ahead and alert when fewer than N future partitions exist. - Monotonic keys on dynamically split stores. Splitting a hot last range just creates a new hot last range. Hash-prefix the key or use random IDs where order is not needed.
- Unique constraints that silently weaken.
UNIQUE(email)cannot survive partitioning byuser_idin Postgres. Enforce it in a separate unpartitioned table, deliberately.
Leadership lens
- Ask for the top five queries by volume and check each one names the partition key; a key chosen for retention that the read path ignores is a design bug, not a tuning task.
- Treat partition creation and retention as owned automation with alerts, not a runbook step; the classic outage is the first insert of a new month at midnight.
- Partitioning one server buys months to years. Ask whether the real limit is single-node write throughput or storage, because only sharding fixes that, and it costs far more.
- Budget converting a live large table as a migration project with a rollback plan, not a schema change.
Check yourself
A table is range-partitioned by month on
created_at. Why isSELECT * FROM t WHERE id = 42slow, and what are two fixes?Show answer
The query has no constraint on the partition key, so it probes every partition's index. Add a
created_atbound from the caller, or encode the partition in the ID (for example a time-ordered ID) so the key can be derived.Why does dropping a partition generate almost no WAL while deleting the same rows generates gigabytes?
Show answer
DROPchanges a few catalog rows and unlinks files, a constant amount of work.DELETElogs a change per tuple and per index entry, and leaves dead tuples for vacuum.A CockroachDB range is at 10,000 QPS but load-based splitting never splits it. What is the likely cause?
Show answer
Most of the load is on one key, and a single key cannot be split across ranges. The fix is in the data model, such as spreading that key's writes over several keys.
Sources and further study
Primary
- Elhemali et al., Amazon DynamoDB (USENIX ATC 2022): how a managed store handles traffic imbalance across partitions at 89 million requests per second.
- PostgreSQL commit c4d5cb71d: increase the number of fast-path lock slots: why 16 slots were too few for partitioned queries, and the Postgres 18 sizing.
Systems docs
- CockroachDB: Cluster settings: the CPU and QPS split thresholds and the rebalancing objective.
- CockroachDB: Replication zones:
range_min_bytesandrange_max_bytes, the size bounds for split and merge. - Amazon DynamoDB: Partition key design: per-partition throughput limits and how to spread a hot key.
- pg_partman: the standard extension for creating time and ID partitions ahead of need and enforcing retention.
Books
- Designing Data-Intensive Applications, Martin Kleppmann, chapter 6 "Partitioning" (1st edition) or chapter 7 "Sharding" (2nd edition): key range versus hash, secondary indexes, rebalancing, request routing.
- Designing Distributed Systems (2nd edition), Brendan Burns, chapter 7 "Sharded Services": sharding functions and hot shards from a service builder's view.
Other curricula
- Azure Well-Architected: Data partitioning: frames partitioning as horizontal, vertical and functional, and treats hot partitions and rebalancing as the main risks.
- donnemartin/system-design-primer: covers the same ground under "federation" (split by function) and "sharding", without in-database partitioning or pruning.
Watch
- PostgreSQL Partitioning in Production: A Practical Guide (Jimmy Angelakos): partitioning an existing huge table on a live system, and what it really fixes.
- They Enabled Postgres Partitioning and their Backend fell apart (Hussein Nasser): a real case of lock manager contention from queries that touched too many partitions.
- Database Partitioning & Sharding Explained (Nishi Ajmera): a walkthrough of range and hash partitioning and rebalancing, following DDIA chapter 6.
Free resources
- PostgreSQL: Table Partitioning: the official guide to declarative partitioning, pruning, and its limitations.
- CockroachDB: Distribution Layer: how one sorted key space is cut into ranges and addressed through meta ranges.
- CockroachDB: Load-Based Splitting: how a hot range picks a split key from sampled per-key load.
- Bigtable: A Distributed Storage System for Structured Data: the paper that introduced tablets, dynamic splitting and the three-level location hierarchy.
- MySQL 8.4: Partitioning: range, list, hash and key partitioning in MySQL, with pruning rules and restrictions.