System design / Databases

Sharding a Relational Database: Shard Keys, Routing, IDs, Hot Shards and Resharding

How to shard PostgreSQL or MySQL without regret: when to shard at all, choosing a shard key, range vs hash vs directory sharding, logical shards, query routing with Vitess and proxies, globally unique IDs, cross-shard queries and 2PC, hot tenants and zero-downtime resharding, with real numbers from Notion, Figma, Instagram, Shopify, Slack and Pinterest.

On this page
  1. Before you shard: the cheaper rungs
  2. Choosing a shard key
  3. Sharding strategies: range, hash and directory
  4. Routing queries: app, proxy or edge (members only)
  5. IDs once auto-increment stops working (members only)
  6. Cross-shard queries and transactions (members only)
  7. Hot shards and noisy tenants (members only)
  8. Resharding without downtime (members only)
  9. Case studies at a glance (members only)
  10. How to say it in the HLD interview (members only)

By mid-2020, Notion's on-call engineers were often woken by database CPU spikes, and Postgres VACUUM had started to stall. The scary part wasn't disk space. It was transaction ID wraparound, the safety stop where Postgres refuses all writes rather than risk overwriting data. Around the same time, Figma watched a single RDS instance run above 65% CPU at peak while its database traffic grew about 3x a year. Both teams ended up doing the same thing: they squeezed one big machine as far as it would go, then split their data across many. This article is about that split: how to choose the key, how a query finds its shard, where IDs come from, what to do about hot shards, and how to reshard without taking the product down.

Where this fits: Part of the Databases under the hood series. The hub gives the one-paragraph version of sharding; here we take it apart with the numbers real companies published.

Before you start: Helpful, not required: Indexes in practice (why random keys hurt B-trees) and Transactions and isolation levels (what a transaction promises on one machine).

After reading this you will be able to

  • Decide whether you need to shard at all, and name the cheaper rungs to climb first.
  • Pick a shard key for a multi-tenant app and handle the queries that don't carry it.
  • Compare range, hash and directory sharding, and explain why logical shards beat hash % N.
  • Route queries, generate unique IDs, avoid cross-shard transactions and reshard without downtime.

We'll stay with relational databases (PostgreSQL and MySQL InnoDB) that you shard yourself or through a layer like Vitess. Distributed SQL databases such as Spanner and CockroachDB are a topic for another day.

01Before you shard: the cheaper rungs

Sharding Definition

Splitting the rows of one logical table across several independent database servers, each with its own primary. Every shard has the same schema and a different slice of the rows. A shard key (one column, or a few) decides which slice a row belongs to.

