Skip to content
CodeBrewerz logoCodeBrewerz
System Design

Sharding a Database: The Key You Pick Is the One Decision You Cannot Undo Cheaply

Splitting a table across machines is easy. Choosing what to split it on decides your skew, your query fan-out and your migration cost simultaneously — and the obvious keys are wrong in three different ways.

Published 8 min readBy Piyush Jain
  • System Design
  • Databases
  • Sharding
  • Scalability

Sharding is what you do after you have run out of the things that are not sharding. Read replicas, a bigger instance, an index that should have existed, moving the two enormous columns into their own table — all of those are reversible in an afternoon, and one of them is usually enough. Sharding is none of those things.

The mechanics are the easy part: put some rows on machine A and some on machine B, and route each query to the right one. The hard part is that the routing function is now baked into your application, your queries, your migrations and your operational runbooks, and changing it later means moving every row while the system is serving traffic.

What you actually give up

Before the key, the price list. Sharding takes away three things you have been using without noticing.

Cross-shard joins. Two tables sharded on different keys cannot be joined by the database. That join moves into your application as two queries and a loop, and it is now your job to make it not be O(n) round trips.

Transactions across shards. A transaction is a property of one database. Spanning two means two-phase commit — with its coordinator, its blocking window, and its ability to leave locks held when the coordinator dies — or it means giving up atomicity and designing for partial failure with compensating actions.

Cheap global queries. SELECT COUNT(*), ORDER BY created_at LIMIT 20, and AUTO_INCREMENT all silently assumed one machine. Counting means asking every shard. Global ordering means fetching a page from every shard and merging in application code. Ids need a generator that is unique without coordination.

None of these are dealbreakers. All of them are work that did not exist before, and the right time to price them is before the migration rather than during it.

The key decides everything at once

Figure 1

Eight shards, one skewed customer base

Hottest shard—
Shards touched per tenant query—
Rebalance—

—

Shard key
A realistic tenant distribution — one customer is 30% of the data — spread across eight shards by four candidate keys. Hashing the row id spreads perfectly and sends every tenant query to all eight shards. Keying by tenant keeps queries on one shard and puts the largest customer alone on a hot one. Keying by time is worst of both: all writes land on the newest shard. There is no key that wins both columns, which is why this is a decision and not an optimisation.

Four candidate keys against the same deliberately-skewed customer base, where the biggest tenant is 30% of the rows. Watch both readouts as you switch, because the trade-off is that they move in opposite directions.

Hash the row id and the distribution is perfect. It is also the worst possible key for queries, because every tenant-scoped query becomes a scatter-gather across all eight shards. Your latency is now the slowest of eight responses on every request, and any query with an ORDER BY and a LIMIT has to over-fetch from each shard and merge.

Key by tenant and every tenant query lands on exactly one shard, which is the property you actually wanted. And your largest customer now owns a machine. You cannot split them without splitting the customer, so that shard fails first, and the failure is concentrated on the account you can least afford to have an incident with. This is the single most common sharding regret.

Key by time looks tidy and is the worst of the three. All writes are for now, so the newest shard takes the entire write workload while the other seven are idle disks. Time is an excellent key for pruning — dropping a whole month is one statement — and a terrible one for spreading load.

Composite is where most people end up: split each tenant across a small fixed number of buckets, so the big tenant no longer owns a shard while a query still touches four shards instead of eight. The cost is honest — the bucket count is now a constant embedded in your routing, and changing it is another migration.

There is no key that wins both columns. Pick which one you are optimising, and write down why, because the next person will assume the other one.

Moving rows while the system is up

Eventually a shard is too big, and you have to split it without a maintenance window.

Figure 2

