How many keys move when N changes? Modulo vs. ring vs. rendezvous
Simulate a modelNeeds Python
Problem
Assigning keys to nodes by hash(key) % N moves almost every key when N changes, because the remainder changes for almost every hash. Consistent hashing (a ring of points) and rendezvous hashing (each key goes to the node with the highest hash of key and node) both move only the keys that must move, about 1/N of them. They differ in balance and cost: a ring needs many virtual points per node to be even and to spread a removed node's keys over all the others; rendezvous is even by construction but computes one hash per node for every lookup.
Take 100,000 keys and 10 nodes. Record the node of every key. Add an 11th node and count how many keys changed node under (a) hash % N, (b) a consistent-hash ring with V = 1 and V = 128 virtual nodes, (c) rendezvous hashing. Then remove one node and look at where its keys went (how many successors received them). Also time 1 million lookups to see the O(N) cost of rendezvous against the ring's binary search.
The question. How many keys move when an eleventh node joins under each scheme, how even is the load, and where does a removed node's data go?
Predict
Modulo moves about 91% of the keys; the ring and rendezvous move about 9% (1/11). With V = 1 the ring's load per node varies a lot (some nodes own several times more than others) and a removed node's keys go to a single successor; with V = 128 and with rendezvous they spread across all remaining nodes.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
how-many-keys-move-when-n.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """How many keys move when N changes. 100,000 keys on 10 nodes; add an 11th and count the keys that change node under modulo hashing, a consistent-hash ring with 1 and 128 virtual nodes, and rendezvous hashing. Then remove a node and see how many successors take its keys, and time a million lookups. """ import argparse, bisect, hashlib, json, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--keys", type=int, default=100_000, help="Keys.") p.add_argument("--nodes", type=int, default=10, help="Nodes before the change.") p.add_argument("--lookups", type=int, default=1_000_000, help="Lookups to time.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() h = lambda s: int(hashlib.md5(s.encode()).hexdigest(), 16) def modulo(keys, nodes): return {k: nodes[h(k) % len(nodes)] for k in keys} def rendezvous(keys, nodes): return {k: max(nodes, key=lambda n: h(f"{k}|{n}")) for k in keys} def ring_fn(nodes, v): pts = sorted((h(f"{n}#{i}"), n) for n in nodes for i in range(v)); xs = [x for x, _ in pts] return lambda k: pts[bisect.bisect(xs, h(k)) % len(pts)][1] def ring(keys, nodes, v): f = ring_fn(nodes, v); return {k: f(k) for k in keys} keys = [f"key-{i}" for i in range(args.keys)] before, after, removed = [f"n{i}" for i in range(args.nodes)], [f"n{i}" for i in range(args.nodes + 1)], [f"n{i}" for i in range(1, args.nodes)] schemes = {"hash % N": modulo, "ring V=1": lambda k, n: ring(k, n, 1), "ring V=128": lambda k, n: ring(k, n, 128), "rendezvous": rendezvous} rows = [] for name, fn in schemes.items(): a, b, c = fn(keys, before), fn(keys, after), fn(keys, removed) moved = sum(a[k] != b[k] for k in keys) / len(keys) load = {} for k in keys: load[a[k]] = load.get(a[k], 0) + 1 heirs = {c[k] for k in keys if a[k] == "n0"} rows.append({"scheme": name, "moved_pct": moved * 100, "max_over_min_load": max(load.values()) / min(load.values()), "heirs_of_removed": len(heirs)}) print(f"{name:<12} add a node: {moved:6.1%} of keys move load max/min {rows[-1]['max_over_min_load']:5.2f} " f"a removed node's keys go to {len(heirs)} node(s)") look = [f"q-{i}" for i in range(args.lookups)] f128 = ring_fn(after, 128) t = time.perf_counter(); [f128(k) for k in look]; t_ring = time.perf_counter() - t t = time.perf_counter(); [max(after, key=lambda n: h(f"{k}|{n}")) for k in look[: args.lookups // 10]]; t_rv = (time.perf_counter() - t) * 10 print(f"{args.lookups:,} lookups: ring (binary search) {t_ring:.2f} s rendezvous (hash every node) {t_rv:.2f} s") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"schemes": rows, "lookups": [{"scheme": "ring V=128", "seconds": t_ring}, {"scheme": "rendezvous", "seconds": t_rv}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run how-many-keys-move-when-n.py uv run how-many-keys-move-when-n.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --keys100,000 | Keys. |
| --nodes10 | Nodes before the change. |
| --lookups1,000,000 | Lookups to time. |
Our run
100,000 keys, 10 nodes then 11
| Scheme | Keys moved | Load max / min | Nodes that take a removed node's keys |
|---|---|---|---|
| hash % N | 90.9% | 1.02× | 9 |
| ring V=1 | 12.2% | 39.6× | 1 |
| ring V=128 | 8.09% | 1.34× | 9 |
| rendezvous | 9.02% | 1.03× | 9 |
1,000,000 lookups
| Scheme | Time |
|---|---|
| ring V=128 | 708 ms |
| rendezvous | 6.25 s |
hash % N add a node: 90.9% of keys move load max/min 1.02 a removed node's keys go to 9 node(s)
ring V=1 add a node: 12.2% of keys move load max/min 39.62 a removed node's keys go to 1 node(s)
ring V=128 add a node: 8.1% of keys move load max/min 1.34 a removed node's keys go to 9 node(s)
rendezvous add a node: 9.0% of keys move load max/min 1.03 a removed node's keys go to 9 node(s)
1,000,000 lookups: ring (binary search) 0.71 s rendezvous (hash every node) 6.25 sWhat we found
Modulo hashing moved 90.9% of the keys; rendezvous moved 9.02% and the ring with 128 virtual nodes 8.09%, close to 1/11. The ring with one point per node moved 12.2%, and its busiest node owned 39.6× as many keys as its least busy one; a removed node's keys all went to 1 successor. With 128 points or rendezvous they went to 9 nodes. Rendezvous lookups took 6.25 s for a million keys against 708 ms for the ring.
Read it
Consistent hashing and rendezvous hashing are two implementations of one idea: stable assignment of keys to nodes. Rendezvous is simpler and even without tuning, and its O(N) lookup is cheap for tens of nodes; a ring with many virtual nodes wins when there are hundreds of nodes or lookups are on a hot path. Plain modulo is acceptable only where N never changes.