Think of a busy restaurant. Before you open a second kitchen across town, you fix the slow dishes, buy a bigger stove and move pastry into its own room. A second kitchen is a different kind of change: from that day on, every order needs an address. Sharding is the second kitchen. It works, but it changes every query and transaction you write afterwards. So climb the cheaper rungs first:

  1. Fix the queries. A missing index or an N+1 loop can look exactly like "the database is too small".
  2. Scale up. Figma moved from an r5.12xlarge to an r5.24xlarge, the largest instance available (How Figma scaled to multiple databases). Notion's verdict on doing this forever: "playing Cookie Clicker with the RDS 'Resize Instance' button is not a viable long-term strategy".
  3. Pool connections and cache. Figma added PgBouncer when its connections reached the thousands.
  4. Add read replicas. These scale reads, not writes, and Figma found some reads couldn't move to replicas because the app was sensitive to replication lag.
  5. Split tables. Partition a big table on the same server, or move groups of tables to their own database (vertical partitioning). By the end of 2022 Figma ran "a dozen vertically partitioned databases" (How Figma's databases team lived to tell the scale).
  6. Shard the rows. Only now.

The ladder below shows the shape: each rung costs more than the one before and is harder to undo.

Fig. 1

The scaling ladder: shard only after the cheaper rungs

The scaling ladder: shard only after the cheaper rungsSix rungs climbing left to right: tune queries, scale up, pool connections and cache, add read replicas, split tables, and finally shard rows. Early rungs are cheap and reversible; the last rungs are expensive and hard to undo.Tune queriesindexes, plansScale upbigger instancePool + cachePgBouncer, cacheRead replicasscale readsSplit tablespartition, verticalShard rowssplit writes123456cost and difficulty to undo grow with every stepcheap, reversibleexpensive, hard to undo
What to notice. The first four rungs keep one primary. Splitting tables is the first rung that breaks cross-table joins, and sharding is the first that splits a single table's writes.

Partitioning is not sharding

TechniqueWhat gets splitWhere the pieces liveWhat it fixesWhat it costs
Table partitioningOne table into child tablesThe same serverSmaller indexes, cheap bulk deletesLittle; the write bottleneck stays
Vertical partitioningGroups of tablesDifferent serversSpreads load by feature areaNo joins or transactions across groups
ShardingRows of one tableDifferent serversWrite throughput, size, IOPSRouting, IDs, cross-shard work, resharding

The PostgreSQL partitioning docs suggest partitioning pays off when a table would otherwise be bigger than the server's memory, and dropping a partition avoids the VACUUM work a bulk DELETE creates. But as PlanetScale's When to shard page puts it, partitioning "does not remove a single-primary write bottleneck".

The signals that it's really time, from PlanetScale's When to shard MySQL and Postgres: the primary stays limited by write throughput or IOPS after tuning, the working set no longer fits in RAM, backups and schema changes take too long, and replicas lag because the primary writes too fast. Figma hit several at once: tables of several terabytes and billions of rows hurt reliability during vacuums, and the busiest tables were heading for the maximum IOPS Amazon RDS supports. Vertical partitioning couldn't go further because "the smallest unit of partitioning is a single table".

Seen this before?

The hub walks the same ladder from the interview side in Database scaling. Here we pick up where it stops.

02Choosing a shard key

The shard key is the decision you'll live with longest, so let's make it on a concrete product. Meet Tasklane, a made-up project-management SaaS. Companies sign up as workspaces. A workspace has projects, projects have tasks, tasks have comments, and one user can belong to several workspaces. List the queries that matter, hottest first:

  1. Load a project board: every task in one project, with assignees and comment counts.
  2. Update a task, or post a comment on it.
  3. Search tasks inside a workspace.
  4. "My tasks": everything assigned to me, across all my workspaces.
  5. A monthly billing report across all workspaces.

Queries 1 to 3 all live inside one workspace. That's a strong hint. Notion faced the same shape and partitioned by workspace ID, because "each block belongs to exactly one workspace" and "users typically query data within a single workspace at a time" (Herding elephants). Shopify used shop_id; Slack started with workspace ID.

A good key passes five tests. The first three are almost word for word from the Vitess resharding guide:

  • It's in the hottest WHERE clauses, so the router can send the query to one shard.
  • It co-locates what you join and write together. One workspace's projects, tasks and comments on one shard means local joins and ordinary transactions.
  • It has high cardinality (many distinct values). Millions of workspace IDs spread well; 200 country codes don't.
  • It spreads load, not only rows. Notion noticed that "a single large enterprise customer... generates more load than many average personal workspaces combined".
  • It never changes. In Vitess the shard-key column can't be updated after insert, since that would mean moving the row (Vindexes). Shard on status and every status change relocates the task.

In practice every sharded table carries the key, and the key leads the primary key:

-- PostgreSQL: every sharded table carries workspace_id
CREATE TABLE tasks (
  workspace_id bigint NOT NULL,
  id           bigint NOT NULL,
  project_id   bigint NOT NULL,
  title        text   NOT NULL,
  status       text   NOT NULL,
  PRIMARY KEY (workspace_id, id)
);

-- The router sees workspace_id, so this hits one shard
SELECT id, title, status
FROM tasks
WHERE workspace_id = 17 AND project_id = 301;

The picture below compares our key with a tempting alternative, sharding tasks by its own id.

Fig. 2

Shard key choice: co-located vs scattered

Shard key choice: co-located vs scatteredLeft: sharding by workspace_id. A query for workspace 17 goes to shard A only, which holds workspace 17 projects, tasks and comments. Right: sharding tasks by task_id spreads one workspace over three shards, so the board query fans out to all of them.Key: workspace_idKey: task_id1 shardWHERE workspace_id = 17Shard Aws 17:projectstaskscommentsShard Bws 42:projectstaskscommentsWHERE workspace_id = 17Shard Csome ws 17tasksShard Dsome ws 17tasksShard Esome ws 17tasksevery board load fans outlocal joins and transactionsCo-locate what you read and write together
What to notice. With workspace_id, the board query, its joins and its transaction stay on one shard. With task_id, the data spreads nicely, but the most common query now has to ask every shard.

The queries that don't carry the key

No key is perfect; what matters is what you do with the leftovers.

  • "My tasks" is keyed by user. Keep a denormalised assigned_tasks table sharded by user_id, updated asynchronously when an assignee changes. Reads become single-shard; the price is a few seconds of staleness.
  • The billing report touches everything. Stream changes into a warehouse and run it there.
  • The users table has no single workspace. Keep it unsharded, or shard it separately by user_id. Slack had exactly this: a "kitchen sink" cluster for data not tied to a workspace (Scaling Datastores at Slack with Vitess).

You don't need one key for the whole schema either. Figma found "no single good candidate" and chose a handful (UserID, FileID, OrgID). Tables sharing a key form a colo, and inside a colo, joins and full transactions work as long as they stick to one shard key value.

03Sharding strategies: range, hash and directory

Next you need a rule that turns a key value into a shard. PlanetScale's Sharding strategies post covers the three families:

  • Range: each shard owns a contiguous range of keys. Range scans are cheap and a hot range can be split. The trap: with a growing key (auto-increment, timestamps), every new row lands on the last shard.
  • Hash: hash the key and give each shard a range of hash values. Load spreads evenly if cardinality is high; cheap range scans on the key are gone.
  • Directory (lookup): a table maps each key to a shard. You can put any tenant anywhere and move it by updating one row, but every query pays for a lookup, so the directory must be cached and highly available.

Figma shows the trade-off in real life. Many of its keys were auto-increment or Snowflake IDs that start with a timestamp, which would have caused "significant hotspots" under range sharding. Rewriting IDs meant a long migration, so they hashed the key and accepted slower range scans, which were rare in their code. PlanetScale says it steers customers toward hash sharding by default for the same reason. Here are the three side by side.

Fig. 3

Range, hash and directory sharding

Range, hash and directory shardingThree panels. Range: key ranges map to shards and the newest range runs hot. Hash: a hash of the key spreads rows evenly but loses order. Directory: a lookup table maps each key to any shard, which is flexible but adds a lookup.RangeHashDirectoryranges of keyS11 to 1MS21M to 2MS32M and uphash(key)S1hash range aS2hash range bS3hash range clookup tableS1ws 17, 99S2ws 42S3big tenantGood: cheap range scansRisk: newest range runs hotGood: even spreadRisk: no cheap range scansGood: place anything anywhereRisk: extra hop, must cacheHash for spread; a directory buys flexibility
What to notice. Range keeps order but runs hot at the newest range. Hash spreads evenly but forgets order. Directory can place anything anywhere, at the cost of a lookup you must cache.

Why hash % N hurts, and the logical-shard fix

The naive rule is shard = hash(key) % N. It works until you add a server. Going from 4 shards to 5, a key stays put only if hash % 4 equals hash % 5, which happens for about 1 key in 5. So roughly 80% of rows would move (our own maths, assuming a uniform hash).

The fix almost everyone uses is to separate logical shards from physical hosts. Hash keys into a large, fixed number of logical shards that never changes, and keep a small table saying which host holds which logical shard. Adding hosts means moving whole logical shards. No key is ever re-hashed.

  • Notion chose 480 logical shards on 32 physical databases, 15 per database, each a Postgres schema. Why 480? It has many divisors, so they could grow from 32 to 40 to 48 hosts and keep every host equal. With 512 they'd have had to double each time.
  • Instagram used "several thousand 'logical' shards", also Postgres schemas, "mapped in code to far fewer physical shards" (Sharding & IDs at Instagram).
  • Pinterest created 4,096 shards up front on eight MySQL servers. To add capacity they replicated a server holding shards 0 to 511 onto a new pair, then changed the config so each owned half (Sharding Pinterest).

Here is Notion's layout before and after its later re-shard.

Fig. 4

Logical shards mapped to physical hosts

Logical shards mapped to physical hostsA workspace id is hashed into one of 480 fixed logical shards, then a mapping table sends that logical shard to a host. Before: 32 hosts, each with 15 schemas. After: 96 hosts, each with 5 schemas. Only the mapping changes; no key is re-hashed.Before: 32 hostsAfter: 96 hostsonly this changesworkspace_idhashlogical shard0 to 479, fixedmapshard to hostHostschemas 001 to 015Hostschemas001 to 005Hostschemas006 to 010Hostschemas011 to 015x 32x 32 groups of 3Notion: 480 logical shards, 32 then 96 hosts
What to notice. The hash and the 480 logical shards never change. Only the last arrow, logical shard to host, gets rewritten, and whole schemas move as units.

Members-only article

Keep reading with the Article Pass

You've read the free part. The other 7 sections of this guide, with every diagram and worked example, are for members. One Article Pass unlocks every members-only article on Engineering Atlas: the ones live today and every one I publish while your pass is active.

  • Routing queries: app, proxy or edge
  • IDs once auto-increment stops working
  • Cross-shard queries and transactions
  • Hot shards and noisy tenants
  • Resharding without downtime
  • Case studies at a glance
  • How to say it in the HLD interview

From ₹99 · one-time payment, no auto-renewal · separate from course passes