skip to content

Why can a hash map's iteration order change after an insert that touched no existing entry?

level: middleimportance: should knowfreq 58%

answer

  1. what changes when the table grows?
  2. capacity is an input to placement
  3. hash is fixed, the reduction is not
  4. every key is re-placed on resize
  5. the sweep follows the new slots

basics

~20 s

Crossing the load-factor threshold triggers a resize: capacity doubles and every key is re-placed by its hash reduced under the new capacity. Entries land in different slots, so the bucket-order sweep emits them in a new sequence even though no entry was edited.

solid answer

~40 s

A key's slot is `hash(key)` reduced by the *current* capacity, so capacity is an input to placement for every key in the table. When an insert pushes occupancy past the load-factor threshold, the table allocates a larger slot array and re-places every entry under the new modulus or mask. Keys that shared a slot may split apart, keys that were far apart may become neighbours, and the left-to-right sweep therefore produces a different sequence. Nothing was modified in the entries themselves — one insertion changed the shape of the container. This is why a report generated by iterating a map can silently reorder itself between two runs whose only difference is a few more rows of input, and why order-sensitive consumers must be fixed rather than the map pinned.

code

pseudocode · 15 lines
pseudocode
// iteration: sweep slots in index order, chain by chain
for b in 0..capacity-1
    node = buckets[b]
    while node != NIL
        visit(node.key)
        node = node.next
// growth: capacity doubles, every key gets a new slot
newBuckets = array of 2 * capacity empty slots
for b in 0..capacity-1
    node = buckets[b]
    while node != NIL
        j = hash(node.key) mod (2 * capacity)
        move node into newBuckets[j]
        node = node.next
...

go deeper

for a junior

Remember that a hash map can reorder itself when it grows, and that growth is triggered by how full it is, not by anything you did to existing entries. Never assert on the order of items read out of one.

for a middle

Be ready to derive it: slot equals the hash reduced by the current capacity, growth changes the capacity, so every key can move and the left-to-right sweep changes. Mention that the triggering insert itself costs linear work.

for a senior

Diagnose it from symptoms. A serialized artifact that reorders after the data set grows, with no code change, points straight at a resize; show that you check size against the load-factor threshold and fix the producer or the consumer rather than the environment.

for a principal

Frame the exposure: this bug fires on data volume, so it skips review and small-fixture staging and lands in production or audit. Decide where your systems make order explicit — sorted at serialization boundaries, order-insensitive comparisons in tests — and make that the default.

## The placement rule has two inputs, not one People remember "a key's position comes from its hash" and stop there. The rule is really `slot = reduce(hash(key), capacity)` — usually `hash mod capacity`, or a mask of the low bits when capacity is a power of two. The hash of a key is fixed for the life of that key. The capacity is not. So every change of capacity is a change of address for potentially every key in the table, without a single key being modified. ## When capacity changes Hash tables keep occupancy below a load factor — commonly somewhere around 0.7 for open addressing, often near 0.75 for chaining — because performance degrades as the table fills: chains lengthen, probe sequences lengthen. When an insertion pushes `size / capacity` past that threshold, the table grows, typically by doubling, and re-places every entry. That re-placement is the operation that makes insertion *amortized* expected `O(1)` rather than plain `O(1)`: the individual insert that triggers growth does `O(n)` work. The fragment below shows both halves — the sweep that produces iteration order, and the growth step that rewrites the slot every key sits in. ## Why the emitted sequence moves Under capacity 16, two keys whose hashes are 5 and 21 both reduce to slot 5 and share a chain. Under capacity 32 they reduce to 5 and 21 — different slots, now separated by sixteen positions in the sweep, and with other keys interleaved between them. Meanwhile a key that was at slot 15 (late in the sweep) may still be at slot 15, which is now early in a 32-slot sweep. The relative order of essentially the whole table can change. Under open addressing the effect is the same for a different reason: probe sequences are computed against capacity, so which key won a contested slot and which got pushed forward can invert. There is a second, smaller source of movement even without growth. Within one chain, an implementation may prepend new entries (making the newest key appear first) or append them (oldest first); a bucket that overflows into a different internal representation reorders its own contents. Deletions in open-addressing tables leave tombstones and change where later insertions settle. None of this is visible from the outside, and none of it is promised. ## The failure this produces in real systems The damaging version is not a crash. Consider a billing export that writes one line per line-item by iterating a map keyed by item identifier, archived monthly and diffed against the previous month for audit. For a year the account has fewer items than the resize threshold, and the export's line order is stable enough that reviewers come to expect it. One month the account crosses that threshold: the table doubles, the export emits the same content in a different sequence, and the diff shows the entire file as changed. Now someone must prove that nothing substantive changed — the cost of the bug is investigator hours, not downtime, and it recurs at every subsequent threshold. Note the shape of the trigger: the input that changed was *volume*, not code and not the map's contents. That is why this class of bug survives code review and skips staging, where the fixture data set is small. ## "Then I'll pre-size it" Pre-sizing the table so it never grows removes the most common perturbation, and it is a legitimate performance optimisation. It is not a fix for an ordering dependency. The order is still a byproduct of hashing under a specific implementation; an upgrade that changes the growth policy, the reduction step, the chain insertion end, or the overflow representation moves it again, and some implementations deliberately vary it between processes. Pre-sizing converts a bug that fires this quarter into one that fires after an upgrade, which is worse, because by then nobody remembers the assumption. Different runtimes have made visibly different choices here, which underlines that none of it is portable: Go deliberately randomizes where map iteration starts, while Python's dictionaries specify insertion order and keep it across resizes — so the same "iterate and write" code is deterministic in one and deliberately not in the other. ## The correct fixes Impose the order where the order matters: sort keys at the serialization boundary, or hold the data in an insertion-order-preserving structure when arrival sequence is part of the meaning. And make the consumer honest — a diff or an assertion that compares parsed structures rather than raw text stops caring about the order entirely, which is usually the cheapest correct answer.

  • If I pre-size the table so it never grows, is the iteration order then safe to depend on?
    No. You have removed the most frequent perturbation, not created a guarantee. The order still depends on the reduction step, the collision-handling details and the chain insertion end, all of which an implementation may change on upgrade, and some deliberately vary per process. Pre-sizing is a fine performance choice and a bad correctness argument.
  • Does deleting an entry also perturb the order of the remaining ones?
    With chaining, a deletion removes one node and leaves the others where they are, so the surviving sequence usually holds. With open addressing it is riskier: deletions leave tombstones, and subsequent insertions can settle into different probe positions than they would have, so later entries can appear at different points in the sweep. Again, neither behaviour is promised.
  • What is the cost of the individual insert that triggers the resize?
    `O(n)` — it allocates the larger slot array and re-places every existing entry. Doubling makes the total re-placement work across `n` insertions a geometric series that sums to `O(n)`, which is why insertion is quoted as amortized expected `O(1)`. That word amortized covers exactly this: one operation in the sequence is slow, and the average over the worst-case sequence is constant.

Doubling the coat-check rack means renumbering every peg, so the attendant's left-to-right sweep collects the same coats in a completely different sequence.

saying these in an interview costs you the question

  • Says order is stable as long as nothing is removed
  • Thinks a resize just reallocates and keeps slot positions
  • Believes the key's hash value itself changes on resize
  • Treats pre-sizing as an ordering guarantee
  • Assumes only the newly inserted key moves

context