What to do when one database machine can't hold or serve all your data: split it. Range, hash and directory partitioning, choosing a shard key, routing, hot keys, queries that touch every shard, resharding without downtime, Postgres partitions, and when not to shard at all.
In one sentencePartitioning (or sharding) means splitting one big dataset into pieces and putting each piece on a different machine, so no single machine has to store or serve everything.
The words get used loosely. Partitioning is the general idea of splitting data; sharding usually means the pieces live on different servers. A DatabaseDatabaseA program whose whole job is storing data safely and finding it again quickly — Postgres, MySQL, MongoDB, Redis. Your app usually talks to it over a network connection; embedded databases like SQLite run inside your program instead. can also be partitioned on one machine (into several files), which §10 shows in PostgreSQLPostgreSQL (Postgres)A popular open-source relational SQL database, known for correctness and features. Used throughout this site's database examples..
Splitting is not copying
People often mix up partitioning with replication, which means keeping full copies of the same data on several machines (a ReplicaReplicaA full copy of a database kept on another machine and updated as the original changes, for reading load and for taking over if the original dies. is one such copy; the replication topic covers it). They solve different problems, and real systems use both. Flip between the three setups:
▶Split it or copy it?
A phone book with names A–Z, stored on 3 machines. Switch between the three ways to do it.
machine 1
📒 A–H
machine 2
📒 I–P
machine 3
📒 Q–Z
Each machine stores
about 33% of the data
Each machine handles writes
about 33% of them
Reads can be shared
yes, each machine serves its own third
One machine dies
a third of the data is gone until it's back
Each machine holds a different slice. Storage and writes are divided by 3. But each slice exists only once: lose a machine and you lose (access to) that slice.
What the rest of this topic covers
Why a single database runs out of room (§02).
How to split: by range, by hash, or with a lookup table (§03), and how to pick the key (§04).
How to find the right shard for a request (§05).
What goes wrong: one shard on fire (§06), queries that have to ask every shard (§07).
How to grow: moving data to new machines without downtime (§08, §09).
Real tools and staff-level advice, including when not to shard (§10, §11).
Foundations§02
Why one database stops being enough
In one sentenceA single database machine has three walls: disk (the data no longer fits), memory (the busy part no longer fits in RAM), and write throughput (it can't apply writes fast enough). Sharding is how you get past them.
Wall 1: the data doesn't fit
Say your app stores 2 KB per order and takes 5 million orders a day. That's about 10 GB a day, or 3.6 TB a year, before indexesIndex (database)An extra, sorted data structure the database keeps next to a table so it can find rows by a column without reading the whole table — like the index at the back of a book.. A big server can hold tens of terabytes, so this wall is further away than people think, but it's real: and long before the disk is full, backups, restores and adding a column take hours.
Wall 2: the busy data doesn't fit in memory
A database is fast when the rows people are asking for are already in RAM. Reading from an SSD vs HDDSSD vs HDDSolid-state drives read a random block in ~0.1 ms; spinning hard disks need ~5–10 ms because a head must physically move. Both are far slower than RAM (~0.0001 ms). is roughly 100 times slower than reading memory (approximate; it depends on the hardware). The rows that are in active use are called the working set. When it grows past the server's RAM, more and more reads go to disk and LatencyLatencyHow long one request or job takes from the moment it arrives until it's finished — including any time spent waiting in a queue. climbs.
Wall 3: too many writes
Reads are easy to spread: add replicasReplicaA full copy of a database kept on another machine and updated as the original changes, for reading load and for taking over if the original dies. and send reads to them. Writes are not, because every replica has to apply every write too. In the usual setup one main server, the primary, accepts all the writes and the replicas copy them. If that primary can apply 20,000 writes a second (a rough number for a well-tuned box) and you need 60,000, more copies won't help. You need three machines that each take a different third of the writes. That's partitioning.
What to try before sharding
Sharding is powerful and expensive: every query, migration and backup gets harder. So the usual order is:
1
Scale up
Move to a bigger machine (scaling upScaling up (vertical scaling)Handling more load by moving to a bigger machine: more CPU cores, more RAM, faster disks. Simple, but there's a largest machine you can buy.). Boring, and often good for years.
2
Copy for reads
Add read replicas and a CacheCacheA small, fast store of answers you already computed or fetched, so the next request for the same thing skips the slow work. The hard part is knowing when a cached answer is out of date. to take load off the primary.
3
Split
Only when writes or data size outgrow the biggest machine you can sensibly run, shard. (§11 has the full checklist.)
Scaling up has a ceiling (the biggest machine you can buy) and a price curve that gets steep near the top. Sharding is the opposite trade, called scaling outScaling out (horizontal scaling)Handling more traffic by adding more machines that each run a copy of your app, instead of buying one bigger machine (scaling up). For databases, each machine holds part of the data.: many ordinary machines, no ceiling in principle, but much more complexity.
Foundations§03
Three ways to split: range, hash, directory
In one sentenceThere are three basic ways to decide which shard a row goes to: by range of the key, by hash of the key, or by looking it up in a directory. Each one makes some queries cheap and others expensive.
Range: like the phone book
With Range partitioningRange partitioningSplitting data by ranges of the key: A–F on one shard, G–M on the next, or one month per shard. Neighbouring keys stay together, so “everything between X and Y” is cheap., each shard owns a continuous slice of key values: names A–F, G–M…, or one month of orders per shard.
Good: neighbouring keys sit together. A range queryRange queryA query asking for all values between two bounds — created_at between March 1 and March 2, price < 100 — instead of one exact value. such as "all orders from last week" goes to one or two shards.
Bad: if new keys always grow, every new row lands in the last range. Order IDs that count up, and timestamps, are like that (a monotonic keyMonotonic keyA key that only ever goes up: auto-increment IDs, timestamps. With range partitioning, every new row lands in the last range.). The newest shard takes all the writes while the others sit idle: a hot spotHot key / hot spotA single key (or a narrow range) that gets far more traffic than the rest, like a celebrity's profile. Because one key always lives on one shard, that shard overloads while the others idle..
Hash: scramble first, then split
With Hash partitioningHash partitioningChoosing the shard from a hash of the key, e.g. hash(user_id) % 1024. Keys spread evenly, but neighbouring keys end up on different shards., you first run the key through a hash functionHash (for sharding)A function that turns a key like "tenant:42" into a big, evenly spread number. The same key always gives the same number; similar keys give very different numbers., which turns it into a big, random-looking number. Then you pick a shard from that number, for example hash(key) % 4 (the remainder after dividing by 4, which is always 0–3).
key
hash(key) (made-up numbers)
% 4 → shard
order 300
2,817,035,113
1
order 301
1,092,446,526
2
order 302
3,550,129,800
0
Good: consecutive keys scatter, so writes spread evenly even when IDs count up.
Bad: the same scattering destroys order. "Orders 300–340" are now on every shard, so a range query has to ask all of them.
(Plain % 4 has a nasty problem when you go from 4 shards to 5; §08 covers it. For now, read it as "a hash picks the shard.")
Directory: a lookup table
With Directory (lookup) partitioningDirectory (lookup) partitioningKeeping an explicit table that says which shard each key (or group of keys) lives on. Flexible, since you can move any key by changing one row, but the table must be stored and looked up., you keep an explicit table: customer acme → shard 0, globex → shard 1, and so on.
Good: total control. Move one customer by changing one row; give a huge customer its own shard.
Bad: every request needs a lookup first, so the table must be fast, cached, and never lost.
Try all three on the same data
Each order has an ID (counting up) and a customer. Press ▶ to add orders and watch the "last 40 writes" bars. Then run the two queries under each scheme.
▶One stream of orders, three ways to split it
300 old orders are already stored. Press play to add new ones (order IDs go up by one each time, so newer = bigger ID).
Range: orders 1–80 → shard 0, 81–160 → shard 1, 161–240 → shard 2, 241 and up → shard 3.
shard 0
stored80
last 40 writes0
shard 1
stored80
last 40 writes0
shard 2
stored80
last 40 writes0
shard 3
stored60
last 40 writes40
latest: order #300 (acme) → shard 3
Press ▶ and watch the “last 40 writes” bars: with range partitioning every new order has the biggest ID so far, so it lands on shard 3.
What you should have seen:
Range (by order ID)
Hash (of order ID)
Directory (by customer)
New writes
🔥 all on the newest shard
✓ even
depends on customer sizes
"Last 40 orders"
✓ 1 shard
✕ all shards
✕ all shards
"All of globex's orders"
✕ all shards
✕ all shards
✓ 1 shard
Moving data around
split a range
hard (see §08)
✓ change one row
Notice that the widget's directory splits by customer, not by order ID. That, not the directory itself, is why the globex query is cheap: hashing by customer would make it cheap too. The scheme (range, hash, directory) and the key (order ID, customer, …) are two separate choices. §04 is about the key.
Practitioner§04
Choosing a shard key
In one sentenceThe shard keyShard key (partition key)The column whose value decides which shard a row goes to, e.g. tenant_id. Queries that include it can go straight to one shard; queries without it have to ask all of them. is the most important decision in a sharded system, and the hardest to change later. Pick the value that almost every query already filters by, that has many distinct values, and that keeps rows used together on the same shard.
Three questions to ask about a candidate key
1
Does every query know it?
If the most common query includes the key, the router can send it to one shard. If not, the query asks every shard (§07).
2
Enough distinct values?
The key's cardinalityCardinalityHow many distinct values something can take. A user_id column has high cardinality (millions of values), a country column low. In metrics, high-cardinality labels (like user IDs) create one time series per value and can overwhelm the monitoring system. limits how far you can split. Shard by country and Brazil is one shard forever, however big it gets.
3
Does load spread?
Even data isn't enough: writes must spread too. Timestamps spread data over time but put every new write on one shard (§03).
A worked example: TaskBoard
TaskBoard is a project-management app sold to companies. Each customer company is a tenant, which makes it a multi-tenantMulti-tenant (tenant)One app serving many customer organisations (tenants) from shared infrastructure, like Slack workspaces or Shopify stores. Each tenant only sees its own data. app. It has three main tables: projects, tasks and users, and every row belongs to one tenant.
The two most common requests look like this:
-- 1. Show a tenant's board: their open tasks, with project namesSELECT t.title, p.name
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE t.tenant_id = 42AND t.status = 'open';
-- 2. Open one task (the URL is /acme/tasks/123, so we know the tenant)SELECT * FROM tasks WHERE tenant_id = 42AND id = 123;
Both queries contain tenant_id. That's the clue. Now compare the candidates. The data is realistic: 40 tenants, and the biggest one, acme, is about 23% of everything.
▶Pick a shard key for TaskBoard
A project-management app. 40 customer companies (tenants); the biggest, acme, has as much data as the smallest 25 combined.
Share of data / of new writesfair = 25%
shard 025% 25%
shard 125% 25%
shard 225% 25%
shard 325% 25%
Coloured bar = stored data. Grey bar = where new writes go. White line = a fair share.
Data spread evenly
✓
no shard much fuller than the rest
New writes spread evenly
✓
every shard shares the work
“Show acme's board”: 4 shards
✕
a tenant's tasks are spread over every shard
“Open task 123”: 1 shard
✓
hash the task ID, go to one shard
Perfectly even, because every tenant's tasks are scattered. But the most common page in the app, “show this tenant's board”, now has to ask every shard and merge the answers, and a JOIN between a task and its project may cross machines.
Why tenant_id wins (with one fix)
Sharding by tenant_id puts all of a tenant's projects, tasks and users on the same shard. This is called co-locationCo-locationPutting rows that are used together (a tenant's projects, tasks and users) on the same shard by giving them the same shard key, so JOINs and transactions stay on one machine., and it buys three things at once:
The JOIN in query 1 runs inside one database, exactly as it did before sharding.
A Database transactionDatabase transactionA group of database changes that happen all together or not at all (COMMIT keeps them, ROLLBACK undoes them). How much other users can see or interfere with the half-done state depends on the isolation level. that touches several tables for one tenant (create a task and update the project's counter) stays on one machine, so it keeps its all-or-nothing guarantee. Cross-shard transactions are hard (§07).
The app barely changes. Every request already knows its tenant, from the URL or the logged-in user.
The weakness is the one the widget showed: one whale tenantWhale tenantA customer so much bigger than the rest that it can overload whichever shard it lands on. Often given its own dedicated shard. makes its shard much fuller than the rest. The usual fix is a hybrid: hash small tenants, and add directory entries that pin each whale to a dedicated shard. If a single whale outgrows even a whole machine, split it by a second key (such as project_id) as a special case.
Other common choices
App
Typical shard key
Why
B2B SaaS (Slack, Shopify, Notion-like)
tenant / workspace / store ID
every request is scoped to one tenant
Consumer social app
user ID
a user's profile, posts and settings go together
Chat
conversation ID
all messages of a chat on one shard, sorted by time
IoT / metrics
device ID (+ time inside the shard)
writes spread across devices, reads are per device
Orders, e-commerce
customer ID
"my orders" is the hot query
Practitioner§05
Finding the right shard: routing
In one sentenceEvery request needs an answer to "which machine holds this key?" Something has to keep the routing tableRouting table (shard map)The data that says which node owns which partition or key range. Every router consults it; it changes when data moves. and use it: your app, a proxy in front of the databases, or the database nodes themselves.
Three places to put the lookup
▶Who finds the shard?
The app wants tenant 42, which lives on shard B. Same data, three places to put the lookup.
1 network hop from app to data · dashed line = who keeps a copy of the routing table
The app itself holds the routing table (a library inside your service) and connects straight to the right shard. Fastest, and no extra box to run. The cost: every service, in every language, must load the same table and update it when shards move. Used by Redis Cluster clients, Cassandra's “token-aware” drivers (drivers that compute the key's hash themselves and go straight to the owning node), and many hand-rolled setups.
In every version, the routing table itself lives in a small metadata storeMetadata storeA small, highly reliable database (often etcd or ZooKeeper) that holds the routing table and tells routers when it changes. (etcd, ZooKeeper, or a special table), and everyone who routes keeps a copy. When data moves, the table changes and the copies must update. A router with an old copy sends queries to the wrong place, so real systems also let a shard answer "not mine" instead of returning wrong results:
A stale router gets redirected
router
→ A (old table)
refresh table
→ B
✓
shard A
not mine
shard B
row
Tenant 42's partition just moved from shard A to shard B. The router's copy of the table is a few seconds old. Shard A refuses instead of answering “not found”, the router refreshes its table and retries. Redis Cluster's MOVED reply works exactly like this.
In the app
Proxy
Any node
Extra network hop
none
one
sometimes
Who must know the table
every service
the proxies
every node
Apps in many languages
each needs the library
✓ easy
✓ easy
Examples
Redis Cluster clients, hand-rolled
VitessVitessAn open-source sharding layer for MySQL, built at YouTube. Apps talk to its vtgate proxies as if to one database; vtgate routes each query to the right shard., MongoDB mongos, CitusCitusA Postgres extension that spreads tables across many Postgres servers. A coordinator node routes queries and merges results.
Cassandra, CockroachDB
Build one: a client-side router
Here's the smallest useful version: the app hashes the shard key to one of 1,024 fixed partitions, then a table says which database server owns each partition. Why not hash straight to a server? Because then adding a server would reshuffle almost every key; §08 explains in detail. With this design, moving data means changing table rows, never the formula.
shard/router.go
package shard
import (
"database/sql""fmt""hash/fnv"
)
// NumPartitions is fixed forever. Nodes come and go; partitions don't.constNumPartitions = 1024// PartitionOf maps a shard key to a partition number 0…1023.funcPartitionOf(key string) int {
h := fnv.New32a() // FNV-1a: fast, and the same result in every process
h.Write([]byte(key)) // a hash's Write never returns an errorreturnint(h.Sum32() % NumPartitions)
}
// Router knows which node owns each partition, and holds one pool per node.typeRouterstruct {
owner [NumPartitions]string// partition → node name, e.g. "db-a"
pools map[string]*sql.DB// node name → connection pool
}
funcNewRouter(owner [NumPartitions]string, pools map[string]*sql.DB) (*Router, error) {
for p, node :=range owner {
if _, ok := pools[node]; !ok { // ← catch typos at startup, not at 3 a.m.returnnil, fmt.Errorf("partition %d: unknown node %q", p, node)
}
}
return &Router{owner: owner, pools: pools}, nil
}
// Node answers "which machine holds this key?"func (r *Router) Node(key string) string { return r.owner[PartitionOf(key)] }
// DB returns the pool to run this key's queries on.func (r *Router) DB(key string) *sql.DB { return r.pools[r.Node(key)] }
Using it looks like ordinary database code, with one extra step to pick the pool. The tenant ID in the query is the same value you routed by:
using the router
key := fmt.Sprintf("tenant:%d", tenantID)
var name string
err := router.DB(key).QueryRowContext(ctx,
"SELECT name FROM projects WHERE tenant_id = $1 AND id = $2", tenantID, projectID,
).Scan(&name)
$1, $2 are placeholders: the driver sends the values separately from the SQL text, which also protects against SQL injectionSQL injectionAn attack where user input pasted into a SQL string changes the query itself (e.g. a name like ' OR 1=1 --). Prevent it by sending values separately as placeholders (?)..
Test it
Routing bugs are silent: a wrong shard just returns "not found". So test the properties that matter. The full test files have a few more cases; these are the two that teach the most:
shard/router_test.go
// Golden values: the JS router must produce exactly the same numbers.funcTestPartitionGolden(t *testing.T) {
for key, want :=rangemap[string]int{"tenant:1": 564, "tenant:42": 491, "acme": 431} {
if got :=PartitionOf(key); got != want {
t.Errorf("PartitionOf(%q) = %d, want %d", key, got, want)
}
}
}
funcTestMovingOnePartitionMovesOnlyItsKeys(t *testing.T) {
owners :=evenOwners("db-a", "db-b", "db-c") // helper: round-robin table
pools :=fakePools("db-a", "db-b", "db-c", "db-d") // helper: nil pools
before, err :=NewRouter(owners, pools)
if err != nil {
t.Fatal(err)
}
owners[7] = "db-d"// ← hand partition 7 to a new node (arrays copy, so before is unchanged)
after, err :=NewRouter(owners, pools)
if err != nil {
t.Fatal(err)
}
for i :=range30_000 {
k := fmt.Sprintf("tenant:%d", i)
if before.Node(k) != after.Node(k) && PartitionOf(k) != 7 {
t.Fatalf("%s moved but is in partition %d", k, PartitionOf(k))
}
}
}
We ran both suites (Go 1.24, Node 22): both pass, and both print the same partition numbers. Spreading 30,000 tenant keys over three nodes gave 9,995 / 10,145 / 9,860, and handing partition 7 to a new node moved 45 keys, all from partition 7. (A perfect hash would move 30,000 ÷ 1,024 ≈ 29; real hashes are a bit lumpy.)
Practitioner§06
Hot keys: one shard on fire
In one sentencePartitioning spreads keys evenly, not traffic. When one key gets a flood of requests, all of it lands on the one shard that owns that key, and that shard melts while the others idle. That's a hot keyHot key / hot spotA single key (or a narrow range) that gets far more traffic than the rest, like a celebrity's profile. Because one key always lives on one shard, that shard overloads while the others idle..
The celebrity problem
A social app shards posts by post ID. A pop star with 100 million followers posts a photo. Within a minute, hundreds of thousands of people open it and tap "like". Every one of those requests contains the same key, post:42, so every one of them goes to the same shard.
Adding more shards does nothing here. The key still lives on exactly one of them. This is why it's also called skew: the load is lopsided, no matter how many machines you have.
Try it
Move the slider to make the post go viral. The salting toggle uses 8 sub-keys (2 per shard), the same number as the code further down. Then try the two fixes, once for reads (people viewing) and once for writes (people liking).
▶A celebrity posts
4 shards, each comfortable up to 100 requests/s, each already doing ~40/s of normal traffic. Then one post (post:42) goes viral.
Load per shard (req/s)capacity = 100
shard 038
shard 138
shard 2259
shard 339
SHARDS OVER CAPACITY
1
LOOKUPS PER VIEW
1
All 220 reads/s for post:42 go to shard 2, because a key always hashes to the same shard. It is at 2.6× capacity: requests queue up and time out, even though the other shards are almost idle.
What the simulator shows:
Hot reads (viewing)
Hot writes (liking)
Cache the key
✓ fixes it: the cache answers almost every read
✕ useless: every write must still be stored
Salt (split) the key
✕ makes it worse: every read asks every sub-key
✓ fixes it: writes spread over N shards
Fix 1 for hot reads: cache it
The same post being read a million times gives a million identical answers. Put a CacheCacheA small, fast store of answers you already computed or fetched, so the next request for the same thing skips the slow work. The hard part is knowing when a cached answer is out of date. in front, either an in-memory one in each app server or a shared one like RedisRedisA popular in-memory data server: a fast key–value store and cache that also offers counters, sorted sets, pub/sub channels and streams (an append-only log for messages).. Even a cache that keeps the post for one second turns 100,000 reads per second into one database read per second per app server.
Fix 2 for hot writes: salt the key
Key saltingKey salting (key splitting)Spreading one hot key over N sub-keys (post:42#0 … post:42#7) that land on different shards. Writes pick one sub-key; reads must combine all N. splits one logical key into N physical sub-keys: post:42#0, post:42#1, …, post:42#7. Each has a different hash, so they land on different shards (we checked: with 4 nodes and our router from §05, the 8 sub-keys of post:42 spread over all 4). Each write picks one sub-key at random; a read of the total adds all of them up.
a salted like-counter
const saltN = 8// how many sub-keys one hot counter is split into// AddLike increments ONE randomly chosen sub-key: writes spread over 8 places.funcAddLike(ctx context.Context, postID int64) error {
key := fmt.Sprintf("post:%d#%d", postID, rand.IntN(saltN)) // "post:42#5"
_, err := router.DB(key).ExecContext(ctx, `
INSERT INTO like_counts (key, n) VALUES ($1, 1)
ON CONFLICT (key) DO UPDATE SET n = like_counts.n + 1`, key)
return err
}
// LikeCount must read ALL sub-keys and add them up: reads fan in from 8 places.funcLikeCount(ctx context.Context, postID int64) (int64, error) {
var total int64for i :=range saltN {
key := fmt.Sprintf("post:%d#%d", postID, i)
var n int64
err := router.DB(key).QueryRowContext(ctx,
"SELECT n FROM like_counts WHERE key = $1", key).Scan(&n)
if err != nil && !errors.Is(err, sql.ErrNoRows) { // a missing sub-key just means 0return0, err
}
total += n
}
return total, nil
}
The Go version reads the sub-keys one after another for clarity; in production you'd run them concurrently like the JS one. Either way it's 8 lookups per read, which is why §07's tail-latency math applies here too.
Other tools against hot spots
Batch the writes. Collect likes in memory for 1 second and write n = n + 437 once. Fewer, bigger writes.
Split the range. If the hot spot is a range (today's orders), move that range to its own shard, or split it further (§08).
Rate-limit or shed. If one tenant is hammering a shard, cap that tenant so it can't take everyone else down with it (load shedding).
Practitioner§07
Queries and transactions across shards
In one sentenceA query that includes the shard key goes to one shard. Anything else has to ask every shard and wait for the slowest one, and a transaction that spans shards loses the simple all-or-nothing guarantee you had on one database.
Scatter-gather
"Top 10 most expensive orders this week, across all tenants" doesn't name a tenant, so the router can't pick a shard. It runs Scatter-gatherScatter-gatherRunning a query by sending it to every shard (scatter), waiting for all the answers, and merging them (gather). Needed when the query doesn't include the shard key.:
Scatter: send the query to all N shards in parallel.
Each shard computes its own top 10.
Gather: wait for all N answers, merge them, keep the overall top 10.
That works, and for an occasional admin query it's fine. The trouble is speed: the query is only as fast as the slowest shard. Each shard is usually quick, but every machine occasionally hiccups (a garbage-collectionGarbage collector (GC)Part of the language runtime that automatically frees memory nobody can reach anymore. Anything still referenced — like a stuck goroutine — is never freed. pause, a slow disk, another program on the same hardware hogging it). With one shard you rarely notice. With 50, someone is almost always hiccuping.
▶Ask every shard, wait for the slowest
Each shard usually answers in ~10 ms, but 1 time in 100 it hiccups (a pause, a slow disk) and takes ~200 ms. A query that asks every shard has to wait for all of them.
One query: each shard's answer timetotal = 13 ms
tallest bar = how long the user waits
MEDIAN
14 ms
P99
237 ms
SLOW QUERIES
15%
Chance a query is slow1 − 0.99N = 15%
Over 2,000 simulated queries, 15% waited for at least one hiccup. With 16 shards, a 1-in-100 problem on each shard becomes a 15% problem for the user.
The maths is short. If each shard is slow 1% of the time, a query is fast only if every shard is fast: 0.99 × 0.99 × … = 0.99N. For N = 50 that's 0.61, so 39% of queries are slow. A rare problem per shard becomes a common problem per query. This is Tail latencyTail latencyThe latency of the slowest few requests — usually reported as p99 or p99.9. It's what your unluckiest users feel, and it grows much faster than the average under load., and it's why you measure p99p99 (99th percentile)The value that 99% of measurements are below — i.e. how bad the slowest 1% is. Averages hide slow outliers; p99 shows them. and not the average (more in Worker Pools §05).
Secondary indexes: local or global?
Users are sharded by user ID. Logging in looks a user up by email. That needs a secondary indexSecondary indexAny index other than the one the table is stored in. Its entries point back to the row: by row address in Postgres, by primary key value in InnoDB. on email, and a sharded database has two ways to build it:
▶Find the user whose email is bob@
Users are sharded by user ID. The query filters by email, which is not the shard key.
shards asked: 3 of 3
Each shard indexes only its own users. To find bob@ the router has no idea which shard he's on, so it asks all three (scatter-gather) and two of them answer “not here”. Writes are simple: changing a user's email updates the user and the index on the same shard, in one transaction.
Local index
Global index
Lookup by email
asks every shard
one or two targeted hops
Changing an email
one shard, one transaction
may touch two shards: either updated asynchronously (fast, briefly stale) or in one distributed transaction (never stale, slower)
Can read stale data?
no
async: yes, briefly (Eventual consistencyEventual consistencyA promise that if writes stop, every copy or derived view of the data (replicas, caches, read models, other services) will end up agreeing — but for a while, different readers may see different (stale) versions.); transactional: no
async: DynamoDB global secondary indexes · transactional: CockroachDB, Spanner
Here is what changing bob@'s email to zed@ looks like in each kind of global index. The user row is on shard 0; the old index entry (b) is on shard 0 and the new one (z) is on shard 2:
Changing an email with a global index
async: write
user row + done
async: index
zed@ → not found
index updated
txn: write
user row
2PC with shard 2
done
txn: index
index entry, same txn
Top: asynchronous (DynamoDB-style). The write returns quickly, but for a moment a login as zed@ finds nothing. Bottom: same transaction (Spanner, CockroachDB). No stale moment, but the write waits for both shards to commit together.
A common hand-built version of a global index is a separate lookup table, email → user_id, itself sharded by email. Login reads it first, then goes to the right user shard.
Transactions across shards
Moving $100 from Alice to Bob is one Database transactionDatabase transactionA group of database changes that happen all together or not at all (COMMIT keeps them, ROLLBACK undoes them). How much other users can see or interfere with the half-done state depends on the isolation level. on a single database: both changes happen, or neither does (the "A" in ACIDACIDThe four guarantees of a database transaction: Atomic (all or nothing), Consistent (rules hold), Isolated (concurrent transactions don't see each other's half-done work), Durable (once committed, it survives a crash).). Now put Alice on shard A and Bob on shard B. They're two separate databases, and each commits on its own:
What goes wrong without coordination
app
debit Alice
💥
shard A
−$100 COMMIT ✓
shard B
+$100 never arrives
Shard A has committed the debit. The app crashes before telling shard B. $100 has vanished, and neither database knows anything is wrong.
Three ways out, from best to worst:
Don't need one. Choose the shard key so things that change together live together (§04). Most SaaS transactions are within one tenant, so with tenant_id this problem mostly disappears.
Let a database that supports it do the work.Distributed SQL databases, which look like one SQL database but shard and replicate underneath (CockroachDB, Spanner, YugabyteDB) and Citus run cross-shard transactions for you, using two-phase commitTwo-phase commit (2PC)A protocol for committing one transaction across several databases: a coordinator first asks everyone to “prepare” (promise they can commit), then tells all of them to commit. Correct but slow, and stuck if the coordinator dies mid-way. under the hood. They're slower than single-shard ones, but correct.
Use a SagaSagaA long business process split into steps, each with its own transaction, plus an “undo” step (a compensation) for each one. If step 3 fails, the compensations for steps 2 and 1 run instead of a rollback.. Split the operation into steps, each a local transaction, with an "undo" step for each (refund Alice if crediting Bob fails). The data is briefly inconsistent, but it heals. See the saga topic.
Two-phase commit, in one picture
coordinator
prepare?
waiting for yes
commit!
shard A
ready ✓
locked
commit
shard B
ready ✓
locked
commit
Phase 1: everyone promises they can commit (and holds their locks). Phase 2: only after every “yes” does the coordinator say commit. If the coordinator dies between the phases, participants wait with locks held, which is the classic weakness of 2PC.
Practitioner§08
Resharding: adding machines without reshuffling everything
In one sentenceSooner or later you add machines. The question is how much data has to move when you do. With hash(key) % N the answer is almost all of it. Every real system uses a design where it's only about 1/N.
Why hash % N is a disaster
Load Balancing §05 worked this out for caches: when N goes from 4 to 5, a key keeps its server only if h % 4 happens to equal h % 5, which is true for about 1 key in 5. So about 80% of keys change server.
For a cache, that's a burst of misses. For a database it's far worse: 80% of your rows have to be copied over the network to a new machine, while the app keeps reading and writing them, and a router has to know for every single key whether it has moved yet. That's Resharding / rebalancingResharding / rebalancingMoving data between shards, usually because you added machines or one shard got too big or too busy, ideally while the app keeps running. at its most painful.
Fix 1: many fixed partitions
Split the formula into two steps, as our router in §05 did:
partition = hash(key) % 1024, where 1,024 never changes.
A table says which node owns each partition.
With 4 nodes, each owns 256 partitions. Add a fifth and it takes about 205 partitions from the others (about 51 from each). Only those partitions move, each as one whole unit, and the formula itself is untouched. Try it:
▶Add a fifth database: what has to move?
240 keys (squares), coloured by the node that stores them. Add node E and count the squares with a white outline: each one is data that must be copied over the network.
Keys per nodefair = 60
A56
B60
C54
D70
Press the button. Then switch modes and press it again.
This is the most common design in production. Redis Cluster has 16,384 hash slots; Couchbase has 1,024 vBuckets; Elasticsearch fixes the number of primary shards when you create an index (Elasticsearch's word for a table, not the lookup structure from §07). The one decision you must get right up front is how many partitions: it's the most nodes you can ever spread across. A common rule of thumb is 10–100× the number of nodes you expect, since each partition has a small fixed overhead.
Fix 2: consistent hashing
Consistent hashingConsistent hashingA way to map keys (user IDs, cache keys) to servers so that adding or removing one server moves only about 1/N of the keys, instead of reshuffling almost all of them as hash(key) % N does. puts nodes and keys on a ring, and each key belongs to the next node clockwise. Adding a node takes over only the keys just before it, about 1/N of the total. With many virtual nodesVirtual node (vnode)One of many points a single server gets on a consistent-hash ring. With ~100 points per server, each server's share of keys evens out, and a removed server's keys spread over all the others. per server, the load stays even. Cassandra and DynamoDB are built this way.
You already know the details: Load Balancing §05 has the interactive ring and the code. Notice that "many virtual nodes per server" and "many fixed partitions per server" are the same idea in two outfits: cut the key space into many more pieces than you have machines, and move pieces.
Fix 3: split ranges as they grow
Range-partitioned systems don't pick a number up front. They start with one range covering everything and split any range that gets too big (or too busy), then move halves to emptier nodes:
▶Dynamic splitting
Ranges that grow too big are split, and halves move to less busy nodes.
node A
a–f
q–z 🔥 too big
node B
g–p
node C
empty (new node)
q–z is now 2.6× bigger than the others (or getting far more traffic).
Bigtable and HBase call these pieces regions or tablets; CockroachDB calls them ranges and splits them at about 512 MiB by default in recent versions; MongoDB calls them chunks. The upside: the system adapts to your data on its own, including hot ranges. The downside: a brand-new empty table starts as one range on one node, so the first burst of writes isn't spread until splits happen (many systems let you pre-split to avoid that).
Which one when
Fixed partitions
Consistent hashing
Range splits
Key order kept (range queries)
✕
✕
✓
Data moved when adding a node
~1/N, in whole partitions
~1/N
only the ranges you move
Up-front decision
number of partitions
number of vnodes
none (but pre-split new tables)
Seen in
Redis Cluster, Couchbase, Elasticsearch, many hand-rolled systems
In one sentenceTo move a partition without downtime, keep the new copy up to date (dual writes or a change stream), copy the old rows in the background (backfill), check the two copies match (verify), then flip reads and writes over one at a time, keeping a way back at each step.
Why not just copy it?
The naive plan: stop writes, copy partition 7 from db-a to db-d, update the routing table, start writes again. For a few megabytes that's fine. For 200 GB the copy takes an hour, and "nobody can write for an hour" is an outage. The trick is to copy while writes continue, without losing any of them.
Step through it
▶Moving partition 7 to a new node, live
Dual writes → backfill → verify → switch reads → switch writes → clean up. Use ‹ › or the arrow keys.
Code
1// writes for partition 7
2switch phase {
3case"normal":
4 oldDB.Write(row)
5case"dual": // backfill, verify
6 oldDB.Write(row) // the truth
7 newDB.Write(row) // keep up
8case"reads-new":
9 oldDB.Write(row)
10 newDB.Write(row)
11case"done":
12 newDB.Write(row)
13}
routing table
p7 → db-a
the app does
writes → db-a
reads ← db-a
db-a (old home)
o1 $10
o2 $25
o3 $7
db-d (new home)
nothing yet
Partition 7 lives on db-a, which is getting full. We want to move it to the new node db-d without stopping the app. Right now every read and write for p7 goes to db-a.
1 / 8
The pieces, named
Dual writesDual writesDuring a migration, writing every change to both the old and the new location so the new one stays up to date. keep the new location current from the moment the move starts. The old location stays the source of truth until the cutover.
The backfillBackfillA background job that copies existing rows from the old location to the new one in small batches, while new writes are handled separately. copies everything that existed before dual writes began. It must be IdempotentIdempotentDoing it twice has the same effect as doing it once. “Set balance to 50” is idempotent; “add 50 to balance” is not — so only the first is safe to retry blindly. (safe to re-run after a crash) and must never overwrite a newer version with an older one: insert-if-missing, or compare a version number or updated_at.
Verify compares row counts and checksumsChecksumA small number computed from a block of data and sent along with it. The receiver recomputes it; if the two don't match, the data was damaged in transit. Also used to check that two copies of data are identical. per batch of rows, and re-copies any batch that differs.
The cutoverCutoverThe moment during a migration when traffic switches from the old location to the new one. Good migrations make it a quick, reversible flip of a setting. is a change to the routing table, reads first, then writes. Each flip is reversible until the old copy is deleted.
The cutover moment
The stepper above flips writes in one clean step. Real life is messier, because every app server keeps its own copy of the routing table (§05), and the copies don't all update at the same instant. For a moment after the flip, some servers still send writes for p7 to db-a only, after db-d has become the truth. With change data capture the copy stream has already been stopped by then, so those writes are simply lost. Step through both versions:
▶The cutover race
Moving the writes for p7 from db-a to db-d, when three app servers each have their own copy of the routing table.
each server's copy of the table
S1: p7 → db-a
S2: p7 → db-a
S3: p7 → db-a
writes in flight
none right now
db-a (old home)
o1 … o7
db-d (new home)
o1 … o7
change stream lag: 0
Three app servers (S1–S3) each keep their own copy of the routing table (§05). Writes for p7 go to db-a, and the change stream copies each one to db-d. Backfill and verify are done.
1 / 4
The fix is a very short write pause, enforced at the old database: hold writes for that one partition, wait until the change stream has caught up completely, flip the routing table, then make the old database refuse writes for p7 so stale routers refresh and retry. Done well, it lasts milliseconds to a couple of seconds and only affects keys in the moving partition.
This same recipe (dual write or change stream → backfill → verify → cutover) is how teams move between databases entirely, change a shard key, or split a MicroserviceMicroserviceA small, separately deployed program that owns one part of a bigger system and talks to the others over the network (HTTP, gRPC, a message queue).'s data out of a shared database. Learn it once.
Practitioner§10
Postgres: partitions on one server, shards with Citus
In one sentencePostgres can split one table into many partitions on the same server. That's not sharding (one machine still does all the work), but it brings two big wins: queries skip partitions they don't need, and old data can be dropped in an instant. To go across servers, extensions like Citus reuse the same idea.
Why partition on one machine at all?
Dropping old data. Deleting 40 million old rows with DELETE is slow and leaves the table bloated (the disk space isn't given back right away). Dropping last year's partition is a quick file deletion.
Skipping work. A query for October only reads the October partition (Partition pruningPartition pruningThe database skipping partitions that can't contain matching rows, by comparing the query's WHERE clause with each partition's range. A query for October reads only the October partition.).
Smaller pieces. Each partition has its own, smaller indexes, and Postgres's background clean-up (VACUUM) runs per partition.
It's also the step many teams take before sharding (§11), and the same table design carries over when they do shard.
Range partitioning by month
This uses Postgres's declarative partitioningDeclarative partitioning (Postgres)Postgres's built-in way to split one table into partitions on the same server: you declare PARTITION BY RANGE / LIST / HASH, and Postgres routes rows and prunes partitions for you.. Everything below ran on PostgreSQL 16; the output is pasted unchanged.
CREATETABLE orders (
id bigintNOTNULL,
created_at timestamptzNOTNULL,
total numericNOTNULL
) PARTITIONBYRANGE (created_at); -- ← the partition keyCREATETABLE orders_2026_08 PARTITION OF orders
FORVALUESFROM ('2026-08-01') TO ('2026-09-01');
CREATETABLE orders_2026_09 PARTITION OF orders
FORVALUESFROM ('2026-09-01') TO ('2026-10-01');
CREATETABLE orders_2026_10 PARTITION OF orders
FORVALUESFROM ('2026-10-01') TO ('2026-11-01');
We inserted 130,000 orders (one per minute from 1 August) into the parent table. Postgres routed each row to the right partition by itself. tableoid::regclass shows which partition a row is physically in (x::type converts x to another type; here an internal table number becomes a readable table name, and GROUP BY 1 ORDER BY 1 means "group and sort by the first selected column"):
SELECT tableoid::regclass ASpartition, count(*) FROM orders GROUPBY1ORDERBY1;
EXPLAIN shows the plan Postgres would use for a query, without running it (the indexes topic teaches how to read plans; COSTS OFF just hides the cost estimates). Ask for one week of October:
EXPLAIN (COSTS OFF)
SELECTsum(total) FROM orders WHERE created_at >= '2026-10-01'AND created_at < '2026-10-08';
Aggregate
-> Seq Scan on orders_2026_10 orders
Filter: ((created_at >= '2026-10-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-10-08 00:00:00+00'::timestamp with time zone))
Read plans from the inside out: Seq Scan (a Seq ScanSeq Scan (sequential scan)A query-plan step that reads every row of a table or partition in order, without using an index.) reads one table or partition row by row, Filter keeps the matching rows, and Aggregate computes the sum. Only one partition appears. August and September were pruned because their ranges can't contain October rows. Now filter by something that isn't the partition key:
EXPLAIN (COSTS OFF) SELECTsum(total) FROM orders WHERE id = 42;
Aggregate
-> Append
-> Seq Scan on orders_2026_08 orders_1
Filter: (id = 42)
-> Seq Scan on orders_2026_09 orders_2
Filter: (id = 42)
-> Seq Scan on orders_2026_10 orders_3
Filter: (id = 42)
Every partition is scanned and the results are glued together (Append = combine the output of several scans). That's scatter-gather (§07) in miniature, on one machine: queries that don't filter by the partition key visit every partition.
Pruning also works with query parameters ($1), the way Go and Node send them. Postgres then prunes when the query runs; with EXPLAIN ANALYZE you see Subplans Removed: 1 instead of the skipped partitions.
Two errors you will meet
Insert a row that no partition accepts:
ERROR: no partition of relation "orders" found for row
DETAIL: Partition key of the failing row contains (created_at) = (2026-12-2500:00:00+00).
Create next month's partition ahead of time (a scheduled job, or the pg_partman extension), or add a catch-all with CREATE TABLE orders_default PARTITION OF orders DEFAULT;.
Now add a Primary keyPrimary keyThe column (or columns) that uniquely identifies each row, like users.id. The database refuses two rows with the same primary key and indexes it automatically. on id:
ERROR: unique constraint on partitioned table must include all partitioning columns
DETAIL: PRIMARY KEY constraint on table "orders" lacks column "created_at" which is part of the partition key.
Postgres only checks uniqueness inside each partition, so it can only guarantee a unique key that includes the partition column: PRIMARY KEY (id, created_at) works. Sharded databases have the same limit at a bigger scale: a unique constraint is only enforced per shard. That's one reason globally unique IDs must come from the ID generator itself (§11).
Dropping a month
ALTERTABLE orders DETACH PARTITION orders_2026_08; -- no longer part of ordersDROPTABLE orders_2026_08; -- 44,639 rows gone, instantly
Hash partitioning
CREATETABLE events (tenant_id bigintNOTNULL, id bigintNOTNULL, body text)
PARTITIONBYHASH (tenant_id);
CREATETABLE events_p0 PARTITION OF events FORVALUESWITH (MODULUS 4, REMAINDER 0);
-- … and the same for REMAINDER 1, 2, 3
MODULUS 4, REMAINDER 0 means "rows where Postgres's hash of tenant_id, divided by 4, leaves remainder 0". With 100,000 rows for 1,000 tenants we got 25,900 / 23,400 / 27,600 / 23,100: roughly even, as hashing should be. A query for tenant_id = 7 was pruned to one partition (events_p3); tenant_id BETWEEN 7 AND 9 scanned all four, because hashing destroys order (§03).
Across servers: Citus
CitusCitusA Postgres extension that spreads tables across many Postgres servers. A coordinator node routes queries and merges results. is a Postgres extension that turns a group of Postgres servers into one sharded database. You create a normal table, then tell Citus which column to distribute by:
Citus hash-partitions the table into many shards (32 by default in recent versions) spread over the worker servers, and a coordinator routes each query. colocate_with puts the same tenants' rows of both tables on the same workers, so JOINs on tenant_id stay local (§04's co-location). Cross-shard transactions use two-phase commit (§07). We didn't run this one (Citus isn't installed in our sandbox), so treat the exact defaults as "check your version's docs".
Staff-level§11
Staff-level: IDs, load, cost, and when not to shard
In one sentenceSharding changes things you used to get for free: unique IDs, balanced load, one backup, one migration. Plan for each of these, and before any of it, make sure you actually need to shard.
1. IDs: auto-increment breaks
On one database, id bigserial (Postgres's auto-increment 64-bit column) hands out 1, 2, 3… On four shards, each shard counts on its own, so there are four order #1s. And as §10 showed, a unique constraint is only checked within a shard. You need IDs that machines can create independently without colliding. Two good options:
UUIDv7UUIDv7A UUID whose first 48 bits are the current time in milliseconds, followed by random bits. New IDs sort roughly by creation time, which keeps B-tree indexes compact. is a 128-bit UUIDUUID (v4 / v7)A 128-bit ID like 3f2b…-…. Version 4 is fully random; version 7 starts with a timestamp, so new IDs are roughly in increasing order. that starts with the current time in milliseconds, followed by random bits. Because new IDs are roughly in time order, they're inserted at the "end" of a B-treeB-treeA wide, shallow, always-sorted tree that most databases use for tables and indexes. Each node fills one disk page and holds hundreds of keys, so finding one row among millions takes only 3–4 page reads. index, like auto-increment IDs, instead of at random places (which is what makes the older, fully random UUIDv4 slow for big indexes). PostgreSQL 18 adds a built-in uuidv7() function; in Go, github.com/google/uuid has uuid.NewV7(); in Node, the uuid package exports v7(). (Node's built-in crypto.randomUUID() makes v4.)
A Snowflake IDSnowflake IDA 64-bit ID made of a timestamp, a machine number and a per-millisecond counter, invented at Twitter. Unique without coordination, sortable by time, and half the size of a UUID. packs a timestamp, a machine number and a counter into 64 bits, half the size of a UUID:
Snowflake layout (64 bits)
bits
0
41: ms since 2026-01-01
10: node
12: sequence
The first bit is always 0, so the ID stays positive when stored in a signed 64-bit integer (Go int64, Postgres bigint). Then 41 bits of milliseconds last about 69 years from your chosen start date. 10 bits allow 1,024 generators. 12 bits allow 4,096 IDs per millisecond per generator.
a tiny Snowflake generator
const epoch = 1767225600000// 2026-01-01 00:00 UTC, in millisecondsfuncNewIDGen(node int64) (*IDGen, error) {
if node < 0 || node > 1023 { // more than 10 bits would spill into the time bitsreturnnil, fmt.Errorf("node %d out of range 0…1023", node)
}
return &IDGen{node: node}, nil
}
typeIDGenstruct {
mu sync.Mutex
node int64// 0…1023, unique per running process
lastMs int64
seq int64// 0…4095 within one millisecond
}
func (g *IDGen) Next() int64 {
g.mu.Lock()
defer g.mu.Unlock()
now :=max(time.Now().UnixMilli(), g.lastMs) // never go back in timeif now == g.lastMs {
g.seq = (g.seq + 1) & 4095// keep the low 12 bits: 4095 + 1 wraps to 0if g.seq == 0 { // 4,096 IDs this millisecond already: wait for the next onefor now <= g.lastMs {
now = time.Now().UnixMilli()
}
}
} else {
g.seq = 0
}
g.lastMs = now
return (now-epoch)<<22 | g.node<<12 | g.seq // time | node | sequence
}
We ran both: consecutive IDs come out increasing (109617025256550400, …401), and decoding the node bits gives back 7. Our JS version keeps its state in module variables, so run one generator per process, each with a different node number.
Two honest limits of this tiny version. Both check that node fits in 10 bits, because a bigger number would silently overwrite the time bits. And if the server's clock jumps backwards by several seconds (it happens when clocks get corrected), max(…, lastMs) keeps IDs unique, but the "wait for the next millisecond" loop can spin until the clock catches up, while holding the mutex. Production generators log loudly or refuse to issue IDs when they see the clock go back.
2. Balance load, not just bytes
Partitions are rarely equal. One tenant runs a nightly import; one region wakes up at 9 a.m. So:
Measure per partition: requests per second, CPU, p99, size. "Shard 7 is at 92% CPU while the average is 40%" is the alert that matters.
Move hot partitions to quiet nodes, or split them (§08). Moving the biggest partition isn't always right; move the busiest per byte moved.
Leave headroom. Run each node at well below its limit (often around 50–60%) so a FailoverFailoverSwitching to a standby (a replica) when the primary server fails, so the system keeps working — automatically or by an operator. or a viral tenant has room to land (HeadroomHeadroomSpare capacity you keep on purpose — e.g. workers that are only 80% busy on average — so random bursts can be cleared quickly instead of piling up.).
3. The N× operational bill
One database
32 shards
One schema migrationDatabase migrationA versioned script that changes a database's structure — add a column, rename a table — so every environment's database can be updated the same way.
32 migrations, some will fail halfway; you need tooling to run, retry and track them
One backup
32 backups taken at slightly different moments, so a "restore everything to 14:00" isn't consistent across shards without extra work
One primary to watch and fail over
32 primaries, each with replicas (see replication)
One Connection poolConnection poolA set of open database (or HTTP) connections that your program keeps and reuses instead of opening a new one per request. Pools usually have a size limit; when every connection is busy, the next caller waits for one to be returned. per app server
up to 32 pools per app server: 200 app servers × 32 shards × 10 connections = 64,000 connections
SELECT … JOIN … anywhere
JOINs only within a shard; everything else is app code or scatter-gather
4. When not to shard
Run through this list first. Each item is much cheaper than sharding, and together they carry most companies a very long way:
Fix the queries. Missing indexes and N+1 query loops (indexes, N+1) often hide a 10× win.
Scale up. A single modern server can have hundreds of CPU cores, terabytes of RAM and tens of terabytes of fast SSD. It's boring and it works.
Read replicas and caching for read-heavy load.
Partition on one server (§10) for retention and pruning.
Split by feature. Put billing, search and analytics in their own databases. Each is smaller, and nobody needs cross-database JOINs between them.
Archive cold data to cheaper storage.
Shard when writes or data size for one logical dataset really exceed what one machine (plus all of the above) can handle, or when you can see that point coming within a year or so. And if you can, consider a database that shards for you (CockroachDB, Spanner, YugabyteDB, Vitess, Citus, DynamoDB, Cassandra) before hand-rolling the router, the rebalancer and the migration tooling yourself.
Practitioner§12
Pitfalls gallery
01
Routing with hash % number_of_servers
It works perfectly until the day you add a server, and then ~80% of keys are suddenly on the wrong machine. Fix: hash to a large, fixed number of partitions and keep a partition → node table (§05, §08), or use consistent hashing or range splits.
02
Range-partitioning by a key that only goes up
Timestamps and auto-increment IDs put every new write on the newest shard (§03). Fix: hash the key, or lead with something that spreads (device_id, then time), or accept it deliberately for log-style data you only append and expire.
03
A shard key with too few values
country, status or plan_type have a handful of values, so you can never split past that number, and the biggest value (the US, status = 'active') is one giant shard. Fix: pick a key with millions of distinct values (§04).
04
The hot path doesn't carry the shard key
The schema is sharded by tenant_id, but the busiest endpoint looks things up by order_id alone, so every call scatters to all shards (§07). Fix: carry the tenant in the URL or session, embed the shard in the ID (§11), or keep a sharded order_id → tenant_id lookup table.
05
Services that hash differently
The Go API uses FNV, a Python worker uses its built-in hash() (randomly seeded per process since Python 3.3), and they quietly read and write different shards. Fix: one documented hash function, golden-value tests in every language (§05), and never a per-process random seed (Go's hash/maphash).
06
Assuming UNIQUE still means unique
UNIQUE (email) on each shard only stops duplicates within that shard. Two users on different shards can register the same email. Fix: a global lookup table sharded by email (insert into it first, with a unique constraint there), or make the uniqueness key the shard key.
07
Auto-increment IDs on every shard
Each shard starts at 1, so IDs collide the moment data is merged, exported or moved. Fix: UUIDv7 or Snowflake-style IDs (§11), from day one.
08
Forgetting the whale
Hash by tenant works on average, then one customer grows to 30% of all traffic and its shard is permanently overloaded. Fix: a directory override that gives whales their own shard (§04), and per-tenant load dashboards so you see it coming.
09
Rebalancing at full speed, at peak time
An unthrottled backfill saturates disks and network, and the migration that was meant to add capacity causes the outage. Fix: throttle, move a few partitions at a time, and add capacity well before you're full (§08, §09).
10
Sharding too early
A team shards a 50 GB database "for scale" and spends the next year on routing bugs, cross-shard reports and N× migrations. Fix: walk the checklist in §11 first: indexes, a bigger machine, replicas, caching, partitioning on one server.
Practitioner§13
Check yourself
Predict the answer before you click. Q1–Q2 are covered in §03, Q3–Q4 in §08, Q5 in §07, Q6 in §06, Q7 in §04, Q8 in §11 and Q9 in §10.
QUESTION 1 / 9
Black Friday on a range-partitioned table
orders is range-partitioned by created_at, one month per shard:
shard 0: Aug shard 1: Sep shard 2: Oct shard 3: Nov
It's 28 November and traffic is 10× normal.
QUESTION 2 / 9
A range query on hash-partitioned data
users ishash-partitioned by user_id over 8 shards.
SELECT * FROM users
WHERE signed_up_at BETWEEN'2026-01-01'AND'2026-03-31';
QUESTION 3 / 9
Growing from 10 to 11 shards with hash % N
QUESTION 4 / 9
1,024 fixed partitions, 4 nodes → 5 nodes
QUESTION 5 / 9
Fan-out to 50 shards
Each shard: 99% of answers in 10 ms, 1% take 200 ms (independently).
A dashboard query asks all 50 shards and waits for every answer.
QUESTION 6 / 9
You salted a hot like-counter into 8 sub-keys
QUESTION 7 / 9
Shard key for a B2B SaaS app
Every request is for one customer company (tenant).
Most screens show one tenant's projects, tasks and users together.
QUESTION 8 / 9
A 64-bit Snowflake ID reaches the browser
// the API returns: {"id": 9007199254740993}const order = await res.json();
console.log(order.id);
QUESTION 9 / 9
Adding a primary key to a partitioned Postgres table