cld-toys › Toys › consistent-hashing

Commentary: consistent-hashing

"Adding a node only reshuffles 1/N of the keys" is true on average and nearly useless per instance. Eight nodes, a perfectly good hash function, and one node holds 74× the keys of another. A study guide for hash_ring.py.

consistent-hashing/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with hash_ring.py open beside you. hash_ring.py is the toy itself (140 lines, one class); demo.py runs four measurements on it; test_hash_ring.py locks in all eleven claims. Stdlib only, nothing written to disk, and no random number generator anywhere — every ring position comes from SHA-1 truncated to 32 bits, so the numbers below are not a sample, they are the numbers, on any machine. Every transcript was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3].
cd consistent-hashing
python3 demo.py            # the aha (§6) — about 5 seconds
python3 test_hash_ring.py  # pins every number this page claims
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy implements a hash ring — the routing layer under Dynamo, Cassandra, Riak, memcached client libraries, Envoy's ring_hash balancer, and most sharded caches you have ever used. Nodes get positions on a circle of 232 slots; a key belongs to the first node position clockwise from the key's own position. Add a node and only the keys in the arc it cuts off change owner.

The sentence everyone knows about it is: "adding a node only reshuffles about 1/N of the keys." That sentence is true. It is also, taken as the lesson, close to useless — and this toy exists to show you why.

The demo measures the actual distribution instead of the average, and three things fall out:

What virtual nodes actually buy Virtual nodes — several ring positions per physical node — are the standard fix. What they actually fix is the surprise: they barely change how many keys move (mean 14.80% → 10.77%), but they change who the keys come from (1 donor → all 8), and they collapse that [0.00%, 36.17%] range to [8.76%, 12.36%].

By the end you should be able to:


2. The problem this mechanism exists to solve

You have a lot of keys and a handful of servers, and you need a rule for which server holds which key. The rule must be computable by every client independently — a lookup table that all of them agree on is a distributed consensus problem you were trying to avoid.

The obvious rule is server = hash(key) % N. It is one line, it is perfectly balanced when the hash is uniform, and it has one catastrophic property: N appears in the formula. Change N and almost every key changes owner. Not some. Almost all:

=== E6: hash(key) % N, the thing consistent hashing replaces === 8 -> 9 servers with modulo: 89.06% of keys move (1 - 1/9 = 88.89%) 8 -> 9 nodes on the ring: 19.76% of keys move 32 -> 33 servers with modulo: 97.01% of keys move (1 - 1/33 = 96.97%) 32 -> 33 nodes on the ring: 7.93% of keys move

The arithmetic behind 88.89%: a key keeps its home only if h % 9 == h % 8, which for a uniform h happens with probability 1/9. So 1 − 1/9 = 88.89% move, and the measured 89.06% is that with 10,000 samples of noise on it. For a cache, that is a total flush at the moment you were adding capacity to survive load. For a database, it is a full data migration.

So there are two goals, and they are in genuine tension:

hash(key) % N gets the second perfectly and the first catastrophically. Consistent hashing is the design that gets the first almost perfectly — and the whole point of this toy is that the second does not come along for free, and the standard explanation quietly implies that it does.

The observation this toy is built to force Consistent hashing replaces the arithmetic partition of % N — which is exact by construction — with a sampled partition, cut by wherever the node hashes happened to land. Sampling is what buys the stability. Sampling is also what costs the balance. You cannot take one without the other.

3. Background you need

None of it is deep, but the argument below leans on all five rows.

ConceptWhere it's used hereOne source
Successor lookup on a sorted array Ring.route — one bisect_left on the ring positions, O(log P) bisect
Hash truncation ring_hash — SHA-1's 160 bits cut down to the ring's 32 hashlib
Uniform points ≠ uniform gaps The result. N uniform points on a circle cut N arcs whose largest averages HN/N, not 1/N Balls into bins
Average vs. distribution §6.4 — "1/N of keys move" is a mean over placements you do not get to draw twice Consistent hashing
Virtual nodes / tokens Ring._labels — one physical node, many hashed labels, many ring points Cassandra: Dynamo

The two that carry the result are the third and the fourth, and they are the same idea twice. A uniform hash function distributes points uniformly; it does not distribute the gaps between those points uniformly, and it is the gaps that are your shards. Everything a reader gets wrong about consistent hashing, they get wrong by silently substituting one for the other.

The arithmetic for row three, since it is the one doing the work: place N uniform points on a circle. The expected size of the largest gap is HN/N, where HN is the Nth harmonic number. For N = 8 that is 2.7179/8 = 33.97% of the ring — against a fair share of 12.50%. The biggest node is expected to be nearly 2.7× oversized by construction, before any bad luck at all.


4. The mental model

Before any code. Below is the real 3-node ring this toy builds — every percentage is ring_hash(label) / 2**32, printed by a scratch run against hash_ring.py, not drawn to make a point.

The ring is the integers 0 .. 2**32-1, bent into a circle. Cut it open at 0 and lay it flat. Every tick below is ring_hash(label) / 2**32: 0% 100% |======================================================================| ^ ^ ^ ^ ^ ^ ^ ^ key1 key3 key2 key0 key4 node2 node0 node1 key1 6.43% key2 53.02% key4 76.29% node1 87.60% node2 9.93% key0 67.85% node0 82.94% key3 23.26% a key belongs to the first node point at or clockwise of it: key3 key2 key0 key4 -> node0 arc ( 9.93% .. 82.94% ] = 73.01% key1 -> node2 arc (87.60% .. 9.93% ] = 22.33% (none of these five) -> node1 arc (82.94% .. 87.60% ] = 4.66% Three nodes. Three positions from an excellent hash function. node0 owns 73% of the ring and four of the first five keys; node1 owns 4.66% and none.

