Hash vs range vs directory partitioning, hot partitions, and the cross-shard queries and joins nobody plans for.
Module P-6 — Sharding and Partitioning
What this module covers: The escalation order that gets you here — index, vertical scaling, read replicas, then shard — and why sharding is last. Partitioning inside one system versus sharding across many, which turn on the same key choice. Hash, range, and directory strategies with the specific failure each produces. Hot partitions and the one whale account that breaks an even scheme. Choosing a shard key that appears in your queries, what scatter-gather costs at the tail, why multi-shard writes become distributed transactions, Postgres pruning and the unique-index constraint, and why resharding cannot be done casually.
Sharding Is the Fourth Thing You Try, Not the First
The pager reason that leads here has one shape: writes at 400 ms p99, a 900 GB table, autovacuum falling behind, and the largest instance the cloud sells already running the database. Someone says "we need to shard." Usually they do, eventually. Almost never this week, and finding that out in the wrong order costs a year.
| Step | Buys you | Costs you |
|---|---|---|
| 1. Index / query fix | 10–1000× on one query path | Write amplification, storage, one more thing the planner can misjudge (Module F-13) |
| 2. Vertical scaling | 2–8× on everything, no code change | Money, non-linearly; a hard ceiling; a failover restart |
| 3. Read replicas | Near-unbounded read capacity | Replication lag, so read-your-writes breaks (Module P-5); zero write capacity |
| 4. Sharding | Write throughput, working set, index size all divided by N | Cross-shard reads and joins, distributed transactions, a key you can't change |
Read the third row twice. It is the most common misdiagnosis here: replicas get added because reads are slow, when the real constraint was write throughput or working-set size, and every replica applies all the writes. Two cheaper steps also sit between 3 and 4 — archiving old rows, since a 900 GB table where 800 GB is older than 90 days is not a sharding problem, and moving one hot table to its own database.
Why this matters in production: sharding does not reduce total work. It divides work across machines and adds coordination on top. A design 3× too slow from a missing index is 3× too slow on eight shards, and you now debug it across eight machines through a router.
Partitioning Splits a Table; Sharding Splits the Fleet
Partitioning splits one logical table into physical pieces inside a single database system: one transaction manager, one WAL. Joins, foreign keys, and ACID transactions still work across partitions, because there is still one database.
Sharding splits data across independent systems, each with its own storage and failure domain. Nothing spans them for free. A cross-shard join is application code. A cross-shard transaction is a distributed transaction (Module A-3). A backup is N backups not taken at the same instant.
Analogy: partitioning is labelled drawers in one filing cabinet — you walk to one cabinet, and the clerk who manages it can see every drawer. Sharding is opening branch offices in different cities. Each branch is faster because it holds less, but no clerk can see everything, and "find this customer's file" depends entirely on whether the label tells you which city to drive to. If it doesn't, you drive to all of them.
They belong together because the hard part is identical: every row needs a bucket, and a key decides which. Choose badly and partitioning gives you a table where no query can prune, while sharding gives you a fleet where every query fans out. Same mistake, different blast radius — which is the argument for partitioning first. Range-partitioning by month is reversible in an afternoon and tells you whether your queries carry the key.
Three Ways to Pick the Bucket, Three Distinct Failures
| Strategy | How the bucket is chosen | Good at | Characteristic failure |
|---|---|---|---|
| Range | Ordered boundaries: created_at by month, IDs 1–1M | Range scans, time windows, cheap retention | Hot partition on recent data — all writes hit the newest bucket |
| Hash | hash(key) mod N, or a ring | Even write spread, no boundary management | No efficient range scans — adjacent keys scattered by design |
| Directory | An explicit key → shard map | Arbitrary placement; move one tenant alone | The lookup service becomes a dependency and a bottleneck |
Range fails on temporal skew. Partition events by month and every insert today lands in one partition: it holds the hot index pages, takes the WAL traffic, absorbs the lock contention, while eleven idle. Storage is distributed perfectly and writes not at all — and sequential integer keys have the same shape, every new signup on the last shard. The exchange is real, though: DROP TABLE events_2025_01 is an instant retention policy where deleting 200 million rows is a week of vacuum work. Range is excellent for read-mostly history, poor on the write path.
Hash fails because you gave up ordering deliberately. hash(user_id) spreads users evenly and guarantees that "signups in the last hour" and "the next page after this cursor" have no locality at all. Cursor pagination (Module F-8) is what bites: a cursor is an ordering assumption, and hashing destroyed the ordering.
Directory fails by being a new single point of failure. A shard map — a small table, etcd, Redis — is the most flexible option and what most mature multi-tenant systems converge on, because moving a noisy tenant becomes an operation rather than a project. Its availability caps yours: map down means you cannot route, so you cannot serve. And you will cache it in every process, because a hop per query is unacceptable, so a tenant migration leaves some processes writing to a shard that is no longer authoritative — which needs a read-only window in the migration protocol, not a shorter TTL.
The Shard Key Must Be in the Query, or Every Read Fans Out
The rule: the shard key must appear as an equality predicate in the large majority of your queries — above all the latency-critical ones.
Take an orders table. order_id is perfectly uniform under hashing and present in exactly one query: "fetch this order." Order history, unpaid balance, and the cart badge all carry a customer_id and no order_id, so each must ask every shard. customer_id is slightly less uniform, since customers order at different rates, but present in nearly every storefront query — so a customer's whole history sits on one shard and the read costs what the unsharded version cost. It is the less uniform option and the right one: query locality beats distribution evenness, up to the point where one key gets hot.
The test before committing: take the top 20 queries by total time from pg_stat_statements and mark whether the candidate key appears as an equality predicate. Fewer than roughly 15 means the key is wrong. Two hours of work that routinely saves a quarter.
For queries that don't carry it: denormalise the key downward, carrying customer_id on order_items so items co-locate with their customer — Module F-13's denormalisation trade with the staleness condition free, because the value never changes after insert. Or add a small unpartitioned lookup (email → customer_id) to turn a login into a single-shard read, at the cost of a second write and therefore the dual-write problem (Module F-14).
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion