Skip to content
Software Atlas

Partitioning & Sharding

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.

⏱ ~50 min read14 sections8 interactive widgetsGo · JavaScript · SQL
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

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 Database can also be partitioned on one machine (into several files), which §10 shows in PostgreSQL.

Splitting is not copying

People often mix up partitioning with replication, which means keeping full copies of the same data on several machines (a Replica 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 storesabout 33% of the data
Each machine handles writesabout 33% of them
Reads can be sharedyes, each machine serves its own third
One machine diesa 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

  1. Why a single database runs out of room (§02).
  2. How to split: by range, by hash, or with a lookup table (§03), and how to pick the key (§04).
  3. How to find the right shard for a request (§05).
  4. What goes wrong: one shard on fire (§06), queries that have to ask every shard (§07).
  5. How to grow: moving data to new machines without downtime (§08, §09).
  6. 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 indexes. 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 HDD 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 Latency climbs.

Wall 3: too many writes

Reads are easy to spread: add replicas 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 up). Boring, and often good for years.
2

Copy for reads

Add read replicas and a Cache 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 out: 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 partitioning, 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 query 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 key). The newest shard takes all the writes while the others sit idle: a hot spot.

Hash: scramble first, then split

With Hash partitioning, you first run the key through a hash function, 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).

keyhash(key) (made-up numbers)% 4 → shard
order 3002,817,035,1131
order 3011,092,446,5262
order 3023,550,129,8000
  • 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) partitioning, 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✓ evendepends 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 aroundsplit a rangehard (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 key 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 cardinality 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-tenant 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 names
SELECT t.title, p.name
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE t.tenant_id = 42 AND t.status = 'open';

-- 2. Open one task (the URL is /acme/tasks/123, so we know the tenant)
SELECT * FROM tasks WHERE tenant_id = 42 AND 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-location, and it buys three things at once:

  1. The JOIN in query 1 runs inside one database, exactly as it did before sharding.
  2. A Database transaction 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).
  3. 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 tenant 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

AppTypical shard keyWhy
B2B SaaS (Slack, Shopify, Notion-like)tenant / workspace / store IDevery request is scoped to one tenant
Consumer social appuser IDa user's profile, posts and settings go together
Chatconversation IDall messages of a chat on one shard, sorted by time
IoT / metricsdevice ID (+ time inside the shard)writes spread across devices, reads are per device
Orders, e-commercecustomer 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 table 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.
routing table (metadata)your app+ router libraryshard Atenants 1–30shard Btenants 31–60shard Ctenants 61–99
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 store (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 appProxyAny node
Extra network hopnoneonesometimes
Who must know the tableevery servicethe proxiesevery node
Apps in many languageseach needs the library✓ easy✓ easy
ExamplesRedis Cluster clients, hand-rolledVitess, MongoDB mongos, CitusCassandra, 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.
const NumPartitions = 1024

// PartitionOf maps a shard key to a partition number 0…1023.
func PartitionOf(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 error
	return int(h.Sum32() % NumPartitions)
}

// Router knows which node owns each partition, and holds one pool per node.
type Router struct {
	owner [NumPartitions]string // partition → node name, e.g. "db-a"
	pools map[string]*sql.DB    // node name → connection pool
}

func NewRouter(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.
			return nil, 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 injection.

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.
func TestPartitionGolden(t *testing.T) {
	for key, want := range map[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)
		}
	}
}

