The object model,
not the syntax.
You write Python every day, so this page is verification rather than learning. The goal is that every rule below stops being a rule you remember and becomes something you can rebuild on the spot from one fact about how objects are laid out in memory.
Why a list cannot be a dict key
The short answer people give is "because lists are mutable". That is true but it is not the mechanism, and an interviewer who is paying attention will ask the follow-up. The real answer is about where a dict physically puts things.
What a dict actually does with a key
A dict is an array of slots. When you write
d[k] = v, Python does four things in order:
- Compute
hash(k), a plain integer. -
Turn that integer into an index by masking off the low
bits:
i = hash(k) & (table_size - 1). -
Look at slot
i. If it is empty, store the key there and stop. If it holds something, compare: first the cached hashes, then==on the keys themselves. Equal means overwrite the value. Not equal means a collision, so probe to another slot and repeat. - Remember that slot number. The dict never recomputes it.
Point four is the whole ballgame. The key's position in the table is a function of its hash at the moment it was inserted. Nothing watches the key afterwards.
Suppose Python let you use a list as a key. What happens here?
# hypothetical: imagine lists were hashable
k = [1, 2]
d = {k: "hello"}
k.append(3)
print(d[k])
print(list(d.keys()))
d[k] raises KeyError, and
yet list(d.keys()) still shows
[[1, 2, 3]]. The key is visibly sitting
in the dict and is simultaneously unreachable.
Why: the entry was filed under slot
hash([1,2]) & mask. After the append,
the lookup computes
hash([1,2,3]) & mask, which is almost
certainly a different slot. Python looks in the new
slot, finds it empty, and reports the key missing. The
data is not corrupted, it is just filed under an
address nobody will ever look at again.
Iteration still finds it because iteration walks the storage array in order and never consults a hash at all.
The invariant this protects
Everything above collapses into a single contract that any hashable object must satisfy:
Ifa == bthenhash(a) == hash(b), and an object's hash must not change for as long as it lives.
Python cannot enforce the second half at runtime without
watching every mutation, which would be ruinously expensive.
So it enforces it structurally instead: the mutable built-in
containers simply do not implement __hash__.
list, dict, set and
bytearray set __hash__ = None, which
is what produces
TypeError: unhashable type: 'list'. The immutable
ones, including tuple and frozenset,
hash fine because their contents can never change.
A tuple is hashable only if everything inside it is.
hash((1, 2)) works,
hash((1, [2])) raises, because tuple's hash is
computed from the hashes of its elements. So "tuples are
hashable" is really "tuples are hashable when their contents
are".
The same rule bites on your own classes. Define
__eq__ and Python sets __hash__ to
None for you, because your new equality almost
certainly disagrees with the default identity-based hash and
would break the invariant silently. You have to opt back in by
defining __hash__ yourself, and it must be built
from the same fields your __eq__ compares. This
is exactly what @dataclass(frozen=True) does:
freeze the fields, then generate a matching hash.
Because the hash is the address. A hash table is fast for exactly one reason: it converts a key into a memory position arithmetically, so it can jump straight there instead of searching. If the address a key computes today differs from the address it computed at insert time, the jump lands in the wrong place and the table gives a wrong answer rather than a slow one.
Two reasons, and either alone would be fatal. First, the list has no idea it is being used as a key, so it cannot notify anyone. You would need every mutable object to carry a registry of every container that has ever hashed it, which is a per-object cost paid by every program whether it uses dicts or not.
Second, rehashing is O(n) in the table size. A single
append becoming a full table rebuild
would destroy the performance guarantee that justifies
using a dict at all.
Because the hash is a pure function of the object's contents, and if the contents provably cannot change then the output provably cannot change. There is nothing left to synchronise. The stability the table needs is guaranteed by construction rather than by discipline or by bookkeeping.
O(1) average, and what makes it degrade
"Dicts are O(1)" is an average over a probability distribution, not a guarantee. Knowing which assumptions that average rests on is the interesting part, because those assumptions are what a pathological workload violates.
The layout since Python 3.6
Modern CPython splits a dict into two arrays rather than one:
- A sparse index array, sized to a power of two, holding small integers. Most entries are the "empty" marker.
-
A dense entries array, holding
(hash, key, value)triples packed together in insertion order with no gaps.
A lookup masks the hash to get a position in the index array, reads the small integer there, and uses it to index into the dense array. That indirection buys two things at once. Memory drops sharply, because the sparse array that must stay mostly empty now holds 1-byte or 2-byte integers instead of full 24-byte triples. And insertion order falls out for free, because the dense array is only ever appended to.
Insertion order preservation was an implementation detail in 3.6 and became a documented language guarantee in 3.7. It is a side effect of a memory optimisation that was then promoted to a promise.
Collision handling
CPython uses open addressing, not chaining.
There are no linked lists hanging off buckets. When slot
i is taken by a different key, the probe
sequence moves to another slot in the same array using a
recurrence that mixes in the still-unused high bits of the
hash:
perturb = hash
j = hash & mask
while occupied(j) and not_our_key(j):
perturb >>= 5
j = (5*j + 1 + perturb) & mask
Feeding the high bits back in matters. Plain linear probing would send every key that collides in the low bits down the identical path, so one collision would make the next more likely, a feedback loop called primary clustering. Perturbing scatters colliding keys onto different paths and keeps clusters from feeding on each other.
Open addressing is chosen because it keeps everything in one contiguous allocation. A probe reads adjacent memory, which is cache friendly, whereas chaining would follow a pointer into a separately allocated node and very likely miss cache. That trade is the same one you will make again on the operating systems page.
Hash table probe visualizer
What actually degrades the average
| Cause | Effect | Do you meet it in practice? |
|---|---|---|
| High load factor | Probe sequences lengthen, more slots inspected per lookup | No. CPython resizes when the table passes roughly two thirds full, so occupancy is bounded by construction. |
| Resize | One insert costs O(n) because every entry is reinserted into a bigger table | Yes, but amortised. The table grows geometrically, so the total resize work across n inserts is O(n), giving O(1) per insert on average. |
Degenerate __hash__ |
Every key masks to the same slot, so the dict becomes a linear scan and lookups go to O(n) |
Yes, and this one is real. It is nearly always a
custom class whose __hash__ returns a
constant or ignores most of its fields.
|
| Adversarial keys | An attacker crafts inputs that all collide, turning an O(n) parse into O(n²) |
Mitigated. String and bytes hashing is salted per
process by PYTHONHASHSEED, so an attacker
cannot precompute colliding keys for your run.
|
Writing def __hash__(self): return 1 to make a
class "work" as a key is technically correct and satisfies
the invariant. It is also how you turn every dict holding
those objects into a linked list with extra steps. Legal,
and quietly quadratic.
Note also that the small-integer path is special:
hash(n) == n for ordinary ints. That is not a
flaw, since the mask then makes the low bits the slot, and
consecutive integers spread across consecutive slots
perfectly. But it means
{i * 8: ... for i in range(1000)} with a table
size that happens to be a multiple of 8 has more clustering
than you would naively expect, which is precisely why the
perturbation exists.
Because the expected probe count depends on the load factor and not on the number of entries. If the table is at most two thirds full, then any given slot is free with probability at least one third, so the expected number of slots you inspect before finding a free or matching one is a small constant near 2 or 3. Add a million more keys and the table grows to keep that ratio, so the constant does not move. A quantity that does not grow with n is what O(1) means.
The "one third of slots are free" step quietly assumes your key is equally likely to land in any slot. That is a statement about the hash function, not about the table. If all your keys hash to the same value then the free slots exist but are unreachable without walking the whole probe chain, and the expected probe count becomes proportional to the number of entries already there.
So O(1) is conditional on approximate hash uniformity. Built-in types provide it. Your classes provide it only if you write them that way.
Because doubling means the resizes happen at sizes 8, 16, 32, 64 and so on, and the cost of each is proportional to its size. Summing that series to reach n gives roughly 2n total work, since a geometric series is dominated by its last term. Spread over n insertions, that is a constant per insertion.
Grow by a fixed amount instead, say 8 slots at a time, and you resize n/8 times at average cost n/2, which is O(n²) total. The geometric growth is what makes the amortisation work.
Generators and lazy evaluation
A generator is not a lazily produced list. It is a function whose execution can be paused, and understanding it that way explains every behaviour it has, including the surprising ones.
What yield does to a function
When the compiler sees yield anywhere in a
function body, it flags the whole function as a generator
function. Calling it now does something quite different from
a normal call: it builds a generator object holding a
suspended stack frame and returns immediately.
None of the body has run. Not one line.
Each next() resumes that frame at the saved
instruction pointer, with local variables exactly as they
were, runs until it hits the next yield, hands
back the value, and suspends again. The frame is a
heap-allocated object that outlives any individual call,
which is why the locals survive across suspensions.
def gen():
print("starting")
yield 1
print("middle")
yield 2
g = gen() # prints nothing at all
next(g) # prints "starting", returns 1
next(g) # prints "middle", returns 2
next(g) # raises StopIteration
That first line explains a bug people hit constantly: validation written at the top of a generator function does not run when you call it. It runs when someone first iterates it, which may be somewhere else entirely, inside a different try block, or never.
Where the memory actually goes
Compare processing a large file two ways:
# eager: the whole file becomes a list of str objects
lines = f.readlines()
total = sum(len(l) for l in lines)
# lazy: one line alive at a time
total = sum(len(l) for l in f)
The eager version allocates one str object per line and a list of pointers to them, so peak memory is proportional to the file. The lazy version holds one line, sums into an int, and lets the previous line's refcount drop to zero so it is freed immediately. Peak memory is proportional to the longest line. For a 10 GB file on a 16 GB machine that is the difference between working and not working, and no amount of faster hardware changes the answer.
The saving is not about speed. Both versions do the same amount of per-line work and the generator version is often marginally slower per item because of the resume overhead. What you buy is a bounded working set.
When laziness is the wrong choice
This is the part people leave out, and it is what separates knowing the feature from knowing when to reach for it.
| Situation | Why lazy hurts |
|---|---|
| You need to traverse twice | A generator is exhausted after one pass. The second loop sees nothing, silently, with no error. If you need two passes you need a list, or you regenerate and pay the cost again. |
You need len() |
There is nothing to count without running to the end, which consumes it. Generators have no length. |
| You need indexing or slicing |
g[5] is a TypeError. You get
itertools.islice, which still walks from
the front.
|
| The data is small and hot | Per-item resume overhead is real. For a few thousand items that fit in cache, a list comprehension usually wins outright. |
| You are feeding NumPy or pandas | They want a contiguous buffer. Handing them a generator forces an internal materialisation anyway, so you paid the overhead and got none of the benefit. |
Generators capture variables by reference, not by value, and they run later. A generator built inside a loop that closes over the loop variable will see that variable's final value when it finally runs, which is the same late binding problem covered further down this page. Laziness and late binding compound.
A generator trades random access and reuse for a bounded working set, so reach for it when the data is larger than memory or when you may stop early, and reach for a list when you will touch the data more than once.
Shallow and deep copy on nested structures
Everything here follows from one fact that you already know but may not have pushed on: a Python container does not contain objects, it contains references to objects. A list of three lists is an array of three pointers.
Three levels of copying
import copy
original = [[1, 2], [3, 4]]
alias = original # no copy: same object
shallow = original[:] # new outer list, same inner lists
deep = copy.deepcopy(original) # new everything, recursively
Three arrows into one inner list is the entire bug surface of shallow copying.
original = [[1, 2], [3, 4]]
shallow = original[:]
shallow[0].append(99)
shallow.append([5, 6])
print(original)
[[1, 2, 99], [3, 4]]
The two statements look symmetric and are not.
shallow[0].append(99) follows the pointer
in slot 0 to the inner list, which
original also points at, and mutates that
shared object. Both names see it.
shallow.append([5, 6]) mutates the outer
list, which is genuinely a separate object, so
original is untouched.
The rule that makes this predictable: a shallow copy makes the top level independent and leaves every level below it shared. Depth 1 is yours, depth 2 and beyond is not.
What deepcopy has to solve
Recursively copying sounds trivial until you consider two structures that break naive recursion:
inner = [1, 2]
shared = [inner, inner] # same object twice
cyclic = [1, 2]
cyclic.append(cyclic) # contains itself
Naive recursion gives shared two distinct copies,
silently breaking the aliasing the original relied on, and
recurses forever on cyclic. So
deepcopy carries a
memo dict mapping
id(original_object) to its copy. Before copying
anything it checks the memo. A hit returns the existing copy
instead of making a new one.
That single mechanism handles both cases. Shared references stay shared in the copy, because the second visit finds the first copy in the memo. Cycles terminate, because by the time recursion comes back around to the outer object, that object is already in the memo, having been registered before its children were processed.
deepcopy is slow: it is a Python-level recursive walk that
touches every object and does a dict insert per object. It
also respects __deepcopy__ and the pickle
protocol, so classes can customise or cheapen it. For plain
nested data, json.loads(json.dumps(x)) is often
much faster, at the price of only supporting JSON types and
silently converting tuples into lists.
Immutable leaves never need copying, and deepcopy knows this: it returns ints, strs, floats and tuples-of-immutables as-is. So a deep copy of a nested list of numbers duplicates the lists and shares the numbers, which is both correct and what you want. Sharing is only dangerous when the shared thing can be mutated.
The mutable default argument
The famous one. Most people can recite the fix. The mechanism is a one-liner and it is worth being able to say precisely, because it also explains decorators and closures.
def add(item, bucket=[]):
bucket.append(item)
return bucket
print(add(1))
print(add(2))
print(add(3, []))
print(add(4))
[1], then [1, 2], then
[3], then [1, 2, 4].
Calls one, two and four all share one list. Call three passes its own, so it is unaffected and does not disturb the shared one either. Call four picks the shared list back up, still carrying 1 and 2.
Why
def is a statement that executes. When Python
runs it, it evaluates the default expressions
once, right there, and stores the resulting
objects on the function object itself:
def add(item, bucket=[]): ...
add.__defaults__ # ([],) one list object, stored on the function
id(add.__defaults__[0]) # the same id on every call, forever
A call that omits bucket does not re-evaluate
[]. It binds the parameter to the object already
sitting in __defaults__. Since a list is mutable
and append mutates in place, every such call is
editing the one object that lives on the function. The
function is carrying state it never asked for.
Immutable defaults are immune for the obvious reason:
def f(x=0) also stores one object, but nothing
can mutate 0, so sharing it is unobservable. The
trap is exactly and only about mutability, which puts it in
the same family as the hashability rule at the top of this
page.
The fix, and why None
def add(item, bucket=None):
if bucket is None:
bucket = [] # evaluated per call, in the body
bucket.append(item)
return bucket
Moving the [] into the body moves it from
def-time to call-time, which is the whole repair.
None is the sentinel because it is a singleton,
so is None is an identity check that cannot be
fooled by a user-supplied value that merely compares equal,
and because an empty list is falsy, which makes
if not bucket a subtly wrong test that would also
replace a deliberately passed empty list.
Def-time evaluation also explains why
def f(x=CONFIG['size']) freezes whatever
CONFIG held at import time, and why the
loop-variable capture bug in the next section is usually
fixed with def f(x, _v=v). Default evaluation
is early. Body evaluation and closure lookup are late.
Almost every surprise in this area is that one boundary.
Closures and late binding
The counterpart to the previous section. Defaults are evaluated too early to be intuitive. Closures are resolved too late.
fns = []
for i in range(3):
fns.append(lambda: i)
print([f() for f in fns])
[2, 2, 2], not [0, 1, 2].
All three lambdas close over the same variable
i, not over three snapshots of its value.
By the time any of them is called, the loop has
finished and i holds 2. Each lambda then
reads 2.
Note the second, quieter fact: a for loop
does not create a scope in Python, so
i is one variable in the enclosing
function for the whole loop and still exists after it
ends.
What a closure physically is
When a nested function refers to a name from an enclosing function, the compiler cannot store that name as an ordinary local in either frame, because the inner function may outlive the outer one. So it puts the variable in a cell, a tiny heap object with one slot, and both functions reference the cell rather than the value.
def outer():
x = 10
def inner():
return x
return inner
f = outer()
f.__closure__ # (<cell at 0x...: int object at 0x...>,)
f.__closure__[0].cell_contents # 10
f.__code__.co_freevars # ('x',)
The cell is read when the inner function runs, not when it is defined. That is what "late binding" means, stated concretely. In the loop example all three lambdas hold the same cell, the loop writes 0, 1 then 2 into that one cell, and all three later read the final contents.
Two fixes, and why each works
# 1. default argument: binds at def time, one per lambda
fns = [lambda v=i: v for i in range(3)]
# 2. a real enclosing scope per iteration: a fresh cell each call
def make(v):
return lambda: v
fns = [make(i) for i in range(3)]
# 3. same idea, spelled with the stdlib
from functools import partial
fns = [partial(lambda v: v, i) for i in range(3)]
Fix one converts a late-bound free variable into an early-bound default, evaluated once per lambda at the moment of definition, which is exactly the def-time behaviour that made the previous section a trap. The same mechanism, used deliberately, is the cure here.
Fix two gives each iteration its own make frame,
so each lambda gets its own cell holding its own value. This
is the version to prefer in real code, because it says what it
means and does not put a decoy parameter in the signature.
Reading an enclosing name works automatically. Assigning to
it makes the name local to the inner function unless you say
nonlocal x, which tells the compiler to keep
using the enclosing cell. global is the same
idea aimed at module scope. Both exist because assignment,
not reading, is what determines a name's scope in Python.
Callbacks and deferred work: building handlers in a loop, registering retry functions, or building a list of generators. Anything defined in a loop and called after the loop has the bug. Since it only shows up when the call is deferred, the immediate-call version works fine in testing and the deferred version fails in production.
Because names in Python are bindings in a namespace, not storage locations with values baked into compiled code. A function body is compiled once, and the compiler records "this name comes from the enclosing scope", not "this name is currently 3". Resolution has to happen at call time because that is the only time you know what the namespace holds.
Capturing by value would also break mutual recursion and any pattern where a nested function is defined before the value it needs is assigned, both of which are common and useful.
Because Python's scopes are function-level, not
block-level. Only def,
lambda, class and module
bodies introduce a namespace. A
for statement introduces none, so
i is a single local of the enclosing
function that the loop rebinds on each pass, which is
also why it is still readable after the loop ends.
Languages where the loop-in-a-closure case does what
you expect, such as modern JavaScript with
let, get that by creating a fresh binding
per iteration. Python does not, so you create the
fresh binding yourself with a function call.
for loop creates
exactly one variable. One cell shared by every
function you build in the loop is therefore the only
possible outcome, and any fix must work by
manufacturing a separate binding per iteration.
Decorators, context managers, and the standard library shelf
Depth calibration drops here. You do not need to derive these from first principles, you need to recognise the situation and reach for the right one without hesitating.
Decorators
A decorator is a function that takes a function and returns
a replacement. The @ syntax is pure sugar:
@d above def f means
f = d(f) after the def runs.
import functools, time
def timed(fn):
@functools.wraps(fn) # copies __name__, __doc__, __wrapped__
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
try:
return fn(*args, **kwargs)
finally: # still times a call that raised
print(fn.__name__, time.perf_counter() - t0)
return wrapper
functools.wraps is not cosmetic. Without it the
decorated function reports the wrapper's name, loses its
docstring and type hints, and breaks anything that
introspects it, which includes pytest fixtures, Sphinx,
dataclasses and most serialisation. Always include it.
A decorator that takes arguments needs one more layer,
because @retry(3) means "call
retry(3), then apply whatever it returns as the
decorator":
def retry(n):
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
for attempt in range(n):
try:
return fn(*a, **kw)
except Exception:
if attempt == n - 1: raise
return wrapper
return deco
Context managers
The point of with is that cleanup happens on
every exit path, including exceptions,
return and break. Two ways to
write one:
class Timer:
def __enter__(self):
self.t0 = time.perf_counter()
return self # this is what "as t" receives
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.t0
return False # False propagates the exception, True swallows it
from contextlib import contextmanager
@contextmanager
def timer():
t0 = time.perf_counter()
try:
yield # everything before is __enter__
finally:
print(time.perf_counter() - t0) # after is __exit__
Two details worth having ready. The
try/finally in the generator form is mandatory,
because an exception in the body is thrown back in at the
yield, and without finally your
cleanup is skipped. And returning a truthy value from
__exit__ suppresses the exception, which is how
contextlib.suppress works and is a thing you
should almost never do by hand.
Argument forms
def f(pos, /, normal, *args, kwonly, **kwargs):
...
# ^ ^ ^ ^ ^
# | | | | collects extra keywords into a dict
# | | | must be passed by name (after * )
# | | collects extra positionals into a tuple
# | either position or name
# positional only (before / ), name is not part of the API
At a call site * and ** unpack
rather than collect: f(*seq, **mapping) spreads
a sequence into positionals and a mapping into keywords.
Positional-only parameters, marked by /, let
you rename a parameter later without breaking callers, which
is why the C-implemented builtins use them heavily.
Comprehensions
squares = [x*x for x in xs if x > 0] # list
uniq = {x.id for x in xs} # set
by_id = {x.id: x for x in xs} # dict
lazy = (x*x for x in xs) # generator, evaluates nothing yet
flat = [y for row in grid for y in row] # nested: outer loop first, left to right
A comprehension has its own scope, so its loop variable does
not leak into the enclosing function. That has been true for
list comprehensions since Python 3 and is why the
loop-variable-after-the-loop trick works for
for statements but not for comprehensions.
The shelf: what to reach for, and when
| Tool | Reach for it when | The cost it removes |
|---|---|---|
collections.deque |
You push and pop at both ends, or you need a
fixed-size sliding window via maxlen
|
list.pop(0) is O(n) because it shifts
every element. deque is O(1) at both ends.
|
collections.Counter |
Counting occurrences, then wanting the top k |
Hand-rolled count dicts, plus
most_common(k) uses a heap internally.
|
collections.defaultdict |
Grouping into lists or sets keyed by something |
The setdefault dance and the branch on
first sight of a key.
|
heapq |
You need the smallest item repeatedly but not a full sort, or a streaming top-k over more data than fits in memory |
Re-sorting on every insert. Push and pop are O(log
n), peeking at h[0] is O(1). Min-heap
only, so negate for a max-heap.
|
bisect |
Lookups into an already-sorted list, bucketing a
value into ranges, or maintaining sorted order with
insort
|
Linear scans. bisect_left is O(log n).
Note insort is still O(n) because of
the shift, so it is for reads far outnumbering
writes.
|
functools.lru_cache |
A pure function with repeated arguments, especially a recursive one |
Recomputation. Requires hashable arguments, which
brings you straight back to section one.
cache is the unbounded version, and
cache_clear() matters in tests.
|
lru_cache keeps strong references to
arguments and results, so caching a method keeps every
self it ever saw alive for the life of the
process. That is a genuine leak in a long-running service,
and it is why cached_property exists for the
per-instance case.
Complexity, stated without being asked
The goal is not to recite this table. It is that when you say "I would use a set here" the reason arrives in the same breath, unprompted. Use quiz mode until the numbers come before the thought.
Complexity reflex table
| Structure | Operation | Average | Worst | Why |
|---|
A list is a contiguous array of pointers, so indexing is constant but inserting or deleting anywhere except the end shifts everything after it. A dict or set is a hash table, so membership is constant on average but only because the hash spreads keys evenly. A deque is a doubly linked list of blocks, so both ends are constant and the middle is not.
Almost every complexity question about Python containers is
answerable from those three structural facts.
x in some_list is O(n) because there is nothing
to do but scan. x in some_set is O(1) because
there is an address to compute.
list.insert(0, x) is O(n) because the array must
shift. deque.appendleft(x) is O(1) because there
is no array to shift. You are not remembering a table, you are
reading the layout.
Know the name, move on
Nobody is testing these on an intern. Knowing what each word means is enough to keep a conversation moving if one comes up.
__dict__, cutting memory
substantially for many small objects. The cost is that you
can no longer add attributes at runtime.
type is the default one.
__get__ or
__set__ that, when stored as a class
attribute, intercepts attribute access on instances. It is
the machinery behind property,
classmethod and every ORM field.