skip to content

What does the PYTHONHASHSEED environment variable control in CPython?

level: middleimportance: should knowfreq 38%

answer

  1. Read once, before your code runs
  2. Three settings, one of them switches it off
  3. The reason is a denial-of-service, not secrecy
  4. Legitimate for reproducing, not for silencing
  5. os.environ at runtime is too late

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.

solid answer

~50 s

`PYTHONHASHSEED` is read once, during interpreter start-up, before any user code runs — assigning to `os.environ` at runtime has no effect on the current process. Unset or `random` means a salt drawn from the OS random source; an integer from 0 to 4294967295 makes hashing reproducible across runs; the special value 0 turns randomization off. Randomization exists to defend against a hash-collision denial-of-service: with a published hash function an attacker can precompute many distinct strings that collide, so a request full of such keys turns dictionary insertion quadratic. A per-process secret makes those collisions impossible to precompute. Pinning is legitimate for reproducing a specific failing run, for bit-reproducible build output, or when debugging; it is not a fix for order-dependent code, and it should never be pinned in a service that hashes untrusted input. Note that `-E` and `-I` ignore environment variables, so the seed goes back to random under them.

code

python · 14 lines
python
import os
import subprocess
import sys

code = "print(hash('ACCT-4417'))"

print('random salt:')
for _ in range(2):
    print(subprocess.run([sys.executable, '-c', code], capture_output=True, text=True).stdout.strip())

print('pinned salt:')
pinned = dict(os.environ, PYTHONHASHSEED='0')
for _ in range(2):
    print(subprocess.run([sys.executable, '-c', code], env=pinned, capture_output=True, text=True).stdout.strip())

go deeper

for a junior

Know that the variable exists, that leaving it unset is normal, and that it controls whether string hashing is randomized. You are not expected to configure it, only to avoid writing code whose result depends on it.

for a middle

Explain the three settings, that the value is read at interpreter start-up before your code runs, and the collision denial-of-service it defends against. Be able to demonstrate a pinned run with a subprocess and an explicit environment.

for a senior

Demonstrate judgement about where pinning belongs: a local reproduction or a build step, never a service parsing untrusted keys and never a CI-wide cure for a flaky ordering assertion. Mention that isolated start-up modes ignore the variable entirely.

for a principal

Own the reproducibility policy: which artefacts must be byte-identical, and whether that guarantee lives in the code that serializes them or in the launcher's environment. Weigh the availability risk of disabling randomization anywhere it could be inherited fleet-wide.

