Skip to main content

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.

Growing 4 → 5 buckets: what moves under each scheme Under modulo hashing, adding a fifth bucket reassigns about eighty percent of keys because every index shifts. Under jump consistent hash, exactly one fifth of keys move, and they move only into the new bucket — no key is ever reassigned between two existing buckets. That is the theoretical minimum for any consistent hashing scheme. hash(key) % N — 4 → 5 jump consistent hash — 4 → 5 b0 — 79% movedb1 — 80% moved b2 — 79% movedb3 — 80% movedb4 — new nearly every key changes owner; the data movement is the size of the whole dataset b0 — 20% outb1 — 20% out b2 — 20% outb3 — 20% outb4 — all new exactly 1/5 of keys move, all of them into the new bucket — never between two existing buckets That "never between existing buckets" property is what makes the migration plan simple: every moving key has one source and one destination, and the destination is always the new shard.

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:

Two mappings: keys to buckets, buckets to machines Jump consistent hash maps a tenant id to one of 64 fixed buckets and never changes. A small alias table maps those buckets to physical shards, and it changes freely: adding a machine reassigns buckets to it, and retiring one repoints its buckets elsewhere. The hash function itself is untouched by either operation. tenant 8842stable 64-bit hash jump hash → bucket 37fixed forever, no table alias tablebucket 37 → shard_2 shard_2 today shard_5 after a move Keep the bucket count generous — 64 or 1,024 — so capacity changes are alias edits rather than rehashes. The alias table is a few hundred rows, versioned like a ring, and it is the only part of the routing that ever changes. Weighting falls out of the same table: give a larger machine more buckets and it takes proportionally more traffic.
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
Two growth paths, two very different costs Adding capacity by repointing aliases moves one bucket's data to a new machine and requires no hash change, so the routing function and every client's view of it stay identical. Increasing the bucket count moves one over N of all keys into the new bucket and requires every client to agree on the new count at the same moment. Repoint an alias — preferred Increase NUM_BUCKETS move one bucket's rows to a new host hash function and count unchanged clients need only the new alias row data moved: 1/64 of the fleet coordination: one compare-and-swap move 1/N of all keys into the new bucket every client must switch count together a client on the old count routes wrongly data moved: 1/65 of the fleet coordination: fleet-wide, versioned The data volumes are almost identical; the coordination cost is not. Start with far more buckets than machines and the second column becomes something you may never need to do. A bucket count of 1,024 across 8 machines costs nothing extra and defers a fleet-wide rehash indefinitely.

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.