Consistent hashing limits placement changes when membership changes; it does not itself move data, make replicas agree, or distribute a hot key’s requests. In the classic clockwise ring, a key belongs to the first node point at or after its hash position, wrapping to the smallest point when necessary.
Get the ownership interval right
A point owns the interval after its predecessor through itself: (predecessor, point]. With points A at 20, B at 50, and C at 80, B owns positions above 20 through 50. A key beyond 80 wraps to A. This is not the arc from B to its successor.
Adding D at 35 takes positions above 20 through 35 from B, the new point’s clockwise successor. Removing B transfers B’s former interval to C. Unaffected points retain their positions and ownership. That preservation is the useful property; saying “only a small arc moves” is insufficient when an existing arc might already cover a large fraction of the ring.
By contrast, directly choosing node index hash(key) modulo node_count changes the divisor when a node is added. Many keys then choose another index. A balanced ring with four equal-capacity nodes expanded to five would ideally move about one fifth of keys, but exact movement depends on the actual points and key distribution, not a universal percentage.
Virtual nodes spread intervals, not individual keys
Assigning many ring points to each physical node usually reduces imbalance from a few randomly placed points. Each physical node owns several smaller intervals, and adding another can take pieces from multiple neighbors. Weights can allocate different point counts for unequal capacity. More points also cost routing-table memory and maintenance work.
One key still resolves to one physical owner in this primary-placement model. If that key receives most requests, increasing virtual-node count does not split those requests across machines. Even equal key counts do not imply equal bytes, request rates, or processing cost. Replication, read distribution, and hot-key handling require additional policies.
Stability alone also does not imply balance. All participants must agree on membership, persistent node identities, key encoding, hash rules, and point counts. A stable digest helps them reproduce positions, but cannot compensate for different membership views or a skewed workload. Rebuilding every node’s points on each membership change would defeat the placement-preservation property.
Placement is not migration or consistency
A new routing answer does not transfer cached or persistent values to the new owner. A cache may experience misses and an origin-load spike; a persistent store needs a migration and ownership protocol. Clients using old and new rings can disagree during rollout. Recovery and synchronization behavior belongs to the surrounding system.
Real products also choose different algorithms and metadata. Redis Cluster uses fixed hash slots whose ownership is reassigned; it is not this example’s direct node-point ring. Cassandra adds replica placement and token-management rules, while Envoy uses hashing to select upstream hosts. Do not infer one product’s failure guarantees from another product’s placement diagram.
When it breaks
A single point per node can be badly unbalanced, and renaming a node can unexpectedly relocate its points. Changing byte encoding or hash configuration can likewise remap keys without any physical machine joining. Record the mapping inputs as part of deployment configuration and compare ownership before rollout.
Measure moved keys separately from moved bytes and real traffic. Virtual nodes improve statistical placement, not a guaranteed maximum load. During membership churn, budget for migration work or cold-cache demand, and verify how clients learn the new view. A placement experiment is not an availability or data-integrity test.
Check it quickly
Save the following as ring_check.py and run python3 ring_check.py with Python 3.10 or newer. This ran with Python 3.14.4. It uses only the standard library, starts no servers, and performs no network or filesystem writes. SHA-256 and explicit UTF-8 inputs make the generated fixture reproducible.
from bisect import bisect_left
from collections import Counter
from hashlib import sha256
def token(value):
return int.from_bytes(sha256(value.encode("utf-8")).digest(), "big")
def ring(nodes, copies):
entries = sorted(
(token(f"node:{node}:{copy}"), node)
for node in nodes for copy in range(copies)
)
positions, owners = zip(*entries)
assert len(set(positions)) == len(positions), "fixture token collision"
return positions, owners
def lookup(layout, position):
positions, owners = layout
return owners[bisect_left(positions, position) % len(positions)]
simple = ((20, 50, 80), ("A", "B", "C"))
joined = ((20, 35, 50, 80), ("A", "D", "B", "C"))
removed = ((20, 80), ("A", "C"))
assert [lookup(simple, value) for value in (0, 20, 21, 50, 51, 80, 81)] == [
"A", "A", "B", "B", "C", "C", "A",
]
for value in range(100):
assert lookup(joined, value) == ("D" if 20 < value <= 35 else lookup(simple, value))
assert lookup(removed, value) == ("C" if 20 < value <= 50 else lookup(simple, value))
assert lookup(joined, 35) == "D"
assert lookup(removed, 50) == "C"
print("a point owns (predecessor, point]; lookups wrap clockwise")
nodes = ("A", "B", "C", "D")
positions = [token(f"key:{index}") for index in range(20000)]
base = ring(nodes, 256)
expanded = ring(nodes + ("E",), 256)
contracted = ring(tuple(node for node in nodes if node != "B"), 256)
assert base == ring(tuple(reversed(nodes)), 256)
before = [lookup(base, position) for position in positions]
after_add = [lookup(expanded, position) for position in positions]
after_remove = [lookup(contracted, position) for position in positions]
add_count = sum(old != new for old, new in zip(before, after_add))
remove_count = sum(old != new for old, new in zip(before, after_remove))
assert 0 < add_count < len(positions)
assert add_count == after_add.count("E")
assert all(old == new or new == "E" for old, new in zip(before, after_add))
assert remove_count == before.count("B")
assert all(old == new or old == "B" for old, new in zip(before, after_remove))
modulo_count = sum(
nodes[position % 4] != (nodes + ("E",))[position % 5]
for position in positions
)
print(f"add E: {add_count}/20000 keys move ({add_count / 200:.2f}%)")
print(f"remove B: {remove_count}/20000 keys move ({remove_count / 200:.2f}%)")
print(f"modulo 4 -> 5: {modulo_count}/20000 keys move ({modulo_count / 200:.2f}%)")
single = ring(nodes, 1)
for label, layout in (("one point", single), ("256 points", base)):
counts = Counter(lookup(layout, position) for position in positions)
print(label, {node: counts[node] for node in nodes})
hot_owner = lookup(base, token("key:hot"))
synthetic_requests = Counter(before)
synthetic_requests[hot_owner] += 100000
assert synthetic_requests[hot_owner] == before.count(hot_owner) + 100000
print("100000 extra requests for one hot key all target", hot_owner)
print("synthetic request counts", dict(sorted(synthetic_requests.items())))
The integer example checks both interval endpoints and wraparound across every position from zero through 99. For 20,000 deterministic keys with 256 points per node, adding E moves 4,023 keys, removing B moves 4,895, and modulo resizing moves 16,013. Assertions also require every addition-related move to end at E and every removal-related move to originate at B.
The printed counts show this fixture’s improved balance with 256 points, not a universal bound. Assigning 100,000 extra synthetic requests to one hot key still directs them all to D. These are calculated placement and demand counts, not measured production requests, transferred bytes, or recovery timings.
Further reading
Sources
- Apache Cassandra’s Dynamo architecture, clockwise ownership, token ranges, and virtual nodes, checked 2026-09-08.
- Envoy load-balancing algorithms, ring hashing, stable host identity, weights, and distribution, checked 2026-09-08.
- Python hashlib, portable SHA-256 digest construction, checked 2026-09-08.
- Redis Cluster specification, fixed slot mapping as a distinct placement design, checked 2026-09-08.