## What the variable does `PYTHONHASHSEED` controls the secret salt that CPython mixes into the hash of `str` and `bytes` objects. It has three meaningful settings. Unset, or set to the literal string `random`, is the default: at start-up the interpreter asks the operating system for random bytes and derives a salt from them. Every process gets its own, so `hash('ab')` differs between two runs of the same script. Set to an integer in the range 0 through 4294967295, the salt is derived from that integer instead. Two processes started with the same value produce identical string hashes, so set iteration order, `repr()` of a set, and anything else that depends on hashing becomes reproducible. Set to 0 specifically, randomization is switched off — the hash function becomes the plain unsalted one, exactly the pre-3.3 behaviour. The variable is consumed during interpreter initialization, before the first line of your module executes. That has a practical consequence people trip over: writing `os.environ['PYTHONHASHSEED'] = '0'` at the top of a script changes nothing for the process doing the writing. It only affects child processes you launch afterwards, which is why reproduction scripts re-exec the interpreter or pass an explicit `env` to `subprocess.run`. ## Why randomization was added The reason is a denial-of-service class, not a cryptographic one. Before Python 3.3, string hashing was a fixed and documented function, so anyone could compute offline a large family of distinct strings sharing one hash value. Feed those as form field names, query parameters or JSON object keys and every single one collides in the dictionary that receives them. Insertion, normally constant time, walks a growing chain of colliding entries, so building a dict of n such keys costs on the order of n squared comparisons. A request measured in kilobytes could burn a CPU core for seconds. The same trick worked across most languages of the era and was disclosed against them together. A per-process secret salt breaks the precomputation: the attacker would have to collide against a function whose key they cannot observe. It is a mitigation rather than a proof — the salt is not a strong secret and hash values are sometimes observable indirectly — but it removes the cheap offline attack, which is what mattered. `sys.hash_info` reports which algorithm is in use; on Python 3.14 that is `siphash13`, the default since 3.11, and SipHash-2-4 was the default from 3.4 under PEP 456. ## When pinning is reasonable There are honest uses. Reproducing a failure. A CI job failed with an ordering-dependent assertion. If the run logged its seed, or you sweep a few values, you can pin it locally and get the same order back to debug it. That is a diagnostic, and it ends when the diagnosis does. Byte-reproducible output. A build step that serializes a set, emits generated code, or hashes a directory tree may need identical bytes on every machine. Pinning the seed is one way to get that, though imposing an explicit sort in the generator is a better one, because it survives an interpreter upgrade and it does not depend on how the process was launched. Benchmarks and fuzzing harnesses, where you want the hash layout held constant so a measurement or a crash is repeatable. ## When pinning is the wrong answer The common misuse is putting `PYTHONHASHSEED=0` into a CI configuration because a test that compares `list(some_set)` to a fixed list keeps flaking. That converts a visible bug into an invisible one. The production process is still launched without the variable, so it still gets a random salt, and the ordering assumption the test was supposed to catch now ships. Fix the assertion instead: compare sets, or sort. The second misuse is any service that builds dictionaries out of untrusted keys — a request parser, a JSON API, a header map. Disabling randomization there restores exactly the attack surface it was introduced to close. If a deployment sets the variable globally, every process inherits it, including ones nobody thought about. ## Interactions worth knowing Isolated and ignore-environment startup modes deliberately drop environment variables, so a process launched that way will not honour a pinned seed and falls back to a random one. Child processes matter too: a child started with the `spawn` or `forkserver` start method is a fresh interpreter that draws its own salt, while a `fork`ed child inherits the parent's memory and therefore the parent's salt. Since Python 3.14 the default start method on Unix platforms other than macOS is `forkserver`, so code that silently relied on parent and child agreeing on `hash()` can start behaving differently after an upgrade. Finally, pinning the seed does not make `hash()` a stable identifier. It is still specific to the interpreter version, the build's word size and the algorithm in force. Nothing durable — a shard number, a cache key, a filename — should be derived from it; use `hashlib` or `zlib.crc32` for that.

  • Why does setting os.environ['PYTHONHASHSEED'] at the top of a script not change its own hashes?
    The variable is consumed during interpreter initialization, before your module is imported, so the salt is already fixed by the time the assignment runs. It affects only child processes launched afterwards that inherit the modified environment. To make the current program reproducible you must set it before the interpreter starts, or re-exec the interpreter with the value in place.
  • Is hash randomization a security feature you can rely on to protect secrets?
    No. It is availability hardening against precomputed collision floods, not confidentiality. The salt is not a strong secret, and hash values can leak indirectly through observable ordering or timing. Never use `hash()` for authentication, integrity or password handling; use `hashlib` and `hmac` for that. Treat randomization as one mitigation among rate limits and input-size caps.
  • A deployment sets PYTHONHASHSEED=0 fleet-wide for reproducibility. What do you push back on?
    That it re-enables the collision denial-of-service for every process that builds a dictionary from untrusted keys — request parsers, JSON handlers, header maps — and that the reproducibility it buys is fragile, since it depends on the launcher rather than the code. Sort at the point of serialization instead, which keeps output canonical without weakening any process.

saying these in an interview costs you the question

  • Thinks the seed protects secrets or is cryptographic
  • Sets it inside the script and expects the current process to change
  • Recommends pinning it fleet-wide for reproducible output
  • Confuses it with the random module's seed
  • Cannot say what the value 0 does differently from an unset variable
  • Believes a pinned seed makes hash() stable across versions

context