That picture is the entire toy. Two consequences follow from it, and only one of them is famous.

The famous one. Adding node3 inserts one new point. It steals the arc between its predecessor and itself, and nothing else in the picture changes. No key that wasn't in that arc has any reason to move. Compare % N, where the whole assignment is recomputed. This is the property the name refers to.

The one this toy is about. The arcs are 73.01%, 22.33% and 4.66%. They were never going to be 33.3% each. The hash spread the three points uniformly and that is all it promised; the gaps between uniform points are lopsided, and the gaps are the shards.

Virtual nodes attack exactly this. Instead of hashing node0, hash node0#0, node0#1, … node0#149 and give every one of those positions to node0. Below are both rings the demo builds, drawn 70 columns wide from their real points() output — each column shows the owner of the last ring point falling inside that 1/70th of the circle, and . means no point at all:

A=node0 B=node1 C=node2 D=node3 E=node4 F=node5 G=node6 H=node7 one point per node (8 nodes, 8 points): |......C...........................GF......................A..BD.....HE| arcs G=39.75% A=32.74% C=11.23% H=7.79% B= 4.66% D= 1.85% E= 1.46% F=0.52% 150 points per node (8 nodes, 1200 points): |HABAEGEFBAHABEBGEFFFCCCDFDAHAGHBBHHHHBDACCGEGBAFGGEHGGEHGBFCCEHEHGEFDH| arcs H=14.06% B=13.24% C=12.60% F=12.44% E=12.32% A=11.85% D=11.76% G=11.72% Each node's 150 arcs are still individually lopsided. Their SUM is not, because averaging 150 draws shrinks the relative spread like 1/sqrt(150).
The mechanism in one sentence The last line of that block is the whole thing, and it is a statistical trick rather than a structural one: virtual nodes do not make any arc fairer. They give each node enough arcs that its total stops caring.

Watch what that does to node6 — the G that owned 39.75% of the ring on the top map. On the bottom map it owns 11.72% and is the lightest node. Nothing about node6 changed. Its old arc was never a property of node6 in the first place; it was a property of where its one neighbour happened to land.

It is worth knowing that this is not a later patch. Karger et al.'s 1997 paper specifies κ·log C points per bucket in the original construction, because the balance property it proves does not hold with one (§10). The one-point-per-node ring that §6 takes apart — the one in every tutorial diagram, including the one above — is a simplification introduced by the people explaining it, not by the people who invented it.


5. Reading the source

140 lines, one class. Read it in this order.

5.1 ring_hash — where the "randomness" comes from

hash_ring.py · lines 31–36
def ring_hash(label):
    """Map a label onto the ring: SHA-1, keep the top 4 bytes as a big-endian
    unsigned int. Truncation is safe here because every bit of a SHA-1 digest
    is equally good; we just need 32 of them."""
    digest = hashlib.sha1(label.encode("utf-8")).digest()
    return int.from_bytes(digest[:4], "big")

There is no random import in this file and no seed anywhere. That is a deliberate and slightly unusual choice for a toy that is fundamentally about statistical behaviour, and it buys two things.

The first is the obvious one: every number on this page is reproducible on your machine, exactly, forever. You can check the 4003 yourself.

The second is the point of the toy. If the imbalance came from an RNG, the honest reading would be "well, that's just a bad seed." It doesn't. It comes from SHA-1, which is as close to a uniform function as this argument needs, and the imbalance is not a defect of it. So the first thing to check is whether SHA-1 is the culprit. I swapped the hash function and re-ran:

=== CF1: is the 74x spread an artifact of SHA-1? (8 nodes, 1 vnode) === sha1 min= 54 max= 4003 max/fair= 3.20 max/min= 74.13 md5 min= 9 max= 3396 max/fair= 2.72 max/min= 377.33 sha256 min= 36 max= 3604 max/fair= 2.88 max/min= 100.11 blake2b min= 121 max= 2846 max/fair= 2.28 max/min= 23.52 sha3_256 min= 106 max= 3051 max/fair= 2.44 max/min= 28.78

Every one of them is lopsided, and the "best" cryptographic hash in the list still puts 23× more keys on one node than another. max/fair — the number you actually provision for — sits between 2.28 and 3.20 for all five. Changing the hash function is not a fix, because the hash function was never the problem.

The truncation to 32 bits deserves a note, since it looks like a corner cut. I widened the ring and re-ran:

=== CF2: does widening the ring past 32 bits help? (8 nodes, 1 vnode) === 32-bit ring min= 54 max= 4003 max/fair= 3.20 max/min= 74.13 64-bit ring min= 54 max= 4003 max/fair= 3.20 max/min= 74.13 128-bit ring min= 54 max= 4003 max/fair= 3.20 max/min= 74.13 160-bit ring min= 54 max= 4003 max/fair= 3.20 max/min= 74.13

Byte-identical, and it has to be: two values that agree in their top 32 bits are the only case where the extra bits could change an ordering, and with 1200 points and 10,000 keys that never happens. The truncation costs nothing and makes every position printable as a readable percentage. What it does cost is collision headroom — see §7.1.

