dict internals
CPython’s dict is a hash table with open addressing. Since Python 3.7, dicts officially preserve insertion order. This file covers what matters for interviews and performance.
Layout (Python 3.6+)
A dict has two parts:
- A compact entries array: contiguous
(hash, key, value)triples in insertion order. - A sparse indices array: integer indices into the entries array, addressed by
hash(key) % table_size.
This layout (introduced in 3.6) saves memory compared to the older sparse-only design. It also gives ordering for free.
indices: [_, _, 0, _, 1, _, _, 2] ← sparse, by hash
entries: [(h1, "a", 1), (h2, "b", 2), (h3, "c", 3)] ← dense, in insertion order
Lookup algorithm (open addressing + perturbation)
To find key k:
- Compute
h = hash(k). - Index
i = h & (size - 1). - Look at
indices[i]. If empty, key not present. - If
indices[i] = j, fetchentries[j]. Compareentries[j].hash == handentries[j].key == k. - If both match, return value.
- If hash matches but key doesn’t (rare), continue probing — perturb the index using
i = (5*i + 1 + perturb) & maskand try again.
The perturbation function uses upper hash bits as the iteration progresses, ensuring good probe distribution.
Hash and equality contract
For a key to work in a dict (or set):
- It must be hashable —
hash(k)returns a stable int. a == bmust implyhash(a) == hash(b)(the key invariant).- Hash must not change for the lifetime of the object (immutable).
That’s why list, dict, set aren’t hashable: they’re mutable. Tuples are hashable if their contents are hashable.
User-defined classes inherit __hash__ from object (based on id). If you override __eq__, you must override __hash__ too — Python sets __hash__ = None when you define only __eq__, making the class unhashable.
When two keys hash to the same slot
Two distinct keys with the same hash are a collision. They go to different entries; the second probes to the next slot. If two keys are equal (==) and hashable, they collapse into one entry.
d = {1: "a", 1.0: "b", True: "c"}
print(d) # {1: 'c'} ← all three hash to the same value AND compare equal
Resizing
Dicts grow when load factor exceeds ~2/3. Resize doubles (or quadruples for small dicts) the capacity, rehashes all entries, and rebuilds the indices array. Cost: O(n) for that one insertion; amortized O(1).
Operations and complexity
| Operation | Average | Worst case |
|---|---|---|
Lookup d[k] |
O(1) | O(n) (pathological collisions) |
| Insert | O(1) amortized | O(n) on resize |
| Delete | O(1) | O(n) |
| Iteration | O(n) | O(n) |
len(d) |
O(1) | O(1) |
Worst case is rare in practice — Python’s hash function is randomized (PYTHONHASHSEED) to defeat adversarial inputs.
Why hash randomization exists
Until Python 3.2, string hashing was deterministic. An attacker who knew the algorithm could pre-compute thousands of strings that all hash to the same bucket. POSTing them as form fields, JSON keys, or HTTP headers would force any dict-backed parser into O(N²) collision-handling — a denial-of-service vector now known as a hash-collision DoS attack.
The 2011 disclosure (oCERT-2011-003 / CVE-2012-1150) showed this affecting Python, Ruby, Java, PHP, Node.js, and others. Python 3.3 made hash randomization the default: every Python process picks a random seed at startup, so the same string hashes to a different value across runs. PYTHONHASHSEED environment variable can pin the seed for reproducibility.
PYTHONHASHSEED=0 python -c 'print(hash("foo"))' # always the same
python -c 'print(hash("foo"))' # different each run (since 3.3)
This makes adversarial input ineffective — an attacker can’t pre-compute colliding keys without knowing the per-process seed.
Key consequences
- Insertion order preserved —
for k in diterates in insertion order. d.keys(),d.values(),d.items()are views, not copies. They reflect later mutations.- Modifying a dict during iteration raises
RuntimeError(in 3.x). Uselist(d)to snapshot keys first. - Set operations on views —
d1.keys() & d2.keys()does set intersection.
Memory: dict vs tuple vs slot class
For small fixed-key records, dicts are wasteful. Per dict, ~232 bytes empty + per-entry overhead. Compare:
| Storage | Bytes/instance (rough) | Notes |
|---|---|---|
dict |
~232 (empty) + ~96/entry | Most flexible, most expensive |
| Plain class (no slots) | ~48 + (~232 + ~96/attr) for __dict__ |
Same overhead as dict for the per-instance dict |
@dataclass (default) |
same as plain class | Generated methods, but instance still has __dict__ |
@dataclass(slots=True) (3.10+) |
~48 + 8/slot | Same as manual __slots__ |
| Slotted class | ~48 + 8/slot | No __dict__, fixed attribute set |
namedtuple |
~56 + 8/slot | Tuple-based, immutable, indexable |
NamedTuple (typing.NamedTuple) |
same as namedtuple | Type-annotated subclass |
tuple |
~56 + 8/element | No attribute names, position-only |
For 1M records, a slotted class can save 100MB+ over a dict. @dataclass(slots=True) gives you the ergonomics of dataclass with the memory profile of slots — usually the right choice for fixed-shape records at scale.
Interview angle
- “What’s the time complexity of dict lookup?” (O(1) average.)
- “Why do equal objects need equal hashes?” (Otherwise they end up in different buckets and the dict lookup fails.)
- “Are dicts ordered?” (Yes, since 3.7 officially; 3.6 unofficially.)
- “What happens if I mutate a key after inserting?” (Lookup fails — hash changed but bucket location is fixed. Use immutable keys.)
- “Two dicts with the same keys/values — are they
==?” (Yes —__eq__ignores order.)