Jump Consistent Hash in Production
Jump consistent hash distributes keys across N buckets with no lookup table, no virtual nodes and provably minimal movement when N changes — in about seven lines of code. Its one constraint shapes everything else: buckets can only be added or removed at the tail. This guide implements it, works around that constraint with an alias table, and says plainly when a ring is the better tool. It extends Hash Routing Algorithms inside Partitioning Implementation Patterns & Routing.
Prerequisites
Step 1 — Implement the algorithm exactly
The published algorithm is short and must not be improvised:
def jump_consistent_hash(key: int, num_buckets: int) -> int:
"""Lamping & Veach. Returns a bucket in [0, num_buckets)."""
b, j = -1, 0
while j < num_buckets:
b = j
key = (key * 2862933555777941757 + 1) & 0xFFFFFFFFFFFFFFFF
j = int((b + 1) * ((1 << 31) / float((key >> 33) + 1)))
return b
# the routing key must be hashed stably before it reaches the function
import xxhash
def bucket_for(tenant_id: int, num_buckets: int) -> int:
key64 = xxhash.xxh64_intdigest(str(tenant_id)) # explicit, versioned, not built-in hash()
return jump_consistent_hash(key64, num_buckets)
Operational note: Python’s built-in hash() for strings is randomised per process unless PYTHONHASHSEED is fixed. Using it here would route the same tenant differently on every deploy — a data-placement bug that looks like random corruption.
DBA tip: Pin the hash with a test that asserts fixed outputs. It is the highest-value test in the routing layer and takes three lines.
Step 2 — Add an alias table so shards can be retired
Jump hash gives you a bucket number; an alias table turns that into a host:
CREATE TABLE bucket_alias (
bucket int PRIMARY KEY,
shard_name text NOT NULL,
host text NOT NULL,
version int NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO bucket_alias (bucket, shard_name, host, version) VALUES
(0, 'shard_0', 'db-0.internal', 7),
(1, 'shard_1', 'db-1.internal', 7),
(2, 'shard_2', 'db-2.internal', 7),
(3, 'shard_3', 'db-3.internal', 7),
(4, 'shard_4', 'db-4.internal', 7);
def route(tenant_id: int) -> str:
bucket = bucket_for(tenant_id, NUM_BUCKETS) # NUM_BUCKETS changes rarely
return alias_cache.host_for(bucket) # the alias changes freely
Retiring a physical machine now means repointing a bucket, which moves that bucket’s data and leaves the hash untouched. The number of buckets and the number of machines have become independent.
Operational note: Keep NUM_BUCKETS larger than the machine count from the start — 64 buckets across 8 machines means a machine can be added by reassigning aliases with no rehashing at all.
DBA tip: Version the alias table exactly like a ring version: publish, then activate with a compare-and-swap, and have clients report which version they hold.
Step 3 — Grow the bucket count when you must
Increasing NUM_BUCKETS is a data movement, and jump hash makes its scope precisely predictable:
# which tenants move when going from 64 to 65 buckets?
moving = [t for t in all_tenants
if bucket_for(t, 64) != bucket_for(t, 65)]
print(len(moving), "of", len(all_tenants)) # ≈ 1/65 of them, all → bucket 64
Operational note: During a bucket-count change, clients must not disagree. Use the dual-read window and atomic version flip from the ring rebalancing guide — the mechanics are identical even though the hash is not.
SRE tip: Compute the moving set in advance and store it. It is the work queue, the verification list and the rollback plan, exactly as with a ring.
Verification
Prove distribution and stability with the two tests that matter:
def test_distribution_is_uniform():
counts = Counter(bucket_for(t, 64) for t in range(1_000_000))
lo, hi = min(counts.values()), max(counts.values())
assert hi / lo < 1.05, f"spread {lo}..{hi} is wider than 5%"
def test_growth_moves_only_the_minimum():
before = {t: bucket_for(t, 64) for t in range(200_000)}
after = {t: bucket_for(t, 65) for t in range(200_000)}
moved = [t for t in before if before[t] != after[t]]
assert all(after[t] == 64 for t in moved), "a key moved between existing buckets"
assert 0.9/65 < len(moved)/200_000 < 1.1/65
1000000 keys over 64 buckets: min 15,402 max 15,984 spread 3.8%
200000 keys, 64 → 65: 3,081 moved (1.54%), all into bucket 64
Choosing Between a Ring and Jump Hash
Both are consistent hashing; they differ in what they let you change and what they ask you to store. The choice is usually decided by a single question: does an arbitrary node ever need to leave?
A ring stores a sorted table of virtual node positions, supports adding and removing any node, and allows weighting — a machine with twice the capacity takes twice the positions. The costs are the table itself, which must be identical on every client, and a vnode count that has to be chosen well enough to keep distribution even.
Jump hash stores nothing, distributes uniformly by construction, and moves the theoretical minimum when the bucket count changes. The cost is the tail-only constraint, and the loss of weighting: every bucket gets an equal share whether or not the machine behind it can handle one.
The hybrid described above — jump hash into many buckets, plus a small alias table from buckets to machines — recovers most of the ring’s flexibility while keeping the hash itself table-free. Weighting becomes “assign more buckets to this machine”, and decommissioning becomes “reassign this machine’s buckets”, both of which are edits to a table of a few hundred rows rather than changes to the hash function.
If you are choosing today for a system that will grow by adding capacity and will occasionally retire a machine, the hybrid is the pragmatic default. Reach for a plain ring when weighting must be fine-grained and dynamic, and for plain jump hash only when the bucket set is genuinely append-only — for example when buckets map to logical partitions inside one database rather than to machines.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Every key reroutes after a deploy | the routing key was hashed with a per-process randomised builtin | hash explicitly with a pinned algorithm; assert fixed outputs in a test that runs on every build |
| A shard cannot be decommissioned | jump hash removes only the last bucket, and the machine to retire is not last | introduce the alias table and repoint the bucket; never renumber buckets to work around it |
| Clients disagree during a bucket-count change | the new count was rolled out by deploy rather than by an atomic version flip | publish the count as versioned routing state, flip with a compare-and-swap, and require clients to report their version |
FAQ
What is the catch with jump consistent hash?
Membership changes only at the tail. Going from N to N+1 buckets moves the theoretical minimum of keys, but you cannot remove bucket 3 from a set of 8 — you can only shrink to 7, which removes the last bucket. Any topology where an arbitrary node must be decommissioned needs a ring, or an indirection layer that maps bucket numbers to physical nodes.
Does it need virtual nodes?
No. The distribution is uniform by construction, which is one of its advantages over a ring — there is no vnode count to tune and no ring table to keep in sync. What you give up is the ability to weight buckets differently, since every bucket receives an equal share.
How do I decommission a specific shard then?
Through an alias table: jump hash maps a key to a bucket number, and a small table maps bucket numbers to physical shards. Retiring a shard becomes repointing its bucket at another host, which moves data but requires no change to the hash. The alias table is tiny, versioned, and the only mutable part of the routing.
Related
- Hash Routing Algorithms — the parent topic comparing modulo, ring and jump hashing
- Step-by-Step Guide to Implementing Consistent Hash Routing — the ring alternative, with arbitrary node removal
- Rebalancing Shards After Adding Nodes to a Consistent Hash Ring — the movement machinery a bucket-count change reuses