Here is code that works. Review it. The interviewer is not looking for bugs — they are looking for whether you can name the contract with types, make the failure observable, make the thing testable without a live warehouse, and get the configuration out of the source. This is the section that separates a senior candidate from a productive one.
What the signature promises, and whether the body can keep the promise.
Making failure observable
4
At 3am the only thing you have is what the job wrote down.
Making it testable
4
If it needs a warehouse and a wall clock to test, it will not be tested.
Configuration & environment
4
Where the bucket name lives, and when the environment is read.
Structure that survives an incident
4
Small functions, released resources, and a return value that reports what happened.
Evergreen · asked verbatim
2
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 / 22
TypingComprehensions & generators
The signature accepts `Iterable[dict]` and the body iterates it twice. Every current caller passes a list, so it works. What is your review comment?
The code as submitted
from typing import Iterable
def summarise(rows: Iterable[dict]) -> str:
total = sum(r["amount"] for r in rows)
count = len(list(rows)) # a second pass over the same argument
return f"{count} rows, {total} total"
print(summarise([{"amount": 10}, {"amount": 20}]))
print(summarise([{"amount": 5}]))
It prints
2 rows, 30 total
1 rows, 5 total
Why they ask this
It is the clearest example of an annotation that promises more than the code delivers. The bug does not exist yet — it is created the day someone takes the signature at its word.
Say this
`Iterable` promises only that you can iterate it once. The body needs two passes, so the annotation is wrong: either narrow it to `Sequence`, which supports `len` and repeated iteration, or compute both values in a single pass.
The reasoning
A type hint is a contract, and this one is unsatisfiable. Anything genuinely `Iterable` — a generator, a file handle, a `zip`, a database cursor — is drained by the first `sum` and yields nothing to the second pass. The result would be a count of 0 alongside a correct total: no error, just a wrong number.
The reason it works today is that every caller happens to pass a list, which is re-iterable. That is the definition of a latent defect: correct by coincidence, and the coincidence is not written down anywhere. A type checker will not flag it, because passing a list where an `Iterable` is expected is perfectly valid — the mismatch is between the annotation and the body, which no tool checks.
Narrowing to `Sequence` is the smaller change and makes the requirement explicit: callers must pass something with a length and stable ordering, and `len(rows)` becomes honest rather than a hidden second traversal. A generator caller now fails at the type checker instead of at run time with a wrong answer.
The alternative — one pass accumulating both values — keeps the wider `Iterable` contract and works on a stream of any size. Which to choose depends on whether streaming matters here. The reviewable point is that the current code chose neither, and picked an annotation that matches the callers rather than the implementation.
After the review same output, verified
Same answers, and the second version cannot be handed something it will silently mishandle.
from typing import Sequence
def summarise(rows: Sequence[dict]) -> str:
total = sum(r["amount"] for r in rows)
count = len(rows)
return f"{count} rows, {total} total"
print(summarise([{"amount": 10}, {"amount": 20}]))
print(summarise([{"amount": 5}]))
Prints
2 rows, 30 total
1 rows, 5 total
The answer most people give
"It works, so there is nothing to fix — do not change working code." The code works for the callers that exist. The annotation is an invitation to a caller that does not exist yet, and the failure it invites is a silent wrong number rather than a crash.
They’ll ask next
Which would you actually pick here — `Sequence`, or one pass over `Iterable`? What does the answer depend on?
Dicts as records is the default in pipeline code and the annotation is technically accurate, which makes it a good test of whether you can argue for a change that fixes nothing today.
Say this
Return a dataclass instead. `dict[str, Any]` tells a reader nothing about which keys exist or what they hold, so every downstream key access is unchecked. A typed record documents the shape once, catches typos before the code runs, and gives the type checker something to work with.
The reasoning
`dict[str, Any]` is the type-system equivalent of no annotation. Neither a reader nor a checker can tell whether the result has `region` or `regions`, whether `amount` is an `int` or a `Decimal`, or whether `id` is guaranteed present. Every consumer five stages downstream has to read this function to find out, and a renamed key is discovered at run time.
A dataclass moves all of that into one declaration. `row.region` is checked by mypy and autocompleted by the editor; a typo is an error before the code runs rather than a `KeyError` in production. The definition itself becomes the documentation, which is worth more than a docstring because it cannot drift.
Making it `frozen=True` adds two things worth having: the record cannot be mutated halfway down the pipeline by a stage that "just adds a field", and it becomes hashable, so it can key a dict or join a set for deduplication. Both are common needs and both are unavailable on a plain dict.
The honest counter-argument is the boundary. Data arriving from JSON or a CSV genuinely is an untyped dict, and forcing it into a typed record before validating just moves the failure. The pattern that works is a narrow untyped edge — parse to a dict, validate, construct the dataclass — and a typed interior. `asdict()` converts back when something downstream needs a plain dict for serialisation, which is why the output here is unchanged.
After the review same output, verified
`asdict` gives back the identical dict, so nothing downstream has to change at all.
The answer most people give
"Add a `TypedDict` — same benefit, no rewrite." A `TypedDict` is a real improvement and does give you key checking, and it is still a plain dict at run time: mutable, unhashable, with no place to put behaviour or validation. It is the right choice when you must keep dict semantics, not a free upgrade.
They’ll ask next
Where would you validate that `amount` is non-negative — in `build`, in `__post_init__`, or in the caller?
A loader takes `**kwargs` and pulls its options out with `.get()`. Every call site works. What does the signature cost you?
Why they ask this
It is a very common shape in glue code and the cost is entirely invisible until someone misspells an option — at which point the code accepts it, ignores it, and runs with a default.
Say this
Everything a signature is for. There is no discoverable parameter list, no default visible to a reader, no type checking, and a misspelled option is silently ignored rather than rejected — so `batch_sze=500` runs the job with a batch of 100 and reports success.
The reasoning
The snippet shows the failure directly: `batch_sze=500` and `nonsense=True` are both accepted without complaint, and both produce a run using the defaults. Nothing raises, nothing logs, and the difference between "I configured a batch of 500" and "I configured nothing" is invisible in the output.
An explicit signature makes the same call a `TypeError` at the call site, before any work happens. It also gives you the three things reviewers rely on: the parameter list is the documentation, defaults are visible in one place, and a type checker can verify that `batch_size` is an `int` rather than the string that arrived from an environment variable.
`**kwargs` earns its place in exactly two situations — a decorator or wrapper forwarding an arbitrary signature it does not own, and an API that genuinely accepts open-ended options destined for something else, such as driver-specific connection parameters. Both are pass-through cases. Using it to avoid writing out four parameters is not one of them.
Where it is genuinely needed, narrow the damage: validate the keys against a known set and raise on anything unrecognised, or use a `TypedDict` with `total=False` so a checker can still see the option names. Silently ignoring an unknown key is a choice, and it is almost never the right one for configuration.
No discoverable options, no checking, and a typo runs the job on defaults and reports success.
See it run on CPython 3.12
Two of these three calls are misconfigured. The function cannot tell, and neither can the log.
The answer most people give
"It is flexible — new options do not need a signature change." Adding a parameter to a signature is a two-second edit that tells every caller and every checker. Not having to make it is not flexibility, it is the absence of a contract.
They’ll ask next
Your loader now has nine options. Is a nine-parameter signature still the right answer?
Status is passed around as a string and compared against a literal tuple. A caller passes `"canceled"` — one L — and gets False. What would you change?
The code as submitted
def is_final(status: str) -> bool:
return status in ("paid", "refunded", "cancelled")
for status in ["paid", "pending", "canceled"]:
print(status, "->", is_final(status))
It prints
paid -> True
pending -> False
canceled -> False
Why they ask this
Stringly-typed domain values are everywhere in data code, and the failure is a plausible False rather than an error, so it survives review and testing.
Say this
Make the domain values an enum. `str` says the argument is text; `Status` says it is one of four known values, so a misspelling fails at construction instead of quietly answering False. A `Literal` type gives most of the same benefit statically.
The reasoning
The current code cannot distinguish "this status is not final" from "this is not a status". Both return False, and the second is a data-quality problem that will keep returning False for every row from that source. A dashboard reports zero cancellations and nothing anywhere reports an error.
A `StrEnum` fixes it at the boundary: constructing `Status("canceled")` raises `ValueError`, so the unknown value is caught at the point it enters the system, with the offending string in the message. Because `StrEnum` members are also `str`, existing comparisons, JSON serialisation and database writes keep working — which is why the printed output here is unchanged.
The static half matters as much. With an enum or `Literal["paid", "pending", ...]` in the signature, a type checker rejects a misspelled literal in the source before anything runs, and an editor autocompletes the valid values. The set of legal statuses becomes something you can find by going to a definition rather than by grepping for string literals.
The reviewable judgement is how to handle the unknown value, and it is worth being explicit that the rewrite here catches it and returns False — preserving behaviour, as a refactor must. In the real change you would decide deliberately: raise and fail the row, or map to an `UNKNOWN` member and count it. Both are defensible; silently returning False for a value nobody recognises is not.
After the review same output, verified
Identical output on purpose. The gain is that `Status("canceled")` now raises somewhere you can act on it.
The answer most people give
"Pull the tuple out into a module-level constant." That removes the duplication and leaves the whole problem — `"canceled"` still is not in the constant, and still returns False without a word.
They’ll ask next
An upstream vendor adds a new status you have never seen. Should the job fail, or count it as unknown? Where does that decision belong?
The job reports progress with `print()`. It is readable in the terminal and it works. Why is that a review comment rather than a preference?
Why they ask this
Everyone knows "use logging instead of print". The signal is whether you can name what logging actually gives you, rather than repeating the rule.
Say this
Because `print` has no level, no timestamp, no source, and no destination control. You cannot turn it down in production or up during an incident, you cannot route it to a file or a log aggregator, and every line looks equally important — which means none of them are.
The reasoning
The five things `print` cannot do, in the order they hurt. Levels: there is no way to emit diagnostics that are off by default and switchable on during an incident, so debug output either ships permanently or does not exist. Configuration: the destination is baked in at the call site rather than chosen by whoever runs the code. Context: no timestamp, no module, no line, so a line in a log file cannot be traced back. Exceptions: no `exc_info`, so a traceback must be assembled by hand. Structure: no way to attach fields that a log aggregator can index and filter.
The last one is what matters at scale. `logger.info("loaded %s rows", n, extra={"dataset": ds, "batch": batch_id})` produces a record that a JSON formatter turns into queryable fields, so "show me every failed batch for this dataset today" is a search rather than a grep. `print` produces a line of text that a human has to read.
The setup discipline is worth stating too, because it is where teams get it wrong: libraries and modules call `logging.getLogger(__name__)` and configure nothing, while the *application entry point* configures handlers, levels and formatters exactly once. A module that calls `basicConfig` at import time hijacks logging for everyone who imports it.
The counter-case is real and small: a one-off script, or a CLI whose output *is* the product — a report, a JSON document meant to be piped. There, `print` to stdout is correct, and diagnostics should still go to a logger on stderr so the two streams stay separable.
Fields a log aggregator can index, so incident questions become searches rather than greps.
print() to stdoutavoid
print(f"loaded {n} rows from {path}")
No level, no timestamp, no source, no routing — and no way to turn it up during an incident.
basicConfig inside the moduleavoid
# top of loader.py
logging.basicConfig(level=logging.DEBUG)
Reconfigures logging for every application that imports you, as a side effect of the import.
The answer most people give
"print writes to stdout and logging writes to stderr, that is the difference." Both destinations are configurable and neither is the point. The point is levels, structure, routing and context — the things that decide whether a log is usable during an incident.
They’ll ask next
Your job runs in a container and the logs go to a JSON aggregator. What changes in the setup, and what stays the same in the module?
The handler logs `logger.error("load failed: %s", error)`. During an incident the log says the load failed and nothing else. What is missing?
Why they ask this
It is the logging half of the "lost traceback" problem, and the fix is one method name, so the only thing being tested is whether you know it exists.
Say this
The traceback. `error()` records only the message you gave it, so the log says *that* something failed and not *where*. `logger.exception(...)` — or `logger.error(..., exc_info=True)` — attaches the full traceback to the same record.
The reasoning
The snippet shows it directly: the first captured record contains no traceback, the second does, and the second names the call that raised. That difference is the whole distance between "the load failed" and "the load failed at line 34, parsing a value of 'twenty'".
`logger.exception(msg)` is shorthand for `logger.error(msg, exc_info=True)` and is only valid inside an exception handler, where it picks up the current exception implicitly. Note that it logs at ERROR level — if you want a traceback at WARNING or CRITICAL you pass `exc_info=True` explicitly, which is the more general form.
Because the traceback is attached to the record rather than interpolated into the message, a JSON formatter can put it in its own field. That keeps the message stable and greppable — every occurrence reads `load failed` — while the varying detail stays queryable. Interpolating `str(error)` into the message does the opposite: every occurrence is a slightly different string, so counting them is hard.
The related habit is to log the exception exactly once, at the level that can do something about it. Logging and re-raising at every layer produces the same failure five times with five tracebacks, which makes an incident log harder to read rather than easier. Catch, log with `exception`, and either handle it or let it propagate — not both.
See it run on CPython 3.12
Both calls log at ERROR. Only one of them records where the failure was.
The answer most people give
"`str(error)` contains the message, so you have the important part." You have the message and not the stack. In a pipeline where four call sites can raise the same `ValueError`, the message alone does not tell you which one did.
They’ll ask next
You catch, log with `exception`, and re-raise. Two layers up someone does the same. What does the incident log look like?
Debug logging uses f-strings: `logger.debug(f"payload: {payload}")`. DEBUG is disabled in production. Is there any cost?
Why they ask this
It is the one performance point in logging that is actually worth making, and it is invisible — the code looks identical to the correct version and the output is the same.
Say this
Yes. An f-string is evaluated before `debug()` is called, so the formatting happens whether or not the level is enabled and the result is then thrown away. `logger.debug("payload: %s", payload)` defers formatting until the record is actually emitted.
The reasoning
The mechanics are ordinary Python: arguments are evaluated before the call. `f"payload: {payload}"` builds the string, calls `__str__` on everything interpolated, and hands the finished text to `debug()`, which checks the level and discards it. The work is already done.
The `%s` form passes the object itself and lets the logging module interpolate only if a handler will emit the record. The snippet counts the renders to prove it: the f-string call renders once with DEBUG disabled, the `%s` debug call renders zero times, and the `%s` info call renders because INFO is enabled and the record is actually written.
Whether that matters depends entirely on what is being formatted. For a small int it is noise. For a row, a payload, a dataframe summary, or anything whose `__repr__` walks a structure, it is real work per call — and debug logging tends to sit in the hottest loops precisely because that is where you want detail. A disabled debug line in a per-row loop can cost more than the row processing.
Two follow-ons worth knowing. `logger.isEnabledFor(logging.DEBUG)` guards genuinely expensive preparation that the `%s` form cannot defer, such as building a summary object. And linters check this — `ruff` flags f-strings in logging calls as `G004` — so the rule can be enforced rather than remembered.
See it run on CPython 3.12
The counter is incremented inside `__str__`. DEBUG is off; the first call still paid for it.
The answer most people give
"f-strings are faster than %-formatting, so it is an improvement." f-strings are faster *when you format*. The point is that the correct version does not format at all, and not doing the work beats doing it quickly.
They’ll ask next
You need to log a summary that costs a full pass over the batch. `%s` cannot defer that — what do you use?
The job logs `"failed to parse row"` for every bad row. There are 4,000 of them across 60 files. What is wrong with the log line?
Why they ask this
It is the difference between a log that answers questions and a log that only proves something happened. Anyone who has been on call has an opinion, and the opinion is the answer.
Say this
It carries no identity. Which file, which line, which dataset, which run — none of it is there, so 4,000 identical lines tell you only that parsing failed 4,000 times. Attach the identifying fields as structured data, not as prose.
The reasoning
The test for a log line is whether it answers the question you will have at 3am. "Failed to parse row" answers "did something fail?" and nothing else. You cannot find the file, cannot reproduce it, cannot tell whether the 4,000 failures are one bad file or sixty slightly bad ones — which is the first thing you would want to know, because those have completely different causes.
The fields to attach are the ones you would use to reproduce it: the dataset, the source path, the row or byte offset, the run or batch id, and the reason. Put them in `extra=` rather than in the message so a JSON formatter emits them as indexed fields; then "group by source_path" is a query, and the one bad file falls out immediately.
Keeping the message constant is the other half. `logger.warning("row rejected", extra={...})` produces 4,000 records with one message and varying fields, which aggregates cleanly. Interpolating the detail into the message gives 4,000 distinct strings, and every dashboard that counts by message sees 4,000 unique events.
Two practical additions. Volume: 4,000 identical warnings is its own problem — log the first few per file and a summary count at the end, rather than one line per row. And redaction: the row is the obvious thing to attach and often the one thing you must not, since it may carry personal data. Log the identifiers and the reason; log the payload only behind a debug flag, and never by default.
Aggregates by message, filters by field — "which file" becomes a query rather than a grep.
Summarise per fileship
logger.warning(
"rows rejected", extra={"source": path, "rejected": n, "sample": reasons[:3]},
)
One record per file instead of 4,000; the shape of the problem is visible immediately.
Detail interpolated into the messageworks
logger.warning("failed to parse %s line %s: %s", path, i, error)
Everything you need is there, and every line is a unique string, so counting by message is useless.
The whole row in the logavoid
logger.warning("failed to parse row: %s", row)
Ships whatever personal data the row held into the log aggregator, permanently.
The answer most people give
"Add the row to the message so you can see what failed." That solves the diagnosis problem by creating a privacy one — the row is exactly the thing most likely to contain personal data, and logs are retained and widely readable.
They’ll ask next
You need the offending value to debug, and it may be personal data. What do you log instead of the value itself?
A function constructs its HTTP client on the first line and then uses it. How would you test it without a network, and what does that tell you about the design?
The code as submitted
class Client:
def get(self, path):
return {"rows": [{"amount": 10}, {"amount": 20}]}
def total_for(day):
client = Client() # constructed inside; no way to substitute
payload = client.get(f"/orders?day={day}")
return sum(r["amount"] for r in payload["rows"])
print("total:", total_for("2026-03-01"))
It prints
total: 30
Why they ask this
Untestable code is almost always code that reaches out for its own collaborators. The question is really "can you spot a hidden dependency", and the fix is a design change, not a test trick.
Say this
You cannot, without patching the module — which couples the test to the import path rather than to the behaviour. Take the client as a parameter. The test then passes a fake, and the production caller passes the real one.
The reasoning
Constructing the dependency inside the function makes it invisible to the caller and unavoidable in a test. The only way in is `unittest.mock.patch("module.Client")`, which is brittle in a specific way: it depends on where the name is imported, so moving the import or renaming the module breaks tests that have nothing to do with the change.
Passing the client in makes the dependency part of the contract. The test constructs a two-line fake and calls the function directly — no patching, no import-path knowledge, and the test now exercises the real code path rather than a mocked one. It also documents what the function needs, which the original signature actively hid.
Typing the parameter as a `Protocol` rather than as the concrete class keeps the dependency pointing the right way: the function declares what it needs (`something with .get(path)`), and neither the real client nor the fake has to import anything from it. That is the structural-typing argument from the Conceptual bank, in the situation where it pays off.
The counter-argument to pre-empt: injection can be taken too far, and threading a dependency through six layers is worse than constructing it once. The rule that works is to inject at the boundary — anything that does I/O, reads the clock, or generates randomness — and construct everything else where it is used. Those three are exactly the things that make tests slow, flaky or impossible.
After the review same output, verified
Same answer, and now a test can pass a three-line fake instead of patching an import path.
The answer most people give
"Use `@patch` — that is what mocks are for." Patching works and it tests your import structure as much as your logic. A test that breaks when you move an import, and passes when the function is wrong, is measuring the wrong thing.
They’ll ask next
Where would you draw the line on injection? Does the JSON parser get injected too?
A partition-path helper calls `datetime.now()` internally. What breaks in testing, and what breaks in a backfill?
Why they ask this
The testing answer is well known. The backfill answer is the one that separates people who have run pipelines from people who have written them — a job that reads the clock cannot reproduce yesterday.
Say this
In testing, the expected value changes every day, so the test is either unreliable or has to freeze time. In a backfill it is worse: re-running last Tuesday's job writes to today's partition. Pass the logical date in as a parameter.
The reasoning
The test problem is the visible one. An assertion about the output depends on when it runs, so you either freeze the clock with `freezegun` or `unittest.mock.patch`, or you write a test that computes the expected value the same way the code does — which asserts nothing at all.
The backfill problem is the serious one, and it is about correctness rather than convenience. Every orchestrator distinguishes the *logical* date a run represents from the wall-clock time it executes; that is what `{{ ds }}` in Airflow, `dbt`'s `run_started_at`, and the equivalent elsewhere are for. Code that calls `now()` internally ignores the distinction, so re-running a failed run from three days ago writes into today's partition and leaves the original gap unfilled.
Passing the date in makes the function a pure function of its arguments: same input, same output, forever. That is what makes it testable, and it is the same property that makes the job idempotent and re-runnable — the two are not separate benefits, they are the same benefit seen from two angles.
The same argument covers the other sources of ambient state: random values, UUIDs, the hostname, the current working directory. Inject them at the boundary, or generate them once at the entry point and thread the value down. A function that reaches for any of them is a function whose output you cannot predict from its input.
A re-run of last Tuesday writes to today, and the gap it was meant to fill stays empty.
The answer most people give
"Mock `datetime.now` in the test and it is fine." That fixes the test and leaves the pipeline unable to reproduce any past run — which is the expensive half of the problem, and the half a test will never reveal.
They’ll ask next
Ingestion timestamps genuinely have to be the wall clock. How do you keep that testable?
One function reads a handle, parses, filters, aggregates and prints. It is twelve lines and it works. What would you split, and why is that not just tidiness?
The code as submitted
import io
def load(handle):
total = 0
rows = 0
for line in handle:
region, amount = line.strip().split(",")
if region != "eu":
continue
total += int(amount)
rows += 1
print(f"wrote {rows} rows totalling {total}")
load(io.StringIO("eu,10\nus,20\neu,30\n"))
It prints
wrote 2 rows totalling 40
Why they ask this
It is the most common refactor in data code and the justification is concrete: the parsing and aggregation can be tested with a list, while the current shape can only be tested with a file.
Say this
Separate the pure transformation from the I/O. `parse` and `eu_total` become functions over iterables that a test drives with a three-element list — no file, no fixture, no temp directory — while `load` shrinks to the wiring that is too small to hold a bug.
The reasoning
The mixed version can only be tested end to end: build a file or a `StringIO`, run the whole thing, capture stdout, and assert on text. That test is slow to write, fragile against any change to the printed format, and tells you nothing about *which* stage was wrong when it fails.
Once the transformation is a function over an iterable, testing it is a one-liner — `assert eu_total([("eu", 10), ("us", 20)]) == (1, 10)`. There is no setup, it runs in microseconds, and a failure points at exactly one stage. The parsing gets its own tests for the cases that actually break in production: a ragged line, a missing field, an unparseable number.
The generator shape is what makes the split free rather than costly. Each stage yields rather than accumulates, so the composed pipeline still processes one row at a time and peak memory is unchanged — you have not traded efficiency for testability, which is the objection people usually raise.
The remaining `load` is worth looking at: it is three lines of wiring with no branching and no arithmetic. That is the goal — push everything that can be wrong into functions that can be tested cheaply, and leave an I/O shell so thin that an end-to-end test of it is a formality rather than the only line of defence.
After the review same output, verified
Identical output, and two of the three functions can now be tested with a literal list.
The answer most people give
"Three functions instead of one is more code for the same result." It is four extra lines and it converts one slow, brittle, whole-pipeline test into two fast tests that name the stage that broke. That trade is why the refactor is worth making.
They’ll ask next
Should `eu_total` return a tuple, or something named? What would you use at ten call sites?
The test suite patches three private helpers to make a loader testable. Every refactor breaks the tests even when behaviour is unchanged. What is the rule being broken?
Why they ask this
Over-mocking produces a suite that is expensive to maintain and proves very little, and the symptom — tests breaking on pure refactors — is one every engineer has experienced without necessarily diagnosing.
Say this
Mock at the boundary you do not own — the HTTP call, the database, the clock — and never the internals you do. Patching private helpers pins the implementation, so the test fails on refactors and passes when the logic is wrong.
The reasoning
A test that patches `_parse_row` and `_build_key` asserts that those functions are called with particular arguments. That is a statement about *how* the code is structured, not about what it produces, so renaming or inlining a helper breaks the test while the behaviour is identical. The suite has become a change-detector rather than a safety net.
The inverse failure is worse and less obvious: because the helpers are mocked, their real behaviour is never exercised. A bug inside `_parse_row` cannot fail this test. The suite is simultaneously fragile and permissive, which is the worst combination — high maintenance cost, low confidence.
The boundary is the line where you stop owning the code: the network, the filesystem, the database, the clock, the random number generator, third-party services. Substitute those, because they are slow, flaky or impossible in CI. Everything on your side of the line should run for real, so the test covers the code you actually wrote.
Two techniques make it easy. Inject the boundary rather than patching it, as in the client question, so the test passes a fake through the front door. And prefer a small hand-written fake to a `MagicMock`: a fake with real behaviour catches misuse, whereas a `MagicMock` returns a new mock for any attribute you touch and will happily accept a call that would fail against the real thing.
Accepts any call, including ones the real object would reject — the test passes, production does not.
The answer most people give
"More mocking means faster, more isolated unit tests." It means fewer lines of your code are executed. A suite where every collaborator is mocked runs quickly and verifies that the mocks were called the way the mocks were set up.
They’ll ask next
Your loader talks to Postgres. Do you mock the driver, use a fake, or run a real container in CI?
A default is written as `def load(batch=int(os.environ["BATCH_SIZE"]))`. Changing the variable later has no effect. Why, and what else does this break?
Why they ask this
It is the mutable-default rule applied to configuration, and the consequences run past the surprise: it breaks tests, breaks per-run overrides, and makes an import crash when a variable is absent.
Say this
Default arguments are evaluated once, when the `def` executes — so the environment is read at import time and frozen. Read it inside the function, or better, load configuration once into an explicit settings object at the entry point.
The reasoning
The snippet shows both halves: after changing `BATCH_SIZE` to 500, the early-bound version still returns 100 and the late-bound one returns 500. This is the same rule as the mutable-default trap — `def` is executable code and its default expressions run exactly once — applied to something that looks like configuration rather than like state.
The consequences go beyond the surprise. A test that sets an environment variable in a fixture has no effect, because the module was imported before the fixture ran. Import order becomes significant, which is a horrible thing to depend on. And `os.environ["BATCH_SIZE"]` at import time raises `KeyError` during the import itself if the variable is unset, producing a stack trace that points at an import statement rather than at a missing configuration value.
Reading inside the function fixes the timing but scatters `os.environ` calls through the codebase, so there is no single place to see what the job is configured with and no single place to validate it. The better shape is to load configuration once at the entry point into a frozen settings object — hand-rolled with a `classmethod` `from_env`, or with `pydantic-settings` — and pass it down.
That gives you the three properties you actually want: one place that knows which variables exist, validation and type conversion in one step with an error message naming the variable, and a plain object that a test can construct directly without touching the environment at all.
See it run on CPython 3.12
The same variable, read two ways. Only one of them notices it changed.
The answer most people give
"Reading it once at import is a performance optimisation." Reading an environment variable is a dictionary lookup. There is no performance to gain, and the cost is that the value is frozen before your application has had a chance to configure anything.
They’ll ask next
Where should a missing `BATCH_SIZE` be detected — at import, at first use, or at startup? What error do you want?
The S3 bucket, the dataset name and the batch size are literals inside the functions that use them. It works in production. What is your review comment?
Hard-coded environment details are the reason "it works on my machine" and "we cannot test against staging" happen, and the fix is small enough that there is no excuse for not making it.
Say this
They are environment, not logic. With `prod-warehouse` compiled into the function there is no way to run against staging, no way to test without touching production, and the batch size cannot be tuned without a deploy. Collect them into a settings object.
The reasoning
The immediate cost is that the code can only ever do one thing. Running against a staging bucket, a developer sandbox, or a test fixture requires editing the source — so nobody does, and the pipeline is only ever exercised against production. That is also why a mistake here is expensive: a test run that accidentally writes to `prod-warehouse` is a data incident.
A frozen settings dataclass puts every environment-dependent value in one place with a declared type and a default. That single definition answers "what does this job need to run?" — a question that otherwise requires reading every module — and it becomes the natural place to add validation, so a malformed bucket name fails at startup rather than at the first write.
Passing settings as a parameter rather than reading a module-level global keeps the code testable: a test constructs `Settings(bucket="test-bucket")` and needs no environment, no patching and no fixtures. It also makes the dependency visible in the signature, which is the same argument as injecting a client.
The layering that works in practice: defaults in the dataclass, overridden by a config file, overridden by environment variables, overridden by command-line flags — resolved once at the entry point. `pydantic-settings` implements exactly that and gives you type conversion and error messages naming the offending field. Note that secrets are the one thing that should never have a default: a missing credential must fail loudly, not fall back to something.
After the review same output, verified
Identical paths and batch size. The difference is that these can now come from somewhere else.
The answer most people give
"Move them to module-level constants." That removes the duplication and leaves them compiled into the source. A constant at the top of the file is still a value you cannot change without a deploy.
They’ll ask next
Which of these three should have a default, and which should fail at startup if unset? What about a database password?
The project pins nothing — `pandas`, `requests`, `pyarrow`, one per line. It has always installed fine. What is the review comment, and what would you replace it with?
Why they ask this
Unpinned dependencies mean the build is not reproducible, which turns an unrelated upstream release into a production incident on a day nobody deployed anything.
Say this
The build is not reproducible: two installs a week apart can produce different code, so a container rebuilt for an unrelated reason can pick up a breaking release. Declare compatible ranges for direct dependencies and commit a lockfile that pins the entire resolved tree, transitive packages included.
The reasoning
The failure mode is specific and nasty. Nothing in your repository changed; a dependency published a new version; the image is rebuilt for an unrelated deploy; the job now behaves differently. The change is real and invisible in your git history, which makes it one of the harder incidents to diagnose — you look for a commit and there is not one.
The distinction that matters is between *declaring* and *locking*. `pyproject.toml` declares what you are compatible with — `pandas>=2.1,<3` says what the code expects and leaves room for patches. The lockfile records what was actually resolved, including every transitive dependency and its hash, so an install reproduces a known-good set exactly. You need both: only ranges and you are not reproducible, only pins and you cannot upgrade anything without editing them by hand.
Pinning direct dependencies with `==` and no lockfile is the common half-measure and it does not work, because your transitive tree is still floating. A pinned `pandas==2.2.1` will happily pull whatever `numpy` it resolves today, and that is enough to change behaviour.
`uv` is the current standard for this and produces `uv.lock` with hashes; `poetry` and `pip-tools` do the equivalent. Commit the lock, install with `--frozen` in CI and in the image build so a drifted lock fails rather than silently re-resolving, and upgrade deliberately with a command that updates the lock and runs the tests. The point is that upgrades become a reviewable commit rather than a side effect of the clock.
The formulations
Ranges in pyproject + committed lockfileship
# pyproject.toml
dependencies = ["pandas>=2.1,<3", "pyarrow>=15", "httpx>=0.27"]
# uv.lock is committed; CI and the image build use:
uv sync --frozen
Declares intent and reproduces the exact tree, transitive packages and hashes included.
Exact pins for everything, by handworks
pandas==2.2.1
pyarrow==15.0.2
httpx==0.27.0
Reproducible for direct deps only — the transitive tree still floats, and upgrades are manual.
Unpinnedavoid
pandas
pyarrow
httpx
# resolved fresh on every install, transitive tree included
Two builds a week apart install different code, so an upstream release becomes your incident.
The answer most people give
"Pin everything with `==` in requirements.txt and you are reproducible." Only for the packages you listed. Their dependencies resolve fresh on every install, and that is enough to change behaviour without a single line of your code changing.
They’ll ask next
How do you upgrade a pinned dependency safely? What does that look like as a pull request?
Packaging & envsConcurrency (threading vs multiprocessing vs asyncio)
The module opens a database connection and reads a config file at module level, so they are ready when the functions run. What does that cost?
Why they ask this
Import-time side effects make a module impossible to import safely — for a test, for documentation tooling, for a CLI `--help`. It is also the root cause of a whole family of confusing failures.
Say this
Importing the module now does I/O. Tests cannot import it without a database, `--help` opens a connection, a missing config file makes the import itself raise, and under multiprocessing every spawned worker re-runs it. Do the work in a function and call it from the entry point.
The reasoning
An import should define things, not do things. Once it connects, reads a file or calls an API, every consumer inherits that cost and that requirement: the test suite needs a live database to collect tests, a `--help` invocation pays a connection round trip, and a documentation generator that imports your modules fails outright.
The error reporting is bad in a specific way. A `FileNotFoundError` raised during import produces a traceback whose top frame is an `import` statement, often several layers away in something that imported you transitively. The actual problem — a missing config path — is buried, and the module that caused it may not be obvious from the stack at all.
Multiprocessing makes it concrete. Under the spawn start method each worker re-imports your module, so a module-level connection becomes N connections, and a module-level `Pool()` outside an `if __name__ == "__main__":` guard recursively spawns processes. That guard exists precisely because import-time side effects are dangerous.
The fix is to make the work explicit and lazy: a `get_connection()` that creates on first call and caches, or a `main()` that builds everything and passes it down. `functools.lru_cache` on a zero-argument factory is a neat way to get a lazily-created singleton. The test for whether you have got it right is simple — importing the module should be safe with no database, no network and no config file present.
The formulations
Build it in the entry pointship
def main() -> int:
settings = Settings.from_env()
with connect(settings.dsn) as conn:
run(conn, settings)
if __name__ == "__main__":
raise SystemExit(main())
Importing the module does nothing; everything that can fail happens where it can be reported.
Importing now needs a database and a file; the traceback for a missing one points at an import.
The answer most people give
"It only runs once, so it is more efficient than creating it per call." A lazily-created cached factory also runs once, and it runs when someone actually needs it rather than when someone merely mentions the module.
They’ll ask next
Under `spawn`, how many connections does a module-level `CONN` create for a pool of eight workers?
A single `run()` parses, validates, aggregates, formats and prints. Nothing is wrong with it. Why split it?
The code as submitted
import io
def run(handle):
rows = []
for line in handle:
parts = line.strip().split(",")
if len(parts) != 2:
continue
rows.append({"region": parts[0], "amount": int(parts[1])})
totals = {}
for row in rows:
totals[row["region"]] = totals.get(row["region"], 0) + row["amount"]
out = []
for region in sorted(totals):
out.append(f"{region}={totals[region]}")
print(" ".join(out))
run(io.StringIO("eu,10\nus,20\neu,30\nbroken\n"))
It prints
eu=40 us=20
Why they ask this
"Break up long functions" is advice everyone repeats. The interview signal is whether you can say what you gain, and whether you can identify the seams rather than cutting arbitrarily.
Say this
Because the seams are already there — parse, aggregate, render — and each is independently testable and independently reusable once named. The rewrite also puts the skipped-row rule in one place, where it can be seen and changed.
The reasoning
The split is not by line count, it is by responsibility, and the give-away is the shape of the original: three loops, each of which builds a complete intermediate value before the next begins. Those are the seams, and the names — `parse`, `totals_by_region`, `render` — were already implicit in the comments you would otherwise have to write.
What you gain first is testability, for the same reason as the earlier extraction question: `totals_by_region` can be driven with a literal list and asserted in one line, where `run` needs a handle and stdout capture. What you gain second is that each function can fail for one reason, so a traceback names the stage.
Reuse is the third gain and the one people over-claim, so it is worth being precise: `render` and `totals_by_region` are genuinely useful elsewhere, while `parse` is specific to this format. Splitting for hypothetical reuse is how you get a codebase of tiny functions nobody can follow. Split where the responsibilities differ, not where reuse might one day happen.
One detail in the rewrite worth defending: the `if len(parts) == 2` filter moved into `parse`, so "malformed lines are skipped" is stated in exactly one place instead of being a `continue` buried in a loop that also does arithmetic. Whether skipping silently is the right policy is a separate question — and now it is a question you can see and answer, which is most of the value.
After the review same output, verified
Same output, including the silently skipped malformed line — now skipped in a named place.
The answer most people give
"Split it because functions should be under ten lines." Line count is a symptom, not the rule. A twenty-line function doing one thing is fine; a six-line function doing three is not.
They’ll ask next
The malformed line is still skipped without a word. Is that the right policy, and where would you change it now?
The code is correct today, which makes it a good test of whether you can argue from maintainability rather than from a bug. The next `raise` someone adds is the point.
Say this
Correct now, and it relies on every future exit path remembering to close. A context manager makes the release automatic — including for exceptions nobody anticipated, `return` statements added later, and `break`.
The reasoning
The current code enumerates its exit paths and handles each one. That works exactly as long as the enumeration stays complete, and it will not: the next person adds an early `return` for an empty batch, or a second validation that raises, and the sink leaks. There is nothing in the code that would catch it, because the omission looks like nothing.
`with` inverts the responsibility. `__exit__` runs on every path — normal completion, any exception, `return`, `break`, `continue` — so the guarantee is a property of the resource rather than a discipline the caller has to maintain. That is the same argument as `try`/`finally`, with the advantage that the acquisition and the release are written together and cannot drift apart.
`contextlib.contextmanager` is the cheapest way to add it to a class you own or a third-party object that lacks one. The essential detail is the `try`/`finally` around the `yield`: without the `finally`, an exception in the body skips the cleanup entirely, which is precisely the failure the construct exists to prevent.
Two extensions worth knowing. `contextlib.ExitStack` handles a number of resources decided at run time — a manifest of files, a set of connections — which a fixed `with` statement cannot. And the same pattern generalises past files: transactions that must roll back, locks that must release, temporary directories that must be removed, and partial outputs that must be deleted when a job fails.
After the review same output, verified
The sink is closed in both versions. Only the second stays closed when someone adds a return.
The answer most people give
"Use `try`/`finally` — same thing, less machinery." It is the same guarantee, and it has to be repeated at every call site. A context manager puts it in the resource, so a caller cannot use the sink without it.
They’ll ask next
You have to open a number of sinks decided at run time. `with` takes a fixed list — what do you reach for?
A loader skips rows with a missing amount and returns the ones it wrote. The caller prints "wrote 2 rows". What is the caller unable to say?
The code as submitted
def load(rows):
written = []
for row in rows:
if row.get("amount") is None:
continue
written.append(row)
return written
written = load([{"amount": 10}, {"amount": None}, {"amount": 30}])
print("wrote", len(written), "rows")
It prints
wrote 2 rows
Why they ask this
It is the "silent skip" problem at the level of a return type, and the fix is a design decision about what a function owes its caller — which is exactly what a review is for.
Say this
Whether anything was skipped. The return value carries the successes and nothing else, so a batch where half the rows were dropped is indistinguishable from a clean one. Return a result object with both, and let the caller decide what to do about the skips.
The reasoning
The function knows something the caller needs and throws it away. It saw a row with no amount, made a policy decision to skip it, and returned a list that records only the outcome of that decision. Every caller is therefore forced to trust that skipping was fine, because they have no way to check.
A result object carries both outcomes. `LoadResult(written=[...], skipped=[...])` lets one caller ignore the skips, another fail the run when `skipped` is non-empty, and a third write them to a dead-letter table — without the loader having to guess which of those is wanted. The `ok` property gives the common case a name so callers do not each re-derive it.
This is the same principle as the exhausted-retries question and the bare-except question, applied to a signature rather than to control flow: partial success is a real state, and a return type that cannot express it forces the caller to treat it as total success. The published output is deliberately unchanged here — the *caller* still prints the same line — because the point is that the capability is now available, not that the behaviour differs.
The alternative designs are worth naming. Raising on any skip is right when partial results are useless downstream. Returning a `(written, skipped)` tuple works and is worse at three call sites, because nobody remembers the order. And a generator that yields both kinds of outcome is the streaming version, which matters when the batch does not fit in memory.
After the review same output, verified
The same line is printed. The difference is that `result.skipped` now exists to be asked about.
The answer most people give
"Log a warning for each skipped row." That helps a human reading logs and gives the calling code nothing to branch on. A job cannot fail itself based on a log line.
They’ll ask next
Should `load` raise when everything was skipped? What is the difference between that and skipping one row?
A normaliser returns a string for a string and a list for a list. Every caller starts with an `isinstance` check. What is the review comment?
The code as submitted
def normalise(value):
if isinstance(value, str):
return value.strip().lower()
return [v.strip().lower() for v in value]
for raw in [" EU ", [" EU ", " US "]]:
out = normalise(raw)
if isinstance(out, str):
out = [out]
print(out)
It prints
['eu']
['eu', 'us']
Why they ask this
Convenience overloads look helpful and push work onto every call site. It is a small, concrete example of an API that optimises for the author rather than the caller.
Say this
The function is doing the caller a favour that costs them three lines each. Always return a list. Accepting either input is fine and often kind; returning either is what forces every caller to branch.
The reasoning
The polymorphism is in the wrong place. Being liberal about what you accept is genuinely helpful — `str | list[str]` saves callers from wrapping a single value. Being unpredictable about what you return is the opposite: every caller has to reconstruct which branch it took, and the `isinstance` check in the original is the tax being paid at each one.
It is also hard to type honestly. `def normalise(value: str | list[str]) -> str | list[str]` is accurate and useless — it does not say that the output type mirrors the input, so a checker cannot help. Expressing the real relationship needs `@overload` declarations, which is a lot of machinery for a convenience nobody asked for.
The rewrite normalises the input on the first line and returns one type. The caller loses two lines and gains the ability to write `for region in normalise(raw)` without thinking. The published output is identical because the original caller was already converting the string case to a list — which is the evidence that the branch was pure overhead.
The same shape appears elsewhere and is worth recognising: functions that return `None` or a value, a scalar or a dataframe, one row or a list of rows. In every case the fix is the same — pick the general type and always return it, since an empty list or a one-element list is a perfectly good answer and needs no special case.
After the review same output, verified
Identical output, and the caller loses the branch it only had to reconstruct what it passed in.
The answer most people give
"Use `@overload` so the type checker knows which it returns." That makes the current design type-check correctly. It does not remove the branch from any call site, and it adds two declarations to maintain.
They’ll ask next
Is accepting `str | list[str]` also a smell, or is being liberal in what you accept fine here?
A code review turns up a class with an __init__ that stores three arguments and exactly one other method. What would you say?
Why they ask this
It is the most common over-abstraction in pipeline code, and the answer has to be a judgement rather than a rule.
Say this
If it has no state that outlives a call and only one method, it is a function with extra ceremony. Make it a function taking those arguments — unless the constructor is doing expensive setup that several calls share, which is the case where the class earns its place.
The reasoning
**The test.** Does anything live between calls? A client, a connection pool, a compiled regex, a cache — those are state, and a class holds them honestly. Three arguments stored and used once is not state; it is arguments, spelled twice.
**Why it matters beyond taste.** A function has one call site and one thing to mock. The class has a constructor and a method, so a test has to build it before it can call it, and every caller now depends on the construction order as well as on the behaviour.
**Where the class is right.** Expensive setup shared across calls, several methods over the same state, or an interface a caller injects — a sink, a clock, an HTTP client. In pipeline code those are exactly the boundaries you want to be able to swap in a test, and a `Protocol`-typed parameter makes them swappable without inheritance.
**And the counter-move.** If the class exists because a function grew five parameters, a class is not the answer either — a config dataclass passed to the function is. Bundling arguments and hiding a call are different problems and only one of them needs a class.
Arguments spelled twice and a construction step in every test.
Config object for many parametersworks
def normalise(rows, config: Config):
Bundles the arguments without hiding the call.
The answer most people give
"Classes are more object-oriented, so it is better." Object-orientation is not the goal; a call somebody can read and a test somebody can write are. A class with no state buys neither.
They’ll ask next
What would make you keep the class — name a concrete thing the constructor could hold.
A function branches on isinstance across four types to decide how to write records. What would you change, and what does it cost?
Why they ask this
It is the standard structural-typing question, and the good answer names test doubles as the concrete benefit rather than reciting a principle.
Say this
Type the parameter as a Protocol describing the methods you actually call, and let anything with them satisfy it. The ladder has to be edited for every new implementation, and it rejects a perfectly valid object for not being in the family tree.
The reasoning
**What the ladder costs.** Every new sink means editing a function that has nothing to do with the new sink. Worse, a test double with exactly the right methods is refused, so tests end up importing production classes just to satisfy an `isinstance` check.
**Protocol, in one sentence.** It is structural typing: anything with the right methods satisfies it, with no inheritance and no import. `class Sink(Protocol): def write(self, rows) -> int: ...` and then `def flush(sink: Sink, rows)`. A type checker verifies the callers; at runtime nothing does.
**When an ABC is the better answer.** When you own every implementation and want a runtime guarantee — an ABC refuses to instantiate a subclass that has not implemented the abstract methods — or when there is shared default behaviour to inherit. A Protocol carries no implementation at all.
**And be honest about the runtime.** A bare Protocol is not `isinstance`-able; `@runtime_checkable` adds that and only checks the methods exist, not their signatures. If you genuinely need a runtime gate, an ABC is the tool. If you need the code to be testable and open to new implementations, the Protocol is.
Any object with write qualifies — including a five-line fake.
ABC you ownworks
class Sink(ABC):
@abstractmethod
def write(self, rows): ...
Runtime guarantee, at the cost of inheritance.
isinstance ladderavoid
if isinstance(sink, S3Sink): ...
elif isinstance(sink, DbSink): ...
Edited for every new type, and rejects valid doubles.
Duck typing with no type at allavoid
def flush(sink, rows):
return sink.write(rows)
Works, and nothing states the contract for the next reader.
The answer most people give
"Use an ABC and make them all inherit from it." Fine when you own every implementation — and impossible for a third-party client, which is exactly when the ladder appears.
They’ll ask next
What does @runtime_checkable actually check, and what does it not?