5.2 _labels — the one method that makes virtual nodes exist

hash_ring.py · lines 54–58
    def _labels(self, node):
        """The labels whose hashes become this node's ring positions. The
        label, not the node name, is what gets hashed — that is the only
        reason one node can sit in many places."""
        return [f"{node}#{i}" for i in range(self.vnodes)]

One line, and it is the entire virtual-node feature. Everything else in the class is indifferent to how many positions a node has.

The # looks like it might matter — a naming scheme where one node's label could collide with another's would be a real bug. It does not otherwise matter, and I checked rather than assumed:

=== CF3: does the vnode label format matter? (8 nodes) === {node}#{i} vnodes= 1 max/fair= 3.20 max/min= 74.13 {node}#{i} vnodes=150 max/fair= 1.13 max/min= 1.23 {node}:{i} vnodes= 1 max/fair= 2.40 max/min= 143.05 {node}:{i} vnodes=150 max/fair= 1.16 max/min= 1.38 {node}-{i} vnodes= 1 max/fair= 2.68 max/min= 24.65 {node}-{i} vnodes=150 max/fair= 1.21 max/min= 1.39 {i}#{node} vnodes= 1 max/fair= 2.19 max/min= 20.17 {i}#{node} vnodes=150 max/fair= 1.17 max/min= 1.35 vn{i}/{node} vnodes= 1 max/fair= 2.20 max/min= 34.80 vn{i}/{node} vnodes=150 max/fair= 1.15 max/min= 1.38

This is the shape of result the counterfactual step exists to produce. The exact digits are entirely a function of the separator — and the conclusion is completely insensitive to it. Every format gives max/fair between 2.19 and 3.20 at one point per node, and between 1.13 and 1.21 at 150. If you rerun this toy with : instead of # you will get different numbers and the same page.

The node names are equally irrelevant, which is worth knowing before you suspect node0..node7 of being rigged:

=== CF7: is the lopsidedness about the names 'node0..node7'? (1 vnode) === node0..node7 min= 54 max= 4003 max/fair= 3.20 max/min= 74.13 10.0.0.1..8 min= 85 max= 3578 max/fair= 2.86 max/min= 42.09 a..h min= 48 max= 4674 max/fair= 3.74 max/min= 97.38 srv-01..srv-08 min= 149 max= 2514 max/fair= 2.01 max/min= 16.87

Real hostnames (10.0.0.1) do it. Single letters do it, harder. The best of the four still runs 17× between its lightest and heaviest node.

5.3 add — the ring is one sorted list

hash_ring.py · lines 60–68
    def add(self, node):
        if node in self.nodes:
            raise ValueError(f"{node!r} is already on the ring")
        self.nodes.append(node)
        for label in self._labels(node):
            pos = ring_hash(label)
            i = bisect_right(self._points, pos)
            self._points.insert(i, pos)
            self._owners.insert(i, node)

Two parallel lists — positions and owners — kept in ring order, rather than a dict or a tree. Parallel arrays are usually a smell; here they are the right shape, because the hot path is a binary search over the positions and bisect wants a plain sorted sequence.

The insert is O(P) in the number of ring points (Python has to shift the tail), so building an 8×150 ring costs 1200 shifting inserts. That is fine here and would be fine in production too: membership changes are rare and route is not.

The property this buys is the one that makes consistent hashing usable at all, and it is easy to miss because nothing in the code announces it: the ring has no memory of the order operations happened in. A position is a pure function of its label, so two clients that learn about the same node set in different orders build byte-identical rings. I verified the round trip:

=== CF6: is remove() the exact inverse of add()? === vnodes= 1 removing node3 moved 164 keys; re-adding it left 0 keys different from the start vnodes=150 removing node3 moved 1146 keys; re-adding it left 0 keys different from the start

Removing node3 moves exactly the 164 keys node3 was holding (§6.1: that is its whole load), and putting it back restores the routing exactly. A node that reboots comes back to its own keys, not to a reshuffled cache.

5.4 route — the successor lookup, and the wrap

hash_ring.py · lines 80–88
    def route(self, key):
        """The successor lookup: first ring position >= hash(key), wrapping
        past the top of the ring back to the first position."""
        if not self._points:
            raise ValueError("the ring is empty")
        i = bisect_left(self._points, ring_hash(key))
        if i == len(self._points):
            i = 0  # past the last point: wrap to the first
        return self._owners[i]

The hot path, and the only part of the algorithm most descriptions bother with. bisect_left returns the index of the first element >= x, which is the clockwise successor by definition.

The three-line wrap is what makes it a ring rather than a line. Without it, keys hashing above the highest node point have no successor and the lookup falls off the end of the array. Deleting it is not a subtle degradation:

=== CF5: delete the `if i == len(self._points): i = 0` wrap === IndexError: list index out of range keys past the last ring point: 147 of 10000

147 keys in 10,000 — 1.5%, because the topmost node point sits at 98.7% of the ring. That is the shape of bug that passes every unit test written against small key sets and pages you at 3am. It is pinned by test_route_wraps_past_the_last_point (test_hash_ring.py lines 26–34), which asserts both the count and that all 147 land on the first owner.

By contrast, the choice of bisect_left over bisect_right — a key hashing exactly onto a node point going to that node rather than the next one — looks like the same class of decision and is not:

