Skip to content
About
Main content

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. (a ring of points) and (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 , (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

With one ring point per node, how many times more keys does the busiest node own than the least busy one?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    how-many-keys-move-when-n.py55 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

100,000 keys, 10 nodes then 11
SchemeKeys movedLoad max / minNodes that take a removed node's keys
hash % N90.9%1.02×9
ring V=112.2%39.6×1
ring V=1288.09%1.34×9
rendezvous9.02%1.03×9

1,000,000 lookups

1,000,000 lookups
SchemeTime
ring V=128708 ms
rendezvous6.25 s
Reference runApple M2 · 8 cores · 8 GB
Output · uv run how-many-keys-move-when-n.py
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 s
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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.