skip to content

questions

4

Why does a Python set of strings iterate in a different order on each run?

level: juniorimportance: must knowfreq 45%

answer

  1. The order was never part of the contract
  2. It differs between processes, not within one
  3. A per-process salt on text hashing
  4. Set walks slots; dict walks insertions
  5. sorted() at the output boundary

basics

~20 s

CPython salts str and bytes hashes with a seed picked at interpreter start-up, so the same strings land in different slots of the set's hash table each run. Set iteration follows slot order, so the order changes.

solid answer

~50 s

Since Python 3.3, the hash of a `str` or `bytes` object is salted with a random seed generated once per interpreter process, so `hash('ab')` differs between two runs of the same script. A `set` keeps its members in a hash table and iterates that table in slot order, so a different salt gives a different order; the set is still correct, only its order was never specified. Contrast `dict`: since Python 3.7 dictionaries iterate in insertion order as a language guarantee, so the seed does not reorder them, while `set` has never promised any order. The fix is to stop depending on set order — wrap it in `sorted()` where you need a deterministic sequence, and compare sets to sets rather than comparing lists built from them. Pinning `PYTHONHASHSEED` hides the bug instead of fixing it.

code

python · 7 lines
python
import subprocess
import sys

code = "s = {'alpha', 'beta', 'gamma', 'delta'}; print(list(s), sorted(s))"
for _ in range(3):
    done = subprocess.run([sys.executable, '-c', code], capture_output=True, text=True)
    print(done.stdout.strip())

go deeper

for a junior

Recall that a set's iteration order is not guaranteed and can differ between runs of the same script. When you need a fixed order, call sorted() on it; when you only need contents, compare the sets directly with ==.

for a middle

Be ready to explain the mechanism: a random salt mixed into str and bytes hashing, chosen once per interpreter process, changes slot placement. Add the contrast that dict has iterated in insertion order since 3.7 while set never promised an order.

for a senior

Show how you keep this class of bug out of production: deterministic ordering imposed where output is written, tests that assert on sets or on sorted lists, and a refusal to pin PYTHONHASHSEED in CI to silence an ordering assumption that still lives in the code.

for a principal

Own the policy that determinism belongs in the code that emits artefacts, not in an environment variable. Say where the codebase requires canonical ordering — snapshots, exports, shard keys — and where unordered comparison is the honest contract.

