Explain it in your own words, with nothing running. These are the questions where someone who writes Python every day still gets caught — not because the syntax is hard, but because the object model, the import-time/run-time split and the concurrency story are rarely said out loud.
A name is a label on an object, not a box holding a value. Most surprises start here.
Iteration & laziness
3
What a generator buys you, and the one thing it takes away.
Functions, decorators & context managers
4
Functions are objects, so they can be wrapped. What that costs, and what `with` guarantees.
Classes, inheritance & abstraction
5
When a class earns its place, and how Python enforces an interface — or declines to.
Threads, processes, asyncio & the GIL
5
Three concurrency models, one interpreter lock, and the wrong choice costing you everything.
Evergreen · asked verbatim
5
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation — a screen still opens with “mutable versus immutable”.
01 / 25
Data structures & complexity
Why does a function defined as `def add_tag(tag, tags=[])` return something different on its second call than on its first?
Why they ask this
It is the cleanest single test of whether you know that `def` is a statement that executes, rather than a declaration the interpreter reads. It also produces a real production bug: state that accumulates across calls in a long-lived worker.
Say this
The default is evaluated once, when the `def` statement runs, so every call that omits the argument shares one list object. Use `None` as the sentinel and build the real default inside the body.
The reasoning
`def` is executable code. When the interpreter reaches it, it evaluates the default expressions there and then, builds a function object, and stores the results on that object — you can read them back from `add_tag.__defaults__`. It never re-evaluates them. So `tags=[]` creates exactly one list, at import time, and every call that does not pass its own list mutates that one.
This is the same rule as everything else in Python: names are labels bound to objects, and `tags` is bound to the same object on every call. Immutable defaults hide the behaviour rather than avoid it — `count=0` is also evaluated once, but since you cannot mutate an `int` in place, rebinding `count` inside the body just points the local name somewhere else and the default is untouched.
The fix is `def add_tag(tag, tags=None):` followed by `if tags is None: tags = []`. It is worth being precise about why `is None` rather than `if not tags`: an empty list the caller deliberately passed is falsy, so the truthiness test would silently replace the caller's object with a fresh one and throw their result away.
The same trap catches any default that is computed rather than constant. `def run(at=datetime.now())` freezes the import time of the module into every call — a scheduled job that stamps every row with the moment the container started rather than the moment the row was written. It is the identical bug wearing different clothes.
See it run on CPython 3.12
Three calls, one list. The last line reads the default straight off the function object.
def add_tag(tag, tags=[]): # evaluated once, at def time
tags.append(tag)
return tags
print(add_tag("a"))
print(add_tag("b"))
print(add_tag("c"))
print("the default itself:", add_tag.__defaults__)
"Python is caching the return value." Nothing is cached — the function body runs every time. What persists is the single default object the function was built with, and you can see it change by printing `__defaults__` between calls.
They’ll ask next
Which default values are safe, then? And is `def run(at=datetime.now())` safe — it is not a list, after all.
You build a list of dicts with `[dict(template) for _ in range(3)]`, change one row, and all three change. Why, and what actually gives you three independent rows?
Why they ask this
Building rows from a template is everyday data-engineering code, and this failure produces plausible-looking output rather than an error — you get three rows, they just all carry the same nested value.
Say this
`dict(template)` is a shallow copy: the new dict has its own keys but the values are the *same objects*. Rebinding a key affects one row, mutating a nested list or dict affects every row. `copy.deepcopy` — or rebuilding the nested value per row — gives you real independence.
The reasoning
A shallow copy duplicates the container and copies the references inside it. After `dict(template)`, the new dict is a separate object, but `new["tags"]` and `template["tags"]` are two names for one list. The distinction that matters is rebinding versus mutating: `row["id"] = 99` rebinds a key in one dict and touches nothing else, while `row["tags"].append("vip")` reaches through to the shared list and every holder of that reference sees it.
This is why the bug is so easy to miss in review. The line that breaks things looks read-only — it is a method call on a value, not an assignment — and the two lines sit next to each other doing visibly different things.
`copy.deepcopy` walks the structure and rebuilds every level, which is correct and costs a full traversal per row. In practice you usually do not want it: for row-building, construct the nested value inside the comprehension (`{"id": i, "tags": []}`) so each row gets a fresh list without copying anything. Reach for `deepcopy` when you genuinely have an arbitrary nested structure of unknown shape.
The same rule explains `list(rows)`, `rows[:]`, `dict.copy()` and `copy.copy` — all shallow, all fine for flat data, all sharing everything nested. If your rows are flat scalars, a shallow copy is the right tool and `deepcopy` is waste.
See it run on CPython 3.12
Line 1 rebinds a key. Line 2 mutates an object all three rows point at.
The answer most people give
"`dict(template)` does not copy at all — it is just another name for the same dict." It does copy the dict: notice that changing `id` on row 0 left the other two alone. Only the nested values are shared, and getting that distinction right is the whole answer.
They’ll ask next
You are handed rows of unknown nested shape from an API. Would you deepcopy every row defensively? What does that cost at a million rows a batch?
What is the difference between `is` and `==`, and why does `a is b` sometimes return True for two values you built separately?
Why they ask this
Everyone knows the one-line answer — identity versus equality. The interesting part is the follow-up about caching and interning, which separates people who memorised the rule from people who understand that the rule has no stable boundary you can rely on.
Say this
`==` asks whether two objects have the same value and is defined by `__eq__`; `is` asks whether they are the same object in memory. Use `is` only for singletons like `None`, because whether two equal values happen to share an object is a CPython implementation detail you must never depend on.
The reasoning
`==` dispatches to `__eq__`, so a class decides what equality means for it — two `datetime`s an hour apart in different zones can compare equal, two dataclass instances with identical fields compare equal. `is` compares identity and cannot be overridden; it is effectively an address comparison.
The confusing part is that CPython reuses objects to save allocations. Small integers (-5 to 256) are pre-created at startup, and short strings that look like identifiers are interned. Beyond that, the compiler folds constants: two literal `257`s in the same code object become one shared constant, so `x is y` is True — while the same value built at run time is a different object and `is` is False. There is no line you can point at.
That is precisely why the rule is "never use `is` for values". It is not that it is always wrong; it is that it is *sometimes* right, so it passes your tests on literals and fails in production the first time the value arrives from a file, a socket or a database driver instead of the source code.
The legitimate uses are singletons where identity is the definition: `x is None`, `x is True` when you genuinely need to exclude `1`, and sentinel objects you create yourself with `_MISSING = object()` to distinguish "not supplied" from "supplied as None".
See it run on CPython 3.12
Written first as `257 is 257` returning False. The harness disagreed: the compiler had folded both into one constant. That correction is the question.
The answer most people give
"`is` is True for integers up to 256 and False above that." That describes the small-integer cache and stops one step short. Constant folding makes two literal 257s the same object too — the honest answer is that the boundary is not specified and you must not build on it.
They’ll ask next
Where would you legitimately use `is`? And what does `_MISSING = object()` buy you that `None` does not?
What is the difference between `[n*n for n in rows]` and `(n*n for n in rows)`, and when does that difference decide whether your job runs at all?
Why they ask this
Streaming versus materialising is the single most consequential structural choice in a Python data job. This question is the entry point to it, and the answer tells an interviewer whether you have processed data larger than memory.
Say this
The first builds the whole list in memory before the next line runs; the second builds an object that computes each value on demand and holds one at a time. For a file that does not fit in RAM, that is the difference between a job that completes and a job the OOM killer stops.
The reasoning
A list comprehension is eager: it runs the loop to completion, allocates a list, and holds every result. A generator expression is lazy: it produces a generator object, and the loop body runs only as something consumes it — one value per `next()` call, with the function's local state frozen in between.
The memory difference is not proportional, it is categorical. A generator over a hundred thousand values is the same size as a generator over a hundred million, because it holds one value and a resume point rather than a collection. The measured numbers are below: a list of 100,000 ints is about 800 KB before you count the int objects themselves, while the generator is 200 bytes.
The cost is that a generator is single-pass and has no length: you cannot ask `len()`, you cannot index it, and once consumed it is empty. So the choice is really "do I need this data more than once, or in a random order?" If yes, materialise it deliberately. If no, stream it and the memory question stops existing.
Chained generators are where this pays off in real pipelines: `read → parse → filter → write` as four generator functions moves one row through all four stages at a time, so peak memory is one row rather than four copies of the dataset. That structure also happens to be far easier to unit test, because each stage is a pure function over an iterable.
See it run on CPython 3.12
Same total, four orders of magnitude apart in footprint. CPython 3.12.
The answer most people give
"Generators are faster." They are not inherently faster — for a small collection you iterate once, the difference is noise, and repeated access to a generator is far slower because you have to rebuild it. The win is memory and the ability to start producing output before the input is finished.
They’ll ask next
You need both the row count and the sum of a column from a 10 GB file. How do you do that with a generator, given that you cannot iterate it twice?
Someone computes `sum(rows)` and then `max(rows)` over the same generator and gets a total that looks right and a max that does not. What happened?
Why they ask this
It is the most common real bug generators cause, and unlike most bugs it fails quietly — `max` on an exhausted generator with a default returns the default, and `list()` returns `[]`, so you get a wrong number rather than an exception.
Say this
A generator is an iterator: consuming it advances it permanently. `sum` drained it, so `max` saw an empty sequence. Either materialise once into a list, or use `itertools.tee`, or restructure to compute both in a single pass.
The reasoning
Iterating a list creates a fresh iterator each time, which is why `for` over a list twice works. A generator *is* the iterator — there is no underlying collection to start over from — so the second consumer picks up where the first one stopped, which is the end.
The reason this is dangerous rather than merely annoying is the failure mode. `max(rows)` on an empty generator raises `ValueError`, which is loud and fine. But `max(rows, default=0)` returns 0, `list(rows)` returns `[]`, `sum(rows)` returns 0 and `any(rows)` returns False. In a pipeline that computes summary statistics, that is a report full of zeroes that nobody questions.
Three fixes, in order of how often they are right. Compute everything in one pass — a single `for` loop tracking total and maximum together is both correct and still O(1) in memory. Or materialise deliberately with `rows = list(rows)` if the data fits, which converts an invisible bug into an explicit memory decision. Or `itertools.tee` for two consumers, remembering that `tee` buffers whatever the slower consumer has not read yet, so it is only cheap when they advance together.
The general habit worth stating in an interview: a function that takes an iterable and iterates it more than once has a bug waiting for the day someone passes a generator. Either document that it needs a sequence, or call `list()` on entry and own the memory.
See it run on CPython 3.12
The same two calls against a generator and against a list.
The answer most people give
"You have to call the generator function again to reset it." Calling the function again does produce a fresh generator, but that also re-reads the source — for a file or an API cursor that is a second full pass, not a rewind, and sometimes it is not repeatable at all.
They’ll ask next
When would `itertools.tee` be the wrong answer? (Hint: what does it do if one consumer runs to the end before the other starts?)
What is the difference between an iterable and an iterator, and why can you loop over a list twice but not a generator?
Why they ask this
It is the protocol underneath every `for` loop in the language. Someone who can state it correctly can reason about generators, files, cursors and `zip` without memorising each one separately.
Say this
An iterable implements `__iter__` and hands out a fresh iterator each time you ask; an iterator implements `__next__` and holds the position. A list is an iterable that is not an iterator, so every loop gets a new cursor. A generator is its own iterator, so there is only ever one cursor and it only moves forward.
The reasoning
`for x in thing` does two things: it calls `iter(thing)` once to get an iterator, then calls `next()` on that iterator until `StopIteration`. Everything about iteration behaviour follows from what `iter()` returns.
For a list, `iter()` builds a brand-new `list_iterator` positioned at the start, which is why two loops over the same list are independent — and why `iter(rows) is iter(rows)` is False. For a generator, `iter()` returns the generator itself, so `iter(gen) is gen` is True and every consumer shares one position.
The same distinction explains behaviour people usually memorise case by case. A file object is its own iterator, so reading it twice without `seek(0)` gives you nothing the second time. `zip`, `map`, `enumerate` and `reversed` all return iterators in Python 3, so they are single-pass too. A `dict` and a `range` are iterables, so they are reusable — `range` in particular is a lazy sequence, not a generator, which is why it supports `len()` and indexing.
The practical rule this gives you: if you write a function that accepts "rows", decide whether your contract is "an iterable I may traverse once" or "a sequence I may traverse repeatedly", and say so in the type hint. `Iterable[Row]` and `Sequence[Row]` are different promises, and the second one is the one that survives a second `for` loop.
See it run on CPython 3.12
The `is` comparisons are the whole answer: one gives a new cursor, the other gives itself.
The answer most people give
"They are the same thing — an iterator is just anything you can loop over." That definition cannot explain why a list survives two loops and a generator does not, which is the only reason the distinction exists.
They’ll ask next
Your function signature says `rows: Iterable[dict]` and the body loops over it twice. Is that a bug? What would you change — the code or the signature?
Explain what a decorator is without using the word "decorator". What does `@retry` above a function definition actually do?
Why they ask this
Decorators are the most-used advanced feature in data-engineering codebases — retries, timing, caching, task registration in every orchestrator — and most candidates can use one but cannot say what it is.
Say this
It is a function that takes a function and returns a replacement. `@retry` above `def load(...)` is exactly `load = retry(load)`, evaluated once at import time, so the name `load` afterwards refers to the wrapper rather than to your original function.
The reasoning
Functions in Python are ordinary objects: you can pass them, store them and return them. A decorator exploits only that. `retry` receives the function object, builds a new function that closes over it, and returns the new one; the `@` line is syntax for the rebinding, not a new language feature.
The timing matters more than the syntax. The rebinding happens once, when the module is imported — not on each call. That is why a decorator can register a task in a global registry, and why a decorator with a bug crashes at import rather than at first use. It also means the wrapper is shared: any state you keep in the enclosing scope, such as a cache or a call counter, is shared by every call to that function.
The wrapper takes `*args, **kwargs` and forwards them so it works for any signature, and it must return the wrapped call's value — a wrapper that forgets to `return` turns every decorated function into one that returns `None`, which is a genuinely common bug and completely silent.
What you get for it is cross-cutting behaviour applied without touching the body: retries, timing, structured logging, caching with `functools.lru_cache`, and task registration. What you pay is a layer of indirection in every traceback and a function that is harder to test in isolation — which is why the useful follow-up is usually "how would you test the undecorated function?"
See it run on CPython 3.12
The retry wrapper swallows two ValueErrors and returns the third call's result.
The answer most people give
"It modifies the function." It does not touch your function at all — the original object is unchanged and still callable if you kept a reference. What changed is which object the *name* points to, and being precise about that is what the question is testing.
They’ll ask next
Now make it `@retry(times=5)`. Why does that need a third level of nesting, and what is `retry(times=5)` on its own?
Your decorator works, but the logs now say every function is called `wrapper` and `help()` shows nothing. What is missing, and why does it matter beyond cosmetics?
Why they ask this
It is a small detail that reveals whether you have actually debugged a decorated codebase, and it has real consequences for logging, docs tooling and anything that dispatches on `__name__`.
Say this
The wrapper is a different function object, so it carries its own `__name__`, `__doc__`, `__module__` and `__wrapped__`. `functools.wraps` copies the original's metadata onto it. Without it, every decorated function in your codebase reports the same identity.
The reasoning
When `retry` returns `wrapper`, the name `load` is now bound to an object that was defined as `def wrapper(*args, **kwargs)`. Nothing about it remembers what it wraps. `load.__name__` is `"wrapper"`, `load.__doc__` is None, and the signature reported by `inspect` is `(*args, **kwargs)`.
The consequences are not only cosmetic. Structured logging that includes `func.__name__` produces logs where every task is called "wrapper", which is precisely the situation where you needed the logs. Sphinx and pdoc generate empty documentation. Frameworks that register tasks by name — several orchestrators do — collide, because every registered function has the same name.
`@functools.wraps(fn)` on the wrapper copies `__module__`, `__name__`, `__qualname__`, `__doc__` and `__dict__`, and sets `__wrapped__` to the original. That last one is what lets `inspect.signature` report the real signature and what gives you an escape hatch in tests: `load.__wrapped__` is the undecorated function, so you can call it directly without retries or caching in the way.
It does not fix everything. `wraps` copies metadata, not behaviour: the traceback still shows a frame for `wrapper`, and stacked decorators still stack frames. It is the difference between a readable identity and an anonymous one, not a way to make the indirection disappear.
See it run on CPython 3.12
The same decorator, without `@wraps`. This is what your logs would be printing.
The answer most people give
"It is just for `help()` output, so it does not really matter." It matters the moment anything reads `__name__` at run time — logging, task registries, metrics labels — and those are exactly the systems decorators are used to build.
They’ll ask next
How would you unit-test the undecorated function, given that the module-level name is now the wrapper?
Does a closure capture the value of a variable or the variable itself? What does that change?
Why they ask this
It is the mechanism behind the classic "all my callbacks return the same thing" bug, and it comes up for real whenever someone builds a list of handlers, tasks or partial functions in a loop.
Say this
It captures the variable, not the value — the inner function looks the name up in the enclosing scope when it runs, not when it was defined. So a lambda built in a loop sees whatever the loop variable holds *after* the loop finished.
The reasoning
A closure stores a reference to the enclosing scope's cell for that name. When the inner function executes, it reads the cell's current contents. Building three lambdas in `for i in range(3)` gives three functions that all point at the same `i` cell, and by the time anyone calls them the loop has left `i` at 2 — so all three return 2.
Note that the loop variable survives the loop in Python; there is no per-iteration scope like the one `let` gives you in JavaScript. That is the whole reason this differs from the intuition people import from other languages.
The standard fix is a default argument: `lambda i=i: i`. Defaults are evaluated at definition time — the same rule that causes the mutable-default trap — so each lambda gets its own snapshot. `functools.partial(fn, i)` does the same thing more explicitly, and a factory function that takes `i` as a parameter is the most readable of the three because the capture becomes a normal function call.
Where this bites in data work is building per-partition or per-table callables in a loop: tasks registered in an orchestrator, retry handlers bound to a URL, transformations bound to a column name. Every one of them ends up bound to the last item, and the job runs the same partition N times without any error to point at.
See it run on CPython 3.12
Three lambdas over one cell, then three lambdas with their own snapshot.
The answer most people give
"Comprehensions have their own scope, so it should work." They do have their own scope — that is why `i` does not leak out of the comprehension — but all three lambdas were created inside that one scope and share its single `i`. The scope is per comprehension, not per iteration.
They’ll ask next
Why does `functools.partial(operator.mul, i)` not have this problem, when a lambda does?
What does a context manager guarantee that a `try`/`finally` does not? And what does `__exit__` returning True do?
Why they ask this
Every pipeline holds resources — files, connections, locks, temp directories, transactions. This question asks whether you understand the release path, which is the one people only think about after an incident.
Say this
`with` guarantees `__exit__` runs on every exit path — normal, exception, `break`, `return` — the same guarantee `finally` gives, but packaged with the acquisition so a caller cannot use the resource without it. Returning True from `__exit__` swallows the exception, which is almost always the wrong thing to do.
The reasoning
Mechanically, `with` is `try`/`finally` with the setup and teardown moved into one object. `__enter__` runs and its return value is bound by `as`; `__exit__` runs afterwards no matter how the block ends, receiving the exception type, value and traceback, or three Nones if the block completed normally.
The advantage over writing `try`/`finally` at each call site is not the guarantee itself — it is that the guarantee moves into the resource, so it cannot be forgotten. It also composes: `with a(), b():` releases in reverse order, and `contextlib.ExitStack` handles a number of resources known only at run time, which is what you want when the file list comes from a manifest.
`__exit__` returning a truthy value tells Python the exception was handled and execution continues after the block. It is a real feature — `contextlib.suppress(FileNotFoundError)` is built on it — but a context manager that swallows exceptions by default is how a pipeline reports success while writing nothing. The safe default is to return None (or False) so the exception propagates after cleanup.
For simple cases `@contextlib.contextmanager` turns a generator into a context manager: everything before the `yield` is `__enter__`, everything after is `__exit__`. The one thing you must get right is wrapping the `yield` in `try`/`finally`, because otherwise an exception in the body skips your cleanup entirely — the exact failure the construct was meant to prevent.
See it run on CPython 3.12
Two managers, one exception. Note the release order and that the error still escapes.
The answer most people give
"`with` closes the file for you." That is one instance of it. The general guarantee is that `__exit__` runs on every exit path and receives the exception, which is what lets a context manager roll back a transaction, release a lock or delete a partial output — not just close things.
They’ll ask next
You have to open a variable number of files decided at run time. `with` takes a fixed list — what do you use?
What does `@abstractmethod` actually prevent, and when does it complain? Why not just raise `NotImplementedError` in the base method?
Why they ask this
Abstraction questions separate people who have designed a codebase from people who have added to one. The timing of the check — instantiation, not definition, not call — is the part that gets missed.
Say this
A class inheriting from `ABC` with abstract methods cannot be instantiated until every abstract method is implemented, and the `TypeError` names the missing ones. `NotImplementedError` only fires when someone calls the method, which may be hours into a run.
The reasoning
The enforcement point is instantiation. Defining `class Blackhole(Sink): pass` is perfectly legal — the class object is created, and you can even subclass it further. The error arrives the first time someone writes `Blackhole()`, and it lists exactly which abstract methods are missing. That is early enough to fail at wiring time rather than mid-run.
Compare the alternative. A base method whose body is `raise NotImplementedError` is not checked at all: the object constructs happily, gets passed through your pipeline, and blows up when that particular code path is finally taken — which for an error handler or a rollback path might be the worst possible moment. The ABC turns a run-time landmine into a startup failure.
The mechanism is `ABCMeta`, a metaclass that collects names marked by `@abstractmethod` into `__abstractmethods__` and refuses `__call__` while the set is non-empty. Two practical consequences: you must inherit from `ABC` (or set the metaclass) for it to do anything — `@abstractmethod` on a plain class is a decoration with no effect — and abstract methods may have implementations that subclasses call through `super()`, so "abstract" means "must be overridden", not "must be empty".
When to use it: you have two or more implementations of the same role — a sink that writes to S3 and one that writes to a warehouse and one that writes to nothing in tests — and you want a new one to fail loudly if it misses part of the contract. When not to: a single implementation, where the ABC is ceremony, or an interface you want third parties to satisfy without importing your package, which is what `Protocol` is for.
See it run on CPython 3.12
Defining the incomplete subclass is fine. Constructing it is not.
The answer most people give
"You get an error as soon as you define the subclass." You do not — class creation succeeds. The check happens at instantiation, and knowing that is the difference between guessing and having used it.
They’ll ask next
What if the implementer cannot import your base class — a plugin in another package, say? How do you express the contract then?
When would you use `typing.Protocol` instead of an abstract base class? What is the actual difference in what gets checked, and when?
Why they ask this
It is the modern version of "explain duck typing", and it tests whether you can distinguish a run-time constraint from a static one — which is the same distinction that decides whether your type hints are load-bearing or decorative.
Say this
An ABC is nominal: a class satisfies it by inheriting from it, checked at instantiation. A Protocol is structural: a class satisfies it by having the right methods, checked by the type checker with no inheritance and no import. Use a Protocol when you do not own the implementations.
The reasoning
With an ABC the implementer must import your class and inherit from it, which is a real coupling — and impossible for classes you did not write, such as a driver from a third-party library that already has the right shape. A `Protocol` describes the shape instead. Any class with a matching `write(self, rows) -> str` satisfies `Sink` whether or not it has ever heard of your module.
The trade is where the check lives. The ABC check is a run-time `TypeError` you cannot avoid; the Protocol check is done by mypy or pyright while you are editing and *does not exist at run time* unless you add `@runtime_checkable`, and even then `isinstance` only verifies that the method names are present — not their signatures or return types.
That makes the choice mostly about ownership. If the implementations live in your codebase and you want an incomplete one to fail loudly at startup, use an ABC; it also lets you share code through concrete methods on the base. If you are describing what you *accept* at a boundary — "anything that has `read()`", "anything with a `write` method" — use a Protocol, because it demands nothing of the caller and keeps the dependency pointing the right way.
They compose, too: it is normal to publish a `Protocol` as the public contract for callers and to use an ABC internally as the base for your own implementations. And structural typing is what the standard library has always used informally — `Iterable`, `Sized`, "file-like object" — Protocol just makes those describable to a type checker.
See it run on CPython 3.12
`S3Sink` never imports `Sink`, and still satisfies it. `Broken` has a method — the wrong one.
The answer most people give
"Protocol is just the new way to write an ABC." They check different things at different times. A Protocol adds no run-time guarantee at all by default, so if your goal is "this must fail at startup when incomplete", swapping in a Protocol quietly removes the enforcement you were relying on.
They’ll ask next
With `@runtime_checkable`, does `isinstance` verify the signature? What happens if my class has `write(self)` taking no rows?
What does `@dataclass` actually write for you, and what changes when you pass `frozen=True`?
Why they ask this
Dataclasses are the default way to model a record in modern Python. Knowing exactly which methods appear — and that `frozen` is what makes an instance usable as a dict key — is the difference between using them and cargo-culting them.
Say this
It generates `__init__`, `__repr__` and `__eq__` from the annotated fields, so instances compare by value instead of by identity. `frozen=True` additionally blocks attribute assignment and generates `__hash__`, which is what lets an instance be a dict key or a set member.
The reasoning
The decorator reads `__annotations__` at class-creation time and writes methods from them. `__init__` takes the fields in declaration order, `__repr__` prints them, and `__eq__` compares the tuple of fields — but only against instances of the same class, so a `Row` never equals a plain tuple. Optional extras: `order=True` adds the comparison operators, `slots=True` swaps `__dict__` for `__slots__`, and `kw_only=True` forces keyword arguments.
The mutable-default rule from ordinary functions applies here too, and the decorator refuses to let you get it wrong: a field defaulting to `[]` raises `ValueError` at class definition. You write `field(default_factory=list)` instead, which calls the factory once per instance.
By default a dataclass is unhashable. That is not an oversight — defining `__eq__` sets `__hash__` to None, because an object whose equality depends on mutable fields would move buckets in a dict when you mutate it. `frozen=True` makes assignment raise `FrozenInstanceError`, which restores the invariant, so Python generates `__hash__` and the instance can key a dict or join a set.
That combination is genuinely useful in pipeline code: a frozen dataclass makes a much better cache key or dedup key than a tuple, because the fields are named. `Key(dataset="orders", day="2026-03-01")` is self-documenting at every call site, where `("orders", "2026-03-01")` needs you to remember the order.
See it run on CPython 3.12
Value equality, a generated repr, a frozen instance used as a dict key, and the write that fails.
The answer most people give
"`frozen=True` makes it immutable." It blocks assignment on the instance — but a frozen dataclass holding a list still lets you append to that list. Immutability is shallow, which matters exactly when you have used the instance as a dict key.
They’ll ask next
A frozen dataclass with a `list` field: is it hashable? Try it, then explain the error.
What is the difference between `@classmethod`, `@staticmethod` and a plain method — and why is `cls` the reason alternative constructors use the first one?
Why they ask this
The alternative-constructor pattern (`Config.from_env()`, `Partition.parse(path)`) is everywhere in pipeline code. This asks whether you know why it is a classmethod rather than a staticmethod, which is a question about inheritance, not style.
Say this
A plain method receives the instance, a classmethod receives the class, and a staticmethod receives nothing. Alternative constructors use `cls` so that calling them on a subclass builds the subclass — a staticmethod would hard-code the base class and silently return the wrong type.
The reasoning
The three differ only in what Python passes in. `self` gives you a specific object; `cls` gives you the class the call was made through, which is not necessarily the class the method was defined on; a staticmethod gets neither and is simply a function that lives in the class namespace for organisational reasons.
That "not necessarily the class it was defined on" is the entire point for constructors. `Partition.parse(...)` returns a `Partition`, and `DailyPartition.parse(...)` returns a `DailyPartition` — without `parse` being redefined — because `cls` follows the call. Written as a staticmethod returning `Partition(...)`, the subclass would inherit a constructor that produces base-class instances, and nothing would warn you.
The idiomatic use of alternative constructors is to keep `__init__` a single dumb assignment of already-valid fields and to put every way of *arriving* at those fields in named classmethods: `from_path`, `from_env`, `from_row`. It reads better at the call site than a constructor with five mutually exclusive optional arguments, and each one can validate its own input.
Staticmethods are the weakest of the three and often a sign the function belongs at module level instead. The honest case for one is a helper that is meaningless outside the class's domain and that you want found through the class — a validator like `is_valid_day` — where making it a module function would leave it orphaned.
See it run on CPython 3.12
One `parse`, two classes. `cls` is what makes the second line produce a `DailyPartition`.
The answer most people give
"They are basically the same, staticmethod just does not take self." That describes the signature and misses the consequence. Swap the classmethod for a staticmethod and subclass construction breaks — the method keeps working, it just returns the wrong class.
They’ll ask next
Where would you put validation that a path is well-formed — in `__init__`, in `parse`, or in the caller? What breaks with each choice?
Why use `super().run()` rather than naming the parent class directly, and what is the MRO deciding when two parents both define `run`?
Why they ask this
Multiple inheritance appears in real code as mixins — a timing mixin, a logging mixin, a retry mixin on a task class. If you cannot explain the MRO you cannot debug the day one mixin's method silently never runs.
Say this
`super()` follows the method resolution order of the *instance's* class, not the lexical parent, so each class in a diamond runs exactly once and in a defined order. Naming the parent directly hard-codes one edge of the graph and skips any class that sits between them.
The reasoning
Every class has an `__mro__`: a linearisation of its ancestors computed by C3, which guarantees that a class precedes its parents and that the order you declared bases in is preserved. `super()` does not mean "my parent" — it means "the next class after me in the MRO of the object being used", which is why the answer depends on the instance and cannot be determined by reading one class in isolation.
In the example, `Job(Timed, Logged)` has the MRO `Job → Timed → Logged → Stage → object`. `Timed.run` calls `super().run()`, and even though `Timed` is written as a subclass of `Stage`, the next class along is `Logged`. So all three bodies run, in order, each once. Had `Timed.run` called `Stage.run(self)` directly, `Logged.run` would never execute — and there would be no error, just a mixin that quietly stopped working.
The requirement this places on cooperative classes is that every implementation calls `super()`. `Logged.run` calling `super().run()` is what lets `Stage.run` execute at all. A mixin that omits the call terminates the chain, which is occasionally intentional and usually a bug.
Two practical notes. Python 3's zero-argument `super()` works because the compiler stores the defining class in a closure cell, so it is both safer and faster than the explicit two-argument form. And `super()` is worth using even in single inheritance — it costs nothing and means the class still behaves when someone later mixes it into a hierarchy you never anticipated.
See it run on CPython 3.12
`Timed` is not declared as a subclass of `Logged`, yet `super()` inside it lands there.
The answer most people give
"`super()` calls the parent class." It calls the next class in the MRO, which in a diamond is frequently a *sibling* — as it is here, where `super()` inside `Timed` reaches `Logged`. The two descriptions only coincide in single inheritance.
They’ll ask next
One of your mixins stops calling `super()`. Nothing errors and one behaviour disappears. How do you find it?
GIL & memoryConcurrency (threading vs multiprocessing vs asyncio)
What is the GIL, what does it protect, and what does it not protect? When is it the thing that is actually slowing your job down?
Why they ask this
The GIL is the most confidently misexplained thing in Python. The signal is whether you can say what it guarantees (interpreter internals stay consistent) without overclaiming that it makes your code thread-safe.
Say this
The GIL is a single lock that lets only one thread execute Python bytecode at a time. It protects interpreter state — reference counts, object internals — not your program's invariants. It is the bottleneck only for CPU-bound pure-Python work, because it is released around I/O and inside many C extensions.
The reasoning
CPython manages memory by reference counting, and updating a refcount from two threads at once corrupts it. The GIL makes that impossible by serialising bytecode execution. The interpreter releases and reacquires it periodically — every 5 ms by default, readable via `sys.getswitchinterval()` — so threads interleave rather than run in parallel.
What follows is the practical rule. Threads are *released* around blocking I/O: a socket read, a disk read, a database driver waiting on a response all drop the GIL while they wait, so a hundred threads waiting on a hundred HTTP calls genuinely overlap. Many C extensions do the same for compute — NumPy releases it around array operations, and a good deal of pandas and pyarrow work does too. So "Python cannot use multiple cores" is wrong in exactly the cases data engineers care about most.
It is the ceiling for CPU-bound pure-Python loops: parsing, string manipulation, arithmetic written in Python. Four threads doing that work take about as long as one, and slightly longer because of switching overhead. That is when you reach for `multiprocessing` or push the work into a library that releases the lock.
The critical thing it does *not* buy you is thread safety for your own data. Individual bytecodes are atomic, so a `list.append` cannot corrupt the list — but any operation that compiles to several bytecodes can be interrupted in the middle, and `counter += 1` is exactly that. Needing a lock is orthogonal to the GIL existing.
Worth knowing for a 2026 interview: PEP 703 added a free-threaded build in 3.13, available as an official option in 3.14, which removes the GIL entirely. It is opt-in, it costs single-threaded performance, and it does not change any of the above about your own invariants — the locks you needed before, you still need.
See it run on CPython 3.12
Four threads, 200k appends each. The list is never corrupted — that is what the GIL guarantees.
The answer most people give
"The GIL means Python cannot do anything in parallel, so threads are useless." Threads are the right answer for I/O-bound work, which is most of what a pipeline does — and NumPy and pyarrow release the GIL for compute too. The claim is only true for CPU-bound pure-Python code.
They’ll ask next
If the GIL makes appends atomic, why does `self.count += 1` from four threads still need a lock?
Concurrency (threading vs multiprocessing vs asyncio)GIL & memoryAPIs & pagination
You have three jobs: 500 API calls, parsing 40 GB of JSON, and a service handling thousands of idle websocket connections. Which concurrency model for each, and why?
Why they ask this
This is the question the GIL discussion exists to serve. Anyone can recite the three models; the signal is whether you map them onto workloads correctly and can name what each one costs.
Say this
Threads for the API calls — they block on I/O and release the GIL. Processes for the JSON parsing — it is CPU-bound pure Python, so it needs real cores. Asyncio for the websockets, because thousands of mostly-idle connections cost a coroutine each rather than a thread stack each.
The reasoning
The decision has two axes: is the work waiting or computing, and how many concurrent things are there. Waiting work does not need the GIL, so threads and asyncio both work; computing work in pure Python does, so only processes help. The count decides between threads and asyncio: threads cost around 8 MB of stack address space each and a kernel scheduling slot, which is fine at a few hundred and unworkable at ten thousand.
500 API calls: `ThreadPoolExecutor` with a bounded pool. The requests spend virtually all their time waiting on sockets with the GIL released, so throughput scales until you hit the remote rate limit. It also works with ordinary blocking libraries, which matters because most database drivers are not async. Asyncio would also work and would use less memory, but it requires the whole call path to be async — one blocking driver in the middle and you have lost the benefit.
40 GB of JSON: `ProcessPoolExecutor`, one worker per core, chunked by file or by byte range. Each process has its own interpreter and its own GIL, so this is the only route to real parallelism for pure-Python compute. The cost is that arguments and results are pickled and copied between processes, so it pays only when the per-chunk work is large relative to the transfer — sending a million tiny rows one at a time is slower than doing it serially. The better answer, if you are allowed it, is to stop parsing JSON in Python: `orjson`, pyarrow or DuckDB do it in C and release the GIL.
Thousands of idle websockets: asyncio. A coroutine is a few hundred bytes and switching between them is a function call in user space rather than a kernel context switch, so idle connections cost almost nothing. The price is that the model is cooperative — one blocking call anywhere on the loop stalls every connection — and that you need async-native libraries throughout.
The sentence worth having ready: threads and asyncio both give you concurrency without parallelism, processes give you parallelism at the cost of copying, and if the hot loop is in a C library that releases the GIL then threads give you parallelism too.
The answer most people give
"Always use multiprocessing, it is the only real parallelism." For 500 HTTP calls that spawns processes that do nothing but wait, pays pickling and start-up costs for every one, and is slower and heavier than a thread pool doing the identical work.
They’ll ask next
Your 500 API calls now have to write each response to Postgres through a blocking driver. Does that change your answer?
Concurrency (threading vs multiprocessing vs asyncio)GIL & memory
Your worker function updates a module-level dict and the parent process never sees the change. Why, and how do you actually get results back?
Why they ask this
It is the first thing that breaks when someone moves working threaded code to multiprocessing, and the fix people reach for first — a global — is exactly the thing that cannot work.
Say this
Each process gets its own memory, so the child mutated its own copy of the dict. Get results back through return values, a `multiprocessing.Queue`, a `Manager` proxy, or shared memory — anything that crosses the process boundary explicitly.
The reasoning
Threads share one address space, so a global dict is genuinely one object and mutations are visible everywhere (which is why threads need locks). Processes do not: the child either inherits a copy-on-write snapshot at fork time or, with spawn, re-imports your module from scratch. Either way the child's `TOTALS` and the parent's `TOTALS` are different objects from the moment the child starts.
The start method decides how surprising this is. On Linux the default was `fork`, which copies the parent's state so the child at least *starts* with the parent's data — until the child writes, at which point copy-on-write makes a private copy. With `spawn` — the default on macOS and Windows, and on Linux from Python 3.14 — the child starts a fresh interpreter and imports your module, so module-level state is whatever the import produces and nothing the parent did at run time is there at all. Code that works on Linux and fails on a colleague's Mac is almost always this.
The mechanisms for getting data back, roughly in order of preference: return a value from the worker and let `Pool.map` or a future collect it; use a `Queue` for a stream of results; use a `Manager().dict()` when you genuinely need shared mutable state, accepting that every access is a proxied round trip over a socket; use `multiprocessing.shared_memory` or `Array` for large numeric buffers where copying would dominate.
The design consequence is worth saying out loud: a worker function should be a pure function of its arguments, returning its result. That is not a stylistic preference under multiprocessing — anything else either fails to propagate or forces you into a manager proxy whose cost quietly eats the parallelism you bought.
See it run on CPython 3.12
The child computed 10. The parent, in the same program, still has 0.
The answer most people give
"Add a lock around the global and it will be fine." A lock coordinates access to shared memory; here there is no shared memory to coordinate. The two dicts are different objects, so no amount of locking makes one visible to the other.
They’ll ask next
Why is `if __name__ == "__main__":` mandatory around that code under spawn, and what happens without it?
Concurrency (threading vs multiprocessing vs asyncio)
If the GIL means only one thread runs at a time, why can four threads incrementing `self.n += 1` lose updates?
Why they ask this
It is the sharpest test of whether "the GIL makes Python thread-safe" is a slogan or an understanding. It also produces the worst class of bug: intermittent, load-dependent, and invisible in tests.
Say this
The GIL serialises bytecodes, not statements. `self.n += 1` compiles to several — load, add, store — and a thread switch between the load and the store means two threads read the same value and both write back the same result. One increment is lost.
The reasoning
Disassembling the increment shows the sequence: load the object, load the attribute, load the constant, add, store the attribute. The interpreter may release the GIL between any two of them. Thread A loads 41; the switch fires; thread B loads 41, adds, stores 42; A resumes with its stale 41, adds, stores 42. Two increments, one net change.
Contrast with `list.append`, which is a single call into C that completes while holding the GIL — no interleaving is possible mid-append, which is why the earlier snippet's four threads produced exactly 200,000 elements. The distinction is not "some operations are safe and some are not" by convention: it is whether the operation is one indivisible step or several, and read-modify-write is always several.
What makes this dangerous is the statistics. The switch has to land inside a window a few bytecodes wide, so on a short test loop it may never happen and the code looks correct. Under production load, with more threads and more contention, it happens constantly. That is the profile of a bug that ships.
The fixes: hold a `threading.Lock` around the read-modify-write, which is what the snippet does and which yields exactly 800,000 every time; or avoid shared mutable state entirely by giving each thread its own counter and summing at the end, which is faster because it removes the contention rather than serialising it; or hand the coordination to a `queue.Queue`, whose operations are atomic by design. The last is usually the best structure for pipeline work.
See it run on CPython 3.12
The opcode list is the answer — every gap in it is a switch point. Timing-dependent output would not be publishable, so the locked run is what is shown.
The answer most people give
"The GIL makes it thread-safe, so no lock is needed." The GIL protects the interpreter's own state, not your invariants. It guarantees the object will not be corrupted; it guarantees nothing about the value being correct.
They’ll ask next
Which of these need a lock: `d[k] = v`, `d[k] += 1`, `lst.append(x)`, `if k not in d: d[k] = []`? Why?
Concurrency (threading vs multiprocessing vs asyncio)APIs & pagination
You made your fetcher async and it is no faster. On inspection there is a `time.sleep` — or a `requests.get`, or a blocking DB driver — inside a coroutine. Why does one blocking call ruin the whole event loop?
Why they ask this
It is the number-one asyncio bug in production code and it produces no error at all. Explaining it correctly requires knowing that asyncio is cooperative rather than pre-emptive, which is the concept the whole model rests on.
Say this
The event loop is a single thread that runs one coroutine until it awaits. A blocking call never awaits, so the loop cannot switch — every other task is stalled for its duration. Push blocking work off the loop with `asyncio.to_thread` or `run_in_executor`.
The reasoning
Asyncio is cooperative multitasking. Concurrency happens only at `await` points, where a coroutine voluntarily yields control back to the loop, which then runs whatever else is ready. There is no pre-emption: the loop cannot interrupt a running coroutine, because it *is* that coroutine's thread.
So `await asyncio.sleep(0.3)` and `time.sleep(0.3)` behave completely differently despite reading similarly. The first registers a timer and yields, letting the other tasks run — which is why the snippet's awaited tasks finish in duration order, fastest first, even though the slowest was started first. The second holds the thread for the full 0.3 s, so the tasks finish in the order they were started and the total is the sum rather than the maximum.
The reason it is hard to spot is that nothing errors. The code is valid, the results are correct, and the only symptom is that your concurrency did nothing. `asyncio.run(..., debug=True)` helps — it warns when a callback blocks the loop for too long — and the structural check is simpler: any third-party call inside a coroutine that is not awaited is a suspect.
The fix is to get the blocking work off the loop thread: `await asyncio.to_thread(blocking_fn, arg)` for the common case, or `loop.run_in_executor` with a process pool when the work is CPU-bound. This is also why mixing asyncio with a synchronous database driver is a design decision rather than a detail — either the whole path is async, or every call to that driver needs an executor hop.
See it run on CPython 3.12
The completion order is the evidence, not the elapsed time — timings vary run to run and would prove nothing.
The answer most people give
"Adding `async` to the function makes it run concurrently." `async` only makes it a coroutine — something that *can* yield. Concurrency comes from the `await` points inside it, and a coroutine with no real await is just a slower function.
They’ll ask next
Your only Postgres driver is synchronous. How do you use it from an asyncio service without stalling the loop, and what does that cost?
What is the difference between mutable and immutable types in Python, and name a bug that follows directly from getting it wrong.
Why they ask this
It is the first question on most Python screens, and it is not trivia — half the surprising behaviour in the language traces back to it.
Say this
Immutable objects cannot be changed after creation — int, float, str, tuple, frozenset. Mutable ones can — list, dict, set, and most class instances. The distinction decides what happens when an object is shared, defaulted or used as a key.
The reasoning
**The two lists.** Immutable: `int`, `float`, `bool`, `str`, `bytes`, `tuple`, `frozenset`, `None`. Mutable: `list`, `dict`, `set`, `bytearray`, and instances of most classes. Rebinding a name is not mutation — `x = x + 1` makes a new int and points `x` at it, which is why immutability does not stop you from writing that.
**Consequence one: shared references.** Two names bound to the same list see each other's changes; two names bound to the same string cannot. `b = a` never copies, so `a.append(1)` is visible through `b` when `a` is a list and the question does not arise when it is a tuple. This is also why `copy()` is shallow by default — the outer list is new, the inner objects are still shared.
**Consequence two: the mutable default argument.** `def f(x, acc=[])` evaluates the default **once, at function definition**, so every call without an argument shares one list and it accumulates across calls. The fix is `acc=None` and `acc = [] if acc is None else acc`. With an immutable default the bug cannot happen, which is why `acc=0` and `acc=()` are safe.
**Consequence three: hashability.** Dict keys and set members must be hashable, and mutable built-ins are deliberately unhashable — if a key could change after insertion it would land in the wrong bucket and become unfindable. So a tuple can be a key and a list cannot, and `frozenset` exists precisely to make a set usable as one.
**And the case that surprises people:** a tuple is immutable, but it can *contain* a mutable object. `t = (1, [2, 3])` — you cannot reassign `t[1]`, and you can call `t[1].append(4)`. Immutability is a property of the container, not a guarantee about everything reachable from it, and `t` is unhashable as a result.
The formulations
None as the defaultship
def f(x, acc=None):
acc = [] if acc is None else acc
The default is evaluated once — so make it something immutable.
Tuple as a dict keyship
counts[(user_id, event_date)] += 1
Hashable because it cannot change after insertion.
Mutable defaultavoid
def f(x, acc=[]):
acc.append(x); return acc # shared across every call
"Immutable means the variable cannot be reassigned." That is a constant, which Python does not have. `s = "a"` then `s = "b"` is fine — the string was never modified, the name was rebound. Immutability is about the object, not the name.
They’ll ask next
Why can a tuple be a dictionary key but a list cannot?
What does the @dataclass decorator actually generate for you, and what does it deliberately not do?
Why they ask this
Everybody has used one. The follow-up separates people who read what it produces from people who copied it from a colleague.
Say this
It reads the annotated class attributes and writes __init__, __repr__ and __eq__ from them, in declaration order. It does not validate types, and it refuses a mutable default outright.
The reasoning
**What it writes.** From the annotated fields it generates `__init__` taking them as parameters in declaration order, a `__repr__` naming each one, and an `__eq__` that compares the fields as a tuple. `eq=True` is the default, and with `order=True` you also get the four comparison operators.
**What `frozen=True` adds.** It blocks attribute assignment and, because the instance can no longer change, generates a `__hash__` consistent with the generated `__eq__`. That is the exact contract a dict key or a set member needs, and it is why a frozen dataclass is the clean answer to "deduplicate these records".
**The rule you must know.** A mutable default is refused at class creation, not at instantiation: `rows: list = []` raises `ValueError: mutable default ... use default_factory`. Without that refusal every instance would share one list, which is the class-level version of the mutable default argument.
**What it does not do.** Annotations are documentation. `amount: float` does not stop `Order(amount='12,5')` — nothing checks, nothing converts, and the record is now well-typed on paper and wrong in fact. Validation is a separate step, and the honest place for it is the boundary where the raw data arrives.
**And a plain `@dataclass` is unhashable.** Defining `__eq__` sets `__hash__` to `None`, so the moment you make a record comparable you also make it unusable as a key — unless you add `frozen=True`, which restores it consistently.
The formulations
A record you mutateship
@dataclass
class Batch:
rows: list = field(default_factory=list)
default_factory is called per instance, so the lists are not shared.
A record used as a keyship
@dataclass(frozen=True)
class OrderKey:
order_id: int
sku: str
Immutable, so it gets a __hash__ that agrees with its __eq__.
Mutable defaultavoid
@dataclass
class Batch:
rows: list = [] # ValueError at class creation
Refused outright — the one trap dataclasses will not let you ship.
Plain dataclass as a dict keyavoid
@dataclass
class K:
a: int
{K(1): 'x'} # TypeError: unhashable
eq=True without frozen=True removes the default hash.
The answer most people give
"It saves you writing __init__." True and not the answer — it also writes __eq__, which changes how the object behaves in sets, dicts and assertions, and that is the part that surprises people.
They’ll ask next
Why does a plain @dataclass become unhashable, and what is the one-word fix?
What does __slots__ do, and when is it worth adding to a class in a data pipeline?
Why they ask this
It is the standard memory question, and the good answer is a measurement rather than a rule.
Say this
It declares a fixed set of attributes and drops the per-instance __dict__, which saves real memory and makes attribute access slightly faster. It is worth it when you hold millions of instances, and pointless below that.
The reasoning
**What it does.** By default every instance carries a `__dict__` mapping attribute names to values, which is what lets you attach an attribute that was never declared. `__slots__ = ('order_id', 'amount')` replaces that with a fixed array of descriptors: less memory per instance, marginally faster attribute access, and an `AttributeError` if you try to set anything else.
**The size of the win.** The saving is per instance and it is not small — commonly half the footprint of a small record. Multiply by ten million rows held at once and it is the difference between fitting in memory and not; multiply by two hundred records and it is noise you have paid readability for.
**What it costs.** No dynamic attributes, and no `__dict__` for anything that expects one — some serialisers and debugging tools reach for it. Inheritance needs care: a subclass without its own `__slots__` reintroduces the dict and undoes the saving silently.
**The data-engineering answer.** Most pipeline code streams rather than holds, and a generator over records costs nothing per row whatever the record type. So the honest answer is that `__slots__` is the fix for a measured memory problem in code that genuinely materialises millions of objects — and that the first move is to check whether you needed to materialise them at all.
The formulations
Streaming insteadship
for row in read_rows(path): # one at a time
yield transform(row)
No instances held, so no per-instance saving to chase.
Slots on a hot recordworks
@dataclass(slots=True)
class Row:
order_id: int
amount: str
Right when profiling shows millions of these alive at once.
Slots everywhereavoid
class Config:
__slots__ = ('sink', 'retries')
A config object exists once. This is cost with no benefit.
The answer most people give
"It makes the class faster." Attribute access is marginally faster, and nobody has ever fixed a slow pipeline with __slots__. The saving is memory, and it only matters at a scale you should be able to state.
They’ll ask next
What happens to the memory saving if a subclass does not declare __slots__ of its own?
What does it mean for an object to be hashable, and what is the contract between __eq__ and __hash__?
Why they ask this
Every dedupe and every lookup map depends on it, and the contract is the part people cannot state even when their code relies on it.
Say this
Hashable means it has a __hash__ that never changes and an __eq__ that agrees with it: equal objects must hash equal. Containers find objects by hash first, so an object whose hash could change would become unfindable.
The reasoning
**The contract, both halves.** If `a == b` then `hash(a) == hash(b)`. The reverse need not hold — two unequal objects may collide, and the container falls back to equality within the bucket. And the hash must not change while the object is in a container, which is why the mutable built-ins are deliberately unhashable.
**Why a list cannot be a key.** Not because lists are special, but because a list can change after insertion. It would be stored in the bucket for its old contents and looked up in the bucket for its new ones, so `x in seen` would return `False` for something visibly present, with no error anywhere. Refusing at insertion is the container protecting its own guarantee.
**What this means for your classes.** Defining `__eq__` sets `__hash__` to `None`, because Python cannot guess a hash consistent with your equality. Either define both — hashing a tuple of exactly the fields `__eq__` compares — or use `@dataclass(frozen=True)`, which writes both for you and blocks the mutation that would break them.
**And the practical version.** A composite key in Python is a tuple of immutable values, because that is the cheapest thing satisfying the contract. `frozenset` exists for the same reason: it is the hashable set, for when the key is "this collection of things, in no particular order".
The formulations
Tuple composite keyship
seen.add((order_id, sku))
Immutable, hashable, and the standard spelling.
Frozen dataclass keyship
@dataclass(frozen=True)
class K:
order_id: int
sku: str
Named fields, readable repr, hash and eq generated together.
k = [1, 2]
# seen.add(k) already raises — and this is why
The hash would move and the object would be lost.
The answer most people give
"Hashable means immutable." Close but not the contract: an object can be mutable and hashable as long as its hash does not depend on the parts that change — which is a design most people should not attempt.
They’ll ask next
Two records compare equal but hash differently. What breaks, and when do you notice?
What is the difference between an iterable and an iterator, and what would you have to write to implement one by hand?
Why they ask this
It is the question behind every "why is the second pass empty" bug, and writing it out is the fastest way to show you understand laziness rather than syntax.
Say this
An iterable has __iter__ and can produce a fresh iterator on demand; an iterator has __iter__ and __next__ and holds a position, so it is consumed once. A list is iterable; a generator is an iterator.
The reasoning
**The two protocols.** `__iter__` returns an iterator. `__next__` returns the next item or raises `StopIteration`. An iterator returns `self` from `__iter__`, which is why it can be used in a `for` loop and why iterating it twice gives you nothing the second time — it is the same object, still at the end.
**Why the distinction matters in a pipeline.** A function that takes an iterable and walks it twice works for a list and silently produces zero rows on the second pass for a generator. The fix is a decision: materialise with `list()`, use `itertools.tee`, or document that the parameter is consumed. All three are fine; not choosing is not.
**Writing one by hand.** A class with `__iter__` returning `self` and `__next__` doing the work is the explicit form. In practice you write a generator function instead — `yield` gives you both methods and the state machine for free — and the class form is worth knowing mainly so you can say what the generator is doing.
**The reason to care.** Laziness buys constant memory over a file larger than RAM, and it charges for it with single consumption and with exceptions that surface at the point of iteration rather than at the point of definition. Both of those are the protocol showing through.
The formulations
A generator functionship
def rows(lines):
for line in lines:
yield parse(line)
The idiomatic answer: yield writes the protocol for you.
Worth writing once to see what the generator generates.
Walking a parameter twiceavoid
def report(rows):
total = sum(r['n'] for r in rows)
count = len(list(rows)) # 0 for a generator
Works for a list, silently wrong for an iterator.
The answer most people give
"They are the same thing." They are not, and the difference is exactly the bug: `for` over a list can be repeated, `for` over a generator cannot, and nothing raises to tell you which one you were handed.
They’ll ask next
A function takes an iterable and needs two passes. What are your three options and what does each cost?