Read it, say what it prints, then check. Drawn from the language corners that trip people who use Python every day — shared mutable state, scope rules that differ from every other language you know, laziness that only bites on the second pass, and equality that is not quite what it looks like.
One object, several names. Almost every surprising mutation traces back here.
Scope & binding
4
Where a name lives, when it is bound, and which block gets its own scope.
Lazy iteration
4
Iterators move forward and never back — including when someone else moved them.
Objects, equality & attributes
3
What `==` means for your class, and where an attribute is actually stored.
Numbers, strings & time
5
The arithmetic and ordering that quietly disagree with your intuition.
Evergreen · asked verbatim
3
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation — because the two fail separately, and a candidate who can debug a pool can still stall on "what does @dataclass generate".
01 / 23
OOP & dataclassesData structures & complexity
Two instances, one list declared in the class body. What does each of the five lines print?
The code — predict the output before reading on
class Job:
tags = [] # on the class, not the instance
def add(self, tag):
self.tags.append(tag)
a, b = Job(), Job()
a.add("extract")
print("b.tags:", b.tags)
print("same object:", a.tags is b.tags is Job.tags)
b.tags = ["own"] # assignment makes an instance attribute
b.add("load")
print("a.tags:", a.tags)
print("b.tags:", b.tags)
print("Job.tags:", Job.tags)
Why they ask this
It tests attribute lookup and the read/write asymmetry in one snippet, and it is a real bug: state that leaks between objects that were supposed to be independent.
Say this
The list is created once, on the class, so `a.add` mutates the object every instance sees — `b.tags` is `["extract"]`. Assigning `b.tags = [...]` creates an instance attribute that shadows the class one, so from then on `b` is independent and `a` is not.
The reasoning
Attribute lookup on an instance checks the instance `__dict__` first and falls back to the class. Attribute *assignment* never falls back — it always writes to the instance. That asymmetry is the whole question.
`a.add("extract")` calls `self.tags.append(...)`, which is a read of `tags` (finding the class list) followed by a mutation of that object. Nothing was assigned, so nothing was created on the instance, and every instance plus the class itself sees `["extract"]`.
`b.tags = ["own"]` is an assignment, so now `b` has its own list in its `__dict__` and lookups on `b` stop there. `b.add("load")` appends to that private list. `a` never assigned anything, so it still resolves to the class list — which is why `a.tags` and `Job.tags` remain `["extract"]` while `b.tags` is `["own", "load"]`.
The fix is to create per-instance state in `__init__` (`self.tags = []`) or to use `field(default_factory=list)` in a dataclass. A class-body mutable is only correct when you genuinely want one shared object, which is rare and worth a comment when you do.
What it actually prints run on CPython 3.12
Line 8 mutates a shared object. Line 11 replaces it, for one instance only.
"`b.tags` is empty — they are separate objects." They are separate *instances*, but neither created a list; both were reading the single one the class body built at import time.
They’ll ask next
Change `tags = []` to `tags: list = []` inside a `@dataclass`. What happens, and when?
A 3x3 grid built two ways, then one cell is set in each. What does each grid look like?
The code — predict the output before reading on
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
built = [[0] * 3 for _ in range(3)]
built[0][0] = 1
print(built)
Why they ask this
It is the shortest possible demonstration that `*` copies references, not objects, and it appears whenever someone initialises a matrix, a set of buckets or a per-partition accumulator.
Say this
The first prints three identical rows, all `[1, 0, 0]`, because `* 3` repeated one list object three times. The comprehension evaluates `[0] * 3` on each iteration, so it builds three separate rows and only the first changes.
The reasoning
`[0] * 3` is safe: integers are immutable, so having three references to the same `0` cannot hurt you. `[[0] * 3] * 3` is not, because the thing being repeated is a *list*. The outer list ends up holding three references to one inner list, and mutating through any of them is visible through all of them.
The comprehension is different because it is a loop: the expression `[0] * 3` is evaluated once per iteration, producing a genuinely new list each time. That is the general rule for building any collection of mutable things — a loop or comprehension, never repetition.
The same trap wears other clothes. `[{}] * 5` gives five references to one dict, `[[]] * n` to one list, and `dict.fromkeys(keys, [])` gives every key the same list. All of them look like they initialised independent containers and none of them did.
It also explains why the bug survives testing. Reading works perfectly — the grid has the right shape and the right values — and only mutation reveals the sharing. A test that builds a grid and asserts its dimensions passes.
What it actually prints run on CPython 3.12
Same shape, same initial values, completely different behaviour on the first write.
The answer most people give
"Both print the same thing — `*` and the comprehension are two ways of writing the same loop." `*` does not loop. It builds one object and stores the same reference n times.
They’ll ask next
Is `[0] * 3` also dangerous? Why is the answer different?
Two ways to build a dict of empty lists, then one append. What does each print?
The code — predict the output before reading on
counts = dict.fromkeys(["eu", "us"], [])
counts["eu"].append(1)
print(counts)
fixed = {k: [] for k in ["eu", "us"]}
fixed["eu"].append(1)
print(fixed)
Why they ask this
It is the same sharing bug as the grid, in the form data engineers actually hit — initialising per-key accumulators before a grouping loop.
Say this
`fromkeys` evaluates the default once and hands every key the same list, so appending to `"eu"` also appends to `"us"`. The dict comprehension evaluates `[]` per key, so only `"eu"` changes.
The reasoning
`dict.fromkeys(keys, value)` takes one already-constructed value. It cannot call a factory per key — there is no factory, just an object — so all keys point at it. With an immutable default like `0` or `None` that is completely fine, which is why `fromkeys` has a good reputation and why the failure is surprising.
The dict comprehension `{k: [] for k in keys}` runs the expression on every iteration, so each key gets a fresh list. That is the correct way to pre-seed mutable per-key state, and it reads no worse.
In practice you usually should not pre-seed at all. `defaultdict(list)` creates the list on first write, which avoids the question entirely and also avoids leaving empty entries for keys the data never contained — an empty bucket in the output is a real reporting difference, not just tidiness.
The rule to carry away: any API that takes a *value* as a default shares it; any API that takes a *callable* does not. `dict.fromkeys` and function default arguments are the first kind. `defaultdict(list)` and `dataclasses.field(default_factory=list)` are the second, and both exist specifically because of this.
What it actually prints run on CPython 3.12
One append, two entries changed — then the same append with per-key lists.
The answer most people give
"`fromkeys` copies the default for each key." It does not copy anything. It stores the one object you handed it against every key.
They’ll ask next
When is `dict.fromkeys` exactly right? (Hint: what is `dict.fromkeys(ids)` good for?)
Almost nobody gets both halves right. It forces you to explain what `+=` actually compiles to, and the answer — that it both succeeded and failed — is genuinely startling.
Say this
It raises, *and* the list is modified: `([1, 2], "eu")`. `x[0] += y` is a load, an in-place extend, then a store back. The extend mutates the list successfully; the store into the tuple is what fails.
The reasoning
`+=` on a mutable object is not simply `x = x + y`. For a list it calls `__iadd__`, which extends in place and returns the same object. But the statement is an *assignment to a subscript*, so the compiler still emits a store: load `row[0]`, call `__iadd__` on it, then `row[0] = result`.
The first two steps work. The list is a normal list and `__iadd__` appends `2` to it — that mutation is complete and permanent before anything goes wrong. The third step calls `tuple.__setitem__`, which does not exist, and raises `TypeError`.
So the exception is real and the mutation is real. The tuple is still the same tuple holding the same list object; that object now has an extra element. This is the sharpest possible illustration that a tuple's immutability is shallow: it guarantees which objects it references, not that those objects will not change.
The practical consequence is that tuples containing mutable values are not safe as dict keys or as "frozen" records. `hash(([1], "eu"))` raises for exactly this reason. If you need a genuinely immutable record, every field has to be immutable too — which is why a frozen dataclass with a `list` field is still unhashable.
What it actually prints run on CPython 3.12
The exception is caught, and the list still gained its element.
The answer most people give
"It raises, so nothing happened — the tuple is unchanged." The raise happens on the last of three steps. The mutation was the second one and it completed.
They’ll ask next
Would `row[0].append(2)` raise? Why is that different?
A comprehension and a for loop both bind `i`. What is `i` after each?
The code — predict the output before reading on
i = "untouched"
squares = [i * i for i in range(3)]
print("after the comprehension:", i)
for i in range(3):
pass
print("after the for loop: ", i)
Why they ask this
It is a small question with a large consequence: knowing that the loop variable survives the loop is what explains the late-binding closure bug, which is the version of this that actually costs people a day.
Say this
The comprehension has its own scope, so `i` is still `"untouched"` afterwards. A `for` statement does not — it binds in the enclosing scope, so `i` is `2` when the loop ends.
The reasoning
In Python 3 a comprehension is compiled as an implicit nested function. Its iteration variable lives in that function's scope and is discarded when the comprehension finishes, which is why it cannot clobber a name you were using. This was changed deliberately from Python 2, where comprehensions did leak.
A `for` statement is not a function. It is a plain statement in the current scope, and its target is an ordinary assignment — so after the loop, `i` holds whatever the last iteration assigned. If the iterable was empty, `i` is not bound at all, and referring to it raises `NameError`.
That surviving binding is exactly why `[lambda: i for i in range(3)]` returns three functions that all print `2`: the lambdas close over one cell, and the loop leaves it at its final value. Understanding the scope rule and understanding that bug are the same piece of knowledge.
It is also why using the loop variable after the loop — a common way to write "the last row processed" — is fragile: it silently depends on the iterable being non-empty, and it reads as if it were scoped to the loop when it is not.
What it actually prints run on CPython 3.12
The comprehension gets its own scope. The statement does not.
The answer most people give
"Both leak — Python has no block scope." Python has no block scope for `for`, `if` or `while`, which is why the second one leaks. Comprehensions are the exception, because they are compiled as functions.
A function prints a module-level `total` on its first line and assigns to `total` on its last. What happens?
The code — predict the output before reading on
total = 100
def report():
try:
print(total)
except UnboundLocalError as error:
print("UnboundLocalError:", error)
total = 0
report()
print("the global is fine:", total)
Why they ask this
It shows that Python decides scope by compiling the whole function, not by executing it line by line. People who think of it as an interpreter that reads top to bottom cannot explain this.
Say this
The `print` raises `UnboundLocalError`. Because `total` is assigned anywhere in the function, it is local for the *entire* function — including lines above the assignment — so the read finds an unbound local rather than the global.
The reasoning
When a function is compiled, Python scans its body for assignments and classifies every name once. A name assigned anywhere in the body is local throughout, and reads of it compile to a local-variable load. There is no fall-back to the global at run time; the lookup instruction is different.
So the read on line 4 is not "check local, then global" — it is "load local `total`", which has not been given a value yet. That is `UnboundLocalError`, a subclass of `NameError`, and its message says exactly that: the variable exists as a local but is not associated with a value.
The module-level `total` is untouched, which is the second half of the answer and the part that makes this a real bug rather than a curiosity: nothing was corrupted, one function just stopped being able to read a value it appears to read.
The fixes depend on intent. If you meant to update the module-level value, declare `global total` — and then ask whether module-level mutable state is what you want. If you meant a local, give it a different name, because shadowing a global you also read is confusing even when it works. In a nested function, `nonlocal` plays the same role for the enclosing scope.
What it actually prints run on CPython 3.12
The assignment on the last line changes what the first line means.
The answer most people give
"It prints 100, then makes a local." That would be true if scope were decided line by line. It is decided once, for the whole function, before any of it runs.
They’ll ask next
Remove the assignment and it prints 100. Add `global total` and it prints 100 too — but what else changes?
Inside a class body, one comprehension over `defaults` works and another raises `NameError`. Why the difference?
The code — predict the output before reading on
class Config:
defaults = ["retries", "timeout"]
upper = [name.upper() for name in defaults] # the first iterable is fine
try:
pairs = [name for name in defaults if name in defaults]
except NameError as error:
print("NameError:", error)
print(Config.upper)
Why they ask this
It is a genuinely obscure corner, and the reasoning — that a comprehension is a function and class bodies are not enclosing scopes — is the same reasoning that explains the loop-variable question. It rewards understanding over memorisation.
Say this
The comprehension is compiled as a nested function, and class bodies are not enclosing scopes for nested functions. The outermost iterable is evaluated in the class body and passed in, so the first use of `defaults` works; every other reference is inside the function and fails.
The reasoning
Name resolution for a nested function walks local, enclosing *function* scopes, global, then builtins. A class body is executed as its own namespace but it is deliberately skipped in that chain — otherwise methods would be able to see class attributes as bare names, which Python does not allow either (that is why methods write `self.x`).
A comprehension is a nested function, so it inherits that restriction. The one exception is the outermost iterable: it is evaluated eagerly in the enclosing scope and passed to the implicit function as an argument. That is why `for name in defaults` resolves, while the `if name in defaults` — which runs inside the function body — does not.
So the rule is oddly precise: exactly one reference works, and it is the first one. Adding a second loop, a condition, or a value expression that mentions a class attribute all fail with `NameError`, which makes the error look intermittent to anyone who has not seen the rule.
The workarounds are to bind it as a default argument (`[n for n in defaults if n in (d := defaults)]` is not it — use a real local: assign to a module-level name, or build the value in `__init_subclass__`, or simply do the work in a `classmethod` or at module level instead. Most of the time the honest fix is that computed class attributes belong outside the class body.
What it actually prints run on CPython 3.12
Same name, two references, one line apart. Only the first one resolves.
The answer most people give
"Class attributes are not visible until the class is created." They are — `defaults` is already in the class namespace, which is why the first comprehension can use it. The problem is that the comprehension's body is a different scope.
They’ll ask next
Why can a method not refer to `defaults` as a bare name either? Is it the same rule?
A counter factory called twice. What do the two print statements produce?
The code — predict the output before reading on
def make_counter():
n = 0
def bump():
nonlocal n
n += 1
return n
return bump
first, second = make_counter(), make_counter()
print(first(), first(), first())
print(second())
Why they ask this
It checks that you know a closure captures a *cell*, and that each call to the factory makes a new one. That is the mechanism behind decorators holding per-function state — caches, call counts, retry budgets.
Say this
`1 2 3` then `1`. Each call to `make_counter` creates a fresh `n`, and the returned `bump` closes over that specific cell, so the two counters are completely independent.
The reasoning
Every invocation of `make_counter` builds a new local `n`. Because the inner function refers to it with `nonlocal`, the interpreter stores it in a cell that outlives the call, and the returned function object holds a reference to that cell in `__closure__`. Two calls, two cells, two independent counters.
`nonlocal` is what makes the write work. Without it, `n += 1` would classify `n` as local to `bump` — the same rule as the `UnboundLocalError` question — and the read half of the increment would fail on the first call. Reading a closed-over variable needs no declaration; only rebinding does.
The evaluation order in `print(first(), first(), first())` is left to right, so the arguments are `1`, `2`, `3` and they are printed in that order. That is guaranteed by the language, not an implementation detail.
This is exactly how a decorator keeps state per decorated function: the wrapper closes over a cell created when the decorator ran, and since the decorator runs once per decorated function, each one gets its own. It is also why that state is shared by all *calls* to a given function, which is what you want for a cache and emphatically not what you want for a request-scoped value.
What it actually prints run on CPython 3.12
Two calls to the factory, two independent cells.
The answer most people give
"`second()` prints 4 — `n` is shared." It would be if `n` lived on the module or the class. It is a local of `make_counter`, and there have been two separate invocations of that function.
They’ll ask next
Remove the `nonlocal`. What error do you get, and on which call?
It looks like a trick until you realise it is the standard idiom for chunking a stream into pairs — `zip(*[it] * n)`. Reading it correctly means understanding that zip pulls from its arguments in order.
Say this
Two iterators give `[(1, 1), (2, 2), (3, 3), (4, 4)]`. One iterator gives `[(1, 2), (3, 4)]`, because zip calls `next` on its first argument and then on its second — and both are the same object, so each tuple consumes two values.
The reasoning
`zip` builds each output tuple by calling `next()` on every argument, left to right, and stops as soon as one of them raises `StopIteration`. When the arguments are distinct iterators, that gives the parallel pairing everyone expects.
When both arguments are the *same* iterator, the two `next()` calls advance one shared position. The first tuple takes 1 then 2; the second takes 3 then 4; the third call exhausts it. So four values become two pairs rather than four.
This is not a curiosity — it is the well-known grouping idiom, usually written `zip(*[iter(rows)] * n)` to make batches of n. It is compact and, in Python 3.12, superseded by `itertools.batched`, which does the same thing readably and handles the final partial batch instead of silently dropping it.
The dropping is the part worth remembering. With five values and pairs, `zip` yields two tuples and throws the fifth away, because it stops at the first exhausted argument. `zip(..., strict=True)` (3.10+) raises instead of truncating, which is what you want any time silent loss would be a correctness bug.
What it actually prints run on CPython 3.12
Identical values both times. The only difference is how many iterators exist.
The answer most people give
"Both print the same thing — zip does not care where the values come from." It cares that both arguments are the same object. Each tuple then costs two advances of one shared cursor.
They’ll ask next
What does the one-iterator version print for `[1, 2, 3, 4, 5]`? What happened to the 5?
Zip a 3-item iterator with a 2-item one. Afterwards, what is left in the longer one?
The code — predict the output before reading on
left = iter([1, 2, 3])
right = iter(["a", "b"])
print("zipped:", list(zip(left, right)))
print("left has left:", list(left))
Why they ask this
The zipped result is obvious; the leftover is not. It matters whenever you zip two streams and then keep reading one of them — a real pattern when reconciling two sorted files.
Say this
Two pairs, and `left` is *empty* — not `[3]`. To discover that `right` was exhausted, zip first pulled `3` from `left`, and that value is consumed and discarded.
The reasoning
On its third round, zip calls `next(left)` and gets `3`. It then calls `next(right)`, which raises `StopIteration`, so zip stops and produces no third tuple. The `3` it had already taken is simply dropped — there is nowhere to put it and no way to push it back.
That is why the leftover is empty rather than `[3]`. The value was not skipped, it was consumed. Any code that zips two iterators and then continues reading the longer one is silently missing one element per zip, at the boundary.
The argument order decides which iterator loses a value, since zip evaluates left to right and stops at the first exhaustion. So the behaviour is deterministic but depends on something that reads as arbitrary — a strong hint that you should not rely on it.
Python 3.10 added `strict=True`, which raises `ValueError` when the inputs differ in length rather than truncating. For anything where the two streams are supposed to correspond — parallel columns, keys and values, a file and its checksums — that is the correct default, because a length mismatch is a bug and truncation hides it.
What it actually prints run on CPython 3.12
The third value was pulled before zip discovered it had nothing to pair with.
The answer most people give
"`left` still has `[3]` — zip only took what it needed." It took `3` and then found it had no partner for it. Discovering exhaustion costs a value from every iterator ahead of the one that ran out.
They’ll ask next
What does `zip(left, right, strict=True)` do here, and when would you want that?
The same `groupby` result, materialised first and read second, versus read as you go. Why do they differ?
The code — predict the output before reading on
from itertools import groupby
rows = [("eu", 1), ("eu", 2), ("us", 3)]
kept = list(groupby(rows, key=lambda r: r[0]))
print("kept then read:", [(k, list(g)) for k, g in kept])
print("read as you go:", [(k, list(g)) for k, g in groupby(rows, key=lambda r: r[0])])
Why they ask this
It is documented behaviour that reliably surprises people, and it produces empty groups rather than an error — a wrong answer that looks like a data problem rather than a code problem.
Say this
The first prints empty groups. `groupby` yields sub-iterators that share the one underlying cursor, so advancing to the next group discards the previous group's unread items. `list(groupby(...))` advances all the way to the end before you read anything.
The reasoning
`groupby` does not build groups. It walks the source once, and each group it yields is a view onto the shared position — valid only until you ask for the next group. That is what makes it constant-memory over a huge sorted file, and it is the direct cause of this behaviour.
`list(groupby(rows, ...))` forces every group to be produced before any is consumed. By the time you loop over `kept`, the cursor is at the end of the source and each sub-iterator has been silently invalidated, so `list(g)` returns `[]`. The keys survive because they were computed when the group started.
Reading as you go works because each group is fully consumed before the next is requested — which is exactly what a comprehension over `groupby(...)` does. The distinction is not "one is materialised", it is *when* the outer iterator advances relative to the inner ones.
If you need the groups to outlive the walk, copy them as you go: `{k: list(g) for k, g in groupby(rows, key=...)}`. That materialises each group at the moment it is valid, at the cost of holding the whole result — which is the trade you were avoiding by using `groupby` in the first place, and a good sign that `defaultdict(list)` was the right tool.
What it actually prints run on CPython 3.12
Identical inputs and key function. Only the order of consumption differs.
The answer most people give
"The list call consumed the rows, so the second line has nothing left." The second line calls `groupby` again on the same list, which is re-iterable. It is the *sub*-iterators from the first call that were invalidated.
They’ll ask next
How would you keep the groups around? What have you given up by doing that?
After `any(r > 2 for r in rows)` over an iterator of 1..5, what is left in `rows`?
The code — predict the output before reading on
rows = iter([1, 2, 3, 4, 5])
print("any(> 2):", any(r > 2 for r in rows))
print("what is left:", list(rows))
Why they ask this
Short-circuiting is well known; the side effect on a shared iterator is not. It is the bug behind "I checked whether the file had any bad rows, and now some rows are missing".
Say this
`[4, 5]`. `any` stops at the first true value, which is `3`, and `3` has already been consumed. Everything before the match is gone too, so only what came after remains.
The reasoning
`any` and `all` pull from their argument until they can decide: `any` stops on the first truthy result, `all` on the first falsy one. Against a list that is a pure optimisation, because a list can be re-iterated. Against an iterator it is a mutation — the cursor is left wherever the decision was made.
So the position after the call is data-dependent, which is the uncomfortable part. If no row had matched, the iterator would be fully exhausted; because `3` matched, it stopped there. A validation pass that returns early leaves a different amount of data behind depending on the data.
The general habit this argues for: a function that takes an iterable and only *inspects* it should either take a sequence, or document that it consumes. Checking a stream for bad rows and then processing that same stream is a bug regardless of which check you used.
If you genuinely need both the verdict and the rows, materialise first (`rows = list(rows)`) and accept the memory, or restructure to a single pass that validates and processes each row as it goes — which is usually better anyway, because it lets you report *which* row was bad instead of just whether one was.
What it actually prints run on CPython 3.12
The 3 that satisfied the check is consumed, along with everything before it.
The answer most people give
"Nothing is left — `any` iterates the whole thing." It stops the moment it can answer. That is the optimisation, and on an iterator it is also the side effect.
They’ll ask next
What would be left if the condition were `r > 99`? Does that make the function safer or more dangerous?
A class defines `__eq__` and nothing else. What happens when you put one in a set?
The code — predict the output before reading on
class Key:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
try:
{Key("a")}
except TypeError as error:
print("TypeError:", error)
print("but equality works:", Key("a") == Key("a"))
Why they ask this
Defining `__eq__` for value semantics is completely normal, and this consequence catches people the first time they try to deduplicate their own objects. The reason behind it is a real invariant, not an arbitrary rule.
Say this
It raises `TypeError: unhashable type`. Defining `__eq__` sets `__hash__` to None, because two objects that compare equal must hash equal — and Python cannot infer a matching hash from your equality.
The reasoning
A hash table relies on an invariant: if `a == b` then `hash(a) == hash(b)`. The default `__hash__` is derived from identity, so two distinct objects hash differently. Once you declare that two distinct objects can be equal, that default is guaranteed to violate the invariant — an object would be findable under one bucket and not the other.
Rather than let you build a set that silently fails to find its own members, Python breaks it loudly: defining `__eq__` in a class sets `__hash__ = None` unless you define it yourself. Equality still works perfectly, which is why the failure only appears the moment you hash.
The fix is to define `__hash__` over the same fields your `__eq__` uses — `def __hash__(self): return hash(self.name)` here — and to make those fields immutable, because mutating a field after insertion moves the object's correct bucket without moving the object. A `@dataclass(frozen=True)` does both for you, which is why it is the right tool for a key.
The reverse also matters: it is legal for unequal objects to share a hash (that is just a collision), so a hash function does not have to be unique. It has to be *consistent* — same value, same hash, for the lifetime of the object.
What it actually prints run on CPython 3.12
Equality works; hashing was disabled the moment `__eq__` appeared.
The answer most people give
"It works — the set uses `__eq__` to deduplicate." A set hashes first and only compares within a bucket. With no hash there is no bucket, so it cannot even start.
They’ll ask next
You add `__hash__` and then mutate `name` after inserting into a set. What goes wrong, and when do you find out?
Two instances share a class attribute `retries = 3`. One does `a.retries += 1`. What are the three values, and what happens after the class attribute changes?
The code — predict the output before reading on
class Sink:
retries = 3
a, b = Sink(), Sink()
a.retries += 1 # reads the class value, writes an instance one
print("a:", a.retries, "b:", b.retries, "class:", Sink.retries)
print("in a's __dict__:", a.__dict__, "| in b's:", b.__dict__)
Sink.retries = 10
print("after changing the class - a:", a.retries, "b:", b.retries)
Why they ask this
It is the immutable counterpart to the shared-list question, and the two together are the complete picture: mutation propagates, assignment does not. The final line shows the consequence people do not anticipate.
Say this
`a: 4 b: 3 class: 3`. The `+=` read the class value and wrote an instance one, so only `a` has its own. Changing the class attribute afterwards moves `b` to 10 and leaves `a` at 4 — `a` has stopped tracking the class.
The reasoning
`a.retries += 1` expands to `a.retries = a.retries + 1`. The read falls back to the class and finds 3; the write, as always, goes to the instance. So `a.__dict__` gains `{"retries": 4}` while `b.__dict__` stays empty and `Sink.retries` is untouched.
That is why the class value is still 3 on the first line even though something was incremented — nothing was incremented in place, because integers are immutable. Compare the class-attribute list question, where the `append` mutated the shared object and every instance saw it. Same lookup rule, opposite outcome, decided entirely by whether the operation was a mutation or an assignment.
The last line is the part that bites in real code. Configuration defaults declared as class attributes are often changed at run time — from an environment variable, a settings loader, a test fixture. Any instance that has ever assigned to that attribute has silently opted out of the update, and the two objects now disagree with no error and no obvious cause.
The design lesson is to keep class attributes for constants and put anything per-instance in `__init__`. If you want a default that can be overridden but still tracks changes, read it explicitly through the class (`type(self).retries`) or through a config object, so that the fall-back is intentional rather than a side effect of nobody having assigned yet.
What it actually prints run on CPython 3.12
The `__dict__` line shows exactly where the 4 went — and where the 3 never was.
The answer most people give
"`Sink.retries` becomes 4 — they all share it." They share it only until someone assigns. Reads fall back to the class; writes never do.
They’ll ask next
Change `retries = 3` to `tags = []` and `+= 1` to `.append("x")`. Why does that one propagate?
It is a one-line bug that produces an empty result rather than an error, and it is written by people who have correctly learned that `.get` is the safe way to read a dict.
Say this
`.get` leaves the dict empty. It returns the default without storing it, so the append lands on a temporary list that is immediately discarded. `setdefault` inserts the default and returns the stored object, so the append persists.
The reasoning
`.get(key, default)` is a pure read: if the key is missing it returns the default and the dict is unchanged. The list you passed as the default is a brand-new object with no other references, so appending to it and then dropping the expression means the value is garbage collected on the same line.
`setdefault(key, default)` inserts the default when the key is missing and returns whatever is now stored. That is why the append persists — you are mutating the object that is actually in the dict. When the key already exists it returns the existing value and ignores the default entirely.
The reason the bug survives review is that both lines are idiomatic in isolation. `.get` with a default is the correct way to *read* a possibly-missing key; the mistake is using a read where a write was intended. The symptom is a grouping loop that runs without error and produces an empty dict.
`defaultdict(list)` avoids the choice: `grouped[key].append(v)` inserts on first touch. The trade, covered in the Approach bank, is that reading a missing key from a defaultdict also inserts it — so pick `setdefault` when you want the insert to be explicit at the call site, and `defaultdict` when the whole dict is write-heavy.
What it actually prints run on CPython 3.12
The first append succeeded — on a list nothing kept a reference to.
The answer most people give
"The append fails silently." Nothing failed — the append worked perfectly on a list that was never stored anywhere. That distinction matters, because there is no error to catch.
They’ll ask next
Does `setdefault` construct the empty list even when the key exists? What does that cost in a hot loop?
Money in floats is the most expensive small mistake in data engineering. The question checks you know both that it happens and what to use instead.
Say this
The float sum is `0.30000000000000004` and does not equal 0.3. `Decimal("0.1") * 3` is exactly `0.3` and compares equal, because Decimal is base-10 and 0.1 has an exact representation there.
The reasoning
A binary float stores a value as a sum of powers of two. One tenth is a repeating fraction in binary, exactly as one third is in decimal, so `0.1` is stored as the nearest representable value rather than the value you wrote. Adding three of those approximations accumulates the error into a number that prints as `0.30000000000000004`.
`print` shows the shortest string that round-trips, which is why a single `0.1` displays as `0.1` and hides the problem. `repr` is the same in modern Python — it is only the arithmetic that exposes it. That is why the bug appears at a comparison or a sum rather than at a read.
`Decimal` stores digits and an exponent in base 10, so `Decimal("0.1")` is exact and the multiplication is exact. Note the string argument: `Decimal(0.1)` takes the already-wrong float and faithfully preserves its error, which is a common way of using the fix incorrectly.
For pipeline work the rules are: use `Decimal` for money and anything that has to reconcile to a penny; never test floats with `==`, use `math.isclose` or an explicit tolerance; and be aware that most warehouses have a true `DECIMAL`/`NUMERIC` type, so the safest thing is to keep the value in that type end to end and never let it become a float in Python at all.
What it actually prints run on CPython 3.12
The float sum is off by 5.5e-17 — enough to fail an equality check, and to fail reconciliation.
The answer most people give
"Round it to two decimal places and it is fine." Rounding at the end hides the error for one calculation. Over a million rows the accumulated drift is real money, and the rounding itself has its own surprises.
They’ll ask next
What does `Decimal(0.1)` give you, without the quotes? Why is that worse than the float?
Every one of these disagrees with school arithmetic, and each has caused a reconciliation mismatch somewhere. It is a compact test of whether you know your tools rather than assuming them.
Say this
`0`, `2`, `2`, `2.67`, then `-3` and `-4`. Python rounds halves to the nearest *even* number, `2.675` is really slightly below 2.675 as a float, and `//` floors toward negative infinity while `int()` truncates toward zero.
The reasoning
Banker's rounding is deliberate. Always rounding halves up biases a large set of values upward; rounding half to even cancels out across many values, which is why it is the IEEE 754 default and why financial systems use it. So `round(0.5)` is 0 and `round(2.5)` is 2 — both round to the even neighbour.
`round(2.675, 2)` giving `2.67` is a different mechanism: it is the float problem from the previous question. The literal `2.675` is stored as slightly less than 2.675, so the nearest two-decimal value genuinely is 2.67. The rounding is correct; the input was never exactly what it looked like.
The last line is two different operations. `-7 / 2` is `-3.5`, and `int()` truncates toward zero, giving `-3`. `//` is floor division, which rounds toward negative infinity, giving `-4`. They agree for positive numbers and diverge for negatives, which is how an off-by-one appears only in the rows with negative values — refunds, adjustments, corrections.
What to do about it: use `Decimal` with an explicit `quantize` and a named rounding mode when the answer has to match a finance system, since that makes the policy visible instead of implicit. And when bucketing or paginating, decide deliberately whether you want floor or truncation, because for any dataset containing negative numbers those are different answers.
What it actually prints run on CPython 3.12
Only the fourth line is about floats; the rest are the documented rules.
The answer most people give
"`round` is broken / it is a floating-point bug." Three of these five are documented, intentional behaviour. Only `round(2.675, 2)` involves float representation, and even there `round` did the right thing with the value it was given.
They’ll ask next
How would you round half *up*, the way a finance team expects? What does that need?
A dict keyed by `1`, `True` and `1.0`, then a set of the same. What prints?
The code — predict the output before reading on
counts = {1: "one", True: "true", 1.0: "float"}
print(counts)
print("distinct:", len({1, True, 1.0}))
flags = [True, True, False, True]
print("sum of bools:", sum(flags))
print("isinstance(True, int):", isinstance(True, int))
print("but they print differently:", [str(f) for f in {0, False}])
Why they ask this
It is a real data hazard, not a puzzle: keys arriving from mixed sources — a JSON `true`, a CSV `1`, a float column — collapse into one, and the count is quietly wrong.
Say this
`{1: 'float'}`, and the set has one element. `bool` is a subclass of `int`, `True == 1 == 1.0`, and they hash equally — so all three are the same dict key and the last value assigned wins.
The reasoning
Dict keys and set members are compared by hash and equality, not by type. Python defines numeric equality across `int`, `float`, `bool` and `Decimal` where the values agree, and requires equal objects to hash equally, so `hash(1) == hash(True) == hash(1.0)`. Three literals, one key.
The dict literal is evaluated left to right, each entry overwriting the last, so the final value is `"float"` — but the *key* displayed is `1`, the one first inserted, because updating a key's value does not replace the key object. That is why the output reads `{1: 'float'}` rather than `{1.0: 'float'}`, and it is the detail almost nobody predicts.
`bool` being a subclass of `int` is a historical decision that mostly helps: `sum(flags)` counting the True values is genuinely useful, and `True + True == 2` follows from it. The same subclassing is what makes `isinstance(True, int)` return True, which breaks type dispatch written as `if isinstance(v, int)` when booleans should have taken a different branch.
Where it hurts in pipelines is deduplication and grouping over keys of mixed provenance. A parser that yields `True` for one file and `1` for another produces one group where you expected two — or two where you expected one, if the reverse is intended. The defence is to normalise types at the parsing boundary and to check `isinstance(v, bool)` *before* `isinstance(v, int)` whenever the distinction matters.
What it actually prints run on CPython 3.12
The surviving key is 1 and the surviving value came from 1.0.
The answer most people give
"The dict has three entries — they are different types." Types are irrelevant to dict keys. Equality and hash are what matter, and all three are equal.
They’ll ask next
You need `1` and `True` to be different group keys. What do you key on instead?
Sorting strings that represent something else — versions, partition numbers, dates in a non-ISO format — is a routine source of wrong ordering that no test catches, because the output is sorted, just not the way anyone meant.
Say this
`["10", "100", "9"]` lexicographically, because comparison is character by character and `"1" < "9"`. And `["Alpha", "Gamma", "beta"]` case-sensitively, because every uppercase letter sorts before every lowercase one.
The reasoning
String comparison walks code points from the left and stops at the first difference. `"10"` versus `"9"` is decided at the first character: `"1"` is U+0031 and `"9"` is U+0039, so `"10"` sorts first regardless of the digits that follow. Length never enters into it unless one string is a prefix of the other.
The case behaviour has the same cause. In ASCII, the uppercase block (65–90) precedes the lowercase block (97–122), so `"Gamma"` sorts before `"beta"` — every capitalised name clusters ahead of every lowercase one. `key=str.lower` compares folded copies while returning the originals, which is almost always what a human expects.
The fix in both cases is a key function, and the important property is that `key` does not alter the values it returns — `sorted(versions, key=int)` yields the original strings in numeric order. For genuinely versioned strings, split into a tuple of ints (`tuple(int(p) for p in v.split("."))`) so that `1.10` sorts after `1.9`.
For user-facing text there is a further layer: `str.lower` is not correct for every language, `str.casefold` is the more aggressive and more correct fold, and true locale-aware collation needs a library like PyICU. For data-engineering keys the practical rule is to normalise once at ingest — case-fold, strip, parse into a real type — and sort on the parsed value rather than on its printed form.
What it actually prints run on CPython 3.12
All four lines are sorted correctly. Only two of them are sorted usefully.
The answer most people give
"Python sorts strings alphabetically." It sorts by code point, which coincides with alphabetical order only within a single case and only for unaccented Latin letters.
They’ll ask next
How would you sort `["1.9", "1.10", "1.2"]` correctly? What is the key?
A `range` over a week of dates, then comparing a naive datetime with an aware one. What prints?
The code — predict the output before reading on
from datetime import date, datetime, timedelta, timezone
start, end = date(2026, 3, 1), date(2026, 3, 8)
days = [start + timedelta(d) for d in range((end - start).days)]
print("range covers:", len(days), "days,", days[0], "..", days[-1])
naive = datetime(2026, 3, 1, 12, 0)
aware = datetime(2026, 3, 1, 12, 0, tzinfo=timezone.utc)
print("naive == aware:", naive == aware)
try:
aware - naive
except TypeError as error:
print("TypeError:", error)
Why they ask this
Backfills are specified as date ranges and events are timestamped, so both halves of this are daily work. The exclusive end is the classic off-by-one, and the naive/aware split is the classic ingestion bug.
Say this
Seven days, `2026-03-01` to `2026-03-07` — `range` excludes the end, so the 8th is missing. And `naive == aware` is False while subtracting them raises `TypeError`: Python refuses to guess a timezone.
The reasoning
`(end - start).days` is 7, and `range(7)` yields 0 to 6, so the last date is the 7th. Whether that is correct depends entirely on whether your range is meant to be half-open `[start, end)` or inclusive. Both conventions are defensible; the bug is not saying which, so that a backfill silently skips its final day.
The habit worth adopting is to use half-open ranges everywhere and say so in the parameter names — `start` and `end_exclusive`. Half-open ranges compose without gaps or overlaps, which is exactly what you want for consecutive partitions, and it matches how SQL `>= AND <` filters are normally written.
The naive/aware split is a deliberate refusal. A naive datetime has no offset, so Python cannot compute the difference without inventing one — and inventing one is how "everything is three hours out" bugs happen. So comparisons return False rather than raising (equality is defined as "not equal" for incomparable instants) and arithmetic raises `TypeError`, which is the loud failure you want.
The rule for pipelines is to make everything aware at the boundary: parse to UTC on ingest, keep UTC everywhere internally, and convert to a local zone only for display. `datetime.now()` returns naive and is best avoided entirely in favour of `datetime.now(timezone.utc)`. And note that `date` objects have no timezone at all, so "which day is it" is a question you can only answer after deciding whose day you mean.
What it actually prints run on CPython 3.12
The range stops on the 7th. The comparison is False rather than an error; the subtraction is not.
The answer most people give
"`naive == aware` raises too." Only the arithmetic raises. Equality quietly returns False, so a filter comparing a parsed timestamp against an aware boundary drops every row without complaining.
They’ll ask next
Your backfill must include the end date. Do you change the range or the parameter name — and what do you call it?
It is the copying question in the form it actually arrives in: somebody copied it and it still changed.
Say this
dict() is a shallow copy. It builds a new outer dict whose values are the same objects as the original's, so every nested list and dict is still shared. Use copy.deepcopy, or rebuild the nested parts.
The reasoning
**Three levels, and only one of them is what people mean.** `b = a` is no copy at all — two names, one object. `dict(a)`, `a.copy()`, `{**a}` and `list(a)` are shallow — a new container holding the same inner objects. `copy.deepcopy(a)` is deep — new objects all the way down.
**Why shallow is usually the wrong depth for config.** Config is nested by nature, and the parts people edit are exactly the nested ones. A shallow copy protects the top-level keys, which nobody was going to change, and shares the lists, which everybody does.
**When shallow is right.** A flat record of scalars has nothing inside it that can be mutated, so a shallow copy is a genuine copy and it is fast. Knowing which case you are in is the whole skill, and it is one question: is anything inside this mutable?
**The cost of the safe answer.** `deepcopy` is recursive and slow, and on a per-row hot path it will dominate the runtime. Copy at a boundary, once, not inside a loop — and where a value is read-only for the rest of its life, freezing it into tuples is cheaper than copying it and turns a silent mutation into a `TypeError`.
The formulations
Deep copy for nested configship
run = copy.deepcopy(template)
New objects all the way down; the template is safe.
Build a new dictship
run = {**template, 'run_id': run_id}
Cheap and honest when only the top level changes.
Shallow copy of nested dataavoid
run = dict(template)
run['columns'].append('x') # template changed
The outer dict is new; the list is shared.
deepcopy per rowavoid
for row in rows:
r = copy.deepcopy(row)
Correct and it will dominate your runtime.
What it actually prints run on CPython 3.12
One template, two copies, and only one of them left it alone.
The answer most people give
"copy() is broken." It does exactly what it says — it copies the container. The mistake is assuming a copy is recursive when nothing ever claimed it was.
They’ll ask next
You need a nested structure nobody can mutate. What is cheaper than deep-copying it every time?
Two @dataclass instances built from the same values are compared with == and with is. What does each return, and what changes with frozen=True?
The code — predict the output before reading on
from dataclasses import dataclass
@dataclass
class Key:
order_id: int
sku: str
a = Key(1042, "A1")
b = Key(1042, "A1")
print("a == b:", a == b)
print("a is b:", a is b)
try:
print(len({a, b}))
except TypeError as exc:
print("set:", exc)
Why they ask this
It is the fastest way to check whether somebody knows what the decorator generates, and it comes up the moment a dataclass meets a set.
Say this
== is True because @dataclass generates a field-by-field __eq__; is is False because they are two objects. frozen=True adds a __hash__, which is what a plain dataclass lacks and why it cannot be a set member.
The reasoning
**Equality is generated, identity is not.** `eq=True` is the default, so the decorator writes an `__eq__` comparing the fields as a tuple — and it also checks the classes match, so a dataclass never compares equal to a different type with the same fields. `is` asks whether the two names point at one object, which they do not.
**The hash consequence.** Defining `__eq__` sets `__hash__` to `None`, in dataclasses as anywhere else. So a plain `@dataclass` is unhashable: putting one in a set raises `TypeError: unhashable type`. That surprises people precisely because the class looks more capable than the dict it replaced.
**What frozen=True changes.** It blocks attribute assignment and generates a `__hash__` from the same fields `__eq__` compares. Now the two instances are equal, hash equally, and a set containing both holds one — which is exactly what you want for a key.
**And the shallow caveat.** Frozen is not recursive. A frozen dataclass with a `list` field still has a mutable list inside it and is still unhashable, because hashing has to reach the contents. Use a tuple for that field.
The formulations
Frozen record as a keyship
@dataclass(frozen=True)
class K:
order_id: int
sku: str
len({K(1,'A'), K(1,'A')}) # 1
Equal, hashable, and deduplicates correctly.
Plain dataclass in a setavoid
@dataclass
class K:
order_id: int
{K(1)} # TypeError
eq without frozen removes the inherited hash.
Frozen with a list fieldavoid
@dataclass(frozen=True)
class K:
tags: list # still unhashable
Frozen is shallow; use a tuple.
What it actually prints run on CPython 3.12
Equality is generated. Hashing is not — until you freeze it.
The answer most people give
"They are the same object because the values are the same." Value equality and identity are different questions, and `is` answers the one almost nobody means.
They’ll ask next
Why does defining __eq__ remove __hash__, rather than leaving the inherited one alone?
Rows are sorted by amount, and two runs over the same data return the tied rows in a different order. Python's sort is stable — how is that possible?
The code — predict the output before reading on
rows = [
{"id": "c", "amount": 120},
{"id": "a", "amount": 120},
{"id": "b", "amount": 90},
]
one_key = [r["id"] for r in sorted(rows, key=lambda r: -r["amount"])]
total = [r["id"] for r in sorted(rows, key=lambda r: (-r["amount"], r["id"]))]
print("one key: ", one_key)
print("total: ", total)
print("reversed:", [r["id"] for r in sorted(rows, key=lambda r: (r["amount"], r["id"]), reverse=True)])
Why they ask this
Stability is the most misunderstood guarantee in the language, and this is the bug it causes.
Say this
Stable means equal elements keep their input order. It says nothing about what the input order was — and if the rows came from a set, a dict rebuild or a parallel read, that order is not a property of your data.
The reasoning
**What stability actually promises.** After sorting, two elements the key function called equal appear in the same relative order they had in the input. That is a real and useful guarantee: it lets you sort by one key, then by another, and keep the first as a tie-break.
**Why it does not save you here.** The guarantee is relative to the input, and the input order came from somewhere. A set has no order. A dict has insertion order, which depends on how it was built. Files read in directory order, or in parallel, arrive in whatever order the filesystem or the scheduler produced. None of that is stable between runs.
**The fix is one component.** Put a unique tie-break in the sort key: `sorted(rows, key=lambda r: (-r['amount'], r['id']))`. Now no two rows compare equal, the order is total, and the same input produces the same output on every machine and every version.
**And the direction trap.** `reverse=True` reverses the whole key, so the tie-break flips as well. Negate the numeric component instead — `(-amount, id)` — which reverses one part and leaves the rest ascending. Strings cannot be negated, which is why mixed directions on text needs two passes.