## What is actually varying A `set` is a hash table. When you add a member, CPython computes `hash(member)`, masks the result down to a slot index in the table, and stores the object there, probing to another slot when that one is taken. Iterating the set walks the table from the first slot to the last and yields whatever it finds. The order you see is therefore a function of the members' hash values, the current table size and the insertion history — never of the order in which you wrote them. For `str` and `bytes`, the hash value is not a pure function of the characters. CPython mixes in a secret salt that is drawn from the operating system's random source once, during interpreter start-up, before any of your code runs. Two runs of the same script get two different salts, so `hash('ab')` differs between them, the members land in different slots, and iteration yields a different order. Within a single process the salt never changes, which is why the order is perfectly stable while the program runs and only appears to move between runs. ## Why the salt exists Before Python 3.3, string hashing was a fixed, published function. An attacker could precompute thousands of distinct strings that all hash to the same value and post them as form fields or JSON object keys. Every one of them collided in the receiving dictionary, and insertion degraded from constant time to linear per key — quadratic overall — so a single small request could pin a CPU core. Randomizing the seed per process means the attacker cannot precompute a colliding set, because the function they would have to collide against is not known outside that process. Randomization has been on by default since 3.3; `PYTHONHASHSEED` is the knob that turns it off or pins it. ## What does not vary Two things are commonly confused here. First, `dict` iteration order. Since Python 3.7 it is a language guarantee that a dictionary iterates in insertion order (it was already true as a CPython implementation detail in 3.6). A dict keeps a compact, insertion-ordered array of entries alongside its index table, and iteration walks that array, so the hash seed never reorders it. `{'b': 1, 'a': 2}` yields `b` then `a` in every run. `set` has no such array and makes no such promise. Second, numeric hashes. Integers and floats are not salted at all: `hash(1)` is `1` in every process. A set of small integers will therefore often look stable across runs — which is exactly the trap, because the same code with string members is not. ## The bug this produces The classic shape is a test that asserts `list(result_set) == ['alpha', 'beta', 'gamma']`. It passes on your machine, passes the next twenty CI runs, and then fails on a run whose seed happened to place `beta` first. The same shape appears in report generation that writes one line per member of a set, in golden-file comparisons, and anywhere a set is serialized without an explicit ordering. Because the trigger is a random seed rather than a code path, the failure is intermittent and does not reproduce on re-run, which is why it burns disproportionate debugging time. ## The fixes Decide what you actually need. If you need to compare contents, compare the sets themselves: `assert result_set == {'alpha', 'beta', 'gamma'}`. Set equality ignores order entirely, so the assertion is both correct and stronger than the list version. If you need a sequence — output written to a file, a UI list, anything a human or a diff will read — impose the order yourself with `sorted(result_set)`, or `sorted(result_set, key=...)` when the natural sort is not the one you want. For JSON output, `json.dumps(obj, sort_keys=True)` gives you a canonical form. Making the order explicit is a one-line change and it documents the intent. What you should not do is set `PYTHONHASHSEED=0` in CI so the flaky assertion goes quiet. That freezes one arbitrary order into your test suite, leaves the ordering assumption in the production code where nothing pins the seed, and — if the same setting ever reaches a service that hashes untrusted keys — removes the collision defence the randomization was added for. Pinning the seed is a debugging tool for reproducing one specific failing run, not a fix. ## Adjacent traps `frozenset` behaves exactly like `set` here. So do set operations on dictionary views: `d.keys() - other` returns a set, so its order is unspecified even though `d` itself iterates in insertion order. And `repr()` of a set is order-dependent, which is why a doctest that prints a set of strings is quietly broken.

  • Does the same effect change the iteration order of a dict?
    No. Since Python 3.7 dict iteration order is a language guarantee: entries come back in insertion order regardless of the hash seed, because the dict walks a compact insertion-ordered entry array rather than its index table. The seed still decides internal slot placement and lookup probing, but not what you see. `set` makes no such promise, so `set(d)` or `d.keys() - other` can still reorder between runs.
  • Two processes compute hash('ACCT-1'). Can you rely on the values matching?
    No, unless both were started with the same `PYTHONHASHSEED`, which is not something to depend on. `hash()` is defined only within one interpreter process. Anything that must agree across processes, machines or restarts — a shard number, a cache key, a filename, a value written to a database — needs a stable function such as `hashlib.blake2b` or `zlib.crc32` over the encoded bytes instead.
  • Within a single run, is set iteration order at least repeatable?
    Yes. The salt is fixed for the life of the process, so iterating the same unmodified set twice yields the same order. That is precisely what makes the bug hard to catch: the assumption looks sound in every local run and only breaks when a new process draws a different salt.

The salt is like reshuffling the shelf labels in a warehouse each morning: every box is still findable by its label, but walking the aisles top to bottom hands them to you in a new order.

saying these in an interview costs you the question

  • Says sets are unordered because iteration picks members at random
  • Claims dict iteration order also changes between runs
  • Fixes a flaky ordering test by pinning PYTHONHASHSEED in CI
  • Believes the salt is re-drawn during a running process
  • Assumes hash() values are stable across machines or restarts
  • Calls sorted(some_set) a set rather than a list

context

open as a page

What does the PYTHONHASHSEED environment variable control in CPython?

level: middleimportance: should knowfreq 38%

basics

~20 s

PYTHONHASHSEED sets the salt CPython mixes into str and bytes hashing. Left unset, a fresh random salt is drawn per process; an integer from 0 to 4294967295 pins it; the value 0 disables randomization entirely.

open as a page

A payment reconciliation job shards accounts with hash(account_id) % 8 — why does it lose rows?

level: seniorimportance: should knowfreq 34%

basics

~20 s

Because hash() of a string is salted per interpreter process. Each worker and each restarted run maps the same account to a different shard, so rows written under one shard are searched for under another. Use hashlib.blake2b instead.

open as a page

Which Python built-in types have randomized hashes, and which stay stable?

level: middleimportance: nice to knowfreq 22%

basics

~20 s

Only byte-oriented values are salted: str, bytes, read-only memoryview, and types whose hash derives from their bytes, such as datetime objects. Numbers are not — hash(1) is 1 in every process. Containers inherit salting from their elements.

open as a page