=== CF4: bisect_left vs bisect_right in route() === vnodes= 1 keys routed differently: 0 of 10000 vnodes=150 keys routed differently: 0 of 10000

Zero keys. The tie needs a 32-bit collision between a key and a node label, and with 10,000 keys against 1200 points the expected number is about 0.003. The line is a correctness statement, not a behavioural one — worth getting right, not worth a paragraph claiming it decides anything.

5.5 arcs — the measurement that turns the result from luck into arithmetic

hash_ring.py · lines 103–114
    def arcs(self):
        """Fraction of the ring's 2**32 slots each node owns. This is the
        thing that actually determines load; the key counts merely sample
        it. A point at p owns the half-open arc (previous point, p]."""
        share = {node: 0 for node in self.nodes}
        for i, pos in enumerate(self._points):
            if len(self._points) == 1:
                span = HASH_SPACE
            else:
                span = (pos - self._points[i - 1]) % HASH_SPACE
            share[self._owners[i]] += span
        return {node: span / HASH_SPACE for node, span in share.items()}

This method is not part of a hash ring. No production implementation has it, and routing never calls it. It is here because without it the headline number is an anecdote.

A distribution over 10,000 keys is a sample. Someone reading "node6 got 4003 keys" is entitled to ask whether that is the hash function being unlucky with this particular key set. arcs() answers by measuring the partition itself, in ring slots, with no keys involved at all — and §6.2 uses it to show the key count converging on the arc share. The claim stops being "we measured 4003" and becomes "node6 owns 39.75% of the ring, so it will hold 39.75% of any uniformly-hashed key set, and 4003/10000 = 40.03% is that."

Two details worth the ink. self._points[i - 1] at i = 0 indexes -1, which in Python is the last element — the wrap-around comes free, and the % HASH_SPACE turns the resulting negative difference into the correct distance round the circle. And the len == 1 branch exists because a one-point ring is the degenerate case where (pos - pos) % HASH_SPACE is 0 when the true answer is "everything."

5.6 balance — choosing which number to be scared by

hash_ring.py · lines 130–140
def balance(counts):
    """Summarise a distribution: (min, max, max/fair-share, max/min).

    `fair` is what a perfectly even partition would give each node, so
    max/fair is "how much more than its share the busiest node holds" — the
    number an operator actually has to provision for."""
    values = list(counts.values())
    total = sum(values)
    fair = total / len(values)
    lo, hi = min(values), max(values)
    return lo, hi, hi / fair, (hi / lo if lo else float("inf"))

Two ratios, deliberately, because they tell different stories and the dramatic one is the less useful one.

max/min is the number that makes the demo shocking — 74× at 8 nodes, 1529× at 32. It is also unbounded and fragile: it is dominated by the emptiest node, and an empty node costs you nothing. At 32 nodes the lightest holds one key, and one more key would halve the ratio.

max/fair is the number you provision for. If every node is sized for 1/N of the keyspace and max/fair is 3.20, you need 3.2× that everywhere, or you accept that one node melts. It is bounded below by 1 and it is the honest headline. The demo prints both so you can watch them disagree about which configuration is worse: from 8 nodes to 32, max/min explodes from 74 to 1529 while max/fair moves from 3.20 to 4.89.


6. The demo, and what it proves

python3 demo.py
=== 1. load balance: 10000 keys === nodes vnodes min max max/fair max/min 8 1 54 4003 3.20 74.13 8 10 893 2237 1.79 2.51 8 150 1146 1409 1.13 1.23 32 1 1 1529 4.89 1529.00 32 10 165 564 1.80 3.42 32 150 241 406 1.30 1.68 === 1b. load is arc length, not luck: 8 nodes, 1 point each === node ring arc keys key share node6 39.75% 4003 40.03% node0 32.74% 3242 32.42% node2 11.23% 1150 11.50% node7 7.79% 763 7.63% node1 4.66% 493 4.93% node3 1.85% 164 1.64% node4 1.46% 131 1.31% node5 0.52% 54 0.54% === 1c. more keys will not fix it: 8 nodes, 1 point each === keys max/fair 1000 3.0720 10000 3.2024 100000 3.1914 1000000 3.1860 the ceiling is 8 x the biggest arc = 3.1799 === 2. one node joins: 8 -> 9 nodes (fair share 11.11%) === vnodes moved% donor nodes biggest donor 1 19.76% 1 node0 gave up 1976 10 15.41% 6 node5 gave up 689 150 11.08% 8 node5 gave up 193 === 2. one node joins: 32 -> 33 nodes (fair share 3.03%) === vnodes moved% donor nodes biggest donor 1 7.93% 1 node29 gave up 793 10 4.79% 7 node8 gave up 176 150 2.86% 31 node13 gave up 24 === 3. the same join, 100 ways (fair share 11.11%) === vnodes min max mean max/min fewest..most keys 1 0.00% 36.17% 14.80% inf 0..3617 10 3.06% 18.75% 10.74% 6.13 306..1875 150 8.76% 12.36% 10.77% 1.41 876..1236 === 4. does adding a node relieve the busiest one? vnodes=1 === busiest before: node6 holds 4003 keys unchanged in 64 of 100 placements (64%) best any placement did: 4003 -> 386 keys === 4. does adding a node relieve the busiest one? vnodes=10 === busiest before: node5 holds 2237 keys unchanged in 8 of 100 placements (8%) best any placement did: 2237 -> 1114 keys === 4. does adding a node relieve the busiest one? vnodes=150 === busiest before: node7 holds 1409 keys unchanged in 0 of 100 placements (0%) best any placement did: 1409 -> 1088 keys