func TestMovingOnePartitionMovesOnlyItsKeys(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 := range 30_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 key.

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 Cache in front, either an in-memory one in each app server or a shared one like Redis. 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 salting 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.
func AddLike(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.
func LikeCount(ctx context.Context, postID int64) (int64, error) {
	var total int64
	for 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 0
			return 0, 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-gather:

  1. Scatter: send the query to all N shards in parallel.
  2. Each shard computes its own top 10.
  3. 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-collection 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%
1 shard64 shards100%
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 latency, and it's why you measure p99 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 index 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.
email = bob@ ?shard 0users (by user ID)#3 zoe@#7 bob@local email indexbob@ → 7zoe@ → 3shard 1users (by user ID)#12 amy@#15 kim@local email indexamy@ → 12kim@ → 15not hereshard 2users (by user ID)#21 raj@#28 dan@local email indexdan@ → 28raj@ → 21not here
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 indexGlobal index
Lookup by emailasks every shardone or two targeted hops
Changing an emailone shard, one transactionmay touch two shards: either updated asynchronously (fast, briefly stale) or in one distributed transaction (never stale, slower)
Can read stale data?noasync: yes, briefly (Eventual consistency); transactional: no
ExamplesMongoDB, Elasticsearch, Cassandra secondary indexesasync: 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 transaction on a single database: both changes happen, or neither does (the "A" in ACID). 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:

  1. 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.
  2. 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 commit under the hood. They're slower than single-shard ones, but correct.
  3. Use a Saga. 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 / rebalancing at its most painful.

Fix 1: many fixed partitions

Split the formula into two steps, as our router in §05 did:

  1. partition = hash(key) % 1024, where 1,024 never changes.
  2. 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 hashing 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 nodes 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 partitionsConsistent hashingRange splits
Key order kept (range queries)✕✕✓
Data moved when adding a node~1/N, in whole partitions~1/Nonly the ranges you move
Up-front decisionnumber of partitionsnumber of vnodesnone (but pre-split new tables)
Seen inRedis Cluster, Couchbase, Elasticsearch, many hand-rolled systemsCassandra, DynamoDB, RiakBigtable, HBase, CockroachDB, Spanner, MongoDB ranged sharding
Practitioner§09

Moving data while the app keeps running

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 writes keep the new location current from the moment the move starts. The old location stays the source of truth until the cutover.
  • The backfill copies everything that existed before dual writes began. It must be Idempotent (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 checksums per batch of rows, and re-copies any batch that differs.
  • The cutover 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 Microservice'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 pruning).
  • 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 partitioning. Everything below ran on PostgreSQL 16; the output is pasted unchanged.

CREATE TABLE orders (
  id         bigint      NOT NULL,
  created_at timestamptz NOT NULL,
  total      numeric     NOT NULL
) PARTITION BY RANGE (created_at);           -- ← the partition key

CREATE TABLE orders_2026_08 PARTITION OF orders
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE orders_2026_09 PARTITION OF orders
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE orders_2026_10 PARTITION OF orders
  FOR VALUES FROM ('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 AS partition, count(*) FROM orders GROUP BY 1 ORDER BY 1;
   partition    | count
----------------+-------
 orders_2026_08 | 44639
 orders_2026_09 | 43200
 orders_2026_10 | 42161

Partition pruning in EXPLAIN

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)
SELECT sum(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 Scan) 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) SELECT sum(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-25 00: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 key 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

ALTER TABLE orders DETACH PARTITION orders_2026_08;   -- no longer part of orders
DROP TABLE orders_2026_08;                            -- 44,639 rows gone, instantly

Hash partitioning

CREATE TABLE events (tenant_id bigint NOT NULL, id bigint NOT NULL, body text)
  PARTITION BY HASH (tenant_id);
CREATE TABLE events_p0 PARTITION OF events FOR VALUES WITH (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

Citus 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:

SELECT create_distributed_table('events', 'tenant_id');
SELECT create_distributed_table('projects', 'tenant_id', colocate_with => 'events');

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:

UUIDv7 is a 128-bit UUID 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-tree 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 ID 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 milliseconds

func NewIDGen(node int64) (*IDGen, error) {
	if node < 0 || node > 1023 { // more than 10 bits would spill into the time bits
		return nil, fmt.Errorf("node %d out of range 0…1023", node)
	}
	return &IDGen{node: node}, nil
}

type IDGen struct {
	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 time
	if now == g.lastMs {
		g.seq = (g.seq + 1) & 4095 // keep the low 12 bits: 4095 + 1 wraps to 0
		if g.seq == 0 {            // 4,096 IDs this millisecond already: wait for the next one
			for 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 Failover or a viral tenant has room to land (Headroom).

3. The N× operational bill

One database32 shards
One schema migration32 migrations, some will fail halfway; you need tooling to run, retry and track them
One backup32 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 over32 primaries, each with replicas (see replication)
One Connection pool per app serverup to 32 pools per app server: 200 app servers × 32 shards × 10 connections = 64,000 connections
SELECT … JOIN … anywhereJOINs 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:

  1. Fix the queries. Missing indexes and N+1 query loops (indexes, N+1) often hide a 10× win.
  2. 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.
  3. Read replicas and caching for read-heavy load.
  4. Partition on one server (§10) for retention and pruning.
  5. Split by feature. Put billing, search and analytics in their own databases. Each is smaller, and nobody needs cross-database JOINs between them.
  6. 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 is hash-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
CREATE TABLE orders (id bigint, created_at timestamptz, ...)
  PARTITION BY RANGE (created_at);
ALTER TABLE orders ADD PRIMARY KEY (id);
Answered 0 / 9
Foundations§14

Cheat sheet

✂️
Split vs copyPartitioning splits data so each machine holds a piece; replication copies it. Real systems replicate every shard.
📏
RangeNeighbours stay together: cheap range queries. A key that only goes up makes the newest range hot.
🎲
HashEven spread, cheap single-key lookups. Every range query has to ask all shards.
📇
DirectoryA lookup table: move any key by changing one row. Use it to pin whales and special cases.
🔑
Shard keyPresent in almost every query, millions of values, keeps rows used together on one shard. For SaaS: tenant_id.
🔥
Hot keysMore shards don't help. Cache hot reads; salt hot writes (and pay N lookups per read).
📡
Scatter-gatherSlowest shard wins: 1% slow per shard, 50 shards → 1 − 0.99⁵⁰ ≈ 39% slow queries. Keep it off hot paths.
🔁
ReshardingNever hash % nodes. Many fixed partitions, a hash ring, or range splits move ~1/N.
🚚
Live movesDual write (or CDC) → backfill → verify → switch reads → switch writes → clean up.
🆔
IDsNo auto-increment across shards. UUIDv7 or Snowflake; send 64-bit IDs to JS as strings.
I want to…Use
Query by time ranges, drop old data fastRange partitioning by time (Postgres PARTITION BY RANGE)
Spread writes evenly by a keyHash partitioning on a high-cardinality key
Give one huge customer its own machineDirectory entry on top of hash-by-tenant
Add a node and move ~1/N of the dataFixed partitions (e.g. 1,024) + partition → node table, or consistent hashing
Survive a celebrity's viral post (reads)Cache the hot key
Survive a viral like-counter (writes)Salt the key into N sub-keys; sum on read
Look up by a column that isn't the shard keyGlobal index or a sharded lookup table (or accept scatter-gather)
Keep money transfers atomicCo-locate by shard key; else a distributed SQL database (2PC) or a saga
Unique IDs across shardsUUIDv7, Snowflake IDs
Postpone shardingFix queries, scale up, read replicas, cache, partition on one server, split by feature
SymptomLikely cause
One shard at 95% CPU, the rest idleHot key, whale tenant, or a monotonic range key
p99 jumps as you add shardsA hot path is doing scatter-gather
"Row not found" right after a migrationA router with a stale routing table, or two services hashing differently
Duplicate emails or order IDsUNIQUE is per shard; auto-increment per shard
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DatabasesSQL vs NoSQL
Read →