Why Hash Tables Need Tombstones

There are two ways to build a hash table. Chaining gives every bucket a linked list and pushes colliding keys onto it. Open addressing keeps everything in one flat array: if a key's home slot is taken, you probe to the next slot, and the next, until you find space or your key. Open addressing wins on speed in practice because it is cache-friendly: one contiguous array, no pointer chasing, no per-entry allocation. Python's dict, Go's map, and most high-performance tables are open-addressed variants.
It also has a sharp edge that everyone cuts themselves on exactly once, and the cut teaches a genuinely subtle lesson about deletion.
The setup: linear probing
Each slot is EMPTY, USED, or (foreshadowing) something else. Insert hashes the key to a home slot and walks forward until it finds the key or an empty slot. Lookup does the same walk and stops at the first empty slot, because an empty slot means "the probe chain ends here, your key is not present." That stop condition is what makes lookups fast: you do not scan the whole table, you scan until the chain runs out.
Now suppose three keys A, B, C all hash to slot 5. They land in 5, 6, 7. Everything works: looking up C starts at 5, sees A, sees B, sees C, done.
The trap: delete from the middle
Delete B. The obvious move is to set slot 6 back to EMPTY. Now look up C. The probe starts at slot 5 (A), moves to slot 6, sees EMPTY, and concludes "chain ended, C is not here." But C is here, in slot 7. By emptying a slot in the middle of a probe chain, you severed the chain and hid every key after it. Your delete did not remove one key, it silently lost several. This is the bug, and it is nasty because the table looks fine until a specific lookup quietly returns "not found" for a key that is sitting right there.
The fix: a tombstone
Deletion cannot leave an EMPTY slot, because EMPTY is the signal that ends a probe. So it leaves a third kind of marker, a tombstone (TOMB): "something was deleted here, but keep walking."
enum { SLOT_EMPTY = 0, SLOT_USED = 1, SLOT_TOMB = 2 };
bool hm_get(HashMap *m, int key, int *out) {
size_t h = hash(key) % m->cap;
for (size_t i = 0; i < m->cap; i++) {
Slot *s = &m->slots[(h + i) % m->cap];
if (s->state == SLOT_EMPTY) return false; // real end of chain
if (s->state == SLOT_USED && s->key == key) { *out = s->val; return true; }
// SLOT_TOMB: skip over it and keep probing
}
return false;
}
Lookup now treats a tombstone as "not my key, keep going," and only an EMPTY ends the search. The chain stays walkable. Delete B by marking slot 6 TOMB, and looking up C still finds it, because the probe steps over the tombstone instead of stopping at it.
The insert that has to be careful
Tombstones make insertion subtly clever. You want to reuse a tombstoned slot (otherwise deletions slowly fill the table with dead markers), but you also must not insert a duplicate. So insertion remembers the first tombstone it passed, yet keeps probing all the way to an EMPTY slot to be sure the key is not already present further down the chain. If it finds the key, it updates in place; if it reaches EMPTY, it inserts at that remembered tombstone:
// remember the first tombstone, but keep scanning for an existing key
if (s->state == SLOT_TOMB) { if (insert_at < 0) insert_at = idx; continue; }
if (s->state == SLOT_EMPTY) { /* key absent: place at insert_at or here */ break; }
if (s->state == SLOT_USED && s->key == key) { s->val = val; return; } // update
Get that logic right and the table reuses dead space and never inserts a duplicate. Get it wrong and you either leak slots or shadow keys.
The lessons that travel
- Deletion is the hard operation. Insert and lookup in open addressing are easy; delete is where the design lives or dies, because it has to preserve an invariant (probe chains stay intact) that the other operations rely on.
- A "deleted" state is not the same as an "empty" state. Conflating them is the root of the bug. Many systems carry a similar distinction (deleted-but-not-yet-reclaimed) for exactly this reason.
- Tombstones have a cost. They accumulate and slow probes down over time, which is why real tables periodically rehash to clear them out. Knowing they exist explains a performance cliff you might otherwise find mysterious.
A flat array, a hash, and a forward walk give you a beautifully fast map. The tombstone is the small, easy-to-miss piece that keeps it correct when keys come and go.
Build it yourself. Solve the Open-Addressing Hash Table challenge on barehands and get graded on correctness and speed. No libraries, just cc -std=c11.