6.1 The imbalance, and where it comes from

Table 1b is the derivation. node6's ring arc is 39.75% of 232 slots, and it holds 40.03% of the keys — the key count is sampling the arc, to within 0.28 percentage points. node5's arc is 0.52% and it holds 0.54%. Every row matches to two significant figures. Nothing here is luck about which keys were chosen; the partition was decided the moment the eight node names were hashed.

The headline ratio, derived: 4003 / 54 = 74.13. And max/fair: a fair share of 10,000 keys across 8 nodes is 1250, so 4003 / 1250 = 3.20.

One fairness check, because "we picked node names that produce a 39.75% arc" is a legitimate accusation. I generated 200 different 8-node name sets and measured the largest arc in each:

=== E7: is node6's 39.75% arc a fluke? expected max arc = H_n/n === 200 different 8-node name sets: mean max arc 34.92% range 16.65%..65.91% theory H_8/8 = 33.97% (fair share is 12.50%) our ring's node6: 39.75% name sets with a max arc >= 39.75%: 55 of 200

The measured mean, 34.92%, lands on the theoretical H8/8 = 33.97% from §3. Our ring sits at roughly the 72nd percentile — mildly unlucky, not cherry-picked, and 55 of 200 randomly-named 8-node clusters are worse than the one on this page. The typical case is a node owning 2.8× its share; the bad case, which one cluster in two hundred draws, is 65.91% of the ring on one machine.

6.2 More keys will not fix it, and this is the surprising part

The instinct on seeing 4003-vs-54 is that 10,000 keys is a small sample and the law of large numbers will sort it out. Table 1c is that instinct being tested to a million keys:

keys max/fair 1000 3.0720 10000 3.2024 100000 3.1914 1000000 3.1860 the ceiling is 8 x the biggest arc = 3.1799

max/fair does not fall. It converges, on 3.1799, and that number is 8 × 0.39748643 — eight times node6's arc share, computed in ring slots by arcs() with no keys involved.

The sentence to carry away More keys make the imbalance more certain, not less. Every extra key is another sample from a distribution whose shape is already fixed. The law of large numbers is working perfectly; it is converging on the wrong number, because the thing that is uneven is not the sampling — it is the partition being sampled.

6.3 The single join everyone quotes, and why it is a trap

Table 2, one point per node: adding a 9th node to an 8-node ring moved 19.76% of the keys, against a fair share of 11.11%. With 150 points per node it moved 11.08%. So far this looks like the familiar story — vnodes brought the number closer to fair.

Look at the last two columns instead:

vnodesmoveddonor nodesbiggest donor
119.76%1node0 gave up 1976
1015.41%6node5 gave up 689
15011.08%8node5 gave up 193

With one point per node, every single one of the 1976 moved keys came out of node0. Not "mostly"; all of them, and test_vnodes_change_who_the_keys_come_from_not_how_many (test_hash_ring.py lines 74–90) asserts the donor Counter is exactly {"node0": 1976}. That is forced by the geometry: a new node with one point lands inside exactly one existing arc and takes the part of it that precedes the new point. One arc, one owner, one donor. It cannot be otherwise.

With 150 points, the new node lands in 150 different arcs, and at 8 existing nodes that is enough to hit all of them. The largest single donation drops from 1976 keys to 193. Same migration, spread over the whole cluster instead of falling on one machine — which, if the migration is real bytes over a real network, is the difference between a rolling operation and an outage on node0.

At 32 nodes the pattern is identical and sharper: 1 donor at one point per node, 31 of the 32 at 150.

6.4 "1/N of keys move" is a statement about a mean you get to draw once

Table 2 is one join. Table 3 is the same join tried with 100 different candidate node names — which is exactly what you are doing when you name a new server, whether you know it or not:

vnodes min max mean max/min fewest..most keys 1 0.00% 36.17% 14.80% inf 0..3617 10 3.06% 18.75% 10.74% 6.13 306..1875 150 8.76% 12.36% 10.77% 1.41 876..1236

The means are 14.80% and 10.77%, against a fair share of 11.11%. Read only that column and virtual nodes look like a rounding correction — a 4-point improvement on a number that was roughly right already.

The ranges are the result:

The inf in the max/min column is not a formatting bug. It is a division by an honest zero, and it is my favourite number in this toy:

=== CF9: the join that moved nothing (8 nodes, 1 vnode) === newnode32 moved 0 keys from [] newnode93 moved 34 keys from [node3:34] newnode43 moved 44 keys from [node1:44] newnode40 moved 3508 keys from [node6:3508] newnode79 moved 3550 keys from [node6:3550] newnode18 moved 3617 keys from [node6:3617] newnode32 landed in an arc of 0.002916% of the ring

newnode32 joined the cluster, was accepted by every client, appears in every routing table — and owns 0.002916% of the ring, which contains no keys. You bought a machine and it is serving nothing. Meanwhile all three of the biggest joins took their keys from node6, the 39.75% node, because a uniformly-placed new point is most likely to land in the biggest arc. That is the one genuinely encouraging fact in this section, and §6.5 is about how little it helps.

