Hashes inside data structures

How hashing fills buckets, what load factor does to collisions, and why consistent and rendezvous hashing stop a cluster from reshuffling every key when a node leaves.

Buckets, collisions and load factor

A hash table turns the hash into a bucket index with a modulo or a bitmask. Two keys can land in the same bucket, so the table must resolve collisions, and the load factor decides how often that happens.

# chaining: each bucket holds a list
class HashMap:
    def __init__(self, capacity=8, load_factor=0.75):
        self.buckets = [[] for _ in range(capacity)]
        self.size = 0
        self.load_factor = load_factor

    def _index(self, key):
        # bitmask works only because capacity is a power of two
        return hash(key) & (len(self.buckets) - 1)

    def put(self, key, value):
        if (self.size + 1) / len(self.buckets) > self.load_factor:
            self._resize()
        bucket = self.buckets[self._index(key)]
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)
                return
        bucket.append((key, value))
        self.size += 1

    def _resize(self):
        old = [pair for bucket in self.buckets for pair in bucket]
        self.buckets = [[] for _ in range(len(self.buckets) * 2)]
        self.size = 0
        for k, v in old:
            self.put(k, v)
Load factorExpected probe lengthBehavior
0.25About 1.2Very fast, wastes memory
0.5About 1.5A good balance for open addressing
0.75About 2.5The common default for chaining
0.9About 5.5Degrades sharply; clusters form
1.0 and aboveUnboundedMust resize; every probe is a collision

Consistent hashing

Modulo arithmetic is fine for one table but catastrophic for a cluster. With hash(key) % N, changing N remaps almost every key, which invalidates every cache entry at once.

import bisect, hashlib

class Ring:
    """Consistent hash ring with virtual nodes."""
    def __init__(self, nodes, vnodes=100):
        self.points = []
        self.owner = {}
        for node in nodes:
            for i in range(vnodes):
                h = int(hashlib.sha256(f"{node}#{i}".encode()).hexdigest()[:8], 16)
                self.points.append(h)
                self.owner[h] = node
        self.points.sort()

    def node_for(self, key):
        h = int(hashlib.sha256(key.encode()).hexdigest()[:8], 16)
        i = bisect.bisect_left(self.points, h) % len(self.points)
        return self.owner[self.points[i]]

ring = Ring(["cache-a", "cache-b", "cache-c"])
for k in ("user:1", "user:2", "user:3"):
    print(k, ring.node_for(k))
  • Removing one node from a ring of N moves roughly 1/N of keys, instead of all of them.
  • Virtual nodes even out the load: without them, a node may own a huge or tiny arc by chance.
  • The ring is a routing decision, not a security control; the hash only needs to be well distributed.
  • Rendezvous hashing solves the same problem without a ring: score every node per key and take the maximum.
# rendezvous hashing: no ring, no virtual nodes
import hashlib

def pick(key, nodes):
    return max(nodes, key=lambda n: int(
        hashlib.sha256(f"{key}|{n}".encode()).hexdigest()[:16], 16))

nodes = ["cache-a", "cache-b", "cache-c"]
print(pick("user:1", nodes))

Stability across languages and runs

ConcernProblemSolution
Randomised string hashPython and Rust randomise per processUse a stable hash such as xxHash or SipHash with a fixed seed
Iteration orderHash tables reorder on resizeSort explicitly if order is observable
Cross-language tablesBuilt-in hashes are not portableDefine the hash in the protocol, not the language
Serialising a hash mapThe byte output differs per runSerialise a sorted list of pairs instead
Sharding by hashChanging the algorithm reshuffles dataVersion the shard scheme and migrate in a controlled way
💡
A hash value is not a stable identifier unless the algorithm, the seed and the input encoding are all fixed. Two processes, two languages or two versions of a runtime can compute different values for the same key, which is why cross-service sharding needs an explicitly defined hash.

FAQ

Chaining or open addressing?
Chaining is simpler and degrades gracefully; open addressing has better cache behaviour and no per-entry allocation but needs a lower load factor. Most standard libraries pick one per language and hide the choice.
Why does removing one node disturb fewer keys with consistent hashing?
Because each key maps to a point on a ring and its node is the next one clockwise. Removing a node only reassigns the keys in that node's arc.

Cache keys, ETags and shard keys Non-cryptographic hashes: CRC32, FNV and xxHash

Last refreshed 2026-09-18.