Building a Hash Table From Scratch in Python: Buckets, Collisions, Chaining

A hash table maps keys to values by hashing each key to a bucket index, then storing the (key, value) pair in that bucket. This post builds a HashTable class with fixed-size buckets and separate chaining (a list per bucket, appended to on collision), then runs put/get/delete on 8 string keys and looks at where each one landed.

The code

class HashTable:
    # Fixed-size array of buckets; each bucket is a list of (key, value)
    # pairs. Collisions (two keys landing in the same bucket) are handled
    # by just appending to that bucket's list — "separate chaining".
    def __init__(self, capacity=8):
        self.capacity = capacity
        self.buckets = [[] for _ in range(capacity)]

    def _index(self, key):
        # A simple deterministic hash (sum of char codes, weighted by
        # position) instead of Python's built-in hash() — str hashing is
        # randomized per process by default, which would make bucket
        # placement (and which keys collide) different on every run.
        h = 0
        for i, ch in enumerate(key):
            h = (h * 31 + ord(ch)) % 1_000_003
        return h % self.capacity

    def put(self, key, value):
        idx = self._index(key)
        bucket = self.buckets[idx]
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)
                return
        bucket.append((key, value))

    def get(self, key):
        idx = self._index(key)
        for k, v in self.buckets[idx]:
            if k == key:
                return v
        raise KeyError(key)

    def delete(self, key):
        idx = self._index(key)
        bucket = self.buckets[idx]
        for i, (k, _) in enumerate(bucket):
            if k == key:
                del bucket[i]
                return
        raise KeyError(key)

    def bucket_sizes(self):
        return [len(b) for b in self.buckets]


ht = HashTable(capacity=8)
pairs = [("apple", 1), ("banana", 2), ("cherry", 3), ("date", 4),
         ("elderberry", 5), ("fig", 6), ("grape", 7), ("honeydew", 8)]

print("=== put ===")
for k, v in pairs:
    idx = ht._index(k)
    ht.put(k, v)
    print(f"put({k!r}, {v}) -> bucket {idx}")

print("\nbucket sizes:", ht.bucket_sizes())
for i, bucket in enumerate(ht.buckets):
    if len(bucket) > 1:
        print(f"collision in bucket {i}: {bucket}")

print("\n=== get ===")
for k, _ in pairs[:3]:
    print(f"get({k!r}) ->", ht.get(k))

print("\n=== delete ===")
ht.delete("banana")
print("deleted 'banana', bucket sizes now:", ht.bucket_sizes())
try:
    ht.get("banana")
except KeyError as e:
    print("get('banana') raised KeyError:", e)

8 keys go into a table with capacity=8. _index hashes each key deterministically so the same key always lands in the same bucket. After all 8 put calls, the code prints bucket sizes and flags any bucket holding more than one entry.

Running it

Real output:

=== put ===
put('apple', 1) -> bucket 3
put('banana', 2) -> bucket 7
put('cherry', 3) -> bucket 2
put('date', 4) -> bucket 5
put('elderberry', 5) -> bucket 2
put('fig', 6) -> bucket 4
put('grape', 7) -> bucket 5
put('honeydew', 8) -> bucket 1

bucket sizes: [0, 1, 2, 1, 1, 2, 0, 1]
collision in bucket 2: [('cherry', 3), ('elderberry', 5)]
collision in bucket 5: [('date', 4), ('grape', 7)]

=== get ===
get('apple') -> 1
get('banana') -> 2
get('cherry') -> 3

=== delete ===
deleted 'banana', bucket sizes now: [0, 1, 2, 1, 1, 2, 0, 0]
get('banana') raised KeyError: 'banana'

Of the 8 keys, 2 pairs collided: 'cherry'/'elderberry' both hashed to bucket 2, and 'date'/'grape' both hashed to bucket 5 — bucket sizes ended up [0, 1, 2, 1, 1, 2, 0, 1], two buckets holding 2 entries each via chaining. get correctly returned each value including from the two-entry buckets. After delete('banana'), bucket 7 dropped to size 0 and a subsequent get('banana') raised KeyError: 'banana' as written.

Takeaway

With 8 keys in 8 buckets, this run landed 2 real collisions (bucket 2 and bucket 5), and both collided buckets still returned the correct value per key via the chained list — showing chaining resolving collisions correctly, not just avoiding them by luck.