The headline, stated precisely Virtual nodes change the expected number of keys that move only modestly, from 14.80% to 10.77%. What they change is everything about the distribution of that outcome: the range collapses from [0.00%, 36.17%] to [8.76%, 12.36%], and the donor set goes from one arbitrary node to the entire cluster. "Only 1/N of keys move" is a statement about a mean. You do not get to draw the mean. You get to draw once.

6.5 The shock: adding a node need not relieve the node that needed relieving

node6 holds 4003 keys and is your hot shard. You add a 9th node. Table 4 enumerates all 100 candidate names:

=== 4. does adding a node relieve the busiest one? vnodes=1 === busiest before: node6 holds 4003 keys unchanged in 64 of 100 placements (64%) best any placement did: 4003 -> 386 keys

In 64 of 100 placements, node6's load is not reduced by a single key. Not reduced a little. Identical — 4003 before, 4003 after.

The mechanism is §6.3's, read backwards. With one point per node, a join has exactly one donor. If that donor is not node6, node6 is untouched. So the question "does adding capacity help my hot node?" reduces to "did the new point happen to land in the hot node's arc?", which happens with probability equal to the hot node's arc share — 39.75%. So the join misses node6 with probability 100% − 39.75% = 60.25%. Predicted 60.25 of 100 unchanged; measured 64. That is well inside the noise of 100 draws.

The whole result in one line of arithmetic The probability that adding a node helps your hottest node is exactly that node's share of the ring. The more overloaded it is, the more likely help arrives — which sounds reassuring until you notice that a node at 2× its fair share on an 8-node ring still gets missed three times in four.

The counterfactual is the whole point of virtual nodes:

vnodesbusiest beforeunchanged inbest placement achieved
1400364 of 1004003 → 386
1022378 of 1002237 → 1114
15014090 of 1001409 → 1088

At 150 points per node, no placement leaves the busiest node untouched, because the newcomer lands in all 8 arcs and therefore necessarily takes some of the busiest one. Adding capacity does what you expected it to do. That guarantee — not the balance, not the movement count — is what you are buying.

The vnodes=1 "best placement" row is its own small horror: some candidate name existed that would have cut node6 from 4003 keys to 386, a 90% reduction, by landing just before it. The difference between that outcome and 64% of outcomes is a string.

Two facts that keep this from being worse than it is, both measured:

=== CF8: can adding a node ever increase some existing node's key count? === vnodes= 1 over 100 joins x 8 nodes: 0 increases observed vnodes=150 over 100 joins x 8 nodes: 0 increases observed

A join can be useless but never harmful — a new point only ever takes an arc, so no existing node's load can go up. And §5.3 showed removal is the exact inverse of addition, so a placement that did nothing can be undone for free.

6.6 It is not an artifact of small N

Eight nodes is a small cluster and it would be fair to suspect the whole effect of being a small-numbers illusion. Table 1 runs 32 nodes as well, and the effect gets worse:

nodesvnodesminmaxmax/fairmax/min
815440033.2074.13
321115294.891529.00
8150114614091.131.23
321502414061.301.68

At 32 nodes with one point each, the lightest node holds one key out of 10,000 while the heaviest holds 1529 — 4.89× its fair share of 312. This is HN/N doing exactly what §3 said: H32/32 = 4.058/32 = 12.7% of the ring for the expected largest arc, against a fair share of 3.1%, so the expected max/fair is 4.06. This ring's largest arc gives 4.75, and the 10,000-key count measures 4.89 on top of that. Since HN grows with N, more nodes makes the relative imbalance worse, not better.

6.7 The tests

python3 test_hash_ring.py
PASS test_hashing_is_deterministic_with_no_rng PASS test_route_wraps_past_the_last_point PASS test_one_point_per_node_is_wildly_unbalanced PASS test_load_is_arc_length_not_key_luck PASS test_vnodes_collapse_the_spread PASS test_vnodes_change_who_the_keys_come_from_not_how_many PASS test_the_per_instance_spread_is_the_real_result PASS test_adding_a_node_usually_does_not_relieve_the_busiest_one PASS test_a_join_never_increases_any_existing_nodes_load PASS test_remove_is_the_exact_inverse_of_add PASS test_ring_rejects_nonsense All 11 tests PASSED

Because there is no RNG, these can assert exact integers rather than bounds: ring_hash("key0") == 2914119475, counts["node5"] == 54, unchanged == 64. Every headline on this page is one of them. If a future edit changes the label format or the hash, the suite fails loudly instead of the page rotting quietly.

6.8 Where the effect vanishes

Worth naming precisely, because the demo is tuned to make the effect vivid and you should be able to tell whether it applies to you.

The imbalance vanishes with enough virtual nodes, and the rate is 1/sqrt(vnodes). Measured on arc shares alone, with no key sampling:

=== E5: arc spread (no key sampling) vs vnodes === N vnodes max arc/fair 1+2.9/sqrt(v) [N=256 only] 8 1 3.180 3.900 8 10 1.790 1.917 8 50 1.090 1.410 8 150 1.125 1.237 8 500 1.076 1.130 32 1 4.751 3.900 32 10 1.849 1.917 32 50 1.229 1.410 32 150 1.181 1.237 32 500 1.108 1.130 256 1 4.485 3.900 256 10 1.890 1.917 256 50 1.372 1.410 256 150 1.226 1.237 256 500 1.120 1.130