Cut over early and see what breaks

    A reader on the new shard sees—
    Rollback—
    Move reads after phase
    Splitting shard 3 in two, live. Each phase exists to close one specific hole: dual-write stops new writes being lost, backfill moves the history, verification proves the two copies agree, and only then can reads move. Cutting over before backfill completes serves empty results for rows that exist; cutting over before verification means you find out from a customer.

    Move the reads early and the figure tells you exactly what a customer sees. The five phases are not ceremony; each one closes one specific hole, and the order is forced.

    Dual-write first, so no new write is lost while you work. Then backfill the history in throttled batches — throttled because an unthrottled backfill is a self-inflicted denial-of-service on the shard still serving production. Then verify by comparing counts and checksums, and keep verifying until they agree, because “the backfill script finished” and “the data is correct” are different claims. Only then move reads.

    The property that makes this survivable is that the old shard stays complete and stays written to for the entire migration, so every phase before the final one rolls back by changing where reads point. The irreversible step is stopping the dual-write, and it should happen days after the cutover, not minutes.

    This is also why the routing table belongs in a lookup service rather than in hash(key) % N in application code. With modulo, adding one shard remaps almost every key at once. With a table mapping virtual buckets to physical shards — a few thousand buckets, assigned to machines — moving load is reassigning a bucket, and the split above becomes a routine operation rather than an event.

    The parts nobody mentions

    Ids have to be generated without coordination. AUTO_INCREMENT per shard produces colliding ids across shards. UUIDv4 is unique and randomly ordered, which fragments every B-tree it is a primary key of. Use a time-ordered id — Snowflake-style, or UUIDv7 — so ids stay unique, sortable, and index-friendly.

    Schema migrations are now N migrations. They will not all succeed, and a shard that fails halfway leaves your fleet on two schemas at once. Every migration has to be backwards-compatible for the duration, which means the expand-migrate-contract pattern is mandatory rather than good practice.

    Rebalancing is not something you do once. Tenants grow at different rates, so a distribution that was even last quarter is not now. If moving a bucket between shards is not a routine, low-risk operation, you will avoid doing it, and you will run hot shards instead.

    Pagination breaks quietly. LIMIT 20 OFFSET 200 across shards means fetching 220 rows from every shard and discarding almost all of them. Cursor pagination on a sortable key is the fix, and retrofitting it after launch is a public API change.

    Reading across shards

    Single-shard queries are the whole point, and you will still have queries that are not. Admin screens, reports, “show me every failed order in the last hour” — these have no tenant to route by, and they become scatter-gather: query every shard, merge in the application.

    Three things about scatter-gather are worth knowing before you write the first one.

    Your latency is the slowest shard, not the average. With eight shards, a query is as slow as the worst of eight responses, so a p99 on one shard becomes roughly a p92 for the query overall. Add shards and this gets worse, not better — the more machines you ask, the likelier one of them is having a bad second.

    Partial failure is now a design decision. One shard timing out means you can return seven-eighths of an answer or no answer. Both are defensible; silently returning the seven-eighths as though it were complete is not, and it is the default if nobody thinks about it. Return the result with an explicit note that one shard was unreachable.

    Aggregation cannot be pushed down. AVG across shards is not the average of the per-shard averages unless you also carry the counts. COUNT DISTINCT cannot be merged from per-shard counts at all. Sorting with a limit means over-fetching the limit from every shard and merging. Every one of these is a place where the obvious application-side merge is quietly wrong, and the wrongness is small enough to survive review.

    The usual escape is to stop doing analytics on the sharded store entirely: replicate into a columnar warehouse and point the reports there. That is one more system, and it is still cheaper than making eight OLTP shards serve a dashboard.

    The short version

    Do everything else first — replicas, indexes, a bigger box, moving cold columns out. Sharding costs you cross-shard joins, cross-shard transactions and cheap global queries, permanently.

    The shard key trades skew against fan-out and you cannot have both. Hashing spreads evenly and scatters every query; keying by tenant keeps queries local and hands your largest customer their own machine to fail; keying by time is a write hotspot dressed as tidiness. A composite key with a fixed bucket count is usually the answer.

    Route through a bucket-to-shard lookup table rather than hash(key) % N, so moving load is a reassignment rather than a full remap. And do the migration in the order that keeps the old shard authoritative the whole way — dual-write, backfill, verify, move reads — because the only irreversible step is the one at the end, and there is no reason to take it on the same day.

    Next step

    Want This Built, Not Just Explained?

    CodeBrewerz builds the systems these posts take apart: web, mobile, cloud and the infrastructure underneath. Tell us what you are building.

    Start a conversation