Partitioning Internals: Key → Partition Function → Node
Splitting data across nodes is a pure function from key to node plus the metadata to find it, and every operational property — balance, hot spots, how much moves when a node joins, which queries fan out — is a property of that function.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
One machine's write throughput, storage or working set is the limit. The data must be split across nodes so that most requests touch one node — and any node must be able to tell which one.
↓ - Naive solution
Node = hash(key) mod N. Uniform spread, trivial to compute anywhere, no metadata.
↓ - Why it breaks
Add a fifth node and mod changes for almost every key: ~80 % of the data must move to grow the cluster by 25 %. Range queries hit every node. And a key that receives 60 % of traffic makes one node carry 60 % of the load no matter how you hash it.
↓ - Better idea
Decouple "which partition" from "which node": map keys to many small partitions with a stable function, then assign partitions to nodes with a table that can change incrementally. Give the routing layer that table. For range queries, partition by ranges of the key instead of by hash.
↓ - Internal mechanism
A partition function (hash of the key onto a ring with virtual nodes, or a sorted table of range boundaries, or an explicit list) plus routing metadata (the ring, the boundary table) held by a coordinator or replicated to every client; rebalancing moves whole partitions; secondary indexes are either local (per partition, scatter to search) or global (partitioned by the indexed value, a second hop).
↓ - Trade-offs
Hash: balance and point lookups, no ranges, rebalancing moves 1/N with a ring. Range: ranges and locality, skew and sequential-key hot spots, split when hot. Every cross-partition operation — join, unique constraint, transaction, count — becomes a fan-out or an application problem.
↓ - Real database
Cassandra and DynamoDB (hash ring with vnodes), HBase and CockroachDB (ranges that split), Citus and Vitess (hash or range with a coordinator), Redis Cluster (16,384 hash slots), PostgreSQL declarative partitioning within one node.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
Sharding means each node holds a slice of the keys. To read or write a key, someone computes which node: a hash of the key, a lookup of which range it falls in, or a list that says "EU customers go here". That computation is the partition function, and the data it needs — node list, range boundaries — is the routing metadata.
Queries that name the key go to one node. Queries that do not must ask every node and merge (scatter/gather). Adding or removing a node means some keys move, and how many depends on the function.
Key → partition function → node
Strip sharding down and there are three things: a key (whatever identifies the row for routing: a tenant id, a user id, a timestamp), a partition function that maps the key to a partition, and an assignment of partitions to nodes. Everything the practical lesson described — "hash sharding", "range sharding", "hot shard", "resharding" — is a statement about the function or the assignment. The separation matters: a function that maps keys directly to nodes (hash mod N) has nothing to change incrementally when N changes; a function that maps keys to many small, stable partitions, plus an assignment table, can move one partition at a time.
HASH node = hash('user:4711') mod 4 → 2 O(1), no metadata, uniform, no ranges
RING p = hash('user:4711') / 2^32 = 0.613 on [0,1) → next vnode clockwise at 0.641 belongs to node 2
RANGE id 4711 ∈ [4000, 6000) → lookup in sorted boundaries: [0,2000)→n0 [2000,4000)→n1 [4000,6000)→n2 [6000,∞)→n3
LIST region('user:4711') = 'EU' → routing table: EU→n0 US→n1 APAC→n2 LATAM→n3
key → partition (stable, many) → partition → node (small table, changes)
hash slot 0..16383 (Redis Cluster) slot ranges per node, moved one slot at a timeHash, range, list
Hash. node = hash(key) mod N, or a hash onto a fixed number of slots (Redis Cluster: CRC16 mod 16384; DynamoDB and Cassandra: a 128-bit token). A good hash makes any key distribution uniform across partitions, so balance is free and a point lookup is one node. Order is destroyed: WHERE ts BETWEEN or WHERE id > 1000 must visit every partition. Composite keys can hash a prefix and keep order within it — Cassandra's partition key vs clustering columns, DynamoDB's partition key vs sort key — so that "all events for device 42 in June" is one partition with a range inside it.
Range. A sorted table of boundaries: keys in [a, b) go to partition 1. Adjacent keys share a partition, so range scans and prefix scans are local, and time-based retention is "drop the oldest range". The key's distribution becomes the partition distribution: names cluster, ids grow, one tenant dominates. A monotonically increasing key — auto-increment, timestamp — means every insert goes to the *last* range: the write hot spot, cured by prefixing a hash or a tenant id ((tenant_id, ts)) at the cost of losing the global time order. Ranges must be split when they grow (HBase regions, CockroachDB ranges at 512 MB) and merged when they shrink.
List / geographic. An explicit mapping from key values to partitions: region = EU → cluster-eu. It is the only option when law dictates where data lives, it gives locality for free, and its partitions are as unequal as the regions are. In practice it is the outer level of a hierarchy: list by region, then hash or range inside.
| Hash | Range | List | |
|---|---|---|---|
| Point lookup by key | 1 partition | 1 partition | 1 partition |
| Range scan on key | all partitions (scatter) | 1–few partitions | depends on the inner scheme |
| Balance | uniform by construction | follows the data: skew | as unequal as the categories |
| Sequential keys | fine | hot last partition | n/a |
| Rebalancing | move slots / vnodes (1/N with a ring) | split a range, move one half | move a category |
| Metadata | node list (mod) or ring | sorted boundaries | explicit table |
| Typical | Cassandra, DynamoDB, Redis Cluster, Citus | HBase, CockroachDB, Spanner, time-partitioned tables | Regions, tenants, compliance |
Consistent hashing and virtual nodes
With hash mod N, changing N changes the result for almost every key: going from 4 to 5 nodes remaps 80 % of keys, which for a terabyte cluster is a terabyte of copying to add 25 % capacity. Consistent hashing maps both nodes and keys onto the same circle of hash values [0, 2³²); a key belongs to the first node clockwise from its position. Adding a node inserts one point and claims only the arc between it and its predecessor — on average 1/N of the keys, taken from exactly one neighbour. Removing a node hands its arc to the next node clockwise. This is the same idea as a Hash Table whose buckets can be added one at a time without rehashing the rest.
One point per node gives uneven arcs (a node might own 40 % of the circle by bad luck) and dumps a departing node's entire load on one neighbour. Virtual nodes fix both: each physical node owns 64–256 points scattered around the ring, so its share is the sum of many small arcs (variance shrinks like 1/√vnodes), a new node takes a sliver from every existing node, and a departing node's arcs are spread across all survivors. Heterogeneous hardware gets more vnodes. Cassandra defaults to 16 vnodes per node (256 historically); DynamoDB and Riak use the same construction. Redis Cluster achieves the same effect differently: 16,384 fixed hash slots assigned to nodes in contiguous ranges, moved one slot at a time.
0.00
C3 ───┬─── A1
B2 ╱ │ ╲ C1
A4 ╱ │ ╲ B3 key 'user:4711' at 0.613 → clockwise → next point is B1 (0.641) → node B
0.75 ┤ ● ├ 0.25
B1 ╲ │ ╱ A2 add D with 4 vnodes at 0.08, 0.31, 0.57, 0.88:
C2 ╲ │ ╱ B4 D takes (C3,0.08] from A1, (A2,0.31] from B3, (B4,0.57] from C2, (A4,0.88] from B1
A3 ───┴─── C4 ≈ 1/4 of all keys move, ~1/12 from each of A, B, C — nothing else changes
0.50
hash mod N, 3 → 4 nodes: key k moves unless hash(k) mod 3 == hash(k) mod 4 → ~75 % of keys moveHot partitions
A partition function balances *keys*; it cannot balance *traffic per key*. If one tenant generates 40 % of requests, the partition holding that tenant carries 40 % of the load however the tenants are hashed. If one row — a celebrity's profile, a global counter, today's feature flag — is read a million times a second, its partition is hot and adding nodes does nothing. Under range partitioning, a time-ordered key makes the newest range hot for *every* write. Detecting it is a per-partition metric (requests, bytes, CPU) with the max far above the mean; DynamoDB surfaces it as throttling on one partition while the table's aggregate is idle.
The remedies are all at the key level. Split the key: counter:42 becomes counter:42#0 … #15, writes pick a suffix at random, reads sum sixteen keys. Salt the range key: prefix a time-based key with a small hash so inserts spread over k partitions, and read all k for a time range. Cache the hot key in front of the store. Isolate: give the whale tenant its own partition or node — an explicit exception in the routing table. Adaptive splitting (DynamoDB adaptive capacity, CockroachDB load-based splitting) does the range version automatically by splitting a range that receives disproportionate traffic, but it cannot split a single key.
Rebalancing: moving partitions vs splitting ranges
Two different operations hide under "rebalance". Moving a fixed partition (a hash slot, a vnode's arc): the partition's data is copied to the new owner while the old owner keeps serving; writes during the copy are either forwarded or double-written; at cutover the metadata changes, requests that arrive at the old owner are redirected (Redis MOVED/ASK), and the old copy is deleted. The unit is fixed and the count is large, so moves are small and parallel. Splitting a range: a range that grew beyond a size or load threshold is split at a median key into two ranges, the metadata gains a boundary, and one half may then be moved. Ranges are created by the data, so the count grows with the table and the split decision is a per-range policy.
Both must handle the same hazards: a client with stale metadata (answer with a redirect and the new epoch), a move that fails halfway (the old owner stays authoritative until the cutover record is durable), and rebalancing load competing with production traffic (rate limits; Cassandra streams, CockroachDB's snapshot rate limit). Never rebalance by changing the function itself — mod 4 to mod 5 — on a live system; that is the naive step from the derivation, and it moves everything at once.
Secondary indexes and cross-partition queries
An index on a column that is not the partition key faces a choice. A local index (document-partitioned) lives inside each partition and indexes only that partition's rows: writes stay local — the row and its index entries are on the same node, in the same transaction — but a query WHERE email = ? must be sent to every partition, because any of them might hold a match. Cassandra secondary indexes, DynamoDB local secondary indexes, Citus and PostgreSQL per-partition indexes work this way. A global index (term-partitioned) is itself partitioned by the indexed value: email hashes to one index partition, which stores (email → partition key), so the query is one index lookup plus one row fetch. Writes now touch two partitions — the row's and the index's — which is either a distributed transaction (CockroachDB, Spanner) or asynchronous and eventually consistent (DynamoDB global secondary indexes, Elasticsearch).
Every query that does not name the partition key is a scatter/gather: send to all P partitions, execute locally, merge. Aggregations merge per group; ORDER BY … LIMIT k needs k from every partition and a k-way merge; COUNT(*) sums; a join across partitions is either co-located (both tables partitioned by the same key, so each partition joins locally — the single most important shard-design trick) or a broadcast of the smaller table to every partition, or a full reshuffle. Latency is the slowest partition's; cost grows linearly with P; and a query that was fine at 4 partitions is a problem at 64. This is why the shard key must appear in almost every query, and why designs that cannot achieve that (Partitioning and Sharding) should not shard.
LOCAL (per partition)
write user 4711 → partition 2: row + index entry, one node, one transaction
SELECT … WHERE email='ada@x.io' → ask p0,p1,p2,p3 → p2 answers, the others return empty → 4 requests, latency = slowest
GLOBAL (partitioned by email)
write user 4711 → partition 2 (row) AND index partition hash('ada@x.io') mod 4 = 0 (entry) → 2 nodes, 2PC or async
SELECT … WHERE email='ada@x.io' → index p0 → (ada@x.io → user 4711) → row on p2 → 2 requests, always
SCATTER/GATHER for ORDER BY created_at DESC LIMIT 10:
every partition: local top-10 → router: merge 4×10, keep 10 (40 rows moved to return 10; with 64 partitions, 640)Key points
- Sharding is key → partition function → node, plus routing metadata. Keep "which partition" (stable, many) separate from "which node" (small table, changes).
- Hash balances keys and kills ranges; range keeps order and inherits skew (sequential keys → hot last range); list encodes policy.
- hash mod N remaps ~everything when N changes; a consistent-hash ring moves ~1/N, and virtual nodes spread that evenly across all nodes.
- Hot partitions come from traffic per key, which no function balances: split the key, salt the range, cache, or isolate.
- Rebalancing moves fixed partitions or splits ranges — never changes the function on a live system.
- Local indexes make writes local and reads scatter; global indexes make reads a two-hop and writes distributed. Any query without the key is O(P) partitions.
Key → partition function → node
When to use — and when not
- This mechanism fits when a single node's write throughput or data size is measurably the limit and the access patterns carry a natural key that appears in almost every query.
- Range partitioning fits time-series and prefix scans; hash fits key-value access with uniform keys; list fits residency and tenancy boundaries.
- This mechanism fits poorly when queries are ad hoc across the whole dataset, when joins cross the key, or when uniqueness must be global — every one becomes a scatter or an application protocol.
- Before vertical scaling, replicas, caching and single-node partitioning are exhausted.
Failure modes
- A monotonic range key: every insert lands on the last partition; the cluster has 15 idle nodes and one on fire.
- Resharding by changing hash mod N: nearly every key moves at once under production load.
- A single hot key (celebrity row, global counter) that no partition function can spread.
- A global secondary index updated asynchronously: reads by that column return rows that no longer match.
- A query pattern that scatters to all partitions, fine at 4, unusable at 64.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSAHash table: hash(key) mod buckets, and rehashing on resize → Hash partitioning, and why resizing moves everything without a ringA shard is a bucket that lives on another machine; resizing costs network transfer instead of memory copies.
- DSABinary search over sorted boundaries → Range partition lookup in the routing table
- NetworkingFan-out and tail latency → Scatter/gather: the query is as slow as the slowest partition