Learn when and how to split data across machines: range, hash and directory sharding, choosing a shard key, hotspots, resharding and consistent hashing.
What you'll learn: By the end of this lesson you will be able to explain what sharding is and when it is worth its cost. You will compare range, hash and directory sharding and choose a shard key for a real workload. You will explain how consistent hashing with virtual nodes limits the data moved when machines are added. You will describe why cross-shard queries and transactions are hard.
Picture a huge paper phone book with a million names. One person holding it becomes a bottleneck: the book is heavy, and only one reader can use it at a time. So the publisher splits it into 26 volumes by first letter, and puts each volume on a different desk. Now 26 people can look things up at once, and no desk holds the whole book.
That is sharding. Instead of one database machine holding all the data, many machines each hold a slice. It is one of the most powerful scaling tools and one of the most expensive to get wrong. You gain capacity, and in return you give up easy queries across the whole dataset.
The core idea
Sharding (also called horizontal partitioning) splits one logical dataset into pieces, called shards, and places each shard on a different node. Each shard holds a different subset of the rows, and each row lives on exactly one shard (plus its replicas). A routing step decides, for any given key, which shard to talk to.
Compare it with replication. Replication puts the same data on several machines, to survive failures and to spread reads. Sharding puts different data on different machines, to spread storage and writes. Real systems use both: every shard is itself replicated, usually as a small leader-follower group.
Terminology varies: some databases use "partition" for a piece inside one machine and "shard" for a piece on a separate machine, others treat them as synonyms. This lesson says "shard" for a unit placed on a node.
Do you need to shard yet?
Sharding is a big step, so first rule out the cheaper ones. For illustration, assume a SaaS application's main database is struggling. Work down this list:
Fix queries and indexes. Many "scale" problems are missing indexes.
Add read replicas to scale reads without splitting data.
Scale up. A bigger machine is boring, effective and often far cheaper than the engineering cost of sharding.
Archive or delete old data, and move large blobs such as files and images to object storage.
flowchart TD
P["Database is struggling"] --> Q1{"Fixed by indexes, caching or replicas?"}
Q1 -->|yes| D1["Do that, do not shard"]
Q1 -->|no| Q2{"Fixed by a bigger machine or archiving?"}
Q2 -->|yes| D2["Scale up or archive, revisit in a year"]
Q2 -->|no| Q3{"Storage, write rate or working set beyond one node?"}
Q3 -->|yes| D3["Shard, and pick the key first"]
Q3 -->|no| D4["Measure again, the bottleneck is elsewhere"]
You shard when, even after these, one node cannot hold the data, cannot sustain the write rate, or cannot keep the working set in memory. Run the numbers from Estimation first: 200 GB of data and 500 writes per second is comfortable for one well-provisioned database (illustrative; real limits depend on hardware and workload).
IMPORTANT
Sharding is much easier to design in at the start than to bolt on later, but that does not mean you should do it at the start. Choose a shard-friendly data model (every table has a natural owner key) even while running on one machine, and shard only when numbers demand it.
Three ways to decide where a row goes
Range sharding
Split the key space into contiguous ranges. User IDs 1 to 1,000,000 go to shard 1, the next million to shard 2, and so on. Or names A to H on one shard, I to P on the next.
Strength: range queries are cheap, because adjacent keys live together. "All photos uploaded between two dates" touches one or two shards.
Weakness: hotspots. If the key is a timestamp or an increasing ID, all new writes land on the newest range, so one shard takes all the write load while the others sit idle.
Flexibility: ranges can be split when they grow. Many databases split ranges automatically.
Hash sharding
Apply a hash function to the key and use the result to pick a shard. The simplest form is shard = hash(key) mod N.
Strength: a good hash spreads keys evenly, so data and load are balanced and sequential keys do not cluster.
Weakness: neighbouring keys are scattered, so range queries must ask every shard. And plain mod N has a serious flaw when N changes, as we see below.
Directory (lookup) sharding
Keep an explicit table that maps each key, or each group of keys, to a shard: "tenant 17 is on shard 4". The application or a router consults the directory.
Strength: total flexibility. You can move one heavy tenant to its own shard, or place data by customer region for legal reasons.
Weakness: the directory is itself a critical component. It must be fast, cached, replicated and consistent, or it becomes a single point of failure and a bottleneck.
Strategy
Good for
Cost
Avoid when
Alternative
Range
Range scans, ordered data
Hotspots on sequential keys
Writes are time-ordered and heavy
Hash a prefix of the key
Hash
Even spread of load
Range queries hit all shards; rebalancing with plain mod
You need frequent range scans
Compound key: hash the first part, keep the rest ordered
Directory
Multi-tenant apps, special placement
Extra lookup, directory must be highly available
Tiny datasets, simple keys
Hash, with a few tenant overrides
Which strategy fits is mostly a question about your queries and your biggest owners:
flowchart TD
S["Pick a sharding strategy"] --> Q1{"Do you need range scans on the key?"}
Q1 -->|yes| Q2{"Is the key time-ordered or sequential?"}
Q2 -->|yes| R1["Range on a compound key, or hash a prefix"]
Q2 -->|no| R2["Range sharding"]
Q1 -->|no| Q3{"A few very large owners or special placement rules?"}
Q3 -->|yes| R3["Directory, with hashing for the long tail"]
Q3 -->|no| R4["Hash with fixed partitions or consistent hashing"]
Read it as a series of cheap questions about your workload. Many multi-tenant systems end at the directory exit sooner than they expect.
Choosing a shard key
The shard key is the field whose value decides placement. It is the most important decision in a sharded design, and it is hard to change later because changing it means moving all the data. A good shard key has these properties:
High cardinality: many distinct values. Sharding by country gives you perhaps 200 values, so at most about 200 shards, and wildly uneven ones.
Even distribution of data and traffic: no value should hold a large fraction of either.
Matches the access pattern: the queries you run most should name the shard key, so they hit one shard.
Stable: the value should not change for a row, or you must move the row between shards.
For a multi-tenant project-tracking SaaS (tenants own projects, projects own tickets), consider the options for the tickets table:
Candidate key
Query it serves well
Problem
ticket_id (hashed)
Fetch one ticket by ID; even spread
"All tickets of tenant X" scatters across every shard
tenant_id (hashed)
A tenant's projects and tickets on one shard, so most queries and transactions stay local
One very large tenant makes a hot shard
created_at
Recent-ticket range queries
All new writes hit one shard
region
Regional reports
Few values and heavy skew
A common answer is tenant_id: nearly every query is "things belonging to this tenant", so one shard answers it. To fetch a ticket by ID alone, embed the tenant or shard number in the ID, or keep a small lookup table. The real point: you pick the shard key for your most important queries, and everything else becomes more expensive.
Hotspots
Even a good key can concentrate load. A huge tenant generates far more traffic than ordinary ones, so its shard becomes hot. Defences include:
Cache the hot items so reads rarely reach the shard.
Split the hot key across sub-keys, for example appending a small random number (0 to 9) to a counter's key and summing on read.
Give heavy tenants their own shard, easy with directory sharding.
Replicate hot data more widely so reads spread.
Why hash mod N hurts, and consistent hashing
Suppose we use shard = hash(key) mod 4 and add a fifth shard, so the formula becomes mod 5. A key stays put only if its hash gives the same answer mod 4 and mod 5, which for uniformly spread hashes holds for about one key in five. Roughly 80% of all keys move, causing heavy network traffic and degraded performance exactly when you added capacity because the system was strained.
Consistent hashing fixes this. Picture a circle (a ring) of hash values. Each node is placed at a point on the ring, and each key is hashed to a point on the same ring. A key belongs to the first node you meet walking clockwise from the key's position.
When you add a node, it takes over only the keys in the arc just before it, from its neighbour. When you remove a node, only that node's keys move to its neighbour. On average, about 1 in N+1 of the keys move when you add the (N+1)th node, instead of nearly all of them.
graph LR
K1["Key 1"] -->|clockwise next| NodeB["Node B"]
K2["Key 2"] -->|clockwise next| NodeC["Node C"]
K3["Key 3"] -->|clockwise next| NodeA["Node A"]
NodeA --> NodeB
NodeB --> NodeC
NodeC --> NodeA
NodeD["New node D joins between B and C"] -.->|takes only keys from C's arc| NodeC
Virtual nodes
With only a few nodes, random positions on the ring produce uneven arcs, so one node may own much more than the others. And when a node dies, its whole load falls on one neighbour. The fix is virtual nodes (vnodes): each physical machine is placed on the ring many times, say 100 or 200 points each. Arcs average out, so load balances better. When a node leaves, its many small arcs scatter across many neighbours, spreading the extra work. When a node joins, it takes a little from many others. You can also give a larger machine more vnodes than a smaller one.
graph TD
Ring["Hash ring with many points"] --> V1["vnode A1"]
Ring --> V2["vnode B1"]
Ring --> V3["vnode A2"]
Ring --> V4["vnode C1"]
Ring --> V5["vnode B2"]
V1 --> PA["Physical node A"]
V3 --> PA
V2 --> PB["Physical node B"]
V5 --> PB
V4 --> PC["Physical node C"]
Consistent hashing appears in distributed caches, Dynamo-style stores such as Cassandra, and many load-balancing setups. A related and common alternative is fixed partitions: create many more logical partitions than nodes, for example 1,024, assign hash ranges to partitions once, and then move whole partitions between nodes when the cluster changes. The key-to-partition mapping never changes; only the partition-to-node map does. This is simpler to operate and is used by several databases.
Resharding is painful
Whichever method you choose, growth means resharding: moving data to new shards while the system stays online. The hard parts are copying live data while it is being written, switching routing atomically so requests do not go to the wrong shard (or both), keeping the system fast while the copy eats disk and network, and rolling back if the new layout misbehaves.
A typical safe pattern: create the new shard, copy a snapshot, stream changes made since, verify the copies match, flip routing for that key range, and delete the old data after a safety period. Plan for it from the start with many small logical partitions and a routing layer you control, and rehearse on non-critical data.
Cross-shard queries and transactions
Once data is split, anything that needs data from several shards gets harder.
Queries that name the shard key go to one shard and stay fast. Queries that do not must be sent to every shard and the results merged, a pattern called scatter-gather. It works, but the slowest shard decides the latency, and every shard does work for every query. At 100 shards, one query becomes 100 sub-queries.
Joins across shards are expensive and often avoided. Options are to store related data together (same shard key for parent and child rows), to denormalise (duplicate data so a query does not need a join), or to join in the application.
Global secondary indexes (for example, look up a user by email when sharded by tenant_id) face a choice. A local index on each shard means a lookup must ask all shards. A global index is itself sharded by the indexed value, so lookups are fast, but each write updates two places, possibly on different nodes.
Transactions that span shards cannot rely on one machine's locking. They need two-phase commit, which is slow and can block if the coordinator fails, or a saga with compensating actions (Advanced: Distributed Transactions). The best approach is to design the shard key so the operations needing atomicity fall inside one shard: if all of a tenant's data shares a shard, "create ticket and update the project's counters" is a local transaction.
Unique IDs and constraints such as "email must be unique" need care because each shard cannot see the others. Use IDs that include the shard or a time-based component, and enforce global uniqueness through a dedicated lookup keyed by the unique value.
Sharding in the architecture
sequenceDiagram
participant C as Client
participant A as App service
participant M as Shard map cache
participant S as Shard 2
C->>A: GET tickets for tenant 8472
A->>M: which shard owns tenant 8472
M-->>A: shard 2
A->>S: query tickets where tenant_id = 8472
S-->>A: rows
A-->>C: response
Many teams put a router or proxy in front of the databases so application code does not need to know the topology, and some databases shard natively and expose one logical table. Either way the trade-offs are the same, and understanding them tells you what the product can and cannot do for you.
In practice: three huge tenants and a hot shard
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A project-tracking SaaS serves about 4,000 business tenants with 12 engineers. Tickets live in PostgreSQL, sharded by tenant_id into 16 logical partitions spread over 4 clusters (each a primary plus a replica), with a small directory table mapping partitions to clusters (and able to hold per-tenant overrides). Traffic is dominated by a few giants: assume three tenants produce 55% of write volume, and two of them landed on cluster 3.
The pain, illustrative: cluster 3 runs at 95% CPU and 80% storage while the other three sit near 25%. Ticket API p95 is about 1.8 seconds for the roughly 1,000 ordinary tenants sharing cluster 3, against about 150 ms elsewhere. One of those two giants also has a contract requiring its data to stay in an EU region, and the budget allows at most two more clusters this year.
Starting architecture
Hashing spread the tenant count evenly, but not the load, because the load per tenant varies by orders of magnitude.
Options considered
Option
What it fixes
What it costs
Risk
A. Smaller change: a larger instance for cluster 3, per-tenant rate limits, and archiving closed tickets older than two years
Immediate pressure on cluster 3
A higher monthly bill, a few days of work
Buys months, not years; the giants keep growing
B. Use the directory to move the two giants on cluster 3 to their own dedicated clusters (the EU-bound one in an EU region); keep hashing for the long tail
The hot shard, noisy neighbours, the residency requirement
Two new clusters and two live tenant migrations
Migration mistakes; each giant must still fit one cluster
C. Reshard all data by (tenant_id, project_id) so giants spread across clusters
Any single tenant outgrowing a cluster
Months; tenant-wide queries become scatter-gather; cross-shard transactions
Largest blast radius, and hardest to roll back
The decision
Context
The skew comes from three known tenants, and almost all queries and transactions are tenant-scoped
Decision
A now, then B. Defer C
Consequences, good
Ordinary tenants are isolated from giants; residency is met; no change to query patterns
Consequences, bad
Dedicated clusters are a special case to operate; a giant on a full cluster has no further escape hatch
Revisit when
A single tenant uses more than about 60% of its cluster's write capacity, at which point split that one tenant by project_id
Rollout and verification
Migrate one tenant at a time, smallest first as a rehearsal:
Migrating one tenant to a new clusterCopy snapshot to Stream changes; Stream changes to Fence tenant on old cluster; Fence tenant on old cluster to Apply last changes; Apply last changes to Flip directory; Flip directory to Keep old data 7 daysCopy snapshot: to new clusterCopy snapshotto new clusterStream changes: lag near zeroStream changeslag near zeroFence tenant on old cluster: writes rejectedFence tenanton old clusterwrites rejectedApply last changes: verify checksumsApply lastchangesverify checksumsFlip directory: tenant entryFlip directorytenant entryKeep old data 7 days: then deleteKeep old data7 daysthen delete
The order matters: the tenant is fenced (its writes rejected, other tenants on the old cluster unaffected) before the final catch-up and verification, because checksums only match once writes have stopped. The tenant's writes pause for the seconds or minutes this takes. Fencing before the flip also means a router with a stale mapping fails loudly instead of writing to the wrong place. Dashboards that prove it worked: per-cluster CPU, write rate and storage; ticket API p95 per cluster and for the top ten tenants; migration lag in rows; checksum mismatches (target zero); and directory version per API pod.
The 2 a.m. failure
After cutover of tenant 17, the page reads "Write errors for tenant 17 on 2 of 6 pods". Those pods still cache the old directory entry and send writes to the old cluster, where tenant 17 is fenced.
sequenceDiagram
participant P as Stale API pod
participant O as Old cluster, tenant 17 fenced
participant D as Directory
participant N as New cluster
P->>O: insert ticket for tenant 17
O-->>P: error, tenant fenced
P->>D: refresh mapping
D-->>P: tenant 17 on new cluster
P->>N: retry insert
N-->>P: ok
First three checks: compare the directory version gauge across pods; SELECT tenant_id, cluster_id, version FROM tenant_directory WHERE tenant_id = 17;; and the count of tickets for tenant 17 created in the last 15 minutes on both clusters, to confirm nothing landed on the old one. Mitigation: trigger a directory refresh on the lagging pods (or, if there is no refresh hook, delete just those two pods so they reload the mapping), and keep tenant 17 fenced on the old cluster. Follow-up: stamp every directory entry with a version and send it with each query so a shard can reject a mismatch, shorten the directory cache TTL (a few seconds) with push invalidation, and put "fence before flip" in the migration runbook.
Architect's notes
Measure skew, not just totals. Rank tenants by write volume, storage and p99 query cost; a long tail plus a few giants needs a different plan from uniform growth.
Ask in the review: "What is the largest tenant we expect in two years, and does it fit one shard?" If not, plan the within-tenant split now.
The cost is the special cases. Dedicated clusters are cheap to create and expensive to remember. Record every override in the directory with an owner.
Treat the directory as tier-0. It needs replication, caching with versioning, and a tested recovery path.
Pick who is paged. Tenant moves are a platform team operation with a runbook, not an ad hoc script someone runs at night.
Defer full resharding until one tenant outgrows a cluster; it is the biggest change you can make and the hardest to reverse.
Common mistakes
Sharding too early. The operational cost is high and permanent. First try indexes, caching, replicas, a larger machine and archiving.
Choosing a low-cardinality or skewed key. Sharding by country or status gives few, uneven shards. Pick a key with many values and an even load.
Using a monotonically increasing key with range sharding. All new writes hit the last shard. Hash the key, or add a prefix that spreads writes.
Using plain hash mod N and planning to add nodes later. Most keys move when N changes. Use consistent hashing or many fixed partitions.
Ignoring cross-shard needs. Reports, joins, uniqueness and transactions become hard. List them before choosing the key and keep them within a shard where possible.
Forgetting that shards need replication. Each shard is a single point of failure without replicas. Replicate every shard and spread replicas across zones.
Having no resharding plan. Moving data live is the hardest operation. Design small logical partitions and rehearse moves early.
Key takeaways
Sharding splits data, replication copies it. Use sharding for storage and write scale, replication for availability and read scale; real systems use both.
Exhaust cheaper options first. Indexes, caching, replicas, scaling up and archiving often postpone sharding for years.
The shard key is the key decision. It should have high cardinality, spread load evenly and match the most important queries.
Range favours scans, hash favours balance, directory favours control. Each has a matching weakness: hotspots, scattered scans, and a critical lookup service.
Hotspots need explicit handling. Cache, split hot keys, isolate heavy tenants or replicate hot data.
Consistent hashing limits movement. Adding a node moves roughly 1 in N+1 keys, and virtual nodes smooth the balance.
Cross-shard operations are the real cost. Scatter-gather queries, joins, secondary indexes and transactions all get harder.
Plan resharding from day one. Use many logical partitions and a routing layer you control.
Practice exercises
Exercise 1 — Starter (30 min): A database is sharded with hash(key) mod 4. Take 20 keys with made-up hash values from 0 to 99, assign each to a shard, then recompute with mod 5. Count how many keys change shards and compare with the "about 80%" estimate.
Exercise 2 — Intermediate (2 hours): For a chat app with tables for users, conversations and messages, evaluate three shard keys (user_id, conversation_id, message_timestamp). For each, write the three most common queries and say whether they hit one shard or all. Pick a key, justify it, and name the one query that becomes expensive because of your choice and how you would mitigate it.
Exercise 3 — Advanced (half day): Implement a small consistent-hashing ring in any language with virtual nodes. Place 3 physical nodes with 100 vnodes each, hash 100,000 sample keys, and report the key counts per node. Then add a fourth node and measure the fraction of keys that moved, and repeat with 1 vnode per node. Write a short comparison of balance and movement between the two settings.
Related reading
System Design: Replication — every shard still needs replicas; the previous lesson covers how they work.
Lesson 3 of 14 · System Design Building Blocks: Data, Messaging and Scale
Learn when and how to split data across machines: range, hash and directory sharding, choosing a shard key, hotspots, resharding and consistent hashing.
In the rest of this lesson
Choosing a shard key
Why hash mod N hurts, and consistent hashing
Resharding is painful
Cross-shard queries and transactions
Sharding in the architecture
In practice: three huge tenants and a hot shard
Enrolling is free. It unlocks every lesson in System Design Building Blocks: Data, Messaging and Scale, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.