The rule of thumb 1 + 2.9/sqrt(vnodes) tracks the N=256 column to within 0.03 across three orders of magnitude, and is a safe upper bound for smaller clusters. Read it as: to halve your imbalance you must quadruple your virtual nodes. 150 buys you about 1.23×. Getting to 1.05× needs several thousand points per node, and §8 explains why nobody does that.

The disruption story vanishes at large N. Everything alarming in §6.4 and §6.5 is a statement about relative spread around a fair share of 1/(N+1). At 32 nodes the fair share is already only 3.03%, so even the worst placement moves a few percent of your data. The effect is loudest exactly where clusters are smallest — which is where most people first meet it, and where Cassandra's docs say vnodes matter most: they "make small clusters look larger."

It vanishes when keys per node gets small, in the other direction. At 256 nodes and 10,000 keys, each node's fair share is 39 keys, and key sampling noise dominates the arc variance so completely that no vnode count can help:

=== E2: N=256, does key granularity cap the balance? (vnodes=150) === 10000 keys ( 39.1 per node) min= 21 max= 59 max/fair= 1.51 100000 keys ( 390.6 per node) min= 291 max= 508 max/fair= 1.30 1000000 keys ( 3906.2 per node) min= 3040 max= 4823 max/fair= 1.23

Only at a million keys does the measured 1.23 settle onto the arc-based 1.226 from the E5 table. Below that you are measuring your key set, not your ring.

And it vanishes if your load is not proportional to key count at all. The whole toy assumes every key is equally expensive. One celebrity key on the best-balanced ring in the world is still a hot shard, and no amount of vnode tuning touches it. That is what §7.4's bounded-load variant exists for.

The tuning choice, stated honestly 8 nodes and 10,000 keys was picked to make the effect vivid in one screen. It is not a strawman — 8-node clusters are extremely common and §6.6 shows 32 is worse — but the numbers on this page are loudest at small N with one point per node, which is a configuration no mature system ships by default any more. That is precisely because of this result.

7. Design decisions and roads not taken

7.1 No RNG, and a 32-bit ring

The usual way to write this toy is random.seed(42) and uniform positions. That would be simpler and it would have destroyed the argument, because the first honest question about a 74× spread is "is your randomness bad?" Deriving positions from SHA-1 makes the answer checkable rather than assertable, and CF1 in §5.1 checks it against five hash functions.

The cost is the 32-bit space, which is genuinely small: by the birthday bound, 1200 ring points in 232 slots collide with probability about 1200²/233 ≈ 0.017%. Two node labels landing on the same integer would give one of them a zero-length arc — harmless here, because add uses bisect_right and both points simply coexist. Production rings use the full 128 or 160 bits, and CF2 showed the choice is invisible at this scale.

7.2 A sorted list and bisect, not a tree or a dict

A dict from position to node cannot answer "smallest key ≥ x", which is the only query the ring ever makes. A balanced BST or skip list can, and is what you would use if membership churned constantly, since it gets O(log P) insertion instead of the O(P) list shift.

For a ring, the list wins, and the reason is the read/write ratio. Memberships change on the order of once a week; routes happen millions of times a second. A flat sorted array is cache-friendly in a way no pointer-chasing tree is, and Python's bisect is C. Envoy, Ketama and most Go implementations all do the same thing.

7.3 vnodes is a ring-wide knob, so weighted nodes are out

Ring.__init__ takes one vnodes for the whole ring, and _labels gives every node the same count. Making it per-node would be a two-line change — and it is exactly how weighted consistent hashing works: a machine with twice the RAM gets twice the ring points and therefore twice the keys. Dynamo says this explicitly, that a node's virtual node count can be chosen "based on its capacity, accounting for heterogeneity."

It is left out because heterogeneous capacity is a second mechanism, and this toy has one. Add a weights dict and re-run demo.py if you want to see it.

7.4 Three alternatives that beat the ring, all absent on purpose

Naming these matters, because the ring's imbalance is a solved problem and a reader should not leave thinking otherwise.

All three are one mechanism each. Implementing any of them would have made this a survey, and a survey cannot make you feel the 64%.

7.5 No replication factor

