Here is the code and here is what it printed. Find the bug and fix it. Most of these do not raise — they return a plausible number, report success, or pass a check that could never have failed, which is the failure mode that actually reaches production.
The handler ran, the job reported success, and the rows are gone.
Retries that make it worse
4
Retrying is only safe if the operation is. Usually nobody checked.
Files, encodings & formats
4
Parsing that works on the sample and not on the file after it.
Concurrency
4
Work that was submitted, never collected, and never missed.
Checks that pass wrongly
4
A quality gate that has never failed, because it cannot.
Evergreen · asked verbatim
3
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation — because the two fail separately, and a candidate who can debug a pool can still stall on "what does @dataclass generate".
A loader skips rows it cannot parse and reports a total. Both the row count and the total look reasonable. What is wrong?
The code as found
RAW = ["1,eu,10", "2,us,twenty", "3,eu,30"]
def load(raw):
rows = []
for line in raw:
try:
id_, region, amount = line.split(",")
rows.append({"id": int(id_), "region": region, "amount": int(amount)})
except Exception:
pass # "just skip the bad ones"
return rows
rows = load(RAW)
print("loaded", len(rows), "of", len(RAW), "rows")
print("total:", sum(r["amount"] for r in rows))
It prints
loaded 2 of 3 rows
total: 40
Why they ask this
`except Exception: pass` is the single most common defect in data-loading code, and this framing tests whether you can articulate the problem when the numbers themselves are not obviously wrong.
Say this
The loader silently discards rows. The count and the total are arithmetically correct for the rows that survived — but a third of the input vanished with no record of which row, why, or that it happened at all.
The reasoning
Look at the two outputs: the surviving rows and their total are identical on both sides. That is the point. The bug is not a wrong number, it is that a caller reading "loaded 2 rows, total 40" has no way to know that three rows were offered. A downstream dashboard showing 40 instead of 60 looks like a business change, not a defect.
`except Exception` also catches far more than a parse failure. A `KeyError` from a renamed column, a `MemoryError`, a `TypeError` from a bug you introduced in the loop body — every one of them becomes "skip this row". The blast radius grows every time someone adds a line inside the `try`.
The fix has three parts and all three matter. Narrow the exception to the one you actually expect (`ValueError` from `int()`), so an unexpected failure still propagates. Collect the rejects with enough context to act on — the line and the message. And decide explicitly what a non-zero reject count means: fail the job, quarantine the rows to a dead-letter location, or accept it under a documented threshold.
The interview answer worth giving is the last of those. "Skip bad rows" is not a policy; "reject rows that fail to parse, write them to a rejects table, and fail the run if rejects exceed 1% of the batch" is. The code should make the threshold visible rather than burying it in a `pass`.
The fix run on CPython 3.12
Same count, same total. The difference is that the second one tells you.
RAW = ["1,eu,10", "2,us,twenty", "3,eu,30"]
def load(raw):
rows, rejected = [], []
for line in raw:
try:
id_, region, amount = line.split(",")
rows.append({"id": int(id_), "region": region, "amount": int(amount)})
except ValueError as error:
rejected.append((line, str(error)))
return rows, rejected
rows, rejected = load(RAW)
print("loaded", len(rows), "of", len(RAW), "rows")
print("total:", sum(r["amount"] for r in rows))
for line, why in rejected:
print("rejected:", line, "->", why)
if rejected:
print("FAILED: 1 row could not be parsed")
Prints
loaded 2 of 3 rows
total: 40
rejected: 2,us,twenty -> invalid literal for int() with base 10: 'twenty'
FAILED: 1 row could not be parsed
The answer most people give
"Change `except Exception` to `except ValueError` and it is fixed." That stops it swallowing unrelated bugs, and the rows still disappear silently. Narrowing the exception is one of three changes, and on its own it is the least important.
They’ll ask next
What reject rate would you fail the job at, and where does that number live — in the code, in config, or in a data contract?
A fetcher catches network errors and returns 0 for the page. It logged "upstream API failed" and returned 0 — but the API responded fine. What happened?
The code as found
def fetch(page):
return {"rows": [{"amount": "10"}, {"amount": "x"}]}
def run():
try:
payload = fetch(1)
return sum(int(r["amount"]) for r in payload["rows"])
except Exception as error:
print("upstream API failed, skipping page:", type(error).__name__)
return 0
print("total:", run())
It prints
upstream API failed, skipping page: ValueError
total: 0
Why they ask this
It tests whether you understand that the *scope* of a try block is a design decision. The handler here is correct; it is guarding too much code, so it mislabels an unrelated bug.
Say this
The `try` wraps both the network call and the parsing of the response. A `ValueError` from `int("x")` in the summing loop is caught by the same handler, reported as an upstream failure, and turned into a silent 0. Only the call that can raise a network error belongs inside.
The reasoning
The handler catches `Exception`, which includes everything the body can raise. The network call succeeded; the failure came from the data — a non-numeric amount in the payload. The log line then actively misleads whoever investigates, pointing them at the vendor rather than at the parsing code.
Returning 0 compounds it. A page that failed and a page with no revenue are now indistinguishable, so the daily total is quietly short and nothing in the pipeline registers an error. This is the same "None means two things" problem in numeric form.
The fix is to shrink the `try` to the single call whose failure it describes, and to name the exception precisely — `OSError` covers the socket-level failures, or the client library's own exception type if it has one. Everything after that call runs outside the handler, so a data problem raises a data error, with a traceback pointing at the line that could not parse.
The general rule worth stating: a `try` block should contain exactly the operation the `except` clause knows how to handle, and no more. Every extra line inside it is another failure mode being relabelled. In review, a `try` covering more than about three lines is worth questioning.
The fix run on CPython 3.12
The API was never the problem. The second version says so, loudly.
The answer most people give
"Log the traceback and you will see what it was." That helps whoever reads the logs, and the function still returns 0 and still reports success. The problem is the control flow, not the logging.
They’ll ask next
Should a bad `amount` fail the whole page, or reject one row? Which does the fix do, and is that right?
RuntimeError - load failed: invalid literal for int() with base 10: 'twenty'
__cause__: None
original type recoverable: False
Why they ask this
It is a small habit with a large effect on how long an incident takes to diagnose, and the fix — `raise ... from` — is something a lot of people have seen without knowing what it does.
Say this
The original exception object, its type and its traceback. All that survives is the message, flattened into a string. `raise RuntimeError(...) from error` attaches the original as `__cause__`, so the traceback shows both frames and callers can still inspect the real type.
The reasoning
Interpolating `str(error)` keeps the words and throws away everything programmatic. `__cause__` is None, so nothing downstream can ask "was this really a parse failure?" — an `except ValueError` two frames up will not match, and a retry policy that treats parse errors and network errors differently has nothing left to branch on.
The traceback is the bigger loss in practice. Without the chain, the printed traceback starts at the `raise` inside the wrapper. The line that actually failed — the `int()` call, three functions down — does not appear. You know the load failed; you do not know where.
`raise NewError(...) from original` sets `__cause__` and makes Python print both tracebacks joined by "The above exception was the direct cause of the following exception". You get your domain-level message *and* the original site. Note that a bare `raise` inside an `except` block re-raises the original untouched, which is what you want when you only meant to log and continue.
The third form is `from None`, which deliberately suppresses the context. That is right when the original is noise the caller should not see — a `KeyError` from an internal dict that you are turning into a clean `MissingRegion` — and wrong everywhere else. The point is that all three are choices, and the buggy version made one by accident.
The fix run on CPython 3.12
The message survives either way. Only the second keeps something you can act on.
The answer most people give
"Python chains it automatically when you raise inside an except block." It does set `__context__` implicitly, which is why you sometimes see "During handling of the above exception" — but `__cause__` stays None, and the distinction is exactly what `from` makes explicit.
They’ll ask next
When would `raise ... from None` be the right call rather than sloppy?
A lookup returns None when the key is absent, and the caller prints "no data". A configured region with zero rows and an unconfigured region look identical. Why is that a bug rather than a display quirk?
The code as found
def lookup(index, key):
try:
return index[key]
except KeyError:
return None
index = {"eu": [1, 2], "us": []}
for key in ["eu", "us", "apac"]:
rows = lookup(index, key)
print(key, "->", "no data" if not rows else f"{len(rows)} rows")
It prints
eu -> 2 rows
us -> no data
apac -> no data
Why they ask this
Conflating "absent" with "empty" is a modelling error that shows up in every pipeline, and it decides whether a missing partition triggers an alert or is silently treated as a quiet day.
Say this
They mean different things operationally. An empty region is a normal day; an unconfigured region is a configuration bug that should be noticed. Returning None for both, and then testing with `if not rows`, collapses the two — and `if not rows` also treats an empty list as missing.
The reasoning
There are two conflations stacked here. The function maps a missing key to None, and the caller's truthiness test maps both None and `[]` to "no data". Either alone would be recoverable; together, three genuinely different states — has rows, configured but empty, not configured — become two.
The operational consequence is the one to lead with. "us returned no data" is something an on-call engineer might reasonably ignore for a quiet region. "apac is not in the config" is a deployment problem that will keep producing zero rows until someone notices, and the longer it hides the more backfill it costs.
The fix raises for the absent case, because it is genuinely exceptional and the caller must decide what to do about it. A sentinel would work too — a distinct `MISSING = object()` — but an exception is harder to ignore by accident, which is the property you want. `from None` suppresses the internal `KeyError`, since the caller does not care that the config happened to be a dict.
The general rule: reserve None for one meaning per function and say which in the type hint. `Optional[list[Row]]` returning None for "unknown region" is defensible only if documented; returning `list[Row]` and raising for the unknown case needs no documentation at all, which is why it is usually better.
The fix run on CPython 3.12
Three distinct states in the input. The first version reports two.
The answer most people give
"Use `if rows is None` instead of `if not rows` and it is fixed." That separates empty from missing at the call site, and every other caller still has to remember to do it. Making the absent case raise means they cannot forget.
They’ll ask next
Same question one layer up: an empty partition file and a missing partition file. How do you tell them apart, and which should page someone?
A writer retries on `ConnectionError`. The connection drops *after* the rows commit. Two rows were sent and the warehouse now has six. Fix it.
The code as found
WAREHOUSE = []
attempts = {"n": 0}
def write(rows):
attempts["n"] += 1
WAREHOUSE.extend(rows)
if attempts["n"] < 3:
raise ConnectionError("connection reset after commit")
return len(rows)
def load(rows):
for _ in range(3):
try:
return write(rows)
except ConnectionError:
continue
raise RuntimeError("gave up")
load([{"id": 1}, {"id": 2}])
print("attempts:", attempts["n"])
print("rows in warehouse:", len(WAREHOUSE))
It prints
attempts: 3
rows in warehouse: 6
Why they ask this
It is the canonical retry bug and it is a data-correctness failure, not an availability one. Anyone who has run a loader at scale has seen duplicate rows from exactly this.
Say this
The write succeeded and the acknowledgement was lost, so every retry appends the same rows again. Retries are only safe on idempotent operations — key the write on something stable (a batch id plus the row key) so a repeat overwrites rather than appends.
The reasoning
The failure is in the gap between "the server committed" and "the client heard about it". From the client's side a lost acknowledgement is indistinguishable from a write that never happened, so it cannot decide correctness by looking at the error. Three attempts, three commits, six rows.
The fix is to make the operation naturally repeatable rather than to make the retry smarter. Writing into a dict keyed by `(batch_id, row_id)` means the second and third attempts overwrite the entries the first one made — the end state is the same whether the write ran once or ten times. In a real sink that is a MERGE on a natural key, an idempotency key on the API request, or writing to a partition path that a re-run replaces wholesale.
The `batch_id` matters as much as the row key. Without it, a genuine second delivery of the same row ids — a corrected batch — would be indistinguishable from a retry. With it, retries collapse and legitimate re-deliveries stay separate, which is the distinction the sink actually needs.
When the operation genuinely cannot be made idempotent — charging a card, sending an email — the answer changes: record intent before acting, in a durable store, and check that record before retrying. That turns "did it happen?" from an unanswerable question into a lookup. Saying this in an interview signals you have thought about the case where the neat fix is unavailable.
The fix run on CPython 3.12
Both versions retry three times. Only one of them ends with the right number of rows.
The answer most people give
"Only retry if the error happened before the commit." The client cannot tell. A dropped connection looks the same whether the server committed or not, which is precisely why the write has to be repeatable.
They’ll ask next
The sink is an HTTP API that appends. You cannot change it. What do you send so a retry is recognised as a duplicate?
It separates people who added retries from people who thought about which failures are transient. Retrying a deterministic failure wastes time, multiplies load on a struggling service, and delays the real error.
Say this
Nothing except five identical failures and a fivefold increase in load. A 4xx means the request is wrong, so repeating it unchanged cannot help. Retry 5xx, 429 and connection errors; fail fast on everything else.
The reasoning
A retry is a bet that the failure was transient. That bet is good for a 500, a 503, a timeout or a dropped connection, and it is guaranteed to lose for a 400 or a 422 — the server has evaluated the request and rejected it, and the identical request will be rejected identically.
The cost is more than wasted seconds. Under a broad outage every client retries every request, which is exactly when the upstream service can least afford five times the traffic; that is the retry storm that turns a partial outage into a total one. And the real error — a malformed date parameter, here — is reported five failures later than it could have been.
429 is the interesting exception: it is a 4xx and it *is* retryable, but only after waiting, and ideally for the duration the `Retry-After` header specifies. 408 and 425 are similar. So the rule is not "retry 5xx only" but "retry the statuses the protocol says are transient", which is worth saying precisely.
The rest of a production retry policy: exponential backoff so attempts spread out rather than clustering, jitter so a fleet of clients does not synchronise, a cap on total elapsed time rather than only on attempt count, and a circuit breaker that stops trying when a service is clearly down. `tenacity` implements all of it; the point of the question is knowing what to configure.
The fix run on CPython 3.12
Five requests versus one, for the same outcome — and the second one says why.
The answer most people give
"Retrying is harmless — it just costs a bit of time." It costs a multiple of your request volume against a service that may already be degraded, and it is the mechanism behind retry storms. Harmless is the one thing it is not.
They’ll ask next
Which 4xx statuses would you still retry, and what would you wait for before doing it?
A sender retries on `ConnectionError`. The first attempt fails, the second succeeds — and reports zero rows sent. The rows existed. Where did they go?
The code as found
def send(rows):
batch = list(rows)
if not hasattr(send, "failed"):
send.failed = True
raise ConnectionError("reset")
return len(batch)
def load(rows):
for _ in range(3):
try:
return send(rows)
except ConnectionError:
continue
raise RuntimeError("gave up")
stream = (r for r in [{"id": 1}, {"id": 2}, {"id": 3}])
print("rows sent:", load(stream))
It prints
rows sent: 0
Why they ask this
It sits at the intersection of two things people know separately — generators are single-pass, retries re-run the body — and it is invisible unless you notice the argument is a generator.
Say this
The rows were a generator. The first attempt consumed it before failing, so the retry re-ran `list(rows)` against an exhausted iterator and got `[]`. Materialise the batch once, before the first attempt, so every attempt sends the same rows.
The reasoning
The retry loop assumes its inputs are unchanged between attempts. That holds for a list and fails for anything single-pass — a generator, a file handle, a socket, a database cursor. The first `list(rows)` drained it; the exception came afterwards; the second call to `list()` on the same object returned an empty list, and the function cheerfully reported success.
Note how quiet the failure is. No exception, no warning, and a return value of 0 that a caller could easily interpret as "there was nothing to send". If the sink is append-only, this is a batch silently lost — worse than the duplicate-write bug, because a duplicate is at least visible.
The fix is one line: materialise before the loop, so the retryable operation is a pure function of a fixed list. This also makes the memory cost explicit, which is the right trade — you cannot retry what you have not kept, so a retryable send is inherently bounded by what you can hold.
When the batch is too large to hold, the structure has to change rather than the loop. Retry at a coarser granularity where the source can be replayed — re-read the file from the offset, re-query with the same cursor — or checkpoint what has been acknowledged and resume from there. The general principle: a retry needs a replayable input, and if you cannot replay it you need a checkpoint instead.
The fix run on CPython 3.12
Both attempt twice. The first sends an empty batch the second time and calls it success.
The answer most people give
"Re-create the generator inside the retry loop." That works only if the generator is cheap and repeatable. For a file, an API cursor or a queue read, re-creating it means re-reading the source — sometimes impossible, and never free.
They’ll ask next
The batch is 2 GB and cannot be held. What do you retry instead, and what do you have to record to make that safe?
The retry logic is correct and the failure handling is absent, which is a very common combination. It also introduces `for/else`, which is the neat way to express "the loop never broke".
Say this
There is no branch for "all attempts failed". The inner loop simply runs out and control falls through to the next page, so a page that never succeeded is indistinguishable from one that did. `for ... else` runs exactly when the loop completed without `break`, which is the case that needs to raise.
The reasoning
The inner `for _ in range(3)` breaks on success and otherwise ends normally. Ending normally is the failure case, and nothing distinguishes it, so the outer loop carries on. The result is a partial dataset presented as a complete one — the worst outcome available, because downstream consumers have no signal that anything is wrong.
`for ... else` is the idiomatic fix and reads badly until you learn it: the `else` runs when the loop finished *without* breaking. Here that means every attempt failed, so it raises with the page number and the attempt count. If the construct still feels obscure, a `success = False` flag checked after the loop is equally correct and more obvious to a reader.
The design question underneath is what "partial" should mean for this job. Failing the whole run is right when downstream aggregates would be wrong without page 2. Continuing and reporting is right when pages are independent and a later backfill can fill the gap — but then the count of failures must be part of the job's output, and the run must be marked degraded rather than successful.
Whichever you choose, the invariant is that the caller can tell. A job that returns rows and a status is honest; a job that returns rows only is asking every consumer to trust that nothing went wrong. This is the same failure as `bare-except`, one level up.
The fix run on CPython 3.12
Page 2 never arrives in either run. Only the second one admits it.
The answer most people give
"Log a warning when the retries run out." A warning in a log nobody reads is not a status. The job's exit condition has to change, or the orchestrator will schedule the next run as if this one worked.
They’ll ask next
Pages are independent and a backfill can fix page 2 later. What does the job return now, and what does the orchestrator do with it?
A parser splits each line on commas. It has worked for months. One vendor sends `1,"Acme, Inc.",10` and the customer name becomes `"Acme`. Why, and what else broke?
The code as found
LINES = ['1,"Acme, Inc.",10', '2,Globex,20']
rows = []
for line in LINES:
parts = line.split(",")
rows.append({"id": parts[0], "name": parts[1], "amount": parts[-1]})
for row in rows:
print(row)
print("names:", [r["name"] for r in rows])
Hand-rolled CSV parsing is everywhere, and the failure is data-dependent — it appears the first time a field legitimately contains the delimiter, long after the code was reviewed.
Say this
`split(",")` knows nothing about quoting, so a quoted field containing a comma is torn in two. Use `csv.reader`, which implements the quoting rules — including escaped quotes and embedded newlines, which no amount of splitting will handle.
The reasoning
CSV is not "text separated by commas". It is a format with quoting rules: a field may be wrapped in double quotes, in which case it may contain commas, newlines and doubled-up quotes. `str.split` implements none of that, so `"Acme, Inc."` becomes two fields and every subsequent index shifts by one.
The code hid it in a way worth noticing: `parts[-1]` for the amount kept working, because the extra field was inserted in the middle and the amount stayed last. So the total is still right and only the name is corrupt — a single wrong column is much easier to miss in review than a crash. Had the parser used `parts[2]`, it would at least have raised or produced obvious nonsense.
`csv.reader` handles the rules and costs nothing to adopt; it takes any iterable of lines, so it works on a file handle, a `StringIO`, or a network stream. Embedded newlines are the argument that ends the discussion — a quoted field can contain a line break, which means iterating the file line by line is *itself* wrong, and only a real parser can know where a record ends.
Two related details for real files: open with `newline=""` so the module handles line endings itself, and pass an explicit `encoding`. And if the file is large and well-formed, `pyarrow.csv` or DuckDB will parse it in C, correctly, faster, with types inferred — which is usually the better answer than either version here.
The fix run on CPython 3.12
The amount survived because it was read from the end. The name did not.
The answer most people give
"Split on a different delimiter, like a pipe or a tab." That moves the problem to whichever character you chose — and you do not control what the vendor puts in a free-text field. Quoting exists precisely because no delimiter is safe.
They’ll ask next
A quoted field contains a newline. Does iterating the file line by line still work? What does that imply about `for line in f`?
A file is decoded as latin-1 because "that is what this vendor sends". The city arrives as `Zürich`, the length is 7 instead of 6, and the lookup misses. What happened, and why did nothing raise?
The code as found
raw = "Zürich,10".encode("utf-8")
text = raw.decode("latin-1") # whatever the vendor "usually" sends
city, amount = text.split(",")
print("city:", city)
print("length:", len(city))
print("matches lookup:", city == "Zürich")
It prints
city: Zürich
length: 7
matches lookup: False
Why they ask this
Mojibake is instantly recognisable to anyone who has ingested third-party files, and the reason it fails silently — latin-1 can decode any byte sequence — is the part that makes it dangerous.
Say this
The bytes were UTF-8 and were decoded as latin-1. `ü` is two bytes in UTF-8 and latin-1 maps every byte to a character, so it produced two characters instead of one. Nothing raised because latin-1 cannot fail — every possible byte is valid.
The reasoning
UTF-8 encodes `ü` as `0xC3 0xBC`. latin-1 is a single-byte encoding covering all 256 values, so it decodes those two bytes as `Ã` and `¼`. The result is a string that is longer than it should be, sorts wrongly, and does not compare equal to the same city name from any other source — so joins and lookups silently miss.
The absence of an error is the whole problem. Because latin-1 has no invalid byte sequences, `decode("latin-1")` *never* raises, which is why it gets used as a "safe" fallback. It is the opposite of safe: it guarantees you get a string, and guarantees nothing about whether it is the right one. Decoding as UTF-8 raises `UnicodeDecodeError` on genuinely non-UTF-8 input, which is the loud failure you want.
The fix is to declare the encoding rather than assume it, and to let it fail when the assumption is wrong. Where the source really is inconsistent, `errors="replace"` makes the damage visible as `�` characters, and `chardet`/`charset-normalizer` can guess — but guessing belongs in a one-off investigation, not in a daily pipeline. The durable fix is to get the encoding into the data contract.
Two practical notes. Always pass `encoding=` explicitly when opening files: the default is locale-dependent, so the same code decodes differently on a developer laptop and a container. And normalise Unicode at ingest with `unicodedata.normalize("NFC", s)`, because `ü` has two valid representations — precomposed and `u` plus a combining diaeresis — that look identical and do not compare equal.
The fix run on CPython 3.12
Six characters became seven, and the comparison that mattered returned False.
The answer most people give
"Use `errors='ignore'` so it does not break." That deletes the characters it cannot handle, turning a visible corruption into an invisible one. If you must tolerate bad bytes, `errors="replace"` at least leaves a mark you can grep for.
They’ll ask next
The vendor genuinely sends a mix of UTF-8 and cp1252 files. What do you do, and where does that decision get recorded?
A function counts the rows in a file handle and then sums a column. The count is right and the total is 0. Why?
The code as found
import io
def count_and_sum(handle):
n = sum(1 for _ in handle)
total = sum(int(line.split(",")[1]) for line in handle)
return n, total
handle = io.StringIO("a,10\nb,20\nc,30\n")
n, total = count_and_sum(handle)
print("rows:", n, "total:", total)
It prints
rows: 3 total: 0
Why they ask this
It is the generator-exhaustion problem in the form that actually reaches production, because a file handle looks like a container and behaves like an iterator.
Say this
A file object is its own iterator. The first pass consumed it, so the second `sum` iterates an exhausted handle and adds nothing. Compute both in one pass — or `seek(0)` if you must, accepting that a network stream cannot be rewound.
The reasoning
Iterating a file yields lines and advances a position that never resets on its own. `iter(handle) is handle`, so there is no fresh cursor for the second loop. The count is correct because it ran first; the sum sees an empty sequence and returns the identity value, 0, without any indication that it read nothing.
The zero is the dangerous part. `sum` of nothing is 0, `max` of nothing raises but `max(..., default=0)` does not, and `list` of nothing is `[]`. A pipeline that computes several statistics over one handle will get the first one right and quietly zero the rest.
One pass is the correct fix here, and it generalises: any time you find yourself iterating the same source twice, ask whether the two computations can share a loop. It is faster, it works on a stream of any size, and it removes the ordering dependency entirely.
`handle.seek(0)` works for a real file on disk and is the wrong habit to build, because the identical code fails on a network response, a `stdin` pipe, a decompression wrapper or an S3 streaming body — none of which are seekable. If you genuinely need multiple passes over a non-seekable source, materialise it deliberately or write it to a temporary file, and make that cost visible.
The fix run on CPython 3.12
Three rows counted, then nothing left to add up.
The answer most people give
"Add `handle.seek(0)` between the two." It fixes this file and hides the design problem. The same function against an HTTP response body raises `io.UnsupportedOperation`, and by then it is deployed.
They’ll ask next
Your function signature says it accepts any iterable of lines. Does `seek(0)` still make sense? What should the signature say instead?
Serialisation boundaries change types quietly, and this one bites whenever a lookup table is cached to disk or passed between services. It also opens the wider question of what JSON cannot represent.
Say this
JSON object keys are always strings. `json.dumps` coerced `1` to `"1"` without complaint, so after the round trip the keys are strings and `restored.get(1)` misses. Convert the keys back on load, or use a format that preserves types.
The reasoning
The JSON spec has no notion of a non-string key. `json.dumps` silently coerces `int`, `float`, `bool` and `None` keys to their string forms rather than raising — a convenience that makes the write succeed and the read wrong. The values keep their types; only the keys change, which is why the dict still looks broadly right when printed.
The lookup then fails in the quietest possible way. `restored.get(1)` returns None, which a caller may well interpret as "no entry for this id" rather than "the entire table is keyed differently now". If the code used `restored[1]` it would at least raise `KeyError`.
The immediate fix is to restore the type on load — `{int(k): v for k, v in loaded.items()}` — and to do it in one place, at the deserialisation boundary, rather than at each lookup. If keys can be more than one type, that is a sign the structure should be a list of records with an explicit key field instead of a dict.
The general lesson is that JSON is a lossy encoding for Python values, and keys are only the first case. `tuple` becomes `list`, so a tuple key fails outright and a tuple value comes back as something that no longer hashes. `Decimal` raises unless you supply a default, and encoding it as a float reintroduces the money problem. `datetime` is not supported at all. For anything where types matter, Parquet, Avro or msgpack preserve them; JSON is for interchange with things that cannot do better.
The fix run on CPython 3.12
The dump succeeded, the load succeeded, and the keys are a different type.
The answer most people give
"`json.dumps` would raise on a non-string key." It raises for keys it cannot coerce, such as a tuple — but integers, floats, booleans and None are converted silently, which is exactly why this reaches production.
They’ll ask next
What happens to a `Decimal` value through the same round trip? And to a tuple used as a value?
Concurrency (threading vs multiprocessing vs asyncio)Error handling & retries
Three pages are submitted to a thread pool. One raises. The job writes two pages and reports success, with no traceback anywhere. Why was the exception never seen?
The code as found
from concurrent.futures import ThreadPoolExecutor
written = []
def write(page):
if page == 2:
raise ValueError("page 2 is malformed")
written.append(page)
with ThreadPoolExecutor(max_workers=2) as pool:
for page in [1, 2, 3]:
pool.submit(write, page)
print("written:", sorted(written))
print("job status: success")
It prints
written: [1, 3]
job status: success
Why they ask this
It is the most common `concurrent.futures` bug and it silently loses data. Knowing that a future stores its exception until someone asks is the difference between a pipeline that fails loudly and one that under-reports for months.
Say this
An exception raised in a worker is stored on its `Future` and only re-raised when you call `.result()`. Nobody kept the futures, so nobody asked, and the exception was discarded when the object was garbage collected. Collect every future and call `.result()`.
The reasoning
`pool.submit` returns a `Future` immediately and runs the callable on a worker thread. If the callable raises, the executor catches it and stores it on the future — it must, because there is no call stack to propagate into. The exception surfaces only when something calls `.result()` or `.exception()` on that future.
Here the return value of `submit` was discarded, so the futures were unreachable and the stored exceptions went with them. Exiting the `with` block calls `shutdown(wait=True)`, which waits for the work to *finish* — it does not inspect outcomes, which is why the block completes cleanly on a failed batch.
The fix is to keep the futures and resolve each one. `pool.map` behaves differently and is worth knowing about: it re-raises on iteration, so failures do surface — but at the first failing element, in input order, which means later results are never reached. `as_completed` plus a per-future `try` gives you every outcome and is the right shape when partial success needs reporting.
The same trap exists in asyncio. A task created with `asyncio.create_task` and never awaited swallows its exception in the same way, and you get at most a "Task exception was never retrieved" warning at garbage-collection time. In both worlds the rule is identical: something must consume the result of every unit of work you start.
The fix run on CPython 3.12
Identical rows written both times. Only the second one notices that a third should have been.
The answer most people give
"The `with` block waits for the tasks, so it would raise if one failed." It waits for them to finish and never looks at how they finished. Waiting and checking are two different things.
They’ll ask next
Would `pool.map` have surfaced this? What would it have done with pages 3 onwards?
Concurrency (threading vs multiprocessing vs asyncio)Testing & mocking
Results from `as_completed` are zipped back against the input list. Every body is attached to the wrong page. Why does this look right in testing?
The code as found
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def fetch(page):
time.sleep(page * 0.05)
return f"body-{page}"
pages = [3, 2, 1]
with ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(fetch, p) for p in pages]
bodies = [f.result() for f in as_completed(futures)]
print(dict(zip(pages, bodies)))
It prints
{3: 'body-1', 2: 'body-2', 1: 'body-3'}
Why they ask this
It produces a perfectly shaped result with the values scrambled — no exception, no missing rows, just wrong associations. It is also the natural mistake for someone who has just learned `as_completed`.
Say this
`as_completed` yields futures in completion order, which has nothing to do with the order they were submitted. Zipping against the input list pairs the fastest result with the first input. Keep a `{future: input}` mapping instead.
The reasoning
`pool.map` preserves input order; `as_completed` deliberately does not — yielding results as they finish is the entire reason to use it. The moment you zip its output against the inputs, you have assumed an ordering it explicitly does not provide.
Here the pages are submitted as 3, 2, 1 and page 1 is fastest, so `bodies` comes back as `body-1, body-2, body-3` and zipping with `[3, 2, 1]` attaches page 1's body to page 3. The dictionary has the right keys, the right number of entries and plausible values, which is why nothing downstream complains.
In testing it usually looks correct, and that is the trap. With fast local stubs the tasks often complete in submission order, so the pairing accidentally lines up; the scrambling appears under real latency, in production, intermittently. Any test that would catch it has to deliberately vary the durations.
The fix is the standard idiom: build `{pool.submit(fn, item): item for item in items}` and look the input up from the future when it completes. That keeps the association explicit rather than positional — the same principle as using a dict index instead of relying on two lists staying aligned.
The fix run on CPython 3.12
Page 3 sleeps longest, so it finishes last — and the zip gives it the first body.
The answer most people give
"Sort the results before zipping." Sort by what? The results carry no reference to their input — that is the information the code threw away when it stored them in a bare list.
They’ll ask next
Why did this pass in CI? What would you have to do to a test to make it fail reliably?
Concurrency (threading vs multiprocessing vs asyncio)GIL & memory
Threaded code is moved to a process pool. The worker still increments a module-level counter, and the parent now prints 0. What has to change?
The code as found
import multiprocessing as mp
TOTALS = {"rows": 0}
def count(chunk):
TOTALS["rows"] += len(chunk)
if __name__ == "__main__":
with mp.get_context("spawn").Pool(2) as pool:
pool.map(count, [[1, 2], [3, 4, 5]])
print("rows counted:", TOTALS["rows"])
It prints
rows counted: 0
Why they ask this
It is the first thing that breaks when threads become processes, and the fix — return values instead of shared state — is a design change rather than a syntax change.
Say this
Processes do not share memory. Each worker mutated its own copy of `TOTALS`, and those copies died with the workers. Return the count from the worker and aggregate in the parent, which is what `Pool.map` is for.
The reasoning
Under threads the module-level dict is genuinely one object, so the original code worked — at the cost of needing a lock. Under processes each worker is a separate interpreter with its own memory; with the spawn start method it re-imports the module from scratch, so `TOTALS` is freshly initialised to zero in every child and the parent's copy is never touched.
Nothing raises, because every individual operation is valid. The workers really did count the rows, in their own address spaces, and then exited. The parent prints the value it started with. This is the same class of failure as the futures bug — work happened and its result was discarded — but here the discarding is a property of the memory model rather than an oversight.
The fix makes the worker a pure function of its argument that returns its result, and lets `Pool.map` collect the return values. That is not a workaround; it is the shape multiprocessing requires, and it happens to be the shape that is easiest to test — you can call `count([1, 2])` directly with no pool at all.
When shared mutable state is genuinely needed there are `Manager().dict()` (a proxy, with a socket round trip per access), `Value`/`Array` for simple numbers, and `shared_memory` for large buffers. All three cost more than returning a value, and all three reintroduce the locking question, so reach for them only when the data is too large to copy.
The fix run on CPython 3.12
Both count the same five rows. Only the second version gets the answer back.
The answer most people give
"Add a `multiprocessing.Lock` around the increment." A lock coordinates access to shared memory. There is no shared memory here — there are three separate dicts, and locking each one changes nothing.
They’ll ask next
The same code worked under `ThreadPoolExecutor` without a lock and gave the right answer in testing. Was it correct?
Concurrency (threading vs multiprocessing vs asyncio)Error handling & retries
Three pages are fetched with `asyncio.gather`. Page 2 raises immediately. The two good pages are lost as well. Why, and how do you keep them?
The code as found
import asyncio
async def fetch(page):
if page == 2:
raise ValueError("page 2 is malformed")
await asyncio.sleep(0.05 * page)
return f"page-{page}"
async def main():
try:
results = await asyncio.gather(*(fetch(p) for p in [1, 2, 3]))
print("results:", results)
except ValueError as error:
print("gather raised:", error)
print("results kept: none")
asyncio.run(main())
It prints
gather raised: page 2 is malformed
results kept: none
Why they ask this
It looks like an error-handling question and is really about what `gather` does on failure. The distinction matters whenever partial success is acceptable, which for a paginated fetch it usually is.
Say this
`gather` propagates the first exception to the caller as soon as it occurs, and the results of the other tasks are never returned — even the ones that had already succeeded. `return_exceptions=True` makes it wait for everything and hand back exceptions as values instead.
The reasoning
By default `gather` re-raises the first exception immediately. The remaining tasks are not cancelled — they keep running in the background — but the `await` has already returned control to the `except` block, so their results have nowhere to go. From the caller's point of view the whole batch produced nothing, and page 1's work was done and thrown away.
`return_exceptions=True` changes the contract: `gather` waits for every task to settle and returns a list positionally aligned with the inputs, where each element is either a result or an exception object. You then partition that list, which gives you both the pages you got and a precise account of the ones you did not.
The positional alignment is the part people miss and the reason this is better than `as_completed` here — the index in the returned list matches the index of the coroutine you passed in, so associating a failure with its page needs no extra bookkeeping.
The design question is what to do with the partition. Failing the run is right when the downstream aggregate needs every page; recording the failures and continuing is right when pages are independent and a backfill can fill the gap. Either way the job must not report plain success — which is the same conclusion as the exhausted-retries question, arrived at from the async side.
The fix run on CPython 3.12
Pages 1 and 3 succeed in both runs. Only the second version still has them afterwards.
The answer most people give
"Wrap each coroutine in its own try/except." That works and it is more code than the flag, and it is easy to get wrong — you have to remember to return a sentinel from every handler so the positions still line up.
They’ll ask next
With `return_exceptions=True`, how does the job decide between "degraded" and "failed"? Where does that threshold live?
Data structures & complexityError handling & retries
A validator rejects rows where `not r["amount"] or not r["note"]`. It rejected two rows out of three, and one of them was fine. Which, and why?
The code as found
ROWS = [
{"id": 1, "amount": 10, "note": "ok"},
{"id": 2, "amount": 0, "note": ""},
{"id": 3, "amount": None, "note": None},
]
missing = [r["id"] for r in ROWS if not r["amount"] or not r["note"]]
print("rows flagged as missing:", missing)
print("rows rejected:", len(missing))
It prints
rows flagged as missing: [2, 3]
rows rejected: 2
Why they ask this
Using truthiness for a null check is one line, reads naturally, and is wrong for exactly the values that matter in data work — zero amounts and empty strings.
Say this
Row 2 has `amount=0` and `note=""`, both of which are falsy but present. `not x` conflates None with zero, empty string, empty list and False. Compare against None explicitly: `r["amount"] is None`.
The reasoning
Python's truthiness rules make `0`, `0.0`, `""`, `[]`, `{}` and `False` all falsy. In business data every one of those is a legitimate value: a zero-value order, a customer who left the note blank, a batch with no tags. Treating them as missing rejects real rows.
This is the direct Python analogue of the SQL question about `COUNT(column)` versus `COUNT(*)` — the distinction between "no value" and "a value that happens to be empty" is the same distinction, and getting it wrong changes the numbers rather than crashing.
The consequence runs in both directions. Here it rejects valid rows, so the loaded total is short. The reverse appears in defaulting code: `amount = row.get("amount") or 0` silently rewrites a legitimate 0 as 0 (harmless) but also rewrites a legitimate `0.0` price or an empty-but-meaningful string. `x if x is not None else default` is the version that means what it says.
Two related habits worth naming. Distinguish "key absent" from "value is None" — `"amount" not in row` and `row["amount"] is None` are different data-quality problems with different causes. And do the check once at the parsing boundary rather than at every use, so there is one place that decides what missing means for this dataset.
The fix run on CPython 3.12
Row 2 is a zero-value order with a blank note. It is complete data, and it was rejected.
The answer most people give
"Zero and empty string should be rejected anyway — they are not real data." That is a business decision, and if it is the decision then it needs to be written as `r["amount"] in (None, 0)` so a reader can see it was intended. Getting the right answer by accident is not the same as being right.
They’ll ask next
Is `"amount" not in row` the same problem as `row["amount"] is None`? Which one indicates a schema change?
Duplicates are collapsed with `{r["id"]: r for r in rows}`. Order id 1 comes back as `pending` when it was paid yesterday. What decided which row survived?
The code as found
ROWS = [
{"id": 1, "status": "paid", "updated_at": "2026-03-02"},
{"id": 1, "status": "pending", "updated_at": "2026-03-01"},
{"id": 2, "status": "paid", "updated_at": "2026-03-01"},
]
latest = {r["id"]: r for r in ROWS} # last one wins
for row in latest.values():
print(row)
The one-line dict comprehension is the standard dedup idiom and it silently encodes "last row in the input wins", which is a policy nobody chose and that depends on file ordering.
Say this
Nothing about the data — only input order. A dict comprehension overwrites on each repeat, so the last occurrence survives. The rows arrived newest-first, so the *oldest* version won. Pick the survivor explicitly, by `updated_at`.
The reasoning
The comprehension has no notion of "latest". It inserts `id: row` for every row and each repeat overwrites the previous value, so the survivor is whichever occurrence came last in iteration order. That is an accident of how the file was written, how the query sorted, or which partition was read first.
It fails silently and inconsistently, which is the worst combination. Change the upstream sort order and the answer changes; re-run the same job on a re-generated file and it may change again. Nothing in the code or the output records that a choice was even made.
The fix states the policy: keep the row with the greatest `updated_at`. That makes the result independent of input order, which is also what makes the job idempotent — re-running it on the same data produces the same answer, which is the property backfills depend on. Note the tie-break question the fix leaves open: with equal timestamps this keeps the first seen, and if that matters you need a second key.
It is worth saying out loud that this is a window function in disguise — `ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC)` is the same operation, and if the data is already in a warehouse that is where it belongs. Doing it in Python is right when the data is in flight; doing it in Python *because* you did not think of the SQL is not.
The fix run on CPython 3.12
Order 1 has two versions. The first result keeps the one from the day before.
The answer most people give
"Sort the rows by `updated_at` first and the comprehension is fine." That does work, and it makes the correctness of the dedup depend on a sort that lives somewhere else in the file. Six months later someone moves the sort and the bug returns with no visible cause.
They’ll ask next
Two rows share an id *and* an `updated_at`. Which survives now, and how would you make that deterministic?
A check counts rows with a negative id, finds none, passes, and then the job writes zero rows. The check has never failed in six months. Should that be reassuring?
The code as found
def parse(raw):
for line in raw:
yield {"id": int(line)}
rows = parse(["1", "2", "3"])
bad = [r for r in rows if r["id"] < 0]
print("negative ids:", len(bad))
print("check passed:", not bad)
print("rows written:", len(list(rows)))
A check that cannot fail is worse than no check, because it produces a green signal that people act on. This version also destroys the data it was validating, which makes the point twice.
Say this
No — the check consumed the generator. The comprehension drained `rows` to look for negatives, so the write that followed had nothing left. The check passes vacuously on any input, and the job writes nothing, and both report success.
The reasoning
The generator is consumed by the validation pass. `[r for r in rows if r["id"] < 0]` walks it to the end, so `len(list(rows))` afterwards is 0. The check is not wrong about the rows it saw — it is that the rows it saw are now gone.
Worse, the check would report "passed" even on data full of negatives, as long as something consumed the generator first. A check whose result does not depend on the data is not a check. The six months of green is evidence of nothing, which is the reason to be actively suspicious of a gate that has never fired.
The fix materialises once and uses the same list for both the check and the write, so the validation is provably about the rows that get written. That is the property you actually want from a quality gate: it must be evaluated over the same data the consumer receives, not over a separate pass that might see something different.
The general practice this argues for is to give every check a deliberate negative test — feed it data you know is bad and confirm it fails. That is cheap, it is the only way to distinguish "no problems" from "no detection", and it would have caught this on day one. In dbt or Great Expectations terms: assert that your assertion can fail.
The fix run on CPython 3.12
Both report a passing check. The first one then writes nothing and calls that success.
The answer most people give
"The check is fine, the bug is only in the write." The check reported a pass over data it had destroyed, and it would report a pass over data it never saw. Both halves are broken by the same line.
They’ll ask next
How would you prove this check works? What is the smallest test that would have caught it?
A paid-rate metric filters out rows with an unknown status, then divides. It reports 50%. Four orders, one paid. What is the metric actually measuring?
The code as found
ROWS = [
{"id": 1, "status": "paid"},
{"id": 2, "status": "failed"},
{"id": 3, "status": None},
{"id": 4, "status": None},
]
known = [r for r in ROWS if r["status"] is not None]
paid = [r for r in known if r["status"] == "paid"]
print("paid rate: {:.0%}".format(len(paid) / len(known)))
print("denominator:", len(known))
It prints
paid rate: 50%
denominator: 2
Why they ask this
Dropping unknowns before computing a rate is the most common way a metric quietly overstates itself, and it is a modelling question rather than a coding one — which is why it belongs in an interview.
Say this
"Paid as a share of orders whose status we know", not "paid as a share of orders". Filtering the denominator hides the unknowns entirely, so as data quality gets *worse* the reported rate goes *up*.
The reasoning
The filtered version divides 1 paid by 2 known statuses and reports 50%. The unfiltered version divides 1 by 4 and reports 25%. Both are arithmetically correct; they answer different questions, and only one of them is the question the metric name claims to answer.
The perverse incentive is what makes this dangerous. If an upstream bug starts nulling out statuses, the known-status denominator shrinks, and the reported paid rate climbs. The metric moves in the opposite direction to the problem, so the dashboard looks better precisely when the pipeline is degrading.
The fix keeps the full denominator and reports the unknown count alongside. That makes the ambiguity visible rather than resolving it silently: a consumer can see 25% paid with 2 of 4 unknown and draw their own conclusion, which they could not do from 50% alone. Publishing the coverage next to the rate is the general pattern.
This is the same reasoning as `COUNT(column)` versus `COUNT(*)` in SQL, and it generalises to any ratio: state what the denominator is in the metric's name, and if unknowns are excluded, say so — `paid_rate_of_known_status` is ugly and honest. Where the exclusion is genuinely right, it should be a documented decision rather than a `WHERE` clause someone added to make a division work.
The fix run on CPython 3.12
Same four orders, same one payment. The reported rate doubles depending on the denominator.
The answer most people give
"You cannot count unknowns as unpaid — that understates the rate." Correct, and the fix does not claim they are unpaid; it reports the rate over all orders *and* how many are unknown. The error is silently choosing one interpretation and printing a single number.
They’ll ask next
The unknown rate climbs from 5% to 40% overnight. Which of the two metrics alerts, and what would you have to add to the other one?
It is the single most common decorator defect, it is invisible until an incident, and the fix is one line.
Say this
A decorator returned an inner function without copying the wrapped function's metadata. Add @functools.wraps(fn) to the wrapper and the name, docstring and module come back.
The reasoning
**What a decorator actually does.** `@retry` above a `def` means `f = retry(f)`. The name is now bound to whatever `retry` returned — usually a closure called `wrapper` — and that object has its own `__name__`, `__doc__` and `__module__`. Anything that introspects the function sees the wrapper.
**Where it shows up.** Tracebacks, `logger.info(func.__name__)`, `help()`, API frameworks that route on the function name, and metrics keyed on it. All of them start describing the decorator, and every decorated function in the codebase looks identical.
**The fix.** `@functools.wraps(fn)` on the inner function copies `__name__`, `__doc__`, `__module__`, `__qualname__` and `__dict__`, and sets `__wrapped__` so tools can still reach the original. It is one line and there is no reason ever to omit it.
**The related defect.** A wrapper written as `def wrapper(row)` instead of `def wrapper(*args, **kwargs)` only works for single-argument functions, and fails with a confusing `TypeError` the first time somebody decorates something else. Forward everything unless you are deliberately narrowing the signature.
def wrapper(row): # breaks on anything else
return fn(row)
Works until somebody decorates a two-argument function.
The fix run on CPython 3.12
The wrapper is doing the same work either way. Only one of them still knows what it wrapped.
The answer most people give
"The logger is misconfigured." The logger is reporting exactly what it was given. The function really is called wrapper now, and no logging change will fix that.
They’ll ask next
What does functools.wraps set that lets a debugger still find the original function?
A helper collects rejected rows and returns them. The first batch reports 12 rejections, the second reports 27, and the numbers only ever go up. Why?
The code as found
def collect(rows, rejected=[]):
for row in rows:
rejected.append(row)
return rejected
print("batch 1:", len(collect(["a", "b"])))
print("batch 2:", len(collect(["c"])))
It prints
batch 1: 2
batch 2: 3
Why they ask this
The mutable default argument, met as an incident rather than as trivia — and in a long-running worker it is exactly this shape.
Say this
The function has a mutable default argument. The default list is created once when the function is defined, so every call that omits the argument appends to the same list for the life of the process.
The reasoning
**Why it happens.** Default arguments are evaluated once, when the `def` statement runs, not on each call. `def collect(rows, acc=[])` therefore has one list attached to the function object, shared by every call that does not supply its own.
**Why it looks intermittent.** A script exits and forgets, so tests pass and a local run looks fine. A scheduled worker keeps the module alive for days, so the list accumulates across every batch in that process — and the counts grow, which reads as a data problem rather than a code one.
**The fix, and the half people get wrong.** Default to `None` and build the real default inside: `acc = [] if acc is None else acc`. Test the sentinel with `is None`, not with `if not acc` — an empty list the caller deliberately passed is falsy, and truthiness would silently discard their object and hand back your own.
**Where else it hides.** The same rule catches `def f(t=datetime.now())`, which freezes the timestamp at import so every row gets the time the process started. And the class-level version: `class Batch: rows = []` gives every instance the same list, because the attribute belongs to the class.
The formulations
None as the sentinelship
def collect(rows, acc=None):
acc = [] if acc is None else acc
The default is immutable, so nothing is shared.
Truthiness checkavoid
acc = acc or [] # discards a caller's empty list
An empty list the caller meant is treated as absent.
Mutable defaultavoid
def collect(rows, acc=[]): # one list, for ever
Evaluated once, at def time — one list for the whole process.
now() as a defaultavoid
def stamp(row, at=datetime.now()):
Frozen at import; every row gets the same timestamp.
The fix run on CPython 3.12
Two batches of the same sizes. The second number is the bug.
The answer most people give
"The batches are overlapping upstream." It is worth ruling out, and the give-away is that the count never resets — an upstream overlap would fluctuate, and this only ever grows.
They’ll ask next
Why is `if not acc` the wrong sentinel test, and what does it break?
It is the classic float-and-rounding incident, and the answer needs two separate causes rather than one.
Say this
Two causes, usually both: money held as float, and round() using banker's rounding where the specification says half-up. Fix by parsing to Decimal from the source string and quantizing with an explicitly named mode.
The reasoning
**Cause one: the representation.** `0.1 + 0.1 + 0.1 == 0.3` is `False`, because binary floating point cannot represent a tenth. Over a million rows those errors accumulate into something a reconciliation can see, and no amount of rounding at the end recovers it.
**Cause two: the rounding mode.** Python's `round()` is round-half-to-even, so `round(0.5)` is `0` and `round(2.5)` is `2`. Warehouses commonly round half-up. Half the ties therefore go the other way, which is a penny per affected row and never reconciles.
**And the one that looks like the tie rule and is not.** `round(2.675, 2)` gives `2.67` because `2.675` is stored slightly below the tie. The digit was gone before rounding started, which is why the fix is the representation rather than the mode.
**The fix.** Parse money with `Decimal` from the **string** the source gave you — `Decimal(0.1)` imports the float's error rather than escaping it — and round with `quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)`, naming the mode even when it is the default.
**And check the total.** Splitting 100.00 three ways and rounding each share gives 99.99. The parts have to sum back to the whole, and allocating the remainder deliberately is a decision somebody has to make rather than a rounding artefact to absorb.
Same arithmetic, two number systems. Every line that changes is a penny somebody has to explain.
The answer most people give
"Round at the end and it will be fine." Rounding a number that already carries accumulated error just rounds the error. The representation has to be right from the parse onwards.
They’ll ask next
Why does round(2.675, 2) give 2.67, and is that the tie rule or something else?