A real ring does not stop at the first successor; it walks the next R-1 distinct nodes to place replicas. That single change makes the ring a replica placement mechanism, brings in preference lists, and is the thing that makes vnodes complicated in practice (Cassandra's token allocator exists because naive vnodes wreck replica diversity across racks).

It is out of scope on purpose — it is the subject of a separate toy. What this one teaches is the partition itself, and the partition has to be understood before the replication of it makes any sense.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Every answer is derivable from the source, and each one was verified by running it.

Question 1

node6 holds 4003 of 10,000 keys. You raise the key count to a million, hoping the imbalance averages out. What is max/fair then, and why?

Answer

3.186, essentially unchanged from 3.20. More keys make it converge, not shrink:

keys max/fair 1000 3.0720 10000 3.2024 100000 3.1914 1000000 3.1860 the ceiling is 8 x the biggest arc = 3.1799

arcs() gives the limit exactly: node6 owns 39.748643% of the ring, so max/fair converges on 8 × 0.39748643 = 3.1799. The key count samples a partition whose shape was fixed when the eight node names were hashed.

Pinned by test_load_is_arc_length_not_key_luck (test_hash_ring.py lines 49–63).

Question 2

With vnodes=1, you add a 9th node and exactly one existing node loses keys. Which one is it most likely to be, and with what probability?

Answer

The one with the largest arc, with probability equal to its arc sharenode6, at 39.75%. The new node has one ring point, which lands uniformly; whichever arc it lands in is the sole donor, and landing in a given arc has probability equal to that arc's size.

Both halves show up in the runs. The three biggest joins out of 100 all took from node6:

newnode40 moved 3508 keys from [node6:3508] newnode79 moved 3550 keys from [node6:3550] newnode18 moved 3617 keys from [node6:3617]

And the complement — placements that missed node6 and therefore left it untouched — was 64 of 100, against the predicted 100 − 39.75 = 60.25.

There is a pleasing corollary: the ring is more likely to relieve an overloaded node than a healthy one, because overload is a big arc. It is just nowhere near likely enough (Q3).

Question 3

Your 8-node ring has vnodes=1, node6 is melting, and you add a 9th node. What is the chance this achieves nothing at all for node6? Does raising vnodes to 150 change the answer?

Answer

About 60%, measured at 64 of 100 placements — and yes, 150 vnodes takes it to zero.

=== 4. does adding a node relieve the busiest one? vnodes=1 === busiest before: node6 holds 4003 keys unchanged in 64 of 100 placements (64%) best any placement did: 4003 -> 386 keys

and, from the same run further down the demo output:

=== 4. does adding a node relieve the busiest one? vnodes=150 === busiest before: node7 holds 1409 keys unchanged in 0 of 100 placements (0%) best any placement did: 1409 -> 1088 keys

"Unchanged" means literally identical: 4003 keys before, 4003 after. With one point per node there is exactly one donor, and it is node6 only when the new point lands in node6's arc (Q2).

With 150 points the newcomer lands in every existing arc, so it must take some of the busiest one — test_adding_a_node_usually_does_not_relieve_the_busiest_one (test_hash_ring.py lines 116–128) asserts 64 and 0 exactly.

Note also that the best vnodes=1 placement was better than any vnodes=150 placement (4003 → 386 is a 90% cut, versus 1409 → 1088). Vnodes did not buy you a better outcome. They bought you the same outcome every time.

Question 4

Delete these two lines from route:

        if i == len(self._points):
            i = 0

Write a test that passes anyway. Then say what breaks in production, and how often.

Answer

Any test whose keys all hash below the highest ring point passes — which, with a handful of hand-picked keys, is most of them. The real behaviour:

=== CF5: delete the `if i == len(self._points): i = 0` wrap === IndexError: list index out of range keys past the last ring point: 147 of 10000

1.47% of keys, because the topmost ring point sits at 98.7% of the ring and everything above it has no clockwise successor in a flat array. The ring stops being a ring and becomes a line with a hole at the end. Those 147 keys belong to the first node — the one just past the wrap — which is what test_route_wraps_past_the_last_point (test_hash_ring.py lines 26–34) asserts.

The frequency is the interesting part: it is not "rare", it is exactly the size of the gap between the last point and 232, which shrinks as you add nodes. So this bug gets quieter as your cluster grows, and looks like flakiness rather than a logic error.

Question 5

A node reboots. Your operator tooling removes it from the ring on failure and re-adds it on recovery. How much of the cluster's data has moved by the time it is back?

Answer

Down, then back — a net zero, with the removed node's own share moving twice.

=== CF6: is remove() the exact inverse of add()? === vnodes= 1 removing node3 moved 164 keys; re-adding it left 0 keys different from the start vnodes=150 removing node3 moved 1146 keys; re-adding it left 0 keys different from the start

Removing node3 at vnodes=1 moves exactly 164 keys, which is precisely what node3 held (see the §6 table). Re-adding it leaves zero keys routed differently from the starting state.

This holds because a ring position is a pure function of its label (hash_ring.py lines 54–58) — there is no counter, no insertion order, no state carried between operations. It is why a rebooting cache node reclaims its own keys rather than a random new set, and it is the property that makes remove() safe to call on a false-positive failure detection.

The production caveat from §8: the routing is restored for free, but the data that migrated away while the node was down does not migrate back by itself.

Question 6

You run a 40-node cache with vnodes=1 and size every machine for 1/40 of the traffic. Roughly what do you actually need to provision, and what is the cheapest fix?

Answer

Four to five times the fair share on the worst machine, and the fix is vnodes, not more machines.

The theory from §3 gives the expected largest arc as HN/N, so max/fair ≈ H40 ≈ 4.28 — the fair share cancels, so the harmonic number is the provisioning factor. The measured 32-node run lands in the same territory:

32 1 1 1529 4.89 1529.00

max/fair of 4.89, and the lightest node holding one key out of 10,000.

The trap is that adding machines makes this worse: HN grows with N, so max/fair went from 3.20 at 8 nodes to 4.89 at 32. Scaling out to fix a hot shard is scaling into a worse ratio.

The cheap fix is vnodes, which costs memory and nothing else — at 32 nodes, 150 points each takes max/fair from 4.89 to 1.30. From §6.8, the payoff goes as 1/sqrt(vnodes), so 150 is roughly the knee of that curve; going to 500 would buy you 1.11 for 3.3× the memory. Modern Cassandra does better still by choosing tokens rather than hashing them (§8), which is why its default is now 16 rather than 256.


10. Further reading

Every link below was fetched and confirmed live when this was written.

Elsewhere in this repo, bloom-filter is the other toy where a uniform hash function produces a result nobody predicts correctly — and where, as here, the fix is "use more independent hashes."