Interview guide for junior Python engineers (1–3 years)
Who this band is for. Engineers with one to three years of professional Python. They ship features with guidance, know the standard library and the everyday tools, write tests for their own code, and debug their own failures. The question this band asks is: does the candidate understand why the obvious approach can fail? A junior doesn't need to know every internal detail, but should spot an aliasing bug, a leaky feature or a non-idempotent load once it's in front of them, and know the standard fix.
Core (expect working knowledge):
- Mutability and aliasing, copying, mutable default arguments.
- Scope and closures (LEGB,
nonlocal, late binding). - Iterators and generators (laziness, exhaustion), comprehensions.
- Decorators (
functools.wraps, decorator factories) and context managers (with,@contextmanager). - Exceptions: reading tracebacks,
try/except/else/finally, not swallowing errors. - Typing: annotating everyday code, what hints do (and don't do) at runtime, Pydantic models.
- Packaging and environments: venvs,
pip install -r, project layout, why imports fail. - Testing with pytest: fixtures, parametrize,
unittest.mockbasics. - Logging: loggers vs handlers, levels, never
printin services. - pandas basics: reading data with explicit types, filtering,
groupby,merge, missing values, dates. - HTTP and APIs: status codes, JSON,
httpx, FastAPI routes and validation, auth basics.
Role tasks at this level: incremental loads and idempotent writes (DE); feature engineering without leakage and the right unit of analysis (DS); the training/inference contract, preprocessing saved with the model and stable splits (MLE); CRUD with validation, SQL done safely, password and API-key basics (BE); forms, URLs, uploads and downloads handled safely end to end (FS).
Lighter or out of scope: designing concurrent systems (knowing threads vs processes vs asyncio at the level of "which one for I/O" is enough), profiling at scale, memory internals beyond "references, not copies", metaclasses and descriptors, distributed systems design. Don't fail a junior for these; treat a good answer as a bonus.
Difficulty tiers at this level
Tiers are relative to this band: a Junior "Hard" question is roughly a Mid "Medium".
| Tier | Meaning at 1–3 years | Time (output / coding) |
|---|---|---|
| E · Easy | Everyday fluency, one concept. Anyone who has shipped Python for a year should get it. | 2–4 min / 10–15 min |
| ET · Easy & Tricky | Looks trivial; the first answer is wrong because of one mechanism (aliasing, a default, a unit). | 3–5 min / 12–15 min |
| M · Medium | The core bar: two concepts together, or careful tracing of real-looking code. | 5–7 min / 15–25 min |
| MT · Medium & Tricky | Two mechanisms interact; a strong junior reasons it out, possibly with one hint. | 6–8 min / 20–25 min |
| H · Hard | A stretch: production behaviour (generators failing mid-stream, caching, HTTP edge cases). Partial credit is normal. | 8–10 min / 25–30 min |
| HT · Hard & Tricky | A differentiator, not a requirement: subtle scoping, import or testing behaviour. | 8–12 min / 30–35 min |
Role-exclusive questions (JR-DE-*, JR-DS-*, JR-MLE-*, JR-BE-*, JR-FS-*) carry their own tier. Theory
questions use the suffix for the tier: -01 E, -02 ET, -03 M, -04 MT, -05 H, -06 HT.
Interview rounds
| Round | Length | Purpose | Draw from |
|---|---|---|---|
| Round 1 · Screening | 40 min | Fluency with everyday Python: names and objects, functions, generators, files, pandas or HTTP basics. Can the candidate read a short snippet and predict it, and write a small correct function with a test? | 3–4 output questions from E and ET, 1 coding task from E (or ET), and 2 theory questions at -01/-02.
Pass bar: clears most E; on ET, reasons sensibly even when falling for the trap and recovers once shown the output. |
| Round 2 · Core Python | 60 min | The language beyond basics: closures, decorators, context managers, exceptions, logging, imports, testing with mocks. This is where the "understands why the obvious approach fails" signal mostly comes from. | 3–4 output questions from M and MT, 1 coding task from M or MT, and 2–3 theory questions at
-03/-04. Pass bar: clears most M; at least one MT with correct reasoning (a hint is fine). |
| Round 3 · Role practical | 60 min | Can they do the day-to-day job of the role: a load that survives re-runs, a feature without leakage, an endpoint with validation and correct status codes? | 2 role-exclusive output questions and 1 role-exclusive coding task (the M one by default; the E/ET one for a candidate near one year, or as a warm-up), plus 2 role theory questions. Pass bar: a working, tested solution that handles the edge cases in the prompt; discusses at least one failure mode unprompted. |
| Round 4 · Code review and production basics | 50 min | Reading other people's code and thinking one step beyond "it works on my machine": what breaks on re-runs, bad input, a second worker, or a slow dependency. Also the place for the H/HT stretch questions. | 1 code-review exercise (JR-CR-01…JR-CR-05, 15–20 minutes), 1–2 output questions from H/HT, and 2
theory questions at -05/-06. Pass bar: finds the main bug in the review and explains its real-world
consequence; partial credit on H is expected, HT is a bonus. |
Which questions for which round
Primary picks first; (alt: …) items are drop-in substitutes of similar tier and length, for re-interviews or when
the candidate has clearly seen a question before. Theory IDs are JR-TH-<CAT>-NN.
| Round | DE · Data Engineer | DS · Data Scientist | MLE · ML Engineer | BE · Backend | FS · Full Stack |
|---|---|---|---|---|---|
| R1 output | JR-E-O03, JR-E-O06, JR-ET-O04, JR-ET-O05 (alt: JR-ET-O03) | JR-E-O06, JR-E-O01, JR-ET-O02, JR-ET-O04 (alt: JR-E-O03) | JR-E-O05, JR-E-O03, JR-ET-O01, JR-ET-O03 (alt: JR-E-O06) | JR-E-O01, JR-E-O04, JR-ET-O01, JR-ET-O06 (alt: JR-E-O02) | JR-E-O01, JR-E-O04, JR-ET-O02, JR-ET-O06 (alt: JR-ET-O01) |
| R1 coding | JR-E-C02 (alt: JR-ET-C01) | JR-ET-C02 (alt: JR-E-C01) | JR-E-C01 (alt: JR-ET-C03) | JR-E-C03 (alt: JR-E-C01) | JR-E-C03 (alt: JR-E-C02) |
| R1 theory | JR-TH-GEN-01, JR-TH-NUM-02 | JR-TH-SRT-01, JR-TH-HSH-02 | JR-TH-MOD-01, JR-TH-TYP-02 | JR-TH-EXC-01, JR-TH-MUT-02 | JR-TH-STR-01, JR-TH-DM-02 |
| R2 output | JR-M-O01, JR-M-O03, JR-MT-O02, JR-MT-O04 (alt: JR-MT-O01) | JR-M-O02, JR-M-O03, JR-MT-O01, JR-MT-O02 (alt: JR-M-O05) | JR-M-O05, JR-M-O06, JR-MT-O01, JR-MT-O06 (alt: JR-M-O02) | JR-M-O01, JR-M-O04, JR-MT-O03, JR-MT-O04 (alt: JR-MT-O06) | JR-M-O04, JR-M-O05, JR-MT-O03, JR-MT-O05 (alt: JR-M-O06) |
| R2 coding | JR-M-C01 (alt: JR-M-C02) | JR-M-C03 (alt: JR-MT-C01) | JR-M-C02 (alt: JR-M-C03) | JR-MT-C03 (alt: JR-MT-C01) | JR-MT-C02 (alt: JR-M-C01) |
| R2 theory | JR-TH-CTX-03, JR-TH-COL-04 | JR-TH-CMP-03, JR-TH-CLO-04 | JR-TH-DEC-03, JR-TH-TST-04 | JR-TH-TST-03, JR-TH-SCO-04 | JR-TH-FN-03, JR-TH-EXC-04 |
| R3 output | JR-DE-O01, JR-DE-O02 | JR-DS-O01, JR-DS-O02 (alt: JR-MLE-O02) | JR-MLE-O01, JR-MLE-O02 (alt: JR-DS-O01) | JR-BE-O01, JR-BE-O02 (alt: JR-FS-O02) | JR-FS-O01, JR-FS-O02 (alt: JR-BE-O02) |
| R3 coding | JR-DE-C01 (alt: JR-DE-C02) | JR-DS-C01 (alt: JR-DS-C02) | JR-MLE-C02 (alt: JR-MLE-C01) | JR-BE-C01 (alt: JR-BE-C02) | JR-FS-C01 (alt: JR-FS-C02) |
| R3 theory | JR-TH-DE-03, JR-TH-DE-04 | JR-TH-DS-03, JR-TH-DS-05 | JR-TH-MLE-03, JR-TH-MLE-05 | JR-TH-BE-03, JR-TH-BE-05 | JR-TH-FS-03, JR-TH-FS-04 |
| R4 H/HT | JR-H-O02, JR-HT-O05 (alt: JR-H-C01 as a discussion) | JR-H-O04, JR-HT-O05 (alt: JR-HT-O06) | JR-H-O05, JR-HT-O06 (alt: JR-HT-O02) | JR-H-O06, JR-HT-O04 (alt: JR-H-O01) | JR-H-O06, JR-HT-O03 (alt: JR-H-O03) |
| R4 code review | JR-CR-04 (alt: JR-CR-01) | JR-CR-01 (alt: JR-CR-02) | JR-CR-02 (alt: JR-CR-03) | JR-CR-03 (alt: JR-CR-05) | JR-CR-05 (alt: JR-CR-01) |
| R4 theory | JR-TH-DE-05, JR-TH-CON-05 | JR-TH-DS-06, JR-TH-MEM-05 | JR-TH-MLE-06, JR-TH-CON-05 | JR-TH-BE-06, JR-TH-ASY-05 | JR-TH-FS-06, JR-TH-ASY-05 |
Must-ask per role (if a round is cut short, keep these): DE: JR-DE-C01, JR-ET-O04, JR-CR-04. DS: JR-DS-O01, JR-DS-C01, JR-M-C03. MLE: JR-MLE-O02, JR-MLE-C02, JR-ET-C03. BE: JR-BE-O01, JR-BE-C01, JR-M-O04. FS: JR-FS-C01, JR-FS-O02, JR-CR-05.
Interviewer guide
Hint policy
- Hint after about a third of the question's time with no forward progress, or when the candidate is confidently heading somewhere that will use up the slot. Don't interrupt productive reasoning, even if it's slow.
- Use the graded hints in order, one at a time. For output questions, first "trace it line by line out loud", then
name the area ("think about what
read_csvtreats as missing"). - At this band, one hint is normal on M and above and costs little: no hints → up to 4; one hint → up to 4 on MT/H/HT and up to 3 on E/ET/M; two hints → up to 3 if they then finish alone, otherwise 2; a walked-through answer → 2.
Scoring scale (per question)
| Score | Meaning |
|---|---|
| 4 · Strong | Correct; explains the mechanism; mentions an edge case or the fix without being asked |
| 3 · Acceptable | Correct or nearly; explanation needs a nudge; corrects own slips |
| 2 · Weak | Partly correct, leans on hints, or right answer with a wrong explanation |
| 1 · Red flag | Wrong with no recovery, or a misconception that would ship a bug |
Round score = weighted average: E/ET 1.0, M/MT 1.5, H/HT 2.0, role-exclusive 2.0 (or its tier's weight if higher), code review 2.0, theory 0.5.
Calibration for 1–3 years
- Hire bar: clears nearly all E, most ET (falling for a trap and then explaining it correctly counts), most M, and about half of MT with at most one hint. On H, a 2–3 is a good result; any 3–4 on HT is a strong signal, never a requirement.
- Round 3 decides more than trivia does: a working, tested role solution with the stated edge cases outweighs a missed output question.
- Within the band: expect a one-year candidate to need the E/ET role task and some hints on M; a three-year candidate should finish the M role task with tests and raise failure modes (re-runs, leakage, bad input) unprompted. Record years of experience with the score so panels compare like with like.
- Right output, can't explain: probe once with a follow-up; if they still can't explain, cap at 2. Wrong output, right mechanism after a tracing slip: give 3.
Red flags at this band
- Builds SQL, URLs or file paths by string concatenation with user input.
- Swallows exceptions (
except: pass) or returnsNoneon errors so the caller can't tell. - Mutates function inputs or shared module-level state without noticing.
- Writes no tests, or tests only the happy path the prompt gave as an example.
- Uses test-set labels or future data in features; computes preprocessing statistics at inference time.
- Treats "it ran without an error" as "it's correct" (silent NaNs, wrong dates, dropped rows).
- Can't read a traceback to find the failing line, or changes code at random until it passes.
Running the loop: keep a per-candidate log of the IDs used so re-interviews take the (alt) column; read the follow-ups before the interview, as most of the signal comes from them; and have two interviewers score the same recorded answers for a few IDs each quarter to keep panels calibrated.
Candidate prep plan
A four-week plan for candidates with 1–3 years of experience (about 1–1.5 hours a day). For every output question: predict first, then run it, then explain the difference out loud. For every coding task: write the tests before reading the reference solution.
| Week | Focus | Practise with |
|---|---|---|
| 1 | Objects and functions: aliasing and copies, mutable defaults, scope, closures, comprehensions, exceptions and tracebacks | JR-E-O01, JR-E-O02, JR-ET-O01, JR-ET-O02, JR-MT-O04, JR-HT-O01, JR-HT-O03; theory MUT, SCO, CLO, FN, EXC (-01 to -04) |
| 2 | Iteration and reusable code: generators, decorators, context managers, logging, imports and venvs | JR-E-O03, JR-E-O04, JR-E-O05, JR-ET-O03, JR-ET-O05, JR-M-O01, JR-M-O05, JR-M-O06, JR-E-C01, JR-E-C02, JR-M-C02; theory GEN, DEC, CTX, MOD |
| 3 | Data and APIs: pandas reading, filtering, groupby, missing values, dates; HTTP status codes, httpx, FastAPI validation; typing and Pydantic | JR-E-O06, JR-ET-O04, JR-ET-O06, JR-M-O02, JR-M-O03, JR-M-O04, JR-MT-O02, JR-MT-O03, JR-H-O04, JR-H-O06, JR-M-C01; theory TYP, NUM, STR, HSH |
| 4 | Your role, testing and review: the four role-exclusive questions for your role, two of another role, pytest and mocks, and every code-review exercise | Role IDs (e.g. JR-DE-O01 … JR-DE-C02), JR-MT-O06, JR-HT-O04, JR-CR-01 … JR-CR-05; theory TST and your role's TH category |
Daily habits that show in interviews:
- Run code in a fresh virtual environment and know how to pin dependencies (
requirements.txtorpyproject.toml). - Write a small pytest file for everything you practise, including at least one "bad input" test.
- When something surprises you, find the mechanism in the docs and write one sentence about it; those sentences are your theory-round answers.
- Talk while you trace: interviewers score reasoning, not only the final line.
Role add-ons: DE: idempotent loads, upserts, time zones and epoch units, reading files with explicit types. DS: joins and row counts, unit of analysis, leakage, date parsing. MLE: saving preprocessing with the model, stable splits, numeric stability, the request → feature-vector contract. BE: parameterised SQL, status codes, dependencies, password hashing. FS: query strings and forms, uploads, CSV exports, cookies and CSRF basics.
Easy (E)
JR-E-O01 · A saved cart, a snapshot and a rebinding
Question: What does this print?
cart = ["chai", "samosa"]
saved = cart
snapshot = cart.copy()
cart.append("vada pav")
print(saved)
print(snapshot)
cart = cart + ["lassi"]
print(saved)
print(cart is saved, snapshot is saved)
Expected output:
['chai', 'samosa', 'vada pav']
['chai', 'samosa']
['chai', 'samosa', 'vada pav']
False False
Explanation:
Assignment never copies. saved = cart gives the same list object a second name, while cart.copy() builds a
new list holding the same items.
cart.append("vada pav")mutates the one object thatcartandsavedboth name, sosavedshows the new item.snapshotis a separate list and still has two items.cart = cart + ["lassi"]is different:+builds a brand-new list, and=rebinds only the namecartto it.savedstill names the original list, which never received"lassi".- After the rebinding,
cartandsavedare different objects (False), andsnapshotwas always separate (False).
Takeaway: ask two questions about every line, "does this mutate an object?" and "does this rebind a name?". Only mutation is visible through other names.
Follow-ups:
- "What changes if the last assignment is
cart += ["lassi"]instead?" For lists,+=callsextendand mutates in place, then rebindscartto the same object. Sosavedwould also show'lassi'andcart is savedwould beTrue.x += yandx = x + yare not the same for mutable types. - "Is
snapshotcompletely independent ofcart?" Only one level deep..copy()is shallow: if the items were dicts (say{"item": "chai", "qty": 1}), both lists would hold the same dict objects, and changing a quantity through one would show in the other.copy.deepcopycopies nested objects too. - "You pass
carttoapply_offer(cart)and don't want it changed. What are your options?" Pass a copy (apply_offer(list(cart))), pass an immutable tuple, or write the function to return a new list instead of mutating its argument. Document whichever you choose.
Scoring: Strong: Exact output; uses the words mutate vs rebind; spots the += difference when asked. Acceptable: Gets lines 1, 2 and 4 right but thinks saved also gets 'lassi'; corrects it once + vs append is pointed out. Red flag: Believes saved = cart makes a copy, or can't say why snapshot differs from saved.
JR-E-O02 · Invoice and refund number generators with closures
Question: What does this print?
def make_counter(label):
count = 0
def tick():
nonlocal count
count += 1
return f"{label}-{count:03d}"
return tick
next_invoice = make_counter("INV")
next_refund = make_counter("REF")
print(next_invoice(), next_invoice())
print(next_refund())
print(next_invoice())
print(next_invoice.__closure__[0].cell_contents)
Expected output:
INV-001 INV-002
REF-001
INV-003
3
Explanation:
Each call to make_counter runs the function body again, so it creates a new local count and a new tick
function. tick is a closure: it keeps access to the count (and label) of the call that created it, even after
make_counter has returned.
next_invoiceandnext_refundhave separatecountvariables, so the refund counter starts at 1 regardless of how many invoices were numbered.nonlocal countletstickrebind the enclosing variable. Without it,count += 1would makecountlocal totick.{count:03d}zero-pads to three digits.- The last line shows where the state lives: the closure keeps the variable in a cell object, and after three
calls the invoice counter's cell holds
3. (__closure__is only for inspection; you would not use it in real code.)
Takeaway: a closure remembers the variables of the call that created it; each call of the outer function gives fresh state.
Follow-ups:
- "What happens if you delete the
nonlocal countline?" The first call raisesUnboundLocalError: cannot access local variable 'count' where it is not associated with a value. Becausecountis assigned insidetick, Python treats it as local totickfor the whole function, andcount += 1reads it before it has a value. - "Would you need
nonlocalifcountwere a list and you didcount[0] += 1?" No. That mutates the list object; it doesn't rebind the namecount.nonlocal(andglobal) are only needed to rebind a name from an outer scope. - "How else could you write this?" A small class with
self.count, oritertools.count(1)wrapped with the label. A class is clearer when you also need reset or a peek method; a closure is fine for a single callable.
Scoring: Strong: Exact output; explains that each make_counter call has its own count, and why nonlocal is needed. Acceptable: Right output but vague on why the counters are independent, or unsure what nonlocal does. Red flag: Expects the counters to share state (REF-003), or says count disappears when make_counter returns.
JR-E-O03 · Fetching API pages with a generator
Question: What does this print?
def fetch_pages(total_pages):
print("start")
for page in range(1, total_pages + 1):
print(f"fetching page {page}")
yield [f"order-{page}a", f"order-{page}b"]
print("no more pages")
pages = fetch_pages(3)
print("generator created")
for batch in pages:
print("got", batch)
if batch[0] == "order-2a":
break
print(next(pages))
print(next(pages, "done"))
Expected output:
generator created
start
fetching page 1
got ['order-1a', 'order-1b']
fetching page 2
got ['order-2a', 'order-2b']
fetching page 3
['order-3a', 'order-3b']
no more pages
done
Explanation:
A function that contains yield is a generator function: calling it runs none of its body. It returns a
generator object, so generator created is printed before start.
- Each step of the
forloop callsnext(), which runs the body until the nextyieldand pauses there. Pages are fetched one at a time, just before they are needed. breakends the loop, but the generator is not finished: it stays paused right after yielding page 2.next(pages)resumes from that pause: it printsfetching page 3and returns the third batch.- The second
nextresumes again, theforloop inside the generator ends,no more pagesis printed, and the function returns. That raisesStopIterationinternally, andnext(pages, "done")turns it into the default value.
Takeaway: a generator does work only when asked for the next item, and it remembers exactly where it paused.
Follow-ups:
- "What would
print(next(pages))(with no default) do as the last line?" RaiseStopIteration. Aforloop handlesStopIterationfor you; a barenext()doesn't. That's why the two-argument form is handy for "first item or a default". - "The real version opens an HTTP session at the start. How do you make sure it is closed if the caller breaks out early?" Put the loop inside
try/finally(or awith httpx.Client() as client:block) in the generator. When the generator is closed, bypages.close(), by garbage collection, or bycontextlib.closing(...)around it, Python raisesGeneratorExitat the pausedyieldand the cleanup runs. - "Why use a generator here instead of returning a list of all pages?" Memory and latency: the caller can start processing page 1 while later pages are not fetched yet, and can stop early without fetching the rest. A list fetches everything up front.
Scoring: Strong: Exact order, including generator created before start and that next resumes at page 3. Acceptable: Knows generators are lazy but thinks break ends the generator or that start prints first; fixes it when asked what calling a generator function returns. Red flag: Expects all pages to be fetched when fetch_pages(3) is called, or can't say what yield does.
JR-E-O04 · An audit decorator on a GST helper
Question: What does this print?
import functools
def audit(func):
print(f"decorating {func.__name__}")
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"-> {func.__name__}{args}")
result = func(*args, **kwargs)
print(f"<- {result}")
return result
return wrapper
@audit
def apply_gst(amount, rate=0.18):
return round(amount * (1 + rate), 2)
print("module loaded")
total = apply_gst(500, rate=0.05)
print(total, apply_gst.__name__)
Expected output:
decorating apply_gst
module loaded
-> apply_gst(500,)
<- 525.0
525.0 apply_gst
Explanation:
@audit above def apply_gst means apply_gst = audit(apply_gst), and it runs once, when the def statement
executes, which is when the module is loaded. So decorating apply_gst prints before module loaded.
- From then on the name
apply_gstrefers towrapper. Calling it prints the arrow line, calls the original function, prints the result, and returns it. argsholds only the positional arguments,(500,).rate=0.05was passed by keyword, so it is inkwargs, which this decorator doesn't print.round(500 * 1.05, 2)is525.0(a float).functools.wraps(func)copies__name__,__doc__and other metadata from the original ontowrapper, soapply_gst.__name__is still'apply_gst'.
Takeaway: decorator code runs at definition time; the wrapper code runs at every call.
Follow-ups:
- "What would the last line print without
@functools.wraps(func)?"525.0 wrapper. Every decorated function would look likewrapperin logs, tracebacks,help()and anything that registers functions by name. - "How would you make the prefix configurable, as in
@audit(prefix="GST")?" Add one more level:audit(prefix)returns a decorator, which takesfuncand returnswrapper.@audit(prefix="GST")first callsaudit(...), then applies the decorator it returns. - "Where have you seen code that runs at decoration time?" Web frameworks:
@app.get("/orders")in FastAPI or@app.routein Flask registers the route when the module is imported, not when a request arrives.@pytest.fixtureand@dataclassalso do their work at definition time.
Scoring: Strong: Gets the order (decorating before module loaded) and explains (500,) and the preserved name. Acceptable: Gets the call lines right but puts decorating after module loaded, or prints (500, 0.05); corrects it with a hint. Red flag: Thinks the decorator body runs on each call, or can't explain what @audit desugars to.
JR-E-O05 · A pipeline step as a context manager
Question: What does this print?
class Step:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"enter {self.name}")
return self.name.upper()
def __exit__(self, exc_type, exc, tb):
status = "ok" if exc_type is None else f"failed: {exc_type.__name__}"
print(f"exit {self.name} ({status})")
return False
with Step("extract") as label:
print("inside", label)
try:
with Step("load") as label:
print("inside", label)
raise ValueError("bad row 42")
print("never printed")
except ValueError as err:
print("caught:", err)
Expected output:
enter extract
inside EXTRACT
exit extract (ok)
enter load
inside LOAD
exit load (failed: ValueError)
caught: bad row 42
Explanation:
with Step("extract") as label: calls __enter__, and its return value is what as binds, here
"EXTRACT", not the Step object. When the block ends, __exit__ runs.
- On a normal exit,
__exit__receives(None, None, None), so it printsok. - When the block raises, the rest of the block is skipped (
never printed), and__exit__receives the exception type, value and traceback. It runs before any outerexcept. __exit__returnsFalse, which means "don't suppress". TheValueErrorcontinues out of thewithand the outerexceptcatches it.
Takeaway: __exit__ always runs, sees any exception, and decides whether it propagates; as gets whatever __enter__
returns.
Follow-ups:
- "What changes if
__exit__returnsTrue?" The exception is suppressed: theexceptnever runs andcaught: ...is not printed. Execution continues after thewithblock as if nothing happened. That's why a context manager should return a truthy value only when swallowing the error is deliberate. - "Write the same thing with
contextlib.contextmanager." A generator: printenter, thentry: yield name.upper()/except Exception as e: print failed; raise/else: print ok. The code beforeyieldis__enter__, the code after is__exit__, and the exception is re-raised at theyieldline. - "Name context managers you use at work."
open()(closes the file),httpx.Client()(closes connections), a database connection or transaction (commit or rollback),threading.Lock(release),pytest.raises,unittest.mock.patch.
Scoring: Strong: Exact output; knows as gets __enter__'s return value and that __exit__ runs before the outer except. Acceptable: Right order but thinks label is the Step object, or unsure whether __exit__ runs on error. Red flag: Puts caught before exit load, or thinks the exception is swallowed by the with.
JR-E-O06 · Filtering and grouping orders in pandas
Question: What does this print?
import pandas as pd
orders = pd.DataFrame({
"city": ["Pune", "Chennai", "Pune", "Jaipur", "Chennai", "Pune"],
"amount": [450, 1200, 800, 300, 650, 1500],
})
big = orders[orders["amount"] >= 500]
print(len(orders), len(big))
print(big.index.tolist())
print(big.groupby("city")["amount"].sum())
Expected output:
6 4
[1, 2, 4, 5]
city
Chennai 1850
Pune 2300
Name: amount, dtype: int64
Explanation:
orders["amount"] >= 500 is a boolean Series, one True/False per row. Indexing the DataFrame with it keeps the
True rows: 1200, 800, 650 and 1500. So the filtered frame has 4 of the 6 rows.
- Filtering keeps the original index labels. The surviving rows were at positions 1, 2, 4 and 5, so the index is
[1, 2, 4, 5], not[0, 1, 2, 3]. groupby("city")["amount"].sum()returns a Series indexed by city: Chennai 1200 + 650 = 1850, Pune 800 + 1500 =- Jaipur's only order (300) was filtered out, so it doesn't appear at all.
groupbysorts the group keys by default, so Chennai comes before Pune.
Takeaway: boolean masks keep the original labels, and groupby(...).sum() gives one row per group present in the data.
Follow-ups:
- "What does
big.loc[0]do? Andbig.iloc[0]?"big.loc[0]raisesKeyError: 0, because there is no row with label 0 any more.big.iloc[0]is the first row by position (Chennai, 1200). Usereset_index(drop=True)if you want labels 0..n-1 again. - "Filter Pune orders of at least ₹500. Why does
(orders.amount >= 500) and (orders.city == "Pune")fail?"andasks for the truth value of a whole Series, which is ambiguous, so pandas raisesValueError. Use the element-wise&with parentheses:orders[(orders.amount >= 500) & (orders.city == "Pune")]. - "The report needs Jaipur listed with 0. How?" Reindex the result over all cities:
big.groupby("city")["amount"].sum().reindex(orders.city.unique(), fill_value=0), or compute the sum on the full frame with the condition inside the aggregation.
Scoring: Strong: Gets the index [1, 2, 4, 5], the sums, and that Jaipur is missing; knows loc vs iloc. Acceptable: Gets the counts and sums but expects [0, 1, 2, 3]; understands once labels vs positions is mentioned. Red flag: Can't read a boolean mask, or expects Jaipur in the groupby output.
JR-E-C01 · A call-counting decorator
Problem:
Write a decorator count_calls that records how many times a function has been called. The count is available as
an attribute: after decorating apply_discount, apply_discount.calls starts at 0 and goes up by one per call.
Requirements:
- Arguments (positional and keyword) and the return value pass through unchanged.
- Each decorated function has its own counter.
- A call that raises still counts, and the exception still reaches the caller.
- The decorated function keeps its original __name__ and __doc__.
Signature: from typing import Any, Callable, TypeVar
F = TypeVar("F", bound=Callable[..., Any])
def count_calls(func: F) -> F:
Constraints: Plain functions only (no methods or async functions needed). Single-threaded use.
Examples:
apply_discount(1000) after decorating→900.0, and apply_discount.calls == 1two decorated functions, each called once→each has .calls == 1apply_discount.__name__→'apply_discount'
Hints:
- Start from the decorator skeleton: a function that takes
func, defineswrapper(*args, **kwargs), and returnswrapper. - Functions are objects: you can set an attribute on
wrapperand update it insidewrapper. - Where should the counter live so that two decorated functions don't share it?
- Which standard-library helper copies
__name__and__doc__fromfunctowrapper?
Reference solution:
import functools
from typing import Any, Callable, TypeVar
F = TypeVar("F", bound=Callable[..., Any])
def count_calls(func: F) -> F:
"""Count how many times `func` is called; the count is on `.calls`.
The wrapper keeps the original name and docstring, passes all
arguments through unchanged, and counts a call even if it raises.
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
wrapper.calls += 1 # type: ignore[attr-defined]
return func(*args, **kwargs)
wrapper.calls = 0 # type: ignore[attr-defined]
return wrapper # type: ignore[return-value]
Complexity: O(1) extra time per call and O(1) memory per decorated function.
Tests:
@count_calls
def apply_discount(amount: float, *, pct: float = 10) -> float:
"""Return amount after a percentage discount."""
if amount < 0:
raise ValueError("negative amount")
return amount * (100 - pct) / 100
@count_calls
def greet(name: str) -> str:
return f"Namaste, {name}"
assert apply_discount.calls == 0
assert apply_discount(1000) == 900.0
assert apply_discount(1000, pct=25) == 750.0 # keyword-only arg passes through
assert apply_discount.calls == 2
assert greet("Asha") == "Namaste, Asha"
assert greet.calls == 1 and apply_discount.calls == 2 # independent counters
try:
apply_discount(-5)
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
assert apply_discount.calls == 3 # failed calls still count
assert apply_discount.__name__ == "apply_discount" # metadata preserved
assert apply_discount.__doc__ == "Return amount after a percentage discount."
assert apply_discount.__wrapped__(200) == 180.0 # original reachable, not counted
assert apply_discount.calls == 3
A common wrong solution (the tests catch it):
calls = 0
def count_calls(func):
def wrapper(*args, **kwargs):
global calls
calls += 1
wrapper.calls = calls
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
Alternatives and tradeoffs:
- A class with
__call__(self.calls,functools.update_wrapper(self, func)) is easier to type-check and can grow areset()method, but it doesn't bind as a method on classes without extra work. - A module-level dict keyed by function name is fragile: two functions with the same name in different modules collide, and it's global state that tests must reset.
unittest.mock.Mock(wraps=func)givescall_countfor free in tests, but it isn't meant for production code.
Pick: the closure version. It's short, each function gets its own counter, and the attribute is easy to read in tests.
Follow-ups:
- "Why does the counter go on
wrapperand not in a global variable?" A global is shared by every decorated function, sogreetwould see the calls ofapply_discount. An attribute on eachwrapper(or anonlocalvariable in the decorator) gives each function its own counter. - "Should a call that raises be counted?" Here, yes: it was a call. If you only want successful calls, increment after
funcreturns, or keep two counters. The point is to decide and test it. - "Two threads call the function at the same time. Is the count correct?" Not guaranteed.
wrapper.calls += 1is a read, an add and a write, and two threads can interleave between them and lose an update. Protect it with athreading.Lock(or useitertools.count, whosenext()is atomic in CPython with the GIL). - "How would you test this decorator with pytest?" Decorate a tiny function inside the test, call it a few times, assert
.calls, assert the return value, and usepytest.raisesfor the error path. Defining the function in the test keeps counts from leaking between tests.
Scoring: Strong: Uses functools.wraps, keeps a per-function counter, passes *args, **kwargs through and returns the result; tests the error path. Acceptable: Working counter but forgets wraps or the return value until a test fails; fixes it quickly. Red flag: Uses one global counter for all functions, or calls func at decoration time.
JR-E-C02 · Temporarily override environment variables
Problem:
Tests keep changing os.environ and forgetting to undo it, so later tests fail at random. Write a context manager
temp_env(**overrides) that sets the given environment variables for the duration of a with block and then puts
everything back exactly as it was.
temp_env(APP_REGION="ap-south-2")sets the variable inside the block.- A value of
Noneunsets that variable inside the block. - On exit, each touched variable returns to its old value, or is removed if it didn't exist before (not set to
""). - Restoration must happen even if the block raises, and the exception must still propagate.
Signature: from collections.abc import Iterator
from contextlib import contextmanager
@contextmanager
def temp_env(**overrides: str | None) -> Iterator[None]:
Constraints: Values are strings; None means "unset this variable inside the block". Single-threaded use (tests).
Examples:
APP_REGION is 'ap-south-1'; with temp_env(APP_REGION='ap-south-2'): ...→inside: 'ap-south-2'; after: 'ap-south-1'APP_DEBUG not set; with temp_env(APP_DEBUG='1'): ...→inside: '1'; after: not in os.environAPP_TOKEN is 'secret'; with temp_env(APP_TOKEN=None): ...→inside: not set; after: 'secret'
Hints:
contextlib.contextmanagerturns a generator with oneyieldinto a context manager.- Save the old values before changing anything.
- "Didn't exist" and "existed as an empty string" are different. How will you remember which it was?
- What happens to the code after
yieldif thewithblock raises? How do you make sure it still runs?
Reference solution:
import os
from collections.abc import Iterator
from contextlib import contextmanager
@contextmanager
def temp_env(**overrides: str | None) -> Iterator[None]:
"""Temporarily set (or, with None, unset) environment variables.
On exit, even if the block raises, every touched variable goes back to
exactly what it was: restored to its old value, or removed if it did not
exist before.
"""
saved = {name: os.environ.get(name) for name in overrides}
try:
for name, value in overrides.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
yield
finally:
for name, old in saved.items():
if old is None:
os.environ.pop(name, None)
else:
os.environ[name] = old
Complexity: O(k) time for k overridden variables on enter and exit; O(k) memory for the saved values.
Tests:
import os
os.environ["APP_REGION"] = "ap-south-1"
os.environ.pop("APP_DEBUG", None)
os.environ["APP_TOKEN"] = "secret"
with temp_env(APP_REGION="ap-south-2", APP_DEBUG="1", APP_TOKEN=None):
assert os.environ["APP_REGION"] == "ap-south-2"
assert os.environ["APP_DEBUG"] == "1"
assert "APP_TOKEN" not in os.environ
assert os.environ["APP_REGION"] == "ap-south-1" # old value restored
assert "APP_DEBUG" not in os.environ # did not exist before: removed, not ""
assert os.environ["APP_TOKEN"] == "secret" # unset inside, restored after
try:
with temp_env(APP_REGION="eu-west-1"):
raise RuntimeError("test failed midway")
except RuntimeError:
pass
assert os.environ["APP_REGION"] == "ap-south-1" # restored even after an exception
with temp_env(): # no overrides: a no-op
pass
assert os.environ["APP_REGION"] == "ap-south-1"
A common wrong solution (the tests catch it):
import os
from contextlib import contextmanager
@contextmanager
def temp_env(**overrides):
saved = {name: os.environ.get(name, "") for name in overrides}
for name, value in overrides.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
yield
for name, old in saved.items():
os.environ[name] = old
Alternatives and tradeoffs:
- pytest's
monkeypatch.setenv/monkeypatch.delenvdoes exactly this inside tests and undoes it automatically; prefer it in pytest code. This helper is for scripts or non-pytest code. unittest.mock.patch.dict(os.environ, {...})restores the whole mapping on exit; it can't easily unset a variable for the duration of the block withoutclear=True, which wipes everything else.- A class with
__enter__/__exit__is equivalent and makes the saved state an attribute; more lines for no gain here.
Pick: @contextmanager with try/finally for its size and clarity; monkeypatch inside pytest.
Follow-ups:
- "Why must the restore code be in
finally?" If the block raises,@contextmanagerre-raises the exception at theyield. Code after theyieldthat isn't infinally(orexcept) is skipped, so a failing test would leave the override in place and break the tests that run after it. - "Why not save
os.environ.get(name, "")?" Then you can't tell "was unset" from "was set to an empty string", and restoring writesAPP_DEBUG="", which is a different state. Code that checks"APP_DEBUG" in os.environwould behave differently after the test. - "Is this safe to use from several threads at once?" No. The environment is process-wide, so one thread's override is visible to all threads, and two overlapping blocks can restore each other's values in the wrong order. It's a test helper; pass config explicitly in concurrent code.
- "How would the code under test read these variables so it's easy to test without this helper at all?" Read the environment once at startup into a settings object (for example a Pydantic
BaseSettingsor a dataclass) and pass that object in. Tests then build the object directly.
Scoring: Strong: try/finally around the yield, saves old values before changing, restores missing variables by deleting them; mentions monkeypatch. Acceptable: Correct for the happy path; adds finally or the delete case once prompted with a test. Red flag: Doesn't restore on exception, or restores missing variables as empty strings and argues it's equivalent.
JR-E-C03 · Validate a signup payload
Problem:
A signup API receives a JSON body. Write validate_signup(payload) that returns a dict mapping each invalid field to
an error message, or {} if the payload is valid. Report every problem, not just the first.
Rules:
- name: required, a string that isn't blank after stripping spaces. Otherwise "required".
- email: required, a string with exactly one @, something before it, and a dot inside the domain part (not just
at its ends). Otherwise "invalid email".
- age: required, an integer (JSON true/false and "27" don't count) → "must be an integer"; and from 18 to
120 inclusive → "must be between 18 and 120".
- phone: optional. If present, a string of exactly 10 ASCII digits starting with 6, 7, 8 or 9. Otherwise
"must be a 10-digit mobile number".
- Any other key: "unknown field".
Signature: from typing import Any
def validate_signup(payload: dict[str, Any]) -> dict[str, str]:
Constraints: payload is a dict parsed from JSON (values can be any JSON type). Allowed fields: name, email, age, phone.
Error messages are exactly: "required", "invalid email", "must be an integer",
"must be between 18 and 120", "must be a 10-digit mobile number", "unknown field".
Examples:
{"name": "Priya Nair", "email": "priya@example.in", "age": 27, "phone": "9876543210"}→{}{}→{"name": "required", "email": "invalid email", "age": "must be an integer"}{... valid ..., "age": "27"}→{"age": "must be an integer"}{... valid ..., "is_admin": true}→{"is_admin": "unknown field"}
Hints:
- Check each field independently and add to an
errorsdict; don't return at the first problem. - Use
.get()so a missing field doesn't raiseKeyError. - Check the type before the value: what does
"27" >= 18do? isinstance(True, int)isTrue. What should"age": trueproduce?- How do you find keys in
payloadthat are not in the allowed set?
Reference solution:
from typing import Any
ALLOWED = {"name", "email", "age", "phone"}
def validate_signup(payload: dict[str, Any]) -> dict[str, str]:
"""Validate a signup payload; return {field: message}, empty if valid.
Rules: name is a non-blank string; email has one "@", a non-empty local
part and a dot in the domain; age is an int (not bool) from 18 to 120;
phone is optional, and when present is 10 digits starting with 6-9.
Unknown fields are reported as errors too. Every problem is reported,
not just the first one.
"""
errors: dict[str, str] = {}
name = payload.get("name")
if not isinstance(name, str) or not name.strip():
errors["name"] = "required"
email = payload.get("email")
if not isinstance(email, str) or email.count("@") != 1:
errors["email"] = "invalid email"
else:
local, domain = email.split("@")
if not local or "." not in domain.strip("."):
errors["email"] = "invalid email"
age = payload.get("age")
if not isinstance(age, int) or isinstance(age, bool):
errors["age"] = "must be an integer"
elif not 18 <= age <= 120:
errors["age"] = "must be between 18 and 120"
if "phone" in payload:
phone = payload["phone"]
if not (isinstance(phone, str) and len(phone) == 10
and phone.isascii() and phone.isdigit() and phone[0] in "6789"):
errors["phone"] = "must be a 10-digit mobile number"
for field in sorted(payload.keys() - ALLOWED):
errors[field] = "unknown field"
return errors
Complexity: O(f + s) time for f fields and total string length s; O(f) memory for the errors.
Tests:
good = {"name": "Priya Nair", "email": "priya@example.in", "age": 27, "phone": "9876543210"}
assert validate_signup(good) == {}
assert validate_signup({"name": "Ravi", "email": "ravi@example.com", "age": 18}) == {} # phone optional, 18 allowed
assert validate_signup({}) == {"name": "required", "email": "invalid email", "age": "must be an integer"}
assert validate_signup({**good, "name": " "}) == {"name": "required"}
assert validate_signup({**good, "email": "priya.example.in"}) == {"email": "invalid email"}
assert validate_signup({**good, "email": "@example.in"}) == {"email": "invalid email"}
assert validate_signup({**good, "email": "a@b@c.in"}) == {"email": "invalid email"}
assert validate_signup({**good, "email": "priya@localhost"}) == {"email": "invalid email"}
assert validate_signup({**good, "age": 17}) == {"age": "must be between 18 and 120"}
assert validate_signup({**good, "age": "27"}) == {"age": "must be an integer"} # JSON string, not int
assert validate_signup({**good, "age": True}) == {"age": "must be an integer"} # bool is an int subclass
assert validate_signup({**good, "phone": "12345"}) == {"phone": "must be a 10-digit mobile number"}
assert validate_signup({**good, "phone": "5876543210"}) == {"phone": "must be a 10-digit mobile number"}
assert validate_signup({**good, "phone": "9876543210"}) == {"phone": "must be a 10-digit mobile number"} # full-width digits
assert validate_signup({**good, "is_admin": True}) == {"is_admin": "unknown field"}
both = validate_signup({"name": "", "email": "x@y.in", "age": 200})
assert both == {"name": "required", "age": "must be between 18 and 120"} # all errors, not just the first
A common wrong solution (the tests catch it):
def validate_signup(payload):
errors = {}
if not payload.get("name", "").strip():
errors["name"] = "required"
email = payload.get("email", "")
if "@" not in email or "." not in email.split("@")[-1]:
errors["email"] = "invalid email"
age = payload.get("age")
if not isinstance(age, int):
errors["age"] = "must be an integer"
elif not 18 <= age <= 120:
errors["age"] = "must be between 18 and 120"
phone = payload.get("phone")
if phone and not (len(phone) == 10 and phone.isdigit()):
errors["phone"] = "must be a 10-digit mobile number"
return errors
Alternatives and tradeoffs:
- A Pydantic model (
EmailStr,Field(ge=18, le=120),pattern=...,model_config = ConfigDict(extra="forbid", strict=True)) is what you'd use in a FastAPI service: declarative, returns all errors, generates API docs. In lax mode Pydantic would accept"27"and convert it to27, which may or may not be what you want. - A real email check is not a short regex. Production code uses a library (
email-validator) for syntax and a confirmation mail for ownership. - Raise on the first error is simpler, but the user then fixes one field per submit. Returning every error is friendlier for forms.
Pick: in a FastAPI app, a Pydantic model with extra="forbid"; this hand-written version shows the same rules
explicitly.
Follow-ups:
- "Why reject unknown fields instead of ignoring them?" Mass assignment: if the handler later does
User(**payload)ordb.update(**payload), a client could setis_adminorbalance. Rejecting (or at least explicitly dropping) unknown fields closes that hole and also catches client typos likeemial. - "How would this look as a FastAPI endpoint?" Define a Pydantic
SignupInmodel and use it as the body parameter. FastAPI validates it and returns422with every error listed, before your function runs. Your handler only sees valid data. - "The phone check uses
isascii()as well asisdigit(). Why?"isdigit()accepts other scripts' digits, such as full-width9or Devanagari९.int()would even convert them. If downstream systems (an SMS gateway) expect ASCII digits, check for them explicitly. - "Should the API return
400or422for these errors?" Either is defensible; be consistent. FastAPI uses422 Unprocessable Entityfor body validation errors and400for malformed requests (such as invalid JSON). Include the per-field messages in the body.
Scoring: Strong: Collects all errors, checks type before value, handles bool and string ages, rejects unknown fields; mentions Pydantic for a real service. Acceptable: Correct for typical inputs; misses bool or unknown fields until shown a failing test, then fixes it. Red flag: Crashes with KeyError/TypeError on a missing or wrong-typed field, or stops at the first error.
Easy & Tricky (ET)
JR-ET-O01 · A cart helper with a default list
Question: What does this print?
def add_to_cart(item, cart=[]):
cart.append(item)
return cart
asha = add_to_cart("chai")
ravi = add_to_cart("samosa")
priya = add_to_cart("lassi", [])
print(asha)
print(ravi)
print(priya)
print(asha is ravi)
Expected output:
['chai', 'samosa']
['chai', 'samosa']
['lassi']
True
The trap:
['chai'], ['samosa'], ['lassi'], False. Tempting because the default looks like it creates an empty list on
each call, as it would in many other languages.
Real-world bug it maps to: a helper like def validate(row, errors=[]) in a long-running API worker. Each request
adds its errors to the same list, so the second customer sees the first customer's validation errors, and the list
keeps growing until the process restarts.
Explanation:
Default values are evaluated once, when the def statement runs, and stored on the function
(add_to_cart.__defaults__). Every call that doesn't pass cart gets the same list object.
- Asha's call appends
"chai"to that shared default list and returns it. - Ravi's call appends
"samosa"to the same list and returns it again.ashaandraviare two names for one list, so both print['chai', 'samosa']andasha is raviisTrue. - Priya passes her own new list, so the default is not involved.
Fix: use None as the default and create the list inside the function:
def add_to_cart(item, cart=None): if cart is None: cart = [].
Takeaway: a mutable default is shared by every call; use None and create a fresh object in the body.
Follow-ups:
- "How can you see the shared list without calling the function?"
add_to_cart.__defaults__holds the default values. After the first call it is(['chai'],). - "Why doesn't
def bump(n=0): n += 1; return nhave the same problem?" Ints are immutable.n += 1builds a new int and rebinds the local namen; the stored default0is never changed. The problem only appears when the default object is mutated. - "Do dataclasses or Pydantic models have the same issue with
items: list = []?" A dataclass refuses it at class creation (ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory); usefield(default_factory=list). Pydantic v2 copies the default for each instance, so it's safe there, butField(default_factory=list)states the intent clearly.
Scoring: Strong: Exact output; explains that defaults are evaluated once at definition time; gives the None fix unprompted. Acceptable: Knows mutable defaults are dangerous but misses that asha and ravi are the same object (prints ['chai'] first). Red flag: Predicts independent lists and can't explain the output after seeing it.
JR-ET-O02 · Booking one seat in a cinema seat map
Question: What does this print?
rows, cols = 3, 4
seats = [[0] * cols] * rows
seats[0][1] = 1 # book row A, seat 2
print(seats)
booked = sum(sum(row) for row in seats)
print("booked:", booked)
print(seats[0] is seats[2])
Expected output:
[[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]]
booked: 3
True
The trap:
[[0, 1, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], booked: 1, False. Tempting because [[0] * cols] * rows reads like
"a 3 × 4 grid of zeros", and printing it before the assignment looks exactly like one.
Real-world bug it maps to: a seat map, a calendar grid ([[None] * 7] * 5 for a month view) or a per-shard batch
list ([[]] * n_shards). One booking or one appended record shows up in every row, and the count is n times too high.
Explanation:
[0] * cols makes one row of four zeros. [row] * rows then makes an outer list containing three references to
that same row object; * copies references, not the objects they point to.
seats[0][1] = 1changes the shared row, so every "row" shows the booking.- The sum counts the same booked seat three times:
booked: 3. seats[0] is seats[2]isTrue: they are the same list.
The inner [0] * cols is fine because ints are immutable: row[1] = 1 puts a new value in one slot, it doesn't
change the 0 object other slots refer to.
Fix: build each row separately, seats = [[0] * cols for _ in range(rows)], or use numpy.zeros((rows, cols), dtype=int).
Takeaway: [x] * n repeats references; with a mutable x you get n names for one object.
Follow-ups:
- "What does
buckets = [[]] * 3; buckets[0].append("x"); print(buckets)print?"[['x'], ['x'], ['x']]. Same mechanism: three references to one empty list. Use[[] for _ in range(3)]. - "How could you have caught this bug before production?" A test that books one seat and asserts
booked == 1and that other rows are untouched. Printing the grid right after creation doesn't help, because it looks correct. - "Does
copy.copy(seats)give independent rows?" No. A shallow copy makes a new outer list holding the same row objects (here, three references to the one row). You need new inner lists: a comprehension orcopy.deepcopy.
Scoring: Strong: Predicts all three lines and explains that * repeats references; gives the comprehension fix. Acceptable: Senses something is off with nested * but can't state the result exactly; explains it once shown. Red flag: Predicts one booked seat and still can't explain the output after running it.
JR-ET-O03 · Two modules both call logging.basicConfig
Question: What does this print?
import logging
import sys
# orders/config.py, imported first
logging.basicConfig(stream=sys.stdout, format="%(levelname)s %(name)s: %(message)s")
# main.py, runs later and wants to see INFO logs
logging.basicConfig(stream=sys.stdout, level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("orders.sync")
log.info("synced 120 orders")
log.warning("3 orders missing GSTIN")
log.debug("raw payload: ...")
print(logging.getLogger().level == logging.WARNING)
Expected output:
WARNING orders.sync: 3 orders missing GSTIN
True
The trap:
Something like <timestamp> INFO synced 120 orders and <timestamp> WARNING 3 orders missing GSTIN. Tempting because
the last basicConfig call "should win", the way a later assignment overwrites an earlier one.
Real-world bug it maps to: a shared utility module calls basicConfig() at import time. The service's own logging
setup in main.py then does nothing, INFO logs never reach production, and during an incident nobody can tell which
orders were synced.
Explanation:
logging.basicConfig configures the root logger only if it has no handlers yet. The first call (in
orders/config.py) adds a stdout handler with the LEVEL name: message format. It doesn't pass level, so the root
logger keeps its default level, WARNING.
The second call in main.py finds a handler already on the root logger and does nothing at all: its level=INFO
and its format are both ignored, and no error or warning is raised.
log.info(...): theorders.synclogger has no level of its own, so its effective level comes from the root: WARNING. INFO is below that, so it is dropped.log.warning(...)passes and is printed with the first call's format.log.debug(...)is dropped. The last line confirms the root level is still WARNING.
Fix: configure logging once, in the application's entry point; library modules should only call
logging.getLogger(__name__). If you must override an existing setup, pass force=True (3.8+) or use
logging.config.dictConfig.
Takeaway: basicConfig is a one-shot convenience; the second call is silently ignored.
Follow-ups:
- "Without changing the
basicConfigcalls, how do you get DEBUG logs fromorders.synconly?"logging.getLogger("orders.sync").setLevel(logging.DEBUG). The logger's own level now allows DEBUG, the record propagates to the root's handler, and that handler has no level filter (NOTSET), so it is printed. Other loggers stay at WARNING. - "Why
logging.getLogger(__name__)rather than one global logger?" Each module gets a logger named after its import path (orders.sync). You can tune levels per package, and the%(name)sfield shows where a message came from. - "
log.info(f"synced {n} orders")orlog.info("synced %d orders", n)?" The%form: the string is only formatted if the record is actually emitted, and log aggregators can group messages by the constant template. It matters most in hot loops with DEBUG logs that are usually off.
Scoring: Strong: Only the WARNING line with the first format; explains the one-shot rule and the default WARNING level; names "configure once in main" or force=True. Acceptable: Knows INFO is hidden by default but expects the second call's format or level to apply. Red flag: Expects all three messages to print, or doesn't know logging levels at all.
JR-ET-O04 · Employee IDs read from a CSV with pandas
Question: What does this print?
import io
import pandas as pd
csv_text = """emp_id,name,phone
00731,Asha,09840012345
01204,Ravi,09812300011
"""
df = pd.read_csv(io.StringIO(csv_text))
print(df["emp_id"].tolist())
print(df["phone"].tolist())
print(df.dtypes.astype(str).to_dict())
print(df[df["emp_id"] == "00731"].shape)
Expected output:
[731, 1204]
[9840012345, 9812300011]
{'emp_id': 'int64', 'name': 'object', 'phone': 'int64'}
(0, 3)
The trap:
['00731', '01204'], the phone numbers with their leading zeros, and (1, 3). Tempting because the file shows the
zeros and nothing in the code asks for numbers.
Real-world bug it maps to: an HR or KYC file is loaded with read_csv, IDs lose their leading zeros, and the join
with the payroll system (which stores "00731") silently matches nothing. No error is raised; the report just shows
fewer employees.
Explanation:
read_csv infers a type for each column. A column whose values all look like whole numbers becomes int64, and
the conversion drops the leading zeros: "00731" becomes 731, and the phone numbers lose their leading 0. Only
name stays as strings (object).
The last line compares an int64 column with the string "00731". pandas doesn't raise; every comparison is simply
False, so the filter returns an empty frame with 0 rows and 3 columns.
Fix: tell pandas which columns are identifiers, pd.read_csv(..., dtype={"emp_id": str, "phone": str}), or
dtype=str for everything and convert numeric columns explicitly.
Takeaway: codes that happen to be made of digits (IDs, phone numbers, PIN codes, account numbers) are text; read them as str.
Follow-ups:
- "What happens to
emp_idif one row has an empty ID?" The column becomesfloat64:[731.0, nan]. Missing values areNaN, a float, so the whole column is upcast. Now the IDs print as731.0. Withdtype=strthe empty cell isNaNbut the others stay"00731". - "How would you find this problem in a large file you didn't write?" Check
df.dtypesright after loading and compare with what you expect, assert ID lengths (df.emp_id.str.len().eq(5).all()after reading asstr), and count rows matched by joins. - "Is
df["emp_id"].astype(str).str.zfill(5)a good fix after loading?" It only works if every ID has the same known width. A zero-padded code with a variable width can't be recovered. Fix it at read time instead.
Scoring: Strong: Predicts the int conversion, lost zeros, and the empty result with no error; fixes with dtype=. Acceptable: Knows pandas infers types but expects the string comparison to raise or to match. Red flag: Assumes read_csv keeps every value as text, and doesn't check dtypes even after seeing the output.
JR-ET-O05 · Returning a lazy reader from inside a with block
Question: What does this print?
from pathlib import Path
Path("orders.txt").write_text("ORD-101\nORD-102\nORD-103\n")
def read_ids_list(path):
with open(path) as f:
return [line.strip() for line in f]
def read_ids(path):
with open(path) as f:
return (line.strip() for line in f)
print(read_ids_list("orders.txt"))
ids = read_ids("orders.txt")
print(type(ids).__name__)
print(list(ids))
Expected output:
['ORD-101', 'ORD-102', 'ORD-103']
generator
ValueError: I/O operation on closed file.
The trap:
The list twice: ['ORD-101', 'ORD-102', 'ORD-103'], generator, ['ORD-101', 'ORD-102', 'ORD-103']. Tempting because
the two functions differ only in [...] versus (...), and switching to a generator "to save memory" looks like a
safe refactor.
Real-world bug it maps to: a refactor changes a loader to return a generator "for large files", and every caller
now fails with I/O operation on closed file. The same pattern happens with a database cursor or an HTTP streaming
response that is returned from inside its with block.
Explanation:
Both functions open the file in a with block, and with closes the file as soon as the block is left, including by
return.
read_ids_listbuilds a list inside the block, so every line is read while the file is open. Then the file closes and the finished list is returned.read_idsreturns a generator expression. A generator expression reads nothing when it is created; it only holds on tof. Thereturnleaves thewithblock and the file is closed.list(ids)then asks the generator for its first line. It tries to read from the closed file and raisesValueError: I/O operation on closed file.
Fix: keep the file open for as long as the data is consumed by making the function itself a generator,
def read_ids(path): with open(path) as f: yield from (line.strip() for line in f). Or return a list when the file is
small.
Takeaway: anything lazy that reads from a resource must be consumed before the with block closes that resource.
Follow-ups:
- "With the
yield fromfix, when is the file closed?" When the generator finishes (all lines read), or when it is closed early:gen.close(), the caller'sforloop ending withbreakfollowed by garbage collection, or the program exiting. If a caller stops halfway and keeps a reference, the file stays open; for strict control, let the caller own thewith. - "Why didn't the error happen when
read_idswas called?" Creating a generator expression evaluates only its outermost iterable (iter(f), which succeeds while the file is still open). Nothing is read untilnext()is called, which happens later, insidelist(ids). - "Where else does this pattern appear?" Returning a query result iterator from inside
with conn:or a session block; returningresponse.iter_lines()from insidewith httpx.stream(...); a pandasread_csv(..., chunksize=...)reader whose file handle was closed.
Scoring: Strong: Predicts the ValueError and explains that the generator expression reads lazily after the with has closed the file; gives the yield fix. Acceptable: Suspects the generator version is different but can't name the error; explains it once told when the file closes. Red flag: Predicts the same list twice and can't explain the error after seeing it.
JR-ET-O06 · An API client that saves error responses
Question: What does this print?
import httpx
def handler(request):
if request.url.path == "/orders/42":
return httpx.Response(200, json={"id": 42, "total": 1499})
return httpx.Response(404, json={"detail": "order not found"})
client = httpx.Client(transport=httpx.MockTransport(handler),
base_url="https://api.shop.example")
for order_id in (42, 99):
try:
response = client.get(f"/orders/{order_id}")
data = response.json()
print("saved", data)
except httpx.HTTPError as exc:
print("failed", type(exc).__name__)
try:
client.get("/orders/99").raise_for_status()
except httpx.HTTPStatusError as exc:
print("failed", exc.response.status_code)
Expected output:
saved {'id': 42, 'total': 1499}
saved {'detail': 'order not found'}
failed 404
The trap:
saved {'id': 42, 'total': 1499} then failed HTTPStatusError for order 99. Tempting because the except
httpx.HTTPError block looks like it covers "any HTTP failure", and many people assume a 404 raises.
Real-world bug it maps to: a nightly job syncs orders from a partner API and writes {"detail": "order not
found"} rows into the warehouse, or, during a partner outage, stores {"error": "service unavailable"} as data for
thousands of orders.
Explanation:
httpx.MockTransport lets the client run without a network: the handler returns 200 for order 42 and 404 with a
JSON error body for anything else.
The key point: httpx does not raise for 4xx or 5xx responses. A 404 is a successful HTTP exchange; the server
answered. client.get returns a normal Response, .json() parses the error body, and the loop "saves" it as if
it were an order.
httpx.HTTPError is raised only for transport problems (connection refused, timeouts: httpx.RequestError) or
when you ask for a status check: response.raise_for_status() raises httpx.HTTPStatusError for 4xx and 5xx. That is
what the last block does, so it prints failed 404.
Fix: call response.raise_for_status() before using the body, or check response.status_code / response.is_success
and handle 404 explicitly.
Takeaway: an HTTP client returns error responses as ordinary responses; check the status before trusting the body.
Follow-ups:
- "Which errors would you retry, and which not?" Retry transport errors (
httpx.ConnectError,httpx.TimeoutException),429 Too Many Requests(respectRetry-After) and502/503/504, with a limit and backoff. Don't retry400,401,403,404or422: repeating the same request gives the same answer. - "Why is
MockTransportuseful in tests?" Tests run without a network and are deterministic, and you can make the "server" return any status, body or exception to test your error handling. The production code under test still uses a realhttpx.Client. - "Does httpx follow redirects by default?" No.
httpxreturns the301/302response unless you passfollow_redirects=True(therequestslibrary follows them by default). A client migrated fromrequestscan suddenly see 3xx responses.
Scoring: Strong: Predicts that the 404 body is "saved"; separates transport errors from status errors; uses raise_for_status. Acceptable: Unsure whether a 404 raises, but immediately proposes checking status_code once told it doesn't. Red flag: Insists a 404 raises an exception, or would store whatever .json() returns without any status check.
JR-ET-C01 · Pick the latest release tag
Problem:
A deploy script must pick the newest release from a repository's Git tags. Release tags look like vMAJOR.MINOR.PATCH
(for example v1.10.0); other tags such as nightly, v1.4 or v1.10.1-rc1 must be ignored. Return the newest
release tag exactly as written, or None if there are none.
"Newest" means compare MAJOR first, then MINOR, then PATCH, as numbers.
Signature: def latest_release(tags: list[str]) -> str | None:
Constraints: Up to 10^4 tags. Release tags look like v1.10.0 (three numeric parts). Anything else is ignored.
Examples:
["v1.2.10", "v1.9.2", "nightly", "v1.10.0"]→"v1.10.0"["v2.0.0", "v10.0.0"]→"v10.0.0"["nightly", "v2"]→None
Hints:
- What does
"v1.9.2" < "v1.10.0"evaluate to, and why? - Turn each tag into something that compares the way versions do.
- Tuples compare element by element, left to right.
- Use
re.fullmatchsov1.3.0-rc1andv1.2.3don't sneak through.
Reference solution:
import re
TAG = re.compile(r"v(\d+)\.(\d+)\.(\d+)")
def latest_release(tags: list[str]) -> str | None:
"""Return the highest release tag of the form vMAJOR.MINOR.PATCH.
Components compare as numbers, so v1.10.0 is newer than v1.9.2.
Tags that don't match exactly (nightly, v1.2, v1.3.0-rc1) are ignored.
Returns None when no tag matches.
"""
best: str | None = None
best_key: tuple[int, int, int] | None = None
for tag in tags:
match = TAG.fullmatch(tag)
if match is None:
continue
major, minor, patch = (int(part) for part in match.groups())
key = (major, minor, patch)
if best_key is None or key > best_key:
best, best_key = tag, key
return best
Complexity: O(n · L) time for n tags of length L (one pass, no sort); O(1) extra memory.
Tests:
tags = ["v1.2.10", "v1.9.2", "nightly", "v1.10.0", "v1.10.1-rc1", "v1.4"]
assert latest_release(tags) == "v1.10.0" # 10 > 9 as numbers
assert latest_release(["v2.0.0", "v10.0.0", "v9.9.9"]) == "v10.0.0"
assert latest_release(["v1.0.9", "v1.0.10"]) == "v1.0.10" # patch compared numerically too
assert latest_release(["v3.1.0", "v3.0.12"]) == "v3.1.0" # minor beats patch
assert latest_release([]) is None
assert latest_release(["nightly", "latest", "v2"]) is None
assert latest_release(["v0.0.1"]) == "v0.0.1"
assert latest_release(["v1.2.3 ", "x-v9.9.9"]) is None # exact match only
A common wrong solution (the tests catch it):
import re
def latest_release(tags):
valid = [t for t in tags if re.fullmatch(r"v\d+\.\d+\.\d+", t)]
return max(valid) if valid else None
Alternatives and tradeoffs:
max(valid, key=parse)with a smallparsefunction returning(major, minor, patch)is the shortest correct version; equivalent to the loop.sorted(..., key=parse)[-1]works but is O(n log n) to find one maximum.packaging.version.Version(the library pip uses) understands pre-releases (1.3.0rc1), post-releases, epochs and PEP 440 ordering. Use it when tags follow Python packaging rules; it isn't in the standard library.
Pick: max with a tuple key for this simple format; packaging.version for real Python package versions.
Follow-ups:
- "Where does this bug show up in practice?" Deploy scripts that pick "the latest" Docker image or Git tag with
sortormax, sov1.9.2is deployed instead ofv1.10.0. The same happens with files namedreport_9.csv/report_10.csvand with invoice numbers stored as strings. - "Now
v1.10.0-rc1should count, but rank belowv1.10.0. How would you change the key?" Parse the suffix and add a fourth key element that sorts pre-releases first, for example(1, 10, 0, 0, 1)forrc1and(1, 10, 0, 1, 0)for the final release. Or usepackaging.version, which already implements these rules. - "How does pip decide that
numpy>=2.0,<3allows2.10.1but not3.0.0?" It parses versions into comparable objects (PEP 440) and compares them numerically per component, exactly the idea here, never as plain strings.
Scoring: Strong: Notices the string-comparison trap before coding, compares numeric tuples, and validates with fullmatch. Acceptable: Writes max(tags) first but finds the bug with the v1.10.0 example and fixes it with a tuple key. Red flag: Ships sorted(tags)[-1] and argues it's fine because the tags "look sorted".
JR-ET-C02 · Summarise product ratings where 0 stars is valid
Problem:
A product page and a recommendation model both need a summary of a product's star ratings. Each rating is 0–5
stars, or None when the customer bought the product but didn't rate it. 0 stars is a real rating.
Return a dict with:
- count: number of ratings given,
- missing: number of None entries,
- average: mean of the given ratings rounded to 2 decimals, or None if there are none,
- lowest: the smallest given rating, or None if there are none.
Signature: def summarise_ratings(ratings: list[int | None]) -> dict[str, int | float | None]:
Constraints: Ratings are integers from 0 to 5, or None for "not rated". Up to 10^6 entries.
Examples:
[5, 4, None, 3]→{"count": 3, "missing": 1, "average": 4.0, "lowest": 3}[5, 0, None, 4]→{"count": 3, "missing": 1, "average": 3.0, "lowest": 0}[None, None]→{"count": 0, "missing": 2, "average": None, "lowest": None}
Hints:
- Which values are falsy in Python? Is
0one of them? - How do you test specifically for "this value is missing"?
- What should
averagebe when nothing was rated, and how do you avoid dividing by zero?
Reference solution:
def summarise_ratings(ratings: list[int | None]) -> dict[str, int | float | None]:
"""Summarise 0-5 star ratings for a product page and a model feature.
None means "not rated" and is skipped; 0 stars is a real rating.
Returns count (ratings given), missing (None entries), average
(rounded to 2 decimals) and lowest; average and lowest are None
when nothing was rated.
"""
given = [r for r in ratings if r is not None]
return {
"count": len(given),
"missing": len(ratings) - len(given),
"average": round(sum(given) / len(given), 2) if given else None,
"lowest": min(given) if given else None,
}
Complexity: O(n) time, O(n) extra memory for the filtered list (O(1) with a single running loop).
Tests:
assert summarise_ratings([5, 4, None, 3]) == {"count": 3, "missing": 1, "average": 4.0, "lowest": 3}
assert summarise_ratings([5, 0, None, 4]) == {"count": 3, "missing": 1, "average": 3.0, "lowest": 0} # 0 counts
assert summarise_ratings([0, 0]) == {"count": 2, "missing": 0, "average": 0.0, "lowest": 0}
assert summarise_ratings([None, None]) == {"count": 0, "missing": 2, "average": None, "lowest": None}
assert summarise_ratings([]) == {"count": 0, "missing": 0, "average": None, "lowest": None}
assert summarise_ratings([4, 5, 5])["average"] == 4.67
for sample in ([5, 0, None], [0], [None, 0, 0, 3]):
s = summarise_ratings(sample)
assert s["count"] + s["missing"] == len(sample) # every entry accounted for
A common wrong solution (the tests catch it):
def summarise_ratings(ratings):
given = [r for r in ratings if r]
return {
"count": len(given),
"missing": ratings.count(None),
"average": round(sum(given) / len(given), 2) if given else None,
"lowest": min(given) if given else None,
}
Alternatives and tradeoffs:
- One loop with running totals (
count,total,lowest) uses O(1) memory, which matters for very large inputs; the list version is easier to read. - pandas:
s = pd.Series(ratings, dtype="Float64");s.count(),s.isna().sum(),s.mean()ands.min()all skip missing values and treat0as a value. Good when the data is already in a DataFrame. statistics.fmeanon the filtered list is fine, but still needs the empty-list check (StatisticsError).
Pick: the filtered list for clarity; the running loop for streams.
Follow-ups:
- "Where else does
if x:silently drop valid data?" Quantity or discount0, an empty string that is a legitimate value,0.0sensor readings, page number0in an API, a flag that isFalse. Any timeNonemeans "missing", testis None, not truthiness. - "This summary becomes a model feature. What do you do for products with no ratings?" Don't silently use
0: it means "terrible" to the model. Use a fill value computed on training data (such as the global mean) and add ahas_ratingsorcountfeature so the model can tell the cases apart. - "Why is the
count + missing == len(ratings)test useful?" It's an invariant that catches any entry that was neither counted nor reported missing, which is exactly what happens to the zeros in the buggy version. Invariant checks like this are cheap and catch whole classes of bugs.
Scoring: Strong: Uses is not None, explains why if r is wrong for 0, handles the empty case, adds an invariant check. Acceptable: Writes if r first but fixes it as soon as the 0-star example is mentioned. Red flag: Treats 0 as missing and defends it, or divides by zero on an all-None list.
JR-ET-C03 · Fill missing features at inference time
Problem:
A credit-scoring model was trained on data where missing numeric features were filled with each column's median. The same filling must happen when the model scores new applications, which arrive in batches of any size, often one row at a time.
Write two functions:
- fit_medians(train, cols) learns the median of each listed column from the training data (ignoring NaN)
and returns them as a dict. Raise ValueError if a column has no non-missing values.
- impute(batch, medians) returns a new DataFrame with NaNs in those columns filled with the stored medians.
Other columns are left as they are, and batch itself is not modified.
Signature: import pandas as pd
def fit_medians(train: pd.DataFrame, cols: list[str]) -> dict[str, float]:
def impute(batch: pd.DataFrame, medians: dict[str, float]) -> pd.DataFrame:
Constraints: train can be large; batch at inference is often a single row. Only numeric columns are listed in cols.
Don't modify the input DataFrames.
Examples:
train income_lakh = [4, 6, NaN, 10, 12]; batch has one row with income_lakh NaN→the row gets 8.0 (training median)batch = income_lakh [NaN, 50, 60]→[8.0, 50.0, 60.0] (not the batch median 55.0)a training column that is all NaN→fit_medians raises ValueError
Hints:
- Which data should the fill value come from, the batch you're scoring or the data the model was trained on?
- What is the median of a one-row batch whose only value is NaN?
- Split the work: learn the numbers once (
fit), apply them many times (transform). DataFrame.fillnaaccepts a dict of{column: value}and returns a new frame.
Reference solution:
import pandas as pd
def fit_medians(train: pd.DataFrame, cols: list[str]) -> dict[str, float]:
"""Learn one fill value per column from the TRAINING data only."""
medians: dict[str, float] = {}
for col in cols:
values = train[col].dropna()
if values.empty:
raise ValueError(f"column {col!r} has no values in training data")
medians[col] = float(values.median())
return medians
def impute(batch: pd.DataFrame, medians: dict[str, float]) -> pd.DataFrame:
"""Fill missing values in `batch` with the stored training medians.
Returns a new DataFrame; `batch` is not modified. Columns without a
stored median are left as they are.
"""
return batch.fillna(value=medians)
Complexity: fit_medians: O(n) per column (median needs a selection pass). impute: O(m · c) for m rows and c columns; returns a new frame, O(m · c) memory.
Tests:
import numpy as np
train = pd.DataFrame({
"income_lakh": [4.0, 6.0, np.nan, 10.0, 12.0],
"age": [25, 31, 40, np.nan, 52],
"city": ["Pune", "Delhi", None, "Pune", "Mumbai"],
})
stats = fit_medians(train, ["income_lakh", "age"])
assert stats == {"income_lakh": 8.0, "age": 35.5}
# inference: one request at a time
one = pd.DataFrame({"income_lakh": [np.nan], "age": [29.0], "city": [None]})
out = impute(one, stats)
assert out.loc[0, "income_lakh"] == 8.0 # training median, not the batch's own
assert out.loc[0, "age"] == 29.0 # present values untouched
assert out.loc[0, "city"] is None # columns without a median left alone
assert np.isnan(one.loc[0, "income_lakh"]) # input batch not modified
# the same row must get the same value whatever else is in the batch
batch = pd.DataFrame({"income_lakh": [np.nan, 50.0, 60.0], "age": [np.nan, 70.0, 80.0]})
filled = impute(batch, stats)
assert filled["income_lakh"].tolist() == [8.0, 50.0, 60.0]
assert filled["age"].tolist() == [35.5, 70.0, 80.0]
try:
fit_medians(pd.DataFrame({"x": [np.nan, np.nan]}), ["x"])
raise AssertionError("expected ValueError")
except ValueError:
pass
A common wrong solution (the tests catch it):
import math
import pandas as pd
def fit_medians(train, cols):
medians = {}
for col in cols:
value = train[col].median()
if math.isnan(value):
raise ValueError(f"column {col!r} has no values in training data")
medians[col] = float(value)
return medians
def impute(batch, medians):
cols = list(medians)
return batch.fillna(batch[cols].median())
Alternatives and tradeoffs:
- scikit-learn
SimpleImputer(strategy="median")inside aPipelinedoes exactly this split (fiton train,transformat inference) and is saved together with the model. Prefer it when you already use scikit-learn. - Storing medians in a JSON file next to the model makes the values visible and versioned; just make sure the serving code loads the file from the same model version.
- Filling with the batch's own median is the wrong approach this question targets: results change with batch size, and single-row batches aren't filled at all.
Pick: whatever your stack uses for a fitted preprocessing step, saved with the model artifact.
Follow-ups:
- "Where does this bug come from in practice?" Notebook code does
df.fillna(df.median())on the whole dataset, and the same line is copied into the serving code. Offline scores look fine; online, single-row requests keepNaNand the model errors or predicts garbage. The fix is a fit/transform split. - "Should the medians be computed before or after the train/test split?" After, on the training part only. Computing them on all data leaks information from the test set into training and makes offline scores look better than they are.
- "Why raise when a training column is entirely missing?" The median would be
NaN, soimputewould silently do nothing. It's better to fail at training time than to discover at serving time that a feature is never filled. - "How would you test that training and serving give the same features?" Save a few training rows with their expected features, run them through the serving code path, and assert they match. Include a single-row request with missing values, which is what this question's tests do.
Scoring: Strong: Separates fit and transform without being told, explains why batch statistics are wrong, doesn't mutate input, handles the all-missing training column. Acceptable: Starts with batch.fillna(batch.median()) but switches to stored training medians once asked about a one-row batch. Red flag: Recomputes statistics at inference and argues it's "more up to date", or mutates the caller's DataFrame.
Medium (M)
JR-M-O01 · Logger levels, handler levels and propagation in a billing service
Question: What does this print? (The handler writes to stdout so you can see every record that gets through.)
import logging
import sys
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
app_log = logging.getLogger("billing")
app_log.addHandler(handler)
app_log.setLevel(logging.INFO)
gst_log = logging.getLogger("billing.gst")
gst_log.setLevel(logging.DEBUG)
gst_log.debug("rate lookup for HSN 8471")
gst_log.info("GST 18% applied to INV-204")
app_log.debug("cache warm")
app_log.info("invoice INV-204 total ₹11,800")
handler.setLevel(logging.WARNING)
gst_log.info("GST 5% applied to INV-205")
gst_log.warning("missing GSTIN for customer C-17")
print(logging.getLogger("billing.upi").getEffectiveLevel())
print(gst_log.parent.name, gst_log.handlers)
Expected output:
DEBUG billing.gst: rate lookup for HSN 8471
INFO billing.gst: GST 18% applied to INV-204
INFO billing: invoice INV-204 total ₹11,800
WARNING billing.gst: missing GSTIN for customer C-17
20
billing []
The trap: Many candidates leave out the first line, reasoning that billing is at INFO, so a DEBUG message "can't get through the parent". That is wrong because propagation checks parent handlers, not parent logger levels. A second common slip is to print the INV-205 INFO line, forgetting that the handler now filters at WARNING.
Explanation:
Loggers form a tree by their dotted names: billing.gst and billing.upi are children of billing, which is a child of the root logger. A record goes through two separate filters:
- The logger's level, checked once, on the logger you called.
gst_logis set toDEBUG, so itsdebug(...)record is created.app_log.debug("cache warm")is dropped at once becausebillingis atINFO. - Each handler's own level. After the logger accepts a record, the record propagates up the tree and is offered to every handler it meets.
billing.gsthas no handlers of its own ([]), so the record reaches the handler attached tobilling. The parent logger's level is not checked again during propagation. That is why aDEBUGrecord frombilling.gstis printed even thoughbillingis atINFO.
Once the handler is raised to WARNING, the INFO record about INV-205 is created by the logger but rejected by the handler; the warning gets through. billing.upi has no level set (NOTSET), so its effective level is inherited from the nearest ancestor that has one: billing's INFO, which is 20.
Takeaway: logger level decides whether a record is created; handler level decides where it is written. Attach handlers high up the tree (usually only on the root, in the application's entry point) and set levels on loggers.
Follow-ups:
- "In a library you ship to other teams, should you call
logging.basicConfig()or add a handler?" No. A library doeslogger = logging.getLogger(__name__)and only logs. The application decides handlers, format and levels, usually once at start-up withbasicConfigordictConfig. A library that adds its own handler causes duplicated lines and ignores the app's settings. - "If you also called
logging.basicConfig(level=logging.INFO)at the top, what would change?" The root logger gets a stderr handler as well. Every record that reachesbilling's handler keeps propagating to root and is printed a second time, on stderr. Fix: attach handlers in one place only, or setapp_log.propagate = False. - "Why write
log.info("order %s", order_id)instead of an f-string?" With%sarguments, the message is only formatted if a handler will actually emit the record, so suppressed debug logs cost almost nothing. Log aggregators can also group records by the constant template. An f-string is formatted every time, before logging even looks at the level.
Scoring: Strong: All six lines, and explains the two filters (logger level once, then handler level) and propagation to the parent's handler. Acceptable: Gets the level filtering right but drops the first DEBUG line or the effective level 20; corrects it after a hint about where the handler lives. Red flag: Thinks setLevel on a logger also filters its children's records during propagation, or can't say what a handler is.
JR-M-O02 · loc vs iloc after filtering an orders DataFrame
Question: What does this print on pandas 2.2?
import pandas as pd
orders = pd.DataFrame({
"order_id": ["A1", "A2", "A3", "A4", "A5"],
"city": ["Pune", "Delhi", "Pune", "Chennai", "Pune"],
"amount": [450, 1200, 300, 800, 950],
})
pune = orders[orders["city"] == "Pune"]
print(pune.index.tolist())
print(pune.iloc[1]["order_id"], pune.loc[2, "order_id"])
print(orders.loc[1:3, "order_id"].tolist(), orders.iloc[1:3]["order_id"].tolist())
top = pune.sort_values("amount", ascending=False)
print(top.iloc[0]["order_id"], top.loc[0, "order_id"])
clean = pune.reset_index(drop=True)
print(clean.index.tolist(), clean.loc[1, "order_id"])
print(pune.loc[1, "order_id"])
Expected output:
[0, 2, 4]
A3 A3
['A2', 'A3', 'A4'] ['A2', 'A3']
A5 A1
[0, 1, 2] A3
KeyError: 1
Explanation:
A DataFrame row has two "addresses": its label in the index and its position. .loc uses labels; .iloc uses positions.
- Boolean filtering keeps the original labels, so
punehas index[0, 2, 4]. Position 1 inpuneis the row labelled2, which is whyiloc[1]andloc[2]both giveA3. - Label slices with
.locinclude the end label (1:3gives labels 1, 2, 3), while position slices with.ilocexclude the end, like Python lists. - Sorting moves rows but keeps their labels. After sorting by amount, the first position is A5 (₹950), but the label
0still belongs to A1. reset_index(drop=True)renumbers labels0..n-1and throws the old index away. That changesclean, notpune:punestill has no label1, sopune.loc[1, ...]raisesKeyError: 1.
Takeaway: after filtering or sorting, use .iloc for "the first row" and .loc for "the row with this label"; call reset_index(drop=True) when the old labels have no meaning.
Follow-ups:
- "What does
pune["order_id"][1]do?" On a Series with an integer index,[]is a label lookup, so it raisesKeyError: 1here. On a string index,s[1]falls back to position, and pandas 2.2 warns that this fallback is deprecated. Because[]means different things depending on the index, be explicit with.locor.iloc. - "Why does
reset_index()withoutdrop=Truesurprise people?" It moves the old index into a new column calledindex. If you then write the DataFrame to CSV or a table, you get an extra meaningless column. - "How do you get the order with the highest amount in one call?"
orders.loc[orders["amount"].idxmax()]:idxmaxreturns the label of the maximum, so it pairs with.loc. Ororders.nlargest(1, "amount").
Scoring: Strong: All six lines, including the inclusive loc slice and KeyError: 1, and states the label-vs-position rule. Acceptable: One slip (usually the inclusive slice or top.loc[0]), fixed after re-reading which accessor is used. Red flag: Thinks filtering renumbers the index, or treats loc and iloc as interchangeable.
JR-M-O03 · Which day does a midnight order belong to in IST?
Question: Orders arrive from two systems: the payments service sends UTC timestamps, the mobile app sends IST. Finance wants totals per IST calendar day (31 March is also the last day of the Indian financial year). What does this print?
from datetime import datetime, timedelta, timezone
IST = timezone(timedelta(hours=5, minutes=30), "IST")
orders = [
("ORD-1", "2026-03-31T17:45:00+00:00", 1200), # from the payments service (UTC)
("ORD-2", "2026-03-31T19:10:00+00:00", 800),
("ORD-3", "2026-04-01T00:40:00+05:30", 450), # from the mobile app (IST)
]
for order_id, ts, amount in orders:
placed = datetime.fromisoformat(ts)
local = placed.astimezone(IST)
print(order_id, placed.date(), local.date(), local.strftime("%H:%M %Z"))
by_ist_day: dict[str, int] = {}
for order_id, ts, amount in orders:
day = datetime.fromisoformat(ts).astimezone(IST).date().isoformat()
by_ist_day[day] = by_ist_day.get(day, 0) + amount
print(by_ist_day)
a = datetime.fromisoformat(orders[1][1])
b = datetime.fromisoformat(orders[2][1])
print(a == b, a.isoformat() == b.isoformat(), b - a)
print(b.astimezone(timezone.utc).isoformat())
print(b.replace(tzinfo=timezone.utc).isoformat(), b.replace(tzinfo=timezone.utc) - b)
Expected output:
ORD-1 2026-03-31 2026-03-31 23:15 IST
ORD-2 2026-03-31 2026-04-01 00:40 IST
ORD-3 2026-04-01 2026-04-01 00:40 IST
{'2026-03-31': 1200, '2026-04-01': 1250}
True False 0:00:00
2026-03-31T19:10:00+00:00
2026-04-01T00:40:00+00:00 5:30:00
Explanation:
Each ISO string carries an offset, so fromisoformat returns an aware datetime: a precise instant plus the offset it was written in.
.date()returns the calendar date in the datetime's own offset. For ORD-2 that is the UTC date, 31 March, but in India it is already 00:40 on 1 April. Grouping byplaced.date()would put ORD-2 in the wrong day and in the wrong financial year.astimezone(IST)converts: same instant, different wall-clock reading. Converting everything to one zone first, then taking.date(), gives the correct per-day totals: 800 + 450 = 1250 on 1 April.- Aware datetimes compare and subtract by instant, so ORD-2 (19:10 UTC) and ORD-3 (00:40 IST) are equal and 0 seconds apart, even though their ISO strings differ. That's why comparing timestamp strings from different systems is unreliable.
replace(tzinfo=...)does not convert; it keeps the wall-clock time and just swaps the label.b.replace(tzinfo=timezone.utc)claims 00:40 UTC, a different instant 5 h 30 min later. Useastimezoneto convert andreplaceonly to label a naive value whose zone you know.
Takeaway: store and compare instants in UTC, convert to the business time zone (astimezone) right before you take .date() or format for people.
Follow-ups:
- "Why did we build
ISTwith a fixed offset? When would you usezoneinfo.ZoneInfo("Asia/Kolkata")instead?" India has no daylight saving, so a fixed +05:30 is correct for IST. For zones with DST (Europe, US) a fixed offset is wrong for half the year; useZoneInfo, which knows the rules (it needs the system tz database or thetzdatapackage). - "What happens if one system sends
"2026-03-31 19:10:00"without an offset?"fromisoformatreturns a naive datetime. Comparing or subtracting naive and aware values raisesTypeError, andastimezoneon a naive value assumes the server's local zone, which differs between a laptop and a UTC container. Decide the zone at the boundary and attach it explicitly. - "In pandas, how would you get IST dates from a column of UTC strings?"
pd.to_datetime(s, utc=True).dt.tz_convert("Asia/Kolkata").dt.date(or.dt.normalize()to keep a datetime).tz_localizeis the pandas equivalent ofreplace: it labels naive values, it doesn't convert.
Scoring: Strong: All seven lines; explains convert-then-take-date, instant-based equality, and replace vs astimezone. Acceptable: Gets the IST dates right but expects a == b to be False or thinks replace converts; corrects it once asked whether the two values are the same instant. Red flag: Groups by .date() of the raw timestamps without converting, or treats ISO strings as safely comparable across offsets.
JR-M-O04 · Status codes from a FastAPI customer endpoint
Question: What does this print? TestClient sends real HTTP requests to the app in memory, so you see what an API client would see. (It may print a deprecation warning on stderr; ignore that, only stdout is shown.)
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
customers: dict[int, dict] = {}
class CustomerIn(BaseModel):
name: str = Field(min_length=1)
phone: str = Field(pattern=r"^[6-9]\d{9}$")
city: str = "Bengaluru"
@app.post("/customers", status_code=201)
def create_customer(body: CustomerIn):
new_id = len(customers) + 1
customers[new_id] = body.model_dump()
return {"id": new_id, **customers[new_id]}
@app.get("/customers/{customer_id}")
def get_customer(customer_id: int):
if customer_id not in customers:
raise HTTPException(status_code=404, detail="customer not found")
return customers[customer_id]
client = TestClient(app)
r = client.post("/customers", json={"name": "Asha Rao", "phone": "9876543210"})
print(r.status_code, r.json())
r = client.post("/customers", json={"name": "Vikram", "phone": "12345"})
print(r.status_code, [e["loc"] for e in r.json()["detail"]])
r = client.post("/customers", json={"phone": "9123456789", "city": 560001})
print(r.status_code, [(e["loc"], e["type"]) for e in r.json()["detail"]])
print(client.get("/customers/1").json()["city"])
r = client.get("/customers/2")
print(r.status_code, r.json())
r = client.get("/customers/abc")
print(r.status_code, r.json()["detail"][0]["type"])
Expected output:
201 {'id': 1, 'name': 'Asha Rao', 'phone': '9876543210', 'city': 'Bengaluru'}
422 [['body', 'phone']]
422 [(['body', 'name'], 'missing'), (['body', 'city'], 'string_type')]
Bengaluru
404 {'detail': 'customer not found'}
422 int_parsing
Explanation:
FastAPI validates the request before your function runs, using the type hints:
- A parameter typed as a Pydantic model (
body: CustomerIn) is read from the JSON body. If validation fails, FastAPI returns 422 Unprocessable Content with adetaillist, one entry per problem. Eachlocsays where the problem is (body→ field), andtypeis a stable error code you can test against. - Pydantic reports all errors at once: the third request is missing
nameand has a badcity. In Pydantic v2's default (lax) mode, an integer is not converted to astrfield, so560001givesstring_type. (The reverse,"42"to anintfield, is allowed.) - The
citydefault fills in when the field is omitted. status_code=201on the decorator sets the success code for creation;HTTPException(404)turns into a JSON body{"detail": ...}.- A path parameter typed
intis validated too:/customers/abcnever reachesget_customer; FastAPI returns 422 with typeint_parsing.
Takeaway: 422 means "your request didn't match the schema", 404 means "valid request, no such resource". Declare types and constraints and let the framework reject bad input before your code runs.
Follow-ups:
- "The ID is
len(customers) + 1. What goes wrong once you add DELETE?" After deleting customer 1 of 2,lenis 1, so the next create reuses ID 2 and overwrites an existing customer. Use a counter that only increases (or let the database assign IDs). With several worker processes, an in-memory dict also isn't shared between workers. - "Should a duplicate phone number return 422, 409 or 400?" 409 Conflict is the usual choice: the request is well-formed but clashes with existing state. 422 is for schema/validation errors. Be consistent and document it.
- "How do you stop the API from returning a field like
password_hash?" Declare a separate output model and setresponse_model=CustomerOut(or a return type annotation). FastAPI filters the response to that model's fields. Never return the stored dict directly.
Scoring: Strong: All six lines, including both errors on the third request and the 422 for /customers/abc; can explain 201 vs 404 vs 422. Acceptable: Gets the status codes right but expects Pydantic to stop at the first error or to turn 560001 into "560001". Red flag: Expects the bad phone to reach the function and fail there, or says invalid input should return 500.
JR-M-O05 · An event-handler registry and a decorator that forgets to return
Question: What does this print?
HANDLERS = {}
def on_event(name):
print(f"factory called for {name}")
def register(func):
print(f"registering {name}")
HANDLERS[name] = func
return func
return register
def audit(func):
print(f"auditing {func.__name__}")
def wrapper(*args):
print(f"audit: {func.__name__}{args}")
return func(*args)
@on_event("order.paid")
def send_receipt(order_id):
return f"receipt for {order_id}"
@on_event("order.refunded")
@audit
def reverse_ledger(order_id):
return f"ledger reversed for {order_id}"
print("--- app ready")
print(sorted(HANDLERS))
print(HANDLERS["order.paid"]("ORD-11"), send_receipt is HANDLERS["order.paid"])
print(reverse_ledger, HANDLERS["order.refunded"])
print(HANDLERS["order.refunded"]("ORD-12"))
Expected output:
factory called for order.paid
registering order.paid
factory called for order.refunded
auditing reverse_ledger
registering order.refunded
--- app ready
['order.paid', 'order.refunded']
receipt for ORD-11 True
None None
TypeError: 'NoneType' object is not callable
Explanation:
A decorator is just a function call that happens when the def runs, which for module-level code means at import time. @d above def f means f = d(f).
@on_event("order.paid")first calls the factoryon_event("order.paid"), which returnsregister; thenregister(send_receipt)runs, stores the function inHANDLERSand returns it unchanged. Sosend_receipt is HANDLERS["order.paid"]isTrue. This is how Flask/FastAPI routes, pytest fixtures and Celery tasks get registered.- With two decorators, Python evaluates the decorator expressions top to bottom (so the factory prints first), then applies them bottom to top:
reverse_ledger = on_event("order.refunded")(audit(reverse_ledger)). auditbuildswrapperbut neverreturns it, soaudit(...)returnsNone.registerthen storesNonein the registry, and the module-level namereverse_ledgerbecomesNonetoo. Nothing fails at import time; the bug only shows up when an event fires andNone("ORD-12")raisesTypeError.
Takeaway: decorators run once at definition time and must return a callable; a missing return wrapper silently replaces your function with None.
Follow-ups:
- "Fix
auditproperly." Addreturn wrapperat the end, decoratewrapperwith@functools.wraps(func)so it keeps the name and docstring, and accept*args, **kwargsso keyword calls work too. - "With
auditfixed, swap the order:@auditon top,@on_event(...)below it. What gets registered, and is auditing applied when the handler runs fromHANDLERS?"on_eventruns first and registers the original, undecorated function.auditwraps whatregisterreturned, so only the module-level name is audited; calls made throughHANDLERSskip the audit. Order matters for registration decorators. - "A handler module is never imported. What happens to its handlers?" They are never registered, because registration happens when the module's code runs. Frameworks solve this with explicit imports, entry points or auto-discovery.
Scoring: Strong: Exact output, including the order of the first five lines and the final TypeError; explains f = d(f) and the top-down evaluate / bottom-up apply rule. Acceptable: Spots the missing return but gets the import-time print order wrong. Red flag: Thinks decorators run when the function is called, or can't explain why reverse_ledger is None.
JR-M-O06 · What happens when a package is imported
Question: What does this print? The first block writes a small package to disk (shop/__init__.py, shop/tax.py, shop/cli.py) next to the script, exactly as if it were part of your repo.
import importlib
import pathlib
import sys
# Build a tiny package on disk next to this script:
# shop/__init__.py, shop/tax.py, shop/cli.py
pkg = pathlib.Path("shop")
pkg.mkdir(exist_ok=True)
(pkg / "__init__.py").write_text('print("init shop")\n')
(pkg / "tax.py").write_text(
'print(f"loading tax as {__name__}")\n'
"GST_RATE = 0.18\n"
"def with_gst(amount):\n"
" return round(amount * (1 + GST_RATE), 2)\n"
'if __name__ == "__main__":\n'
' print("self-test", with_gst(100))\n'
)
(pkg / "cli.py").write_text(
"from .tax import with_gst\n"
'print(f"loading cli as {__name__}")\n'
)
importlib.invalidate_caches() # we just created files; tell the import system
import shop.tax
import shop.cli
from shop import tax as tax_again
from shop.tax import GST_RATE
print(shop.tax.with_gst(1000), tax_again is shop.tax)
shop.tax.GST_RATE = 0.05
print(shop.tax.with_gst(1000), GST_RATE)
print("shop.cli" in sys.modules, __name__)
Expected output:
init shop
loading tax as shop.tax
loading cli as shop.cli
1180.0 True
1050.0 0.18
True __main__
Explanation:
Importing a module runs its top-level code once and caches the module object in sys.modules. Every later import of the same name just looks it up there.
import shop.taxfirst imports the packageshop(running__init__.py, which printsinit shop), then runstax.py. Inside an imported module,__name__is its dotted name,"shop.tax", so theif __name__ == "__main__":self-test does not run.shop/cli.pydoesfrom .tax import with_gst.shop.taxis already insys.modules, sotax.pyis not run again: there is no second "loading tax" line.from shop import tax as tax_againalso returns the same cached object (True).from shop.tax import GST_RATEcopies the current value into a new name in this script. Reassigningshop.tax.GST_RATE = 0.05later changes the module's attribute (sowith_gst, which reads its own module's global, now gives 1050.0) but not our separate nameGST_RATE, which still holds 0.18.- The script being run directly always has
__name__ == "__main__".
Takeaway: module code runs once per process; if __name__ == "__main__": keeps script-only code from running on import; from x import name copies a binding, import x + x.name always sees the current value.
Follow-ups:
- "Why is
python shop/cli.pylikely to fail, whilepython -m shop.cliworks?" Run as a file,cli.pyis__main__with no parent package, so the relative importfrom .taxraisesImportError: attempted relative import with no known parent package.-m shop.cliimports it as part of theshoppackage from the current directory, so relative imports resolve. - "You
pip install -e .your project andimport shopstill loads an old copy. How do you check which file was imported?"print(shop.__file__)orpython -c "import shop; print(shop.__file__)"and checksys.path. Usually it is the wrong virtual environment (which python,python -m pip) or a strayshop.py/ old install shadowing it. - "Why is printing or connecting to a database at import time a bad idea in a real package?" It runs for every importer, including tests and tools that only need one helper, slows start-up and can fail in CI with no network. Put side effects in functions or a
main()called from the__main__guard.
Scoring: Strong: All six lines; explains sys.modules caching, __name__ for imported vs run modules, and why GST_RATE stays 0.18. Acceptable: Gets the import lines right but expects GST_RATE to change, or prints "loading tax" twice; fixes it after a hint about caching. Red flag: Thinks the __main__ block runs on import, or that every import statement re-executes the file.
JR-M-C01 · Check installed packages against requirements.txt pins
Problem:
"It works on my machine" bugs often come from a virtual environment that has drifted from requirements.txt. Write check_pins(requirements, installed) for a CI step. requirements is the text of a requirements file; installed maps package names to installed versions (as importlib.metadata would report them). Return a dict with three sorted lists:
missing: required packages that aren't installed;mismatched: packages pinned with exactly one==whose installed version differs, formatted"<name>: want <pinned>, have <installed>";unpinned: installed packages whose requirement is not a single==pin (>=, ranges, or no version).
Parsing rules:
- Ignore blank lines, comment lines, trailing comments (
# ...) and option lines starting with-(such as-r other.txtor--index-url ...). - Ignore extras (
uvicorn[standard]) and environment markers (; python_version >= "3.8"). - Package names are compared the way pip does (PEP 503): case-insensitive, and runs of
-,_and.are equivalent, soZope_Interface,zope.interfaceandzope-interfaceare the same package. Report names in this normalised form. - A line that isn't a requirement raises
ValueErrorcontaining the line.
Also expose normalize(name).
Signature: def normalize(name: str) -> str:
def check_pins(requirements: str, installed: dict[str, str]) -> dict[str, list[str]]:
Constraints: Standard library only. Versions are compared as exact strings (no version ordering needed).
Examples:
'pydantic_core == 2.27.1 # transitive' with installed {'pydantic-core': '2.27.1'}→nothing reported'uvicorn[standard]==0.30.6' with installed {'Uvicorn': '0.30.1'}→mismatched: ['uvicorn: want 0.30.6, have 0.30.1']'Requests>=2.31' with installed {'requests': '2.32.3'}→unpinned: ['requests']'numpy==2.1.3' with numpy not installed→missing: ['numpy']
Hints:
- Normalise names on both sides before comparing; build a normalised view of
installedonce. - Strip things in a fixed order: comment, then marker, then split name / extras / version spec. A regex with groups for name, optional
[extras]and the rest keeps it readable. pandas>=2.0,==2.2.3contains==but is a range, not a pin.
Reference solution:
import re
_SEPARATORS = re.compile(r"[-_.]+")
_REQUIREMENT = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(\[[^\]]*\])?\s*(.*)$")
def normalize(name: str) -> str:
"""PEP 503 name normalisation: case-insensitive, runs of - _ . are equivalent."""
return _SEPARATORS.sub("-", name).lower()
def check_pins(requirements: str, installed: dict[str, str]) -> dict[str, list[str]]:
"""Compare a requirements.txt against installed versions."""
have = {normalize(name): version for name, version in installed.items()}
report: dict[str, list[str]] = {"missing": [], "mismatched": [], "unpinned": []}
for raw in requirements.splitlines():
line = raw.split(" #", 1)[0].strip()
if not line or line.startswith(("#", "-")):
continue
line = line.split(";", 1)[0].strip() # drop environment markers
match = _REQUIREMENT.match(line)
if match is None:
raise ValueError(f"cannot parse requirement: {raw!r}")
name, _extras, spec = match.groups()
key = normalize(name)
spec = spec.replace(" ", "")
if key not in have:
report["missing"].append(key)
elif spec.startswith("==") and "," not in spec:
if have[key] != spec[2:]:
report["mismatched"].append(f"{key}: want {spec[2:]}, have {have[key]}")
else:
report["unpinned"].append(key)
return {kind: sorted(items) for kind, items in report.items()}
Complexity: O(total length of the requirements text + number of installed packages). Memory O(number of packages).
Tests:
REQS = """
# web
fastapi==0.115.0
pydantic_core == 2.27.1 # transitive pin, written with an underscore
uvicorn[standard]==0.30.6
Requests>=2.31
python-dateutil==2.9.0.post0 ; python_version >= "3.8"
-r dev-requirements.txt
--index-url https://pypi.example.in/simple
numpy==2.1.3
zope.interface==7.0
httpx
"""
installed = {
"FastAPI": "0.115.0",
"pydantic-core": "2.27.1",
"Uvicorn": "0.30.1",
"requests": "2.32.3",
"python_dateutil": "2.9.0.post0",
"Zope_Interface": "7.0",
"httpx": "0.28.1",
}
assert check_pins(REQS, installed) == {
"missing": ["numpy"],
"mismatched": ["uvicorn: want 0.30.6, have 0.30.1"],
"unpinned": ["httpx", "requests"],
}
# name normalisation follows PEP 503
assert normalize("Zope_Interface") == normalize("zope.interface") == "zope-interface"
assert normalize("Pydantic__Core") == "pydantic-core"
# a range is not a pin, even if it includes ==
assert check_pins("pandas>=2.0,==2.2.3", {"pandas": "2.2.3"})["unpinned"] == ["pandas"]
# empty input, comments only
assert check_pins("", {}) == {"missing": [], "mismatched": [], "unpinned": []}
assert check_pins("# nothing\n\n \n", {"x": "1"}) == {"missing": [], "mismatched": [], "unpinned": []}
# a line that isn't a requirement must fail loudly, not be skipped
try:
check_pins("==1.0", {})
raise AssertionError("expected ValueError")
except ValueError as exc:
assert "==1.0" in str(exc)
A common wrong solution (the tests catch it):
def check_pins(requirements, installed):
report = {"missing": [], "mismatched": [], "unpinned": []}
for raw in requirements.splitlines():
line = raw.split("#", 1)[0].strip()
if not line or line.startswith("-"):
continue
if "==" in line:
name, version = line.split("==", 1)
name = name.split("[", 1)[0].strip().lower()
version = version.split(";", 1)[0].strip()
if name not in installed:
report["missing"].append(name)
elif installed[name] != version:
report["mismatched"].append(f"{name}: want {version}, have {installed[name]}")
else:
name = line.split(">", 1)[0].split("<", 1)[0].split("[", 1)[0].strip().lower()
if name not in installed:
report["missing"].append(name)
else:
report["unpinned"].append(name)
return {k: sorted(v) for k, v in report.items()}
Alternatives and tradeoffs:
- Use the real parser:
packaging.requirements.Requirement(line)handles every PEP 508 detail (extras, markers, URLs), andpackaging.utils.canonicalize_namedoes PEP 503 normalisation. In production prefer it over a hand-written regex; it isn't in the standard library, but pip vendors it and most environments have it. - Get installed versions with
{d.metadata["Name"]: d.version for d in importlib.metadata.distributions()}, orpip freeze/pip list --format=jsonin a subprocess. - Avoid drift instead of detecting it: lock files (
pip-tools,uv lock, Poetry) andpip install --require-hashes, and rebuild the venv in CI from the lock file every time.
Follow-ups:
- "Why does pip treat
pydantic_coreandPydantic-Coreas the same package?" PEP 503 defines a normalised name (lowercase, runs of-_.become-) used by package indexes and installers, so different spellings in different files can't install two copies or fail to match. Any tool comparing names must normalise the same way. - "Should a library's
pyproject.tomlpin exact versions like an application's requirements file?" No. A library declares compatible ranges (pandas>=2.0,<3) so it can be installed alongside other packages; an application pins exact versions (usually in a lock file) so every deploy is reproducible. - "The CI check passes, but production still behaves differently. What else can differ?" The Python version, OS-level libraries (e.g. different wheels for Linux/macOS), packages installed outside the requirements file, environment variables, and transitive dependencies that weren't pinned at all. A lock file with hashes and a container image built once and promoted across environments close most of these gaps.
Scoring: Strong: Clean parsing in a fixed order, PEP 503 normalisation on both sides, correct handling of extras, markers, trailing comments, option lines and ranges; mentions packaging and lock files as the production answer. Acceptable: Works for the simple cases; needs a hint for name normalisation or for == inside a range. Red flag: Splits on == and compares raw names, or doesn't know why a venv and pinned requirements matter.
JR-M-C02 · A context manager that writes a report file atomically
Problem:
A nightly job writes gst_report.csv, which the finance team's tool picks up. If the job crashes half-way (a query times out, the pod is killed), today's code leaves a half-written file and finance files GST on partial data.
Write a context manager atomic_write(path) that yields a text file object. Readers of path must only ever see either the old complete file or the new complete file:
- On a clean exit, the new content replaces
path(creating it if it didn't exist). - If the
withbody raises,pathis left exactly as it was (or still doesn't exist), no temporary file is left behind, and the exception propagates unchanged. - Text is written as UTF-8 by default.
Signature: def atomic_write(path: str | os.PathLike[str], encoding: str = "utf-8") -> Iterator[TextIO]:
Constraints: Standard library only. path may be a str or a Path. Use as with atomic_write(p) as f: f.write(...). Writing it with @contextlib.contextmanager (hence the Iterator[TextIO] return type) or as a class are both fine.
Examples:
with atomic_write("r.csv") as f: f.write("a,b\n")→r.csv contains 'a,b\n'with atomic_write("r.csv") as f: f.write("x"); raise RuntimeError("timeout")→RuntimeError propagates; r.csv still has its old content; no temp file in the folder
Hints:
- Write somewhere else first. Where must that "somewhere else" be for a rename to be atomic?
os.replace(src, dst)renames over an existing file in one step on the same filesystem.- With
@contextmanager, code afteryielddoesn't run if the body raises unless you usetry. What must happen in each case?
Reference solution:
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import TextIO
@contextmanager
def atomic_write(path: str | os.PathLike[str], encoding: str = "utf-8") -> Iterator[TextIO]:
"""Write text to `path` so readers see either the old file or the complete new one."""
target = Path(path)
fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding=encoding) as f:
yield f
os.replace(tmp_name, target)
except BaseException:
Path(tmp_name).unlink(missing_ok=True)
raise
Complexity: O(size of the content) time; O(1) extra memory (data streams to the temporary file). Needs disk space for one extra copy while writing.
Tests:
import os
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as d:
report = Path(d) / "gst_report.csv"
# creates a new file
with atomic_write(report) as f:
f.write("invoice,gst\nINV-1,180\n")
assert report.read_text() == "invoice,gst\nINV-1,180\n"
# replaces an existing file
with atomic_write(str(report)) as f:
f.write("invoice,gst\nINV-2,90\n")
assert report.read_text() == "invoice,gst\nINV-2,90\n"
# a failure half-way leaves the old content untouched and re-raises
try:
with atomic_write(report) as f:
f.write("invoice,gst\n")
raise RuntimeError("export query timed out")
except RuntimeError as exc:
assert str(exc) == "export query timed out"
else:
raise AssertionError("exception was swallowed")
assert report.read_text() == "invoice,gst\nINV-2,90\n"
# a failure when the file didn't exist yet leaves no file behind
fresh = Path(d) / "new.csv"
try:
with atomic_write(fresh) as f:
f.write("partial")
raise ValueError("bad row")
except ValueError:
pass
assert not fresh.exists()
# no temporary files are left lying around
assert sorted(p.name for p in Path(d).iterdir()) == ["gst_report.csv"]
# non-ASCII text round-trips with the default encoding
with atomic_write(report) as f:
f.write("total,₹1,180\n")
assert report.read_text(encoding="utf-8") == "total,₹1,180\n"
A common wrong solution (the tests catch it):
from contextlib import contextmanager
@contextmanager
def atomic_write(path, encoding="utf-8"):
with open(path, "w", encoding=encoding) as f:
yield f
Alternatives and tradeoffs:
- Class-based manager with
__enter__/__exit__: same logic;__exit__checksexc_typeto decide between replace and cleanup, and returnsFalseso exceptions propagate. - Build the content in memory, then write once: shrinks the window but isn't atomic; a crash during the write still leaves a partial file.
- Write to
report.csv.tmpwith a fixed name: two concurrent jobs would clobber each other's temp file;tempfile.mkstempgives a unique name. - For durability across a power cut, also
f.flush(); os.fsync(f.fileno())before the replace, and fsync the directory.
Follow-ups:
- "Why must the temporary file be in the same directory as the target, not in
/tmp?"os.replaceis only atomic within one filesystem./tmpis often a different filesystem (tmpfs), and then the rename fails withOSError(cross-device link) instead of being atomic. - "
mkstempcreates the file with permissions0600. Why might the finance tool suddenly be unable to read the report?" The replaced file keeps the temporary file's permissions, so it is readable only by the job's user. Copy the old file's mode (os.chmod(tmp, os.stat(path).st_mode)) or set the intended mode explicitly before replacing. - "How would you test the 'crash half-way' case in pytest?" Use the
tmp_pathfixture, write an initial file, thenwith pytest.raises(RuntimeError): with atomic_write(p) as f: f.write(...); raise RuntimeError(...), and assert the old content and thatlist(tmp_path.iterdir())has only the report.
Scoring: Strong: Temp file in the same directory, os.replace only on success, cleanup plus re-raise on failure (try/except BaseException or finally), and can explain why the rename is atomic. Acceptable: Correct happy path and cleanup, but uses a fixed temp name or /tmp; fixes it after the cross-device or concurrency question. Red flag: Opens the target directly with 'w' (which truncates it immediately), or swallows the exception so the job reports success.
JR-M-C03 · Point-in-time customer features with pandas
Problem:
For a churn model, build one row of features per customer as of a given date. orders has columns customer_id, order_date (ISO date strings), amount (₹, float) and status ("delivered" or "cancelled").
Using only orders placed strictly before as_of and not cancelled, compute per customer:
n_orders: number of orders (int)total_spend: sum ofamount(float)avg_order_value: mean amount, rounded to 2 decimalsdays_since_last_order: whole days from the latest order toas_of
Every customer that appears anywhere in orders must be in the result (index customer_id, sorted). A customer with no eligible orders gets n_orders = 0, total_spend = 0.0 and NaN for the other two. Don't modify orders.
Signature: def customer_features(orders: pd.DataFrame, as_of: str) -> pd.DataFrame:
Constraints: pandas 2.2. Columns in this order: n_orders, total_spend, avg_order_value, days_since_last_order.
Examples:
C1 orders: 2026-01-10 ₹450, 2026-02-01 ₹300, 2026-03-05 ₹5000; as_of='2026-03-01'→C1 → n_orders 2, total_spend 750.0, avg_order_value 375.0, days_since_last_order 28C3 has only a cancelled order→C3 → 0, 0.0, NaN, NaN
Hints:
- Filter first (date and status), then group. Which rows must never be seen when you train on data 'as of 1 March'?
groupby(...).size(),.sum(),.mean()and.max()each give a Series indexed by customer; a dict of them makes a DataFrame.- Customers who disappear after filtering can be brought back with
reindex; then decide what each missing value should be.
Reference solution:
import pandas as pd
def customer_features(orders: pd.DataFrame, as_of: str) -> pd.DataFrame:
"""Per-customer features using only orders placed before `as_of` and not cancelled."""
cutoff = pd.Timestamp(as_of)
dates = pd.to_datetime(orders["order_date"])
eligible = orders[(dates < cutoff) & (orders["status"] != "cancelled")].assign(
order_date=dates
)
grouped = eligible.groupby("customer_id")
features = pd.DataFrame({
"n_orders": grouped.size(),
"total_spend": grouped["amount"].sum(),
"avg_order_value": grouped["amount"].mean().round(2),
"days_since_last_order": (cutoff - grouped["order_date"].max()).dt.days,
})
all_customers = pd.Index(sorted(orders["customer_id"].unique()), name="customer_id")
features = features.reindex(all_customers)
features["n_orders"] = features["n_orders"].fillna(0).astype("int64")
features["total_spend"] = features["total_spend"].fillna(0.0).astype("float64")
return features
Complexity: O(n) to filter and O(n) hash-based groupby (plus sorting the customer ids). Memory O(n) for the filtered copy.
Tests:
import math
import pandas as pd
orders = pd.DataFrame({
"customer_id": ["C2", "C1", "C1", "C2", "C3", "C1", "C4"],
"order_date": ["2026-01-05", "2026-01-10", "2026-02-01", "2026-02-20",
"2026-01-15", "2026-03-05", "2026-03-02"],
"amount": [1200.0, 450.0, 300.0, 800.0, 999.0, 5000.0, 150.0],
"status": ["delivered", "delivered", "delivered", "delivered",
"cancelled", "delivered", "delivered"],
})
original = orders.copy()
f = customer_features(orders, "2026-03-01")
assert list(f.index) == ["C1", "C2", "C3", "C4"]
assert f.index.name == "customer_id"
assert list(f.columns) == ["n_orders", "total_spend", "avg_order_value", "days_since_last_order"]
# C1: the 2026-03-05 order is after as_of and must not leak into the features
assert f.loc["C1", "n_orders"] == 2
assert f.loc["C1", "total_spend"] == 750.0
assert f.loc["C1", "avg_order_value"] == 375.0
assert f.loc["C1", "days_since_last_order"] == 28
assert f.loc["C2", "n_orders"] == 2
assert f.loc["C2", "total_spend"] == 2000.0
assert f.loc["C2", "days_since_last_order"] == 9
# C3 only cancelled, C4 only ordered after as_of: still present, with zero counts
for cid in ["C3", "C4"]:
assert f.loc[cid, "n_orders"] == 0
assert f.loc[cid, "total_spend"] == 0.0
assert math.isnan(f.loc[cid, "avg_order_value"])
assert math.isnan(f.loc[cid, "days_since_last_order"])
assert f["n_orders"].dtype == "int64"
# an order ON the as_of date is excluded (strictly before)
g = customer_features(orders, "2026-02-20")
assert g.loc["C2", "n_orders"] == 1
# the input is not modified
pd.testing.assert_frame_equal(orders, original)
A common wrong solution (the tests catch it):
import pandas as pd
def customer_features(orders, as_of):
cutoff = pd.Timestamp(as_of)
eligible = orders[orders["status"] != "cancelled"].assign(
order_date=pd.to_datetime(orders["order_date"])
)
grouped = eligible.groupby("customer_id")
features = pd.DataFrame({
"n_orders": grouped.size(),
"total_spend": grouped["amount"].sum(),
"avg_order_value": grouped["amount"].mean().round(2),
"days_since_last_order": (cutoff - grouped["order_date"].max()).dt.days,
})
all_customers = pd.Index(sorted(orders["customer_id"].unique()), name="customer_id")
features = features.reindex(all_customers)
features["n_orders"] = features["n_orders"].fillna(0).astype("int64")
features["total_spend"] = features["total_spend"].fillna(0.0).astype("float64")
return features
Alternatives and tradeoffs:
- Named aggregation:
eligible.groupby("customer_id").agg(n_orders=("amount", "size"), total_spend=("amount", "sum"), ...): one pass, readable; add the days column afterwards. - SQL (
WHERE order_date < :as_of AND status <> 'cancelled' GROUP BY customer_id, then a LEFT JOIN from the customer table) when the data lives in a warehouse. - For many
as_ofdates (one per training example), loop over dates or use a merge-as-of style join; recomputing from scratch per date is simplest to get right.
Follow-ups:
- "Why 'strictly before'
as_of, and what goes wrong if you use all orders?" The label (did the customer churn afteras_of?) is decided by what happens fromas_ofonwards. Using later orders leaks the answer into the features: the model looks great offline and fails in production, where future orders don't exist yet. - "Is
NaNthe right value fordays_since_last_orderwhen there is no order?" It is honest, but many models can't take NaN. Options: a large sentinel (days since signup), a separatehas_orderedflag, or leave NaN for models such as gradient-boosted trees that handle missing values. Decide together with whoever trains the model, and use the same rule at inference. - "
order_datearrives as strings like '05/01/2026' from another source. What do you check?" Parse explicitly withformat="%d/%m/%Y"(ordayfirst=True). Otherwise 5 January can be read as 1 May. Assert that the parsed min/max dates are plausible.
Scoring: Strong: Filters by date and status before grouping, keeps customers with no eligible orders via reindex, gets dtypes and NaN handling right, and explains the leakage reason unprompted. Acceptable: Correct features for active customers but drops the inactive ones or forgets the date filter until asked about leakage. Red flag: Loops over rows in Python to build dicts, or uses orders after as_of without seeing a problem.
Medium & Tricky (MT)
JR-MT-O01 · A discount generator built in one notebook cell and used in another
Question: What does this print?
prices = [100, 250, 400]
discount = 0.10
discounted = (round(p * (1 - discount)) for p in prices)
prices = [999, 999] # a later cell reloads prices
discount = 0.50 # ...and someone tries a festive-sale discount
print(list(discounted))
print(list(discounted))
prices = [100, 250, 400]
discount = 0.10
eager = [round(p * (1 - discount)) for p in prices]
lazy = (round(p * (1 - discount)) for p in prices)
prices.append(1000)
discount = 0.0
print(eager, list(lazy))
Expected output:
[50, 125, 200]
[]
[90, 225, 360] [100, 250, 400, 1000]
The trap: The two usual wrong answers are [90, 225, 360] ("the generator remembers the values from when it was created") and [500, 500] ("everything is lazy, so it uses the new prices too"). Each is half right. Candidates also often print the first list twice, forgetting that a generator can only be consumed once.
Explanation:
Two rules combine:
- The first iterable of a generator expression is evaluated immediately.
for p in pricescallsiter(prices)when the generator is created, so the generator holds the original list object. Rebinding the namepricesto a new list later has no effect on it. Mutating that same list (theappendin the second part) is seen, because the generator iterates the object it captured. - Everything else is evaluated lazily, when items are pulled.
discountinside the expression is looked up each time the generator produces a value, which only happens duringlist(discounted). By thendiscountis0.50, so the original prices are halved:[50, 125, 200].
A generator is also single-use: the second list(discounted) finds it exhausted and returns []. The list comprehension eager is computed on the spot with discount = 0.10; lazy is computed only when printed, with discount = 0.0 and the appended 1000.
Real-world bug: in a notebook or a feature pipeline, a generator built in one place is consumed later, after a config value changed, so features are computed with the wrong threshold or rate. Nothing errors and the numbers look plausible.
Takeaway: when the values must be fixed now, build a list; when you keep a generator, pass what it needs as arguments (def apply(prices, discount): ...) so later changes to globals can't leak in.
Follow-ups:
- "How would you make
discountedfreeze the 10% rate but still stay lazy?" Wrap it in a function with parameters:def discount_all(prices, rate): return (round(p * (1 - rate)) for p in prices). Arguments are bound at call time, so later changes to the global don't matter. - "Why does Python evaluate the first iterable eagerly?" So that errors such as iterating over
Noneor a misspelled name surface where the generator is created, not at some distant consumer. Only the outermostforis evaluated early; nestedforclauses andiffilters are lazy. - "How can a function that receives an iterable protect itself from being given a one-shot generator?" If it needs two passes, convert once:
items = list(items). Or type the parameter asSequenceinstead ofIterableto say it needs something re-iterable.
Scoring: Strong: All three lines, and names both rules: the first iterable is captured at creation, other names are looked up at consumption time. Mentions exhaustion for line 2. Acceptable: Gets one of the two rules right (usually prints [90, 225, 360]), and reasons to the correct answer when asked when discount is read. Red flag: Can't explain the difference between a list comprehension and a generator expression, or expects a generator to be re-iterable.
JR-MT-O02 · Assigning a sorted or filtered Series to a DataFrame column
Question: What does this print on pandas 2.2?
import pandas as pd
df = pd.DataFrame({
"sku": ["rice", "dal", "ghee", "atta"],
"price": [60, 120, 550, 45],
})
df["gst"] = (df["price"] * 0.05).sort_values()
print(df["gst"].tolist())
premium = df[df["price"] > 100]
df["premium_price"] = premium["price"] * 0.9
print(df["premium_price"].tolist())
ranked = df.sort_values("price", ascending=False).reset_index(drop=True)
df["rank_label"] = ranked["sku"]
print(df[["sku", "rank_label"]].values.tolist())
df["rank_pos"] = df["price"].rank(ascending=False).astype(int)
print(df["rank_pos"].tolist(), df["premium_price"].dtype)
Expected output:
[3.0, 6.0, 27.5, 2.25]
[nan, 108.0, 495.0, nan]
[['rice', 'ghee'], ['dal', 'dal'], ['ghee', 'rice'], ['atta', 'atta']]
[3, 2, 1, 4] float64
The trap: People who think of a column as a Python list expect assignment to be positional: [2.25, 3.0, 6.0, 27.5] for the sorted GST, a length-mismatch error for premium_price, and rank_label to be "the sku ranked first, second, ..." as intended. Each guess is wrong for a different reason.
Explanation:
Two mechanisms work together:
- Assigning a Series to a column aligns on the index labels, not on position. pandas matches each value to the row with the same label.
- Sorting keeps labels, so
(price * 0.05).sort_values()is the same label→value pairs in a different order. Assigning it puts every value back in its own row: the column looks unsorted, and is correct. premiumonly has labels 1 and 2. Rows 0 and 3 have no matching label, so they getNaN. No error, no warning. BecauseNaNis a float, the column isfloat64.reset_index(drop=True)throws the labels away.rankedis sorted by price and then relabelled 0..3, so label 0 now means "most expensive" (ghee). Alignment then pairs row 0 (rice) with ghee, row 2 (ghee) with rice: the values are silently scrambled. Only dal and atta look right, by coincidence.
The last line shows the safe way to get per-row ranks: compute them from the column itself (rank), so the labels never change.
Real-world bug: a feature column such as "rank within category" or "predicted score" is computed on a sorted or filtered copy, the index is reset "to tidy it up", and the result is assigned back. Every row gets some other row's value; the model trains on noise and nothing raises.
Takeaway: column assignment from a Series is label-aligned. Keep labels intact between the computation and the assignment, and use .to_numpy() only when you have guaranteed the order is identical.
Follow-ups:
- "You want
premium_priceto be 90% of the price for items above ₹100 and the normal price otherwise. Write it without any alignment risk."df["premium_price"] = df["price"] * np.where(df["price"] > 100, 0.9, 1.0). Everything is computed from the full frame in its own row order, so there is nothing to misalign. - "When does
df["x"] = some_series.to_numpy()help, and when is it dangerous?" It forces positional assignment. Correct only ifsome_serieshas the same length and the same row order asdf. After a sort or filter it hides the very bug alignment would have shown asNaN. - "How would you write a quick check that a new feature column wasn't misaligned?" Spot-check a few rows by key (
df.set_index("sku").loc[["ghee", "rice"]]), or recompute the feature for one key independently and compare. Keeping a stable business key as the index (set_index("sku")) also makes alignment work in your favour.
Scoring: Strong: All four lines; explains label alignment, why the sorted assignment is harmless while the reset one is not, and where the NaNs and float64 come from. Acceptable: Gets line 2 (NaN) but expects positional assignment for line 1 or 3; reasons it through once reminded that sorting keeps labels. Red flag: Expects an error or positional behaviour throughout and can't explain what the index is for.
JR-MT-O03 · Three ways to post an order to a partner API
Question: httpx.MockTransport routes requests to a local function instead of the network, so you can see exactly what the client sends. The fake partner API expects a JSON object. What does this print?
import json
import httpx
def handler(request: httpx.Request) -> httpx.Response:
# a fake partner API: it expects a JSON object
print(request.headers.get("content-type"))
try:
body = json.loads(request.content)
except ValueError:
print(" raw body:", request.content)
return httpx.Response(400, json={"error": "body is not JSON"})
if not isinstance(body, dict):
print(" raw body:", request.content)
return httpx.Response(422, json={"error": f"expected an object, got {type(body).__name__}"})
return httpx.Response(201, json={"qty": body["qty"], "qty_type": type(body["qty"]).__name__})
client = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://partner.example")
order = {"sku": "ghee-1l", "qty": 2, "gift": False}
for kwargs in ({"data": order}, {"json": json.dumps(order)}, {"json": order}):
r = client.post("/orders", **kwargs)
print("->", r.status_code, r.json())
Expected output:
application/x-www-form-urlencoded
raw body: b'sku=ghee-1l&qty=2&gift=false'
-> 400 {'error': 'body is not JSON'}
application/json
raw body: b'"{\\"sku\\": \\"ghee-1l\\", \\"qty\\": 2, \\"gift\\": false}"'
-> 422 {'error': 'expected an object, got str'}
application/json
-> 201 {'qty': 2, 'qty_type': 'int'}
The trap: The usual wrong answer is 201 for all three: "it's the same order dict every time". People coming from JavaScript expect data= to send JSON (axios does), and people who know data= is a form often "fix" it with json=json.dumps(order), which looks even more like JSON but isn't an object any more.
Explanation:
The keyword you pass decides how the dict becomes bytes, and the request needs both the right bytes and the right Content-Type:
data=sends an HTML-form body (application/x-www-form-urlencoded):sku=ghee-1l&qty=2&gift=false. Every value becomes text (the server would seeqtyas"2") and nesting is impossible. A JSON API can't parse it: 400.json=serialises whatever you give it.json.dumps(order)is already a string, sojson=encodes that string again: the body is a JSON string literal with escaped quotes, not an object. The header says JSON and the body is valid JSON, so the bug gets past the parser and is only caught by the server's shape check: 422. (Without that check the server would crash onbody["qty"]with aTypeError.)json=orderserialises the dict once: a JSON object withContent-Type: application/json, andqtyarrives as a number.
Real-world bug: an integration that works in Postman fails from Python with 400 or 422. The quickest diagnosis is to look at what was actually sent: Content-Type and the raw body. A body that starts with "{\" is the signature of double encoding.
Takeaway: for JSON APIs, pass the Python object to json= exactly once; use data= only for form endpoints.
Follow-ups:
- "How would you see the raw request against a real API, without a mock?" Inspect
r.request.headersandr.request.contenton the response, or add an event hook:httpx.Client(event_hooks={"request": [log_request]}). Mask theAuthorizationheader before logging. - "You must send
content=yourself (for example, a pre-serialised payload you also sign). What else do you need?" Set the header explicitly:client.post(url, content=body_bytes, headers={"Content-Type": "application/json"}). And sign exactly those bytes, because re-serialising may change spacing or key order and break the signature. - "When is
data=the right choice?" When the server expects a form: OAuth2 token endpoints (grant_type=client_credentials&...), legacy form handlers, and FastAPI endpoints declared withForm(...). File uploads usefiles=(multipart).
Scoring: Strong: All eight lines; explains form vs JSON encoding, recognises the double-encoded body and why it gets a 422 rather than a 400. Acceptable: Predicts the 400 for data= but expects the json.dumps version to succeed; gets it once asked what type json.dumps returns. Red flag: Believes the three calls send the same body, or can't say what Content-Type is for.
JR-MT-O04 · The last error in a UPI retry loop
Question: What does this print?
def charge(attempt):
if attempt < 3:
raise ConnectionError(f"UPI gateway timeout (attempt {attempt})")
return "TXN-9001"
def pay_with_retries(max_attempts):
err = None
for attempt in range(1, max_attempts + 1):
try:
return charge(attempt)
except ConnectionError as err:
print(f"retrying after: {err}")
print("giving up")
return f"failed: {err}"
print(pay_with_retries(3))
print(pay_with_retries(2))
Expected output:
retrying after: UPI gateway timeout (attempt 1)
retrying after: UPI gateway timeout (attempt 2)
TXN-9001
retrying after: UPI gateway timeout (attempt 1)
retrying after: UPI gateway timeout (attempt 2)
giving up
UnboundLocalError: cannot access local variable 'err' where it is not associated with a value
The trap: The natural answer for the second call is failed: UPI gateway timeout (attempt 2), because "err was set to the last exception". The more careful wrong answer is failed: None, assuming except ... as err creates a separate variable that disappears and leaves the outer err = None alone.
Explanation:
Two rules combine:
except E as namebinds an ordinary local variable in the function. There is no separate block scope in Python, so it is the sameerrthat was initialised toNone; the handler overwrites it.- At the end of the
exceptblock, Python deletes that name (as ifdel errran). It does this so the exception, which holds its traceback and through it every local variable of the frames involved, doesn't stay alive in a reference cycle.
So after the first failed attempt, err is not "the last exception" and not None: it is unbound. The first call succeeds on attempt 3 and never reads err. The second call runs out of attempts, reaches return f"failed: {err}", and gets UnboundLocalError. The error message says local variable because err is local to pay_with_retries; it was deleted, not never assigned.
Fix: use a different name for the handler and copy it:
except ConnectionError as exc:
last_error = exc
Real-world bug: the "keep the last error and report it after retries" pattern is common in API clients and payment integrations. It works in every test where a retry eventually succeeds, and crashes only on the path where all retries fail, which is exactly when you need the error message.
Takeaway: never reuse an outer variable's name as the except ... as target; copy the exception into a different name if you need it after the block.
Follow-ups:
- "Rewrite the function so the caller can tell what went wrong instead of getting a string." Keep
last_error = excin the handler and after the loopraise PaymentFailed(f"gave up after {max_attempts} attempts") from last_error. The caller catches a domain exception and the original cause is in__cause__and the traceback. - "Would this happen at module level too, outside a function?" Yes. At module level the name is a global and it is still deleted at the end of the handler, so a later
print(err)raisesNameError: name 'err' is not defined. - "What would a linter tell you?" A type checker such as Pyright reports
erras possibly unbound at the finalreturn. That warning is the hint: the code path where every attempt fails has no binding forerr.
Scoring: Strong: Exact output; explains both that the handler rebinds the same local and that it is deleted at the end of the except block, and gives the last_error = exc fix. Acceptable: Predicts an error on the second call but can't say why, or answers failed: None and gets to the right answer after a hint about what happens to the name. Red flag: Thinks except ... as err creates a block-scoped variable like in Java/JavaScript and never considers that err might be unbound.
JR-MT-O05 · Caching stock levels as JSON
Question: A quick-commerce service caches store data as JSON strings (as you would in Redis) and reads it back. What does this print?
import json
stock = {101: 5, 102: 0, "103": 7}
slot = {"store": "Indiranagar", "window": (9, 12)}
cache = {}
cache["stock"] = json.dumps(stock)
cache["slot"] = json.dumps(slot)
stock_back = json.loads(cache["stock"])
slot_back = json.loads(cache["slot"])
print(stock_back)
print(101 in stock_back, stock_back.get(101, "missing"), stock_back["101"])
print(slot_back == slot, slot_back["window"])
print(slot_back["window"] == (9, 12), tuple(slot_back["window"]) == (9, 12))
print(json.loads(json.dumps({1: "a", "1": "b"})))
Expected output:
{'101': 5, '102': 0, '103': 7}
False missing 5
False [9, 12]
False True
{'1': 'b'}
The trap: The tempting answer treats json.loads(json.dumps(x)) as a perfect copy: {101: 5, 102: 0, '103': 7}, True 5 5, True (9, 12). JSON looks so much like a Python dict literal that it's easy to forget it has fewer types.
Explanation:
JSON has fewer types than Python, so a round trip is not always lossless. Two different conversions happen here:
- Object keys must be strings.
json.dumpssilently convertsintkeys (alsofloat,bool,None) to strings, so101becomes"101". After loading,101 in stock_backisFalseand.get(101)returns the default, whilestock_back["101"]works. If a dict has both1and"1", both become"1": the JSON text contains the key twice, andjson.loadskeeps the last one, so one value is silently lost. - Tuples become arrays, and arrays load as lists.
(9, 12)is written as[9, 12]and comes back as a list. In Python, a list never equals a tuple, soslot_back == slotisFalseeven though the data "looks" the same.
Real-world bug: a service caches {product_id: qty} in Redis. In-memory lookups by integer ID work; after a restart or on another worker, the data comes from the cache and every lookup misses, so the app shows everything as out of stock. Tests pass because they never go through the cache.
Takeaway: JSON is a wire format, not a Python serializer. Decide the shape explicitly at the boundary: convert keys back ({int(k): v ...}) or validate with a schema (for example a Pydantic model with dict[int, int]), and don't compare raw loaded JSON to Python objects with tuples.
Follow-ups:
- "How would you load
stockback with integer keys in one line?"{int(k): v for k, v in json.loads(raw).items()}. Or let Pydantic do it:TypeAdapter(dict[int, int]).validate_json(raw), which also rejects keys that aren't numbers. - "What does
json.dumpsdo with aset, adatetimeor aDecimalamount like ₹199.50?" It raisesTypeError: Object of type ... is not JSON serializable. Convert explicitly (sorted list, ISO-8601 string, string or integer paise) or passdefault=to handle them. - "Why not just use
picklefor the cache?" Pickle keeps Python types, but loading untrusted pickle data can execute arbitrary code, it ties the cache to Python and to your class definitions, and other services can't read it. JSON with explicit conversion is safer and portable.
Scoring: Strong: All five lines; explains string-only keys and tuple-to-list, and names a production consequence such as cache lookups by int ID missing. Acceptable: Knows tuples become lists but expects int keys to survive (or the reverse); corrects it once asked what a JSON object key can be. Red flag: Treats json.loads(json.dumps(x)) as a deep copy of any Python object.
JR-MT-O06 · A green test with a misspelled method on a Mock
Question: can_checkout is the code under test; the Mock objects are what a test would pass in instead of a real risk-service client. What does this print?
from unittest import mock
class RiskClient:
def is_user_blocked(self, user_id: int) -> bool:
raise NotImplementedError("calls the risk service over HTTP")
def can_checkout(client, user_id):
if client.is_blocked(user_id): # bug: real method is is_user_blocked
return False
return True
client = mock.Mock()
client.is_user_blocked.return_value = True
print("blocked user:", can_checkout(client, 1))
client = mock.Mock()
client.is_user_blocked.return_value = False
print("normal user:", can_checkout(client, 2))
print(client.is_blocked.called, client.is_user_blocked.called)
print(bool(client.is_blocked(2)), client.is_blocked(2) is client.is_blocked(3))
strict = mock.create_autospec(RiskClient, instance=True)
strict.is_user_blocked.return_value = False
print("normal user (autospec):", can_checkout(strict, 2))
Expected output:
blocked user: False
normal user: False
True False
True True
AttributeError: Mock object has no attribute 'is_blocked'
The trap: Most candidates answer blocked user: False and normal user: True, assuming that because the test configured is_user_blocked, the code "uses" it. Others expect an AttributeError on the very first call because is_blocked doesn't exist on the real class.
Explanation:
Two behaviours of a plain Mock combine:
- A
Mockcreates any attribute you ask for.client.is_blockeddidn't exist, so the mock created a new child mock on the spot, and calling it returned yet anotherMock(the samereturn_valueobject every time, henceisgivesTrue). The test configuredis_user_blocked, which the code never calls (calledisFalse). - A
Mockobject is truthy. Like most objects, it has no__bool__or__len__returning false, soif client.is_blocked(user_id):is always true andcan_checkoutreturnsFalsefor every user.
So the "blocked user" test passes for the wrong reason, and the "normal user" case reveals the bug only if someone wrote that test. In production the real client raises AttributeError immediately.
create_autospec(RiskClient, instance=True) builds a mock that only has the attributes and method signatures of the real class. Accessing is_blocked now raises AttributeError, so the typo fails in the test instead of in production. (Mock(spec=RiskClient) gives the attribute check; autospec also checks call arguments.)
Takeaway: a plain Mock accepts anything, so tests can pass while the code calls methods that don't exist. Use spec/autospec, test both the true and false branches, and assert on the call you expect (client.is_user_blocked.assert_called_once_with(2)).
Follow-ups:
- "Which single assertion would have caught this bug even with a plain
Mock?"client.is_user_blocked.assert_called_once_with(1)after the first call. It fails because the method was never called. Asserting on interactions you rely on is what makes a mock-based test meaningful. - "On Python 3.12,
client.assret_called_once()andclient.called_once_with(1)raiseAttributeErrorinstead of returning a Mock. Why does Mock make an exception for these names?" They look like assertion methods. If Mock auto-created them like any other attribute, the misspelled 'assertion' would do nothing and the test would always pass. So Mock refuses names that start withassert(unless it is a real assertion method) or with common misspellings such asassretandasert, and, since 3.12, names likecalled_once_withthat are missing theassert_prefix. That protects only assertion names;spec/autospecprotects every attribute. - "When would you avoid mocking the client altogether?" When a small fake is easy: a
FakeRiskClientclass with an in-memory set of blocked IDs. Fakes break loudly when the interface changes and make tests read like real usage. Mock at the boundary (HTTP) with tools such ashttpx.MockTransportorrespx.
Scoring: Strong: Exact output; explains attribute auto-creation and Mock truthiness, and fixes the test with spec/autospec plus an assert_called_once_with. Acceptable: Realises is_blocked is auto-created but expects normal user: True; gets it after being asked what bool(Mock()) is. Red flag: Thinks a Mock only has the methods you configured, or that a passing mock-based test proves the code calls the real API correctly.
JR-MT-C01 · A batching writer for bulk inserts, as a context manager
Problem:
A loader inserts lakhs of order rows into a warehouse. One INSERT per row is too slow, so rows are sent in batches through a sink(batch) function (a bulk insert). Write a class BatchWriter(sink, size=500) used like this:
with BatchWriter(bulk_insert, size=500) as writer:
for row in read_rows():
writer.add(row)
add(row)buffers the row; when the buffer reachessize, it callssinkwith that batch.flush()sends any buffered rows (never callssinkwith an empty batch).- On a clean exit from the
withblock, the remaining rows are flushed. - If the block raises, the partial batch is discarded (batches already sent stay sent) and the exception propagates.
writer.flushedcounts rows handed tosink.- The sink may keep a reference to the list it receives (a test double, a queue, a retry buffer). That list must never change afterwards.
size < 1raisesValueError.
Signature: class BatchWriter:
def __init__(self, sink: Callable[[list[Row]], None], size: int = 500) -> None: ...
def add(self, row: Row) -> None: ...
def flush(self) -> None: ...
Constraints: Standard library only. Row = dict[str, Any].
Examples:
size=3, add ORD-1..ORD-7, clean exit→sink receives [1,2,3], [4,5,6], [7]; flushed == 7size=2, add ORD-1..ORD-3, then raise ValueError inside the block→sink received only [1,2]; the ValueError propagates
Hints:
- After
sink(self._buffer), how do you empty the buffer? What does.clear()do to the list the sink just stored? __exit__receives the exception type (orNone). What should it do in each case, and what must it return so the exception isn't swallowed?- Should the partial batch really be thrown away on error? Think about what a half-validated batch means for the warehouse.
Reference solution:
from collections.abc import Callable
from types import TracebackType
from typing import Any
Row = dict[str, Any]
class BatchWriter:
"""Buffer rows and hand them to `sink` in batches of `size`.
On a clean exit the remaining rows are flushed. If the with-block raises,
the unflushed rows are discarded and the exception propagates.
"""
def __init__(self, sink: Callable[[list[Row]], None], size: int = 500) -> None:
if size < 1:
raise ValueError("size must be >= 1")
self._sink = sink
self._size = size
self._buffer: list[Row] = []
self.flushed = 0
def add(self, row: Row) -> None:
self._buffer.append(row)
if len(self._buffer) >= self._size:
self.flush()
def flush(self) -> None:
if not self._buffer:
return
batch, self._buffer = self._buffer, [] # hand over this list, start a new one
self._sink(batch)
self.flushed += len(batch)
def __enter__(self) -> "BatchWriter":
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
if exc_type is None:
self.flush()
else:
self._buffer = []
Complexity: add is amortised O(1); each row is copied into exactly one batch. Memory O(size) for the buffer.
Tests:
class ListSink:
"""Stands in for a bulk INSERT: just remembers what it was given."""
def __init__(self):
self.batches = []
def __call__(self, batch):
self.batches.append(batch)
rows = [{"order_id": f"ORD-{i}", "amount": 100 * i} for i in range(1, 8)]
sink = ListSink()
with BatchWriter(sink, size=3) as writer:
for row in rows:
writer.add(row)
assert len(sink.batches) == 2 # two full batches flushed while adding
assert [[r["order_id"] for r in b] for b in sink.batches] == [
["ORD-1", "ORD-2", "ORD-3"], ["ORD-4", "ORD-5", "ORD-6"], ["ORD-7"],
]
assert writer.flushed == 7
# the batches the sink received must not change afterwards
first = sink.batches[0]
writer.add({"order_id": "ORD-8", "amount": 800})
assert [r["order_id"] for r in first] == ["ORD-1", "ORD-2", "ORD-3"]
# an error inside the block: full batches already sent stay sent, the partial
# batch is NOT written, and the exception is not swallowed
sink = ListSink()
try:
with BatchWriter(sink, size=2) as writer:
for row in rows[:3]:
writer.add(row)
raise ValueError("bad amount in ORD-4")
except ValueError as exc:
assert str(exc) == "bad amount in ORD-4"
else:
raise AssertionError("exception was swallowed")
assert [[r["order_id"] for r in b] for b in sink.batches] == [["ORD-1", "ORD-2"]]
assert writer.flushed == 2
# nothing added: the sink is never called with an empty batch
sink = ListSink()
with BatchWriter(sink, size=10):
pass
assert sink.batches == []
try:
BatchWriter(ListSink(), size=0)
raise AssertionError("expected ValueError")
except ValueError:
pass
A common wrong solution (the tests catch it):
class BatchWriter:
def __init__(self, sink, size=500):
if size < 1:
raise ValueError("size must be >= 1")
self._sink = sink
self._size = size
self._buffer = []
self.flushed = 0
def add(self, row):
self._buffer.append(row)
if len(self._buffer) >= self._size:
self.flush()
def flush(self):
if not self._buffer:
return
self._sink(self._buffer)
self.flushed += len(self._buffer)
self._buffer.clear()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.flush()
return True
Alternatives and tradeoffs:
- Generator-based batching:
itertools.batched(rows, 500)(3.12+) and a plain loopfor batch in batched(...): sink(list(batch)). Simpler when you have all rows in one iterable; the class is useful when rows come from many places (callbacks, several loops). @contextmanagerfunction yielding anaddcallable: less code, but noflush()method orflushedcounter without extra machinery.- Copy instead of swap:
self._sink(list(self._buffer))thenclear(). Also correct; swapping avoids one copy.
Follow-ups:
- "Why does the test double that stores batches catch the aliasing bug when the real database sink wouldn't?" A DB driver serialises the rows during the call and never looks at the list again, so later
clear()is harmless. A sink that keeps the reference (like a retry queue that re-sends a batch after a timeout) sees the list change under it. Tests should use sinks that keep what they receive, so they catch exactly this. - "The job died after 40 of 100 batches were inserted. How do you make a re-run safe?" Make inserts idempotent: upsert by a natural key (
order_id) or load into a staging table and swap/merge at the end in one transaction. Then re-running from the start, or from a saved checkpoint, can't create duplicates. - "When would you flush the partial batch on error instead of discarding it?" When the rows are independent and already validated, and the error came from something unrelated (e.g. reading the next row). Discarding is the safer default when a failure could mean the batch itself is bad. Either way, log what was dropped.
Scoring: Strong: Hands over the buffer by swapping in a new list (or copying), flushes on clean exit only, returns a falsy value from __exit__, never sends empty batches, and explains why a storing sink exposes the aliasing bug. Acceptable: Correct batching and exit logic but uses clear() on the sent list; fixes it after being asked what the sink holds afterwards. Red flag: __exit__ returns True, or the candidate can't explain why two names for one list both see clear().
JR-MT-C02 · Reconcile split UPI settlements against orders, to the paisa
Problem:
Finance downloads the payment gateway's settlement report and wants it matched against the order system every morning.
orders: list of(order_id, amount)where amounts look like"₹1,25,000.50","Rs. 499"(Indian digit grouping, optional₹orRs.prefix).settlements: list of(order_id, amount)from the gateway's CSV, e.g."125000.5"or"1,00,000.00". One order can be settled in several rows (split settlements), and the gateway occasionally sends a row twice.
Write to_paise(text) -> int that parses an amount exactly into integer paise (raise ValueError for negative, non-numeric, NaN/infinite, or more than 2 decimal places), and reconcile(orders, settlements) that returns:
unsettled: order IDs with no settlement rows (sorted);unknown: settlement order IDs that aren't inorders(sorted, unique);mismatched:(order_id, expected_paise, settled_paise)for orders whose settled total differs from the order amount (sorted).
A duplicate order ID in orders raises ValueError.
Signature: def to_paise(text: str) -> int:
def reconcile(orders: list[tuple[str, str]], settlements: list[tuple[str, str]]) -> dict[str, list]:
Constraints: Standard library only. No float anywhere in the money path.
Examples:
to_paise("₹1,25,000.50")→12500050order ₹1,999.80 settled as 1499.70 + 500.10→not reported (it matches exactly)order ₹2,50,000 settled as 1,00,000.00 + 1,00,000.00→mismatched: ('ORD-3', 25000000, 20000000)
Hints:
- What is
1499.70 + 500.10in floating point? Try it. Decimal("1499.70")is exact. How do you turn it into an integer number of paise, and detect a third decimal place?- If you build
{order_id: amount for ...}from the settlement rows, what happens to an order settled in two parts?
Reference solution:
from collections import defaultdict
from decimal import Decimal, InvalidOperation
def to_paise(text: str) -> int:
"""'₹1,25,000.50' / 'Rs. 499' / '125000.5' -> integer paise. Exact; no floats."""
cleaned = text.strip().removeprefix("₹").removeprefix("Rs.").replace(",", "").strip()
try:
amount = Decimal(cleaned)
except InvalidOperation:
raise ValueError(f"not an amount: {text!r}") from None
if not amount.is_finite() or amount < 0:
raise ValueError(f"not an amount: {text!r}")
paise = amount * 100
if paise != paise.to_integral_value():
raise ValueError(f"more than 2 decimal places: {text!r}")
return int(paise)
def reconcile(
orders: list[tuple[str, str]], settlements: list[tuple[str, str]]
) -> dict[str, list]:
"""Match gateway settlement rows (possibly several per order) against orders."""
expected: dict[str, int] = {}
for order_id, amount in orders:
if order_id in expected:
raise ValueError(f"duplicate order id: {order_id}")
expected[order_id] = to_paise(amount)
settled: defaultdict[str, int] = defaultdict(int)
for order_id, amount in settlements:
settled[order_id] += to_paise(amount)
return {
"unsettled": sorted(o for o in expected if o not in settled),
"unknown": sorted(o for o in settled if o not in expected),
"mismatched": sorted(
(o, expected[o], settled[o]) for o in expected if o in settled and settled[o] != expected[o]
),
}
Complexity: O(len(orders) + len(settlements)) to parse and sum, plus O(k log k) to sort the k reported items. Memory O(number of distinct order IDs).
Tests:
assert to_paise("₹1,25,000.50") == 12_500_050
assert to_paise("Rs. 499") == 49_900
assert to_paise(" 125000.5 ") == 12_500_050
assert to_paise("0.10") == 10
for bad in ["12.345", "abc", "-5", "", "NaN", "Infinity"]:
try:
to_paise(bad)
raise AssertionError(f"accepted {bad!r}")
except ValueError:
pass
orders = [
("ORD-1", "₹1,999.80"),
("ORD-2", "₹499.00"),
("ORD-3", "₹2,50,000"),
("ORD-4", "₹99.90"),
]
settlements = [
("ORD-1", "1499.70"), # ORD-1 settled in two parts
("ORD-1", "500.10"),
("ORD-2", "499"),
("ORD-3", "1,00,000.00"), # ORD-3 settled in three parts, one part missing
("ORD-3", "1,00,000.00"),
("ORD-9", "10.00"), # not one of ours
]
report = reconcile(orders, settlements)
assert report == {
"unsettled": ["ORD-4"],
"unknown": ["ORD-9"],
"mismatched": [("ORD-3", 25_000_000, 20_000_000)],
}, report
# all good
assert reconcile([("A", "0.30")], [("A", "0.10"), ("A", "0.20")]) == {
"unsettled": [], "unknown": [], "mismatched": [],
}
# a duplicated settlement row shows up as an over-payment, not silently ignored
assert reconcile([("A", "100")], [("A", "100"), ("A", "100")])["mismatched"] == [("A", 10_000, 20_000)]
try:
reconcile([("A", "1"), ("A", "2")], [])
raise AssertionError("expected ValueError")
except ValueError as exc:
assert "A" in str(exc)
A common wrong solution (the tests catch it):
import math
def _to_rupees(text):
cleaned = text.strip().removeprefix("₹").removeprefix("Rs.").replace(",", "").strip()
try:
value = float(cleaned)
except ValueError:
raise ValueError(f"not an amount: {text!r}") from None
if not math.isfinite(value) or value < 0 or round(value, 2) != value:
raise ValueError(f"not an amount: {text!r}")
return value
def to_paise(text):
return round(_to_rupees(text) * 100)
def reconcile(orders, settlements):
expected = {}
for order_id, amount in orders:
if order_id in expected:
raise ValueError(f"duplicate order id: {order_id}")
expected[order_id] = _to_rupees(amount)
settled = {}
for order_id, amount in settlements:
settled[order_id] = settled.get(order_id, 0.0) + _to_rupees(amount)
return {
"unsettled": sorted(o for o in expected if o not in settled),
"unknown": sorted(o for o in settled if o not in expected),
"mismatched": sorted(
(o, round(expected[o] * 100), round(settled[o] * 100))
for o in expected if o in settled and settled[o] != expected[o]
),
}
Alternatives and tradeoffs:
- pandas: parse amounts to integer paise first, then
groupby("order_id").sum()on settlements and an outermerge(..., indicator=True)with orders;_mergegives unsettled/unknown directly. Good for millions of rows; keep money asint64paise, neverfloat64. - SQL in the warehouse:
FULL OUTER JOINof orders withSELECT order_id, SUM(amount_paise) ... GROUP BY order_id. collections.Counterworks as the accumulator too (settled[o] += paise), the same asdefaultdict(int).
Follow-ups:
- "Why convert to integer paise instead of keeping
Decimaleverywhere?" Integers are exact, fast, serialise to JSON and databases without surprises, and sum in pandas/SQL without precision settings.Decimalis ideal for parsing and for arithmetic with rates (GST at 18%), followed by an explicit rounding rule (quantize(Decimal("0.01"), ROUND_HALF_UP)) back to paise. - "The gateway deducts its fee (say 2%) before settling. How does the reconciliation change?" Compare
settled + feewith the order amount, where the fee comes from the report's fee column, not recomputed with your own rounding. If you must compute it, agree on the rounding rule with finance, because a paisa of difference per order becomes real money across lakhs of orders. - "How do you tell a duplicated row from a genuine split settlement?" Use the gateway's unique settlement or transaction ID: dedupe on it first, then sum. Without such an ID you can only flag over-payments for a person to review, as this function does.
Scoring: Strong: Exact parsing with Decimal into paise, rejects bad input, sums split settlements with defaultdict(int), reports all three categories correctly, and can explain the float failure with a concrete example. Acceptable: Uses Decimal but keeps only one settlement row per order (or vice versa: sums correctly with floats); fixes it once shown the split or the 1499.70 + 500.10 case. Red flag: Uses float for money and compares with ==, or 'fixes' it with round(..., 2) without understanding why floats are wrong.
JR-MT-C03 · Apply payment-gateway webhooks: retries and out-of-order delivery
Problem:
A payment gateway (think Razorpay or Cashfree) calls your webhook with events. Gateways retry deliveries, so the same event can arrive more than once, and they don't guarantee order, so payment.authorized can arrive after payment.captured.
Each event is a dict: {"id": "evt_1", "order_id": "ORD-1", "type": ..., "amount": ...} where type is payment.authorized, payment.captured or refund.processed (which also has an amount in paise).
Write apply_events(orders, seen, events) that returns a new (orders, seen) pair:
ordersmaps order ID to{"status": ..., "refunded": <paise>}. An order seen for the first time starts as{"status": "created", "refunded": 0}.- An event whose
idis inseen(or already processed in this batch) is skipped. - Status only moves forward:
created→authorized→captured. An older status arriving late is ignored. refund.processedaddsamounttorefunded(a single order can have several partial refunds).- An unknown event type raises
ValueErrornaming it. - The
ordersandseenpassed in must not be modified, including the inner order dicts.
Signature: def apply_events(orders: dict[str, dict[str, Any]], seen: set[str], events: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], set[str]]:
Constraints: Standard library only. Amounts are integers in paise (no floats for money).
Examples:
authorized(evt_1), captured(evt_2), authorized(evt_1 again) for ORD-1→ORD-1 status 'captured'captured(evt_3) then authorized(evt_4) for ORD-2→ORD-2 status stays 'captured'refund 20000 (evt_5), the same evt_5 again, refund 5000 (evt_6)→ORD-1 refunded 25000
Hints:
- Two different problems: duplicates (same event twice) and reordering (an old event after a new one). Which rule handles which?
- A duplicate refund would be wrong; a duplicate
capturedwould be harmless. Why must you still dedupe by event ID? dict(orders)copies the outer dict only. What happens to the caller's data when you doorder["refunded"] += ...?
Reference solution:
import copy
from typing import Any
STATUS_RANK = {"created": 0, "authorized": 1, "captured": 2}
EVENT_STATUS = {"payment.authorized": "authorized", "payment.captured": "captured"}
def apply_events(
orders: dict[str, dict[str, Any]],
seen: set[str],
events: list[dict[str, Any]],
) -> tuple[dict[str, dict[str, Any]], set[str]]:
"""Apply gateway webhook events; return NEW (orders, seen) without mutating the inputs.
- an event id is applied at most once (gateways retry deliveries)
- status only moves forward: created -> authorized -> captured
- refund.processed adds its amount (in paise) to the order's refunded total
"""
orders = copy.deepcopy(orders)
seen = set(seen)
for event in events:
if event["id"] in seen:
continue
kind = event["type"]
if kind not in EVENT_STATUS and kind != "refund.processed":
raise ValueError(f"unknown event type: {kind}")
seen.add(event["id"])
order = orders.setdefault(event["order_id"], {"status": "created", "refunded": 0})
if kind == "refund.processed":
order["refunded"] += event["amount"]
elif STATUS_RANK[EVENT_STATUS[kind]] > STATUS_RANK[order["status"]]:
order["status"] = EVENT_STATUS[kind]
return orders, seen
Complexity: O(len(events)) time plus O(size of orders) for the deep copy; O(len(seen) + len(events)) memory.
Tests:
import copy
events = [
{"id": "evt_1", "order_id": "ORD-1", "type": "payment.authorized"},
{"id": "evt_2", "order_id": "ORD-1", "type": "payment.captured"},
{"id": "evt_1", "order_id": "ORD-1", "type": "payment.authorized"}, # retried delivery
{"id": "evt_3", "order_id": "ORD-2", "type": "payment.captured"}, # authorized arrives late
{"id": "evt_4", "order_id": "ORD-2", "type": "payment.authorized"},
{"id": "evt_5", "order_id": "ORD-1", "type": "refund.processed", "amount": 20_000},
{"id": "evt_5", "order_id": "ORD-1", "type": "refund.processed", "amount": 20_000}, # retry
{"id": "evt_6", "order_id": "ORD-1", "type": "refund.processed", "amount": 5_000},
]
orders, seen = apply_events({}, set(), events)
assert orders == {
"ORD-1": {"status": "captured", "refunded": 25_000},
"ORD-2": {"status": "captured", "refunded": 0},
}
assert seen == {"evt_1", "evt_2", "evt_3", "evt_4", "evt_5", "evt_6"}
# replaying the whole batch (e.g. after a crash) changes nothing
again, seen_again = apply_events(orders, seen, events)
assert again == orders and seen_again == seen
# inputs are never mutated, including the nested order dicts
before_orders, before_seen = copy.deepcopy(orders), set(seen)
after, _ = apply_events(orders, seen, [
{"id": "evt_7", "order_id": "ORD-2", "type": "refund.processed", "amount": 1_000},
{"id": "evt_8", "order_id": "ORD-3", "type": "payment.authorized"},
])
assert orders == before_orders and seen == before_seen
assert after["ORD-2"]["refunded"] == 1_000
assert after["ORD-3"] == {"status": "authorized", "refunded": 0}
# a status never moves backwards, even across batches
later, _ = apply_events(after, set(), [{"id": "evt_9", "order_id": "ORD-1", "type": "payment.authorized"}])
assert later["ORD-1"]["status"] == "captured"
# unknown event types are rejected loudly
try:
apply_events({}, set(), [{"id": "evt_x", "order_id": "ORD-9", "type": "payment.teleported"}])
raise AssertionError("expected ValueError")
except ValueError as exc:
assert "payment.teleported" in str(exc)
A common wrong solution (the tests catch it):
STATUS_RANK = {"created": 0, "authorized": 1, "captured": 2}
EVENT_STATUS = {"payment.authorized": "authorized", "payment.captured": "captured"}
def apply_events(orders, seen, events):
orders = dict(orders) # shallow copy: inner dicts are still shared
seen = set(seen)
for event in events:
if event["id"] in seen:
continue
kind = event["type"]
if kind not in EVENT_STATUS and kind != "refund.processed":
raise ValueError(f"unknown event type: {kind}")
seen.add(event["id"])
order = orders.setdefault(event["order_id"], {"status": "created", "refunded": 0})
if kind == "refund.processed":
order["refunded"] += event["amount"]
elif STATUS_RANK[EVENT_STATUS[kind]] > STATUS_RANK[order["status"]]:
order["status"] = EVENT_STATUS[kind]
return orders, seen
Alternatives and tradeoffs:
- In a database: a unique constraint on
event_idin aprocessed_eventstable, inserted in the same transaction as the order update, so a retry hits the constraint and is skipped. Status moves with a conditional update:UPDATE orders SET status = 'captured' WHERE id = :id AND status IN ('created', 'authorized'). - Re-fetch instead of trusting the event: on any event, call the gateway's API for the payment's current state. This handles reordering for free at the cost of an API call.
- Mutate in place for speed, if the function owns the state; then document it and don't also return it, so callers aren't confused about aliasing.
Follow-ups:
- "
seengrows forever. What would you do in production?" Keep event IDs for a window longer than the gateway's retry period (for example, 7 days), in a table with a timestamp or a Redis set with TTL, and delete older ones. After the window, a duplicate is no longer possible. - "How do you check the webhook really came from the gateway?" Verify the signature header: the gateway sends an HMAC of the raw request body using a shared webhook secret. Compute it over the raw bytes (before JSON parsing) and compare with
hmac.compare_digest; reject on mismatch. - "Why does the handler return 200 even when it skipped a duplicate event?" From the gateway's point of view the event was delivered successfully. Returning an error would make it retry again and again. Return non-2xx only when you couldn't process the event and want a retry.
Scoring: Strong: Dedupes by event ID, moves status forward only via a rank, accumulates refunds, deep-copies (or rebuilds) inner dicts, and explains why both rules are needed. Acceptable: Handles duplicates and ordering but mutates the caller's nested dicts; fixes it after the shallow-copy hint. Red flag: Last-write-wins, no dedupe of refunds, or floats for money.
Hard (H)
JR-H-O01 · What type hints, TypedDict and NewType do at runtime
Question: What does this print?
from typing import NewType, TypedDict
OrderId = NewType("OrderId", int)
class Order(TypedDict):
id: OrderId
amount: float
def with_gst(orders: list[Order], rate: float = 0.18) -> float:
return round(sum(o["amount"] for o in orders) * (1 + rate), 2)
o = Order(id="A-7", amount=100)
print(o, type(o).__name__)
print(with_gst([o]), with_gst([o], rate=1))
oid = OrderId(42)
print(oid, type(oid).__name__, oid + 1)
print(with_gst.__annotations__["orders"], with_gst.__annotations__["return"])
try:
with_gst([o], rate="0.05")
except TypeError as exc:
print("TypeError:", exc)
try:
print(isinstance([o], list[Order]))
except TypeError as exc:
print("TypeError:", exc)
Expected output:
{'id': 'A-7', 'amount': 100} dict
118.0 200
42 int 43
list[__main__.Order] <class 'float'>
TypeError: unsupported operand type(s) for +: 'int' and 'str'
TypeError: isinstance() argument 2 cannot be a parameterized generic
Explanation:
Python stores type hints but never checks them. They are read by type checkers (mypy, pyright), IDEs, and libraries that choose to look at them (Pydantic, FastAPI, dataclasses).
TypedDictonly describes the shape of a dict for the type checker. CallingOrder(...)just builds a plaindict:id="A-7"is accepted although the hint saysOrderId, andtype(o)isdict.- Return hints are not enforced either.
with_gst([o])givesround(118.0, 2)=118.0. Withrate=1, the sum is the int100,100 * 2is the int200, andround(200, 2)returns an int. The function promisedfloatand returned200; nothing complained. NewTypeis an identity function at runtime:OrderId(42)returns the very sameint. The distinct type exists only for the checker, sotype(oid)isintandoid + 1is an ordinary43.- The hints are kept in
__annotations__as real objects:list[Order]is atypes.GenericAlias(its repr shows the module,__main__.Order) and the return hint is the classfloat. - Passing
rate="0.05"is not rejected at the call. It fails later, inside the function, when1 + "0.05"runs, with an error that says nothing about the parameter. - You can't use a parameterised generic like
list[Order]withisinstance; checking element types would mean walking the whole container, so Python refuses.
Takeaway: hints document intent and power tools; if you need checks at runtime, validate explicitly (Pydantic, a TypeAdapter, or your own checks) at the boundary where untrusted data enters.
Follow-ups:
- "Which of these lines would mypy flag, and which would it accept?" It flags
Order(id="A-7", ...)(str is notOrderId) andrate="0.05"(str is not float). It acceptsrate=1, because PEP 484 lets anintbe passed wherefloatis expected. It also acceptsOrderId(42) + 1; the result is typed as plainint, notOrderId. - "How would you get real validation of the incoming order dicts without rewriting them as classes?" Pydantic v2 can validate a TypedDict directly:
TypeAdapter(list[Order]).validate_python(payload). It raises aValidationErrornaming the bad field ("A-7"cannot be parsed as an int), which is far easier to debug than aTypeErrordeep inside the arithmetic. - "What changes with
from __future__ import annotationsat the top of the module?" Annotations are stored as strings ('list[Order]') and not evaluated at definition time, so forward references work and imports are cheaper. Tools that need the real objects calltyping.get_type_hints(with_gst), which evaluates the strings.
Scoring: Strong: All six lines right; states plainly that hints aren't enforced, knows TypedDict builds a plain dict and NewType is an identity function, and spots that rate=1 returns an int despite -> float. Acceptable: Knows hints aren't enforced and gets most lines; unsure about the NewType line or the isinstance error until talked through it. Red flag: Expects Order(id="A-7", ...) or rate="0.05" to be rejected at the call because of the annotations.
JR-H-O02 · A three-stage generator pipeline hits a bad row
Question: What does this print? Read carefully which stage runs when.
from itertools import islice
def read_lines(lines):
for line in lines:
print(f"read {line!r}")
yield line
def parse(rows):
for row in rows:
name, amount = row.split(",")
print(f"parse {name}")
yield name, int(amount)
def large(orders, minimum):
for name, amount in orders:
if amount >= minimum:
yield name
raw = ["asha,1200", "ravi,300", "meera,5000", "bad-row", "kiran,9000"]
pipeline = large(parse(read_lines(raw)), 1000)
print("built")
print(list(islice(pipeline, 2)))
try:
next(pipeline)
except ValueError as exc:
print("ValueError:", exc)
print(next(pipeline, "exhausted"))
Expected output:
built
read 'asha,1200'
parse asha
read 'ravi,300'
parse ravi
read 'meera,5000'
parse meera
['asha', 'meera']
read 'bad-row'
ValueError: not enough values to unpack (expected 2, got 1)
exhausted
Explanation:
Calling a generator function runs none of its body; it only creates a generator object. Building the three-stage pipeline therefore prints nothing, and built comes first.
Values are pulled from the outside in. islice(pipeline, 2) asks large for an item; large asks parse, which asks read_lines, which reads one line. So each row travels through every stage before the next row is read, and the read/parse lines interleave. ravi is read and parsed, but large drops it (300 < 1000) and simply pulls the next row. Once islice has two names it stops asking, so bad-row has not been read yet.
The next next(pipeline) resumes the chain: read_lines yields "bad-row", and row.split(",") gives a one-element list, so the unpacking in parse raises ValueError. The exception travels up through large to the caller. A generator that lets an exception escape is finished for good: parse and large are both closed, so the next call gets the default "exhausted" and kiran is never processed.
Takeaway: a generator pipeline streams one record through all stages at a time, using memory for one record rather than the whole file, but one unhandled bad record kills the whole chain. Handle bad rows inside the stage (catch, log, count, skip) if the pipeline must keep going.
Follow-ups:
- "Change
parseso a bad row is logged and skipped instead of killing the pipeline. Where exactly does thetrygo?" Around the per-row work, inside the loop:try: name, amount = row.split(","); value = int(amount)/except ValueError: log.warning("bad row %r", row); continue/ thenyield name, value. Putting thetryaround the wholeforloop would still end the generator at the first bad row. - "Roughly how much memory does this pipeline use on a 10 GB file, and what would ruin that?" About one line at a time plus whatever the consumer keeps, because nothing materialises the data. Calling
list()on any stage, or a stage that collects rows (for example to sort them), loads everything into memory. - "Why didn't
islice(pipeline, 2)read the whole input to find two matches 'efficiently'?" It can't look ahead; it only callsnext()until it has two items, then stops. That laziness is exactly whybad-rowwasn't touched until the followingnext().
Scoring: Strong: Exact interleaving, explains that bad-row isn't read during the islice and that the generator is dead after the exception, so kiran is never seen. Acceptable: Gets the laziness and interleaving but expects kiran to be returned by the last next; corrects it when asked what state a generator is in after raising. Red flag: Prints all read lines before any parse line (thinks each stage runs to completion), or expects the error at pipeline = ....
JR-H-O03 · @contextmanager that swallows one error and skips cleanup on another
Question: What does this print?
from contextlib import contextmanager
@contextmanager
def transaction(name):
print(f"BEGIN {name}")
try:
yield name.upper()
except KeyError as exc:
print(f"ROLLBACK {name}: {exc}")
else:
print(f"COMMIT {name}")
finally:
print(f"END {name}")
@contextmanager
def audit(name):
print(f"open audit {name}")
yield
print(f"close audit {name}")
with transaction("orders") as tx:
print("inside", tx)
with transaction("refunds"):
{}["order_42"]
print("after refunds")
try:
with audit("wallet"), transaction("wallet"):
raise ValueError("negative balance")
except ValueError as exc:
print("caught:", exc)
Expected output:
BEGIN orders
inside ORDERS
COMMIT orders
END orders
BEGIN refunds
ROLLBACK refunds: 'order_42'
END refunds
after refunds
open audit wallet
BEGIN wallet
END wallet
caught: negative balance
Explanation:
@contextmanager turns a generator into a context manager. The code before yield is the setup, and the yielded value is what as binds (ORDERS). When the with body finishes normally, the generator resumes after the yield, so the else branch commits. When the body raises, the exception is re-raised inside the generator at the yield, so ordinary try/except/finally around the yield decides what happens.
- refunds: the
KeyErroris caught byexcept KeyError, which prints and does not re-raise. The generator then finishes normally, andcontextmanagertakes that to mean "handled", so the exception is suppressed andafter refundsprints. (str()of aKeyErroris the repr of the key, hence the quotes.) - wallet: in
with audit(...), transaction(...)the managers are entered left to right and exited right to left. TheValueErrorreachestransactionfirst: it is not aKeyError, so there is no rollback message and no commit,finallyprintsEND wallet, and the exception continues outward. Then it is raised insideauditat its bareyield. With notry/finallythere, the generator just dies,close audit walletnever prints, and the exception reaches the outerexcept.
Takeaway: in a generator-based context manager, put yield inside try/finally for cleanup that must always run, and end every except with raise unless you really mean to swallow the error.
Follow-ups:
- "Rewrite
transactionso it rolls back on any exception but never hides it from the caller."try: yield .../except BaseException: rollback(); raise/else: commit()/finally: release(). The bareraisere-raises the original exception with its traceback; catchingBaseExceptionalso rolls back onKeyboardInterrupt. - "What is the class-based equivalent of 'swallowing' here?"
__exit__(self, exc_type, exc, tb)returning a truthy value suppresses the exception. ReturningNone(the default) orFalselets it propagate, which is what you want almost always. - "When is suppressing an exception in a context manager actually correct?" When it is the whole point and is explicit, like
contextlib.suppress(FileNotFoundError)around deleting a temp file that may already be gone. Hiding aKeyErrorinside a 'transaction' helper, so the caller thinks the work succeeded, is a bug.
Scoring: Strong: All lines right; explains that the exception is raised at the yield, that a non-re-raising except suppresses it, the right-to-left exit order, and why close audit is missing. Acceptable: Gets orders and refunds right, but expects close audit wallet to print; understands why after being reminded there is no finally. Red flag: Expects the KeyError to propagate and crash the script, or thinks the code after yield always runs.
JR-H-O04 · Missing ratings in groupby aggregations
Question: Store ratings come from a feedback form where most customers skip the rating. What does this print?
import pandas as pd
ratings = pd.DataFrame({
"store": ["Pune", "Pune", "Pune", "Delhi", "Delhi"],
"rating": [4.0, None, 5.0, None, None],
})
g = ratings.groupby("store")["rating"]
print(g.size().to_dict())
print(g.count().to_dict())
print(g.mean().to_dict())
print(g.sum().to_dict())
print(ratings["rating"].mean(), ratings["rating"].fillna(0).mean())
print((ratings["rating"] == None).sum(), ratings["rating"].isna().sum())
print(g.sum(min_count=1).to_dict())
Expected output:
{'Delhi': 2, 'Pune': 3}
{'Delhi': 0, 'Pune': 2}
{'Delhi': nan, 'Pune': 4.5}
{'Delhi': 0.0, 'Pune': 9.0}
4.5 1.8
0 3
{'Delhi': nan, 'Pune': 9.0}
Explanation:
None in a float column becomes NaN. Most pandas reductions skip NaN by default, and that one default explains every line:
size()counts rows per group, missing or not: Delhi 2, Pune 3.groupbysorts keys, so Delhi comes first.count()counts non-missing values: Delhi has none, Pune has two.mean()averages only the values that are present: Pune is (4 + 5) / 2 = 4.5. Delhi has nothing to average, so it getsNaN.sum()of nothing is 0 (the defaultmin_count=0), so Delhi shows a total of0.0. A report built from sums would show Delhi as "rated zero" when it was never rated at all.- The overall mean skips the three missing values (9 / 2 = 4.5).
fillna(0)turns "no rating" into "rated 0" and drags the average down to 9 / 5 = 1.8, a very different business claim. == Nonecompares element by element, andNaN == NoneisFalse, so it counts 0. Useisna(), which finds all 3.sum(min_count=1)returnsNaNwhen a group has fewer than one real value, which keeps "no data" distinct from "zero".
Takeaway: decide on purpose what a missing value means (unknown, zero, or excluded), check count() next to every mean()/sum(), and test for missing data with isna().
Follow-ups:
- "Product asks for 'average rating per store' on the dashboard. What would you show for Delhi?" Not 0 and not a bare NaN. Show the mean together with the number of ratings (
g.agg(['mean', 'count'])) and render Delhi as 'no ratings yet'. Also consider hiding means based on very few ratings, because one 5-star review isn't a reliable average. - "Why does
ratings['rating'] == np.nanalso find nothing?"NaNis not equal to anything, itself included (IEEE 754). That's why pandas providesisna()/notna(), and whynp.isnanexists. - "Would this change if
storeitself had missing values?" Yes: by defaultgroupbydrops rows whose key is NaN, so their ratings disappear from every group. Passdropna=Falseto keep them as their own group.
Scoring: Strong: All seven lines; clearly separates size from count, knows an all-NaN sum is 0 but an all-NaN mean is NaN, and gives the business consequence of fillna(0). Acceptable: Gets the skip-NaN behaviour and size vs count, but expects the all-NaN sum to be NaN; accepts the min_count explanation. Red flag: Thinks mean() treats missing values as 0, or uses == None to find missing values.
JR-H-O05 · lru_cache returns the same list every time
Question: pincodes_for stands in for a slow lookup, so someone added @lru_cache. What does this print?
from functools import lru_cache
calls = []
@lru_cache(maxsize=None)
def pincodes_for(city):
calls.append(city)
return ["411001", "411002"] if city == "Pune" else []
a = pincodes_for("Pune")
a.append("999999")
b = pincodes_for("Pune")
print(b, a is b, calls)
pincodes_for("Delhi")
pincodes_for(city="Delhi")
print(pincodes_for.cache_info())
try:
pincodes_for(["Pune"])
except TypeError as exc:
print("TypeError:", exc)
pincodes_for.cache_clear()
print(pincodes_for("Pune"), len(calls))
Expected output:
['411001', '411002', '999999'] True ['Pune']
CacheInfo(hits=1, misses=3, maxsize=None, currsize=3)
TypeError: unhashable type: 'list'
['411001', '411002'] 4
Explanation:
lru_cache stores the object the function returned and hands back that same object on every hit; it doesn't copy anything. The caller who did a.append("999999") mutated the cached list, so the second call returns the corrupted list (a is b is True) and the function body never ran again (calls == ['Pune']). Every later caller in the process now sees a fake pincode.
The cache key is built from the arguments as passed. pincodes_for("Delhi") and pincodes_for(city="Delhi") produce different keys, so both are misses. Total: Pune miss, Pune hit, Delhi miss, city="Delhi" miss, giving hits=1, misses=3, currsize=3.
Building the key means hashing the arguments, so an unhashable argument such as a list fails with TypeError before the function runs.
cache_clear() empties the cache; the next call recomputes and returns a fresh, clean list, and calls has grown to 4.
Takeaway: only cache functions whose results are immutable (return a tuple/frozenset, or copy on the way out), and remember the cache is shared by the whole process.
Follow-ups:
- "How would you fix this without dropping the cache?" Return an immutable value:
return ("411001", "411002")or afrozenset. Callers that need a list dolist(pincodes_for(city)), which gives them their own copy. A wrapper that copies on every hit also works but costs an allocation per call. - "What's the risk of
@lru_cacheon an instance method?"selfbecomes part of the key, so the cache holds a strong reference to every instance it has seen and they are never garbage-collected (a memory leak in a long-running service). It also mixes per-instance results into one global cache. Prefer a module-level function,functools.cached_property, or a dict owned by the instance. - "
maxsize=Nonevs the defaultmaxsize=128: when does it matter?"None(orfunctools.cache) never evicts, so the cache grows with every distinct argument. That's fine for a small fixed set such as city names, and a memory leak for something like user IDs. A bounded size evicts the least recently used entries.
Scoring: Strong: All lines right, including hits=1, misses=3; explains the shared returned object and that positional vs keyword calls make different keys; proposes returning tuples. Acceptable: Spots the aliasing bug and the unhashable error; miscounts the cache stats until asked to walk through each call. Red flag: Believes the cache returns a fresh copy, or expects pincodes_for(["Pune"]) to work.
JR-H-O06 · Redirects, raise_for_status and query params in httpx
Question: The orders API moved from /v1 to /v2. Using httpx's MockTransport to fake the server, what does this print?
import httpx
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/v1/orders":
return httpx.Response(301, headers={"Location": "/v2/orders"})
if request.url.path == "/v2/orders":
return httpx.Response(200, json={"query": request.url.query.decode()})
return httpx.Response(404, json={"detail": "not found"})
client = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://api.shop.in")
r = client.get("/v1/orders")
print(r.status_code, r.is_success, r.next_request.url)
try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
print("raised for", exc.response.status_code)
params = {"city": "Navi Mumbai", "tag": ["new", "cod"], "page": 2}
r = client.get("/v1/orders", params=params, follow_redirects=True)
print(r.history[0].request.url.query.decode())
print(r.status_code, r.url.path, r.json())
Expected output:
301 False https://api.shop.in/v2/orders
raised for 301
city=Navi+Mumbai&tag=new&tag=cod&page=2
200 /v2/orders {'query': ''}
Explanation:
- httpx does not follow redirects by default (unlike
requests). The firstgetreturns the 301 response itself.is_successis true only for 2xx, so it'sFalse.next_requestholds the request httpx would send next, with the relativeLocationresolved against the base URL. - httpx's
raise_for_status()raises for anything that isn't 2xx, redirects included. (requestsraises only for 4xx/5xx.) Code that callsraise_for_status()without enabling redirects will fail on a harmless 301. - Query encoding: a space becomes
+, a list becomes repeated keys (tag=new&tag=cod), and the int is turned into a string. Order is preserved. - With
follow_redirects=Truethe final response is the 200, andr.historyholds the 301. The server'sLocationwas/v2/orderswith no query string, and a redirect goes exactly whereLocationpoints, so all the query parameters were dropped. The/v2handler saw an empty query: the client asked for page 2 of COD orders in Navi Mumbai and silently got the default listing.
Takeaway: know your client's redirect policy, check status codes deliberately, and after following a redirect check r.url and r.history, because a redirect can change what you are actually asking for.
Follow-ups:
- "What happens to a POST that gets a 302 redirect?" httpx, like browsers, re-sends it as a GET without the body for 301/302 (on a POST) and for 303. Only 307 and 308 keep the method and body. So a redirected
POST /v1/orderscan silently turn into aGETand never create the order. APIs that move endpoints should use 308. - "Why test HTTP code with
MockTransportinstead of calling the real API?" Tests become fast, deterministic and work offline, and you can produce any status code, header or malformed body on demand. It's the samehttpx.Clientcode path. Inject the client (or transport) into your code so tests can swap it. - "Your client sends an
Authorizationheader and the server redirects to another host. What should happen?" The token must not be forwarded to a different origin. httpx strips theAuthorizationheader when a redirect goes to a different host. That's also a reason not to follow redirects blindly to hosts you don't control.
Scoring: Strong: All four lines; knows httpx doesn't follow redirects by default and raises for 3xx, and notices that the query string was dropped. Acceptable: Gets the status lines but expects the query to survive the redirect; understands once shown the Location header. Red flag: Assumes any non-200 response raises an exception automatically, or can't explain what a 301 response means.
JR-H-C01 · Lazy iterator over a cursor-paginated API
Problem:
Your team pulls orders from a partner's REST API that pages with a cursor. Each call returns
{"items": [...], "next": "<cursor>" | None}, and you pass the previous next value to get the following page
(the first call uses None).
Write a generator iter_records(fetch_page, max_pages=1000) that yields every record, one page fetched at a time.
fetch_page(cursor) is injected: in production it wraps an httpx call, and in tests it's a fake.
The partner API has known quirks you must handle:
- It sometimes returns an empty page that still has a
nextcursor. Keep going. - When data shifts between calls, a record can appear on two pages. Yield each
"id"once. - A bug on their side once returned the same cursor forever. Detect a repeated cursor and raise
RuntimeError, so the job fails instead of looping forever. - Never fetch more than
max_pagespages (raiseRuntimeError). - If a response isn't shaped like a page (for example
{"error": "rate limited"}), raiseValueError. Never treat it as "no more data".
Signature: def iter_records(fetch_page: Callable[[str | None], dict], max_pages: int = 1000) -> Iterator[dict]:
Constraints:
- Must be lazy: if the consumer stops after the first page's items, the second page must never be requested.
- Records are dicts with a hashable
"id". - Standard library only (the tests use
httpxjust to show it works over HTTP).
Examples:
pages: None -> ids [1, 2], next 'c2'; 'c2' -> [], next 'c3'; 'c3' -> ids [2, 3], next None→yields ids 1, 2, 3 (three fetches: None, 'c2', 'c3')list(islice(iter_records(fetch), 2)) on the pages above→ids [1, 2], with only ONE fetch madean API whose 'next' is always 'x'→RuntimeError (repeated cursor)
Hints:
- Write it as a generator function with
yield; laziness then comes for free. - Which condition actually means 'no more pages': an empty
itemslist, ornextbeing None? - Keep two sets: cursors already used and ids already yielded.
Reference solution:
from collections.abc import Callable, Iterator
from typing import Any
Page = dict[str, Any]
def iter_records(fetch_page: Callable[[str | None], Page], max_pages: int = 1000) -> Iterator[dict]:
"""Yield every record from a cursor-paginated API, one page fetched at a time.
`fetch_page(cursor)` returns {"items": [...], "next": str | None}; the first
call gets cursor=None. Records are de-duplicated by "id". Raises RuntimeError
if the API repeats a cursor or more than `max_pages` pages are needed.
"""
cursor: str | None = None
seen_cursors: set[str] = set()
seen_ids: set[Any] = set()
for _ in range(max_pages):
page = fetch_page(cursor)
if not isinstance(page, dict) or not isinstance(page.get("items"), list):
raise ValueError(f"unexpected page shape: {page!r}")
for item in page["items"]:
if item["id"] in seen_ids:
continue
seen_ids.add(item["id"])
yield item
cursor = page.get("next")
if cursor is None:
return
if cursor in seen_cursors:
raise RuntimeError(f"API repeated cursor {cursor!r}")
seen_cursors.add(cursor)
raise RuntimeError(f"gave up after {max_pages} pages")
Complexity: Time: O(P + N) for P pages and N records, plus whatever fetch_page costs (in practice the network dominates).
Space: O(N) for the set of seen ids and O(P) for seen cursors. Records themselves are not kept; one page is in memory at a time.
Tests:
from itertools import islice
import httpx
def fake_api(pages: dict, log: list):
def fetch(cursor):
log.append(cursor)
return pages[cursor]
return fetch
pages = {
None: {"items": [{"id": 1}, {"id": 2}], "next": "c2"},
"c2": {"items": [], "next": "c3"}, # empty page in the middle
"c3": {"items": [{"id": 2}, {"id": 3}], "next": None}, # id 2 repeated after a shift
}
log: list = []
gen = iter_records(fake_api(pages, log))
assert log == [] # nothing fetched until iterated
assert list(islice(gen, 2)) == [{"id": 1}, {"id": 2}]
assert log == [None] # lazy: page 2 not fetched yet
assert list(gen) == [{"id": 3}]
assert log == [None, "c2", "c3"]
# Single page, no next
assert list(iter_records(lambda c: {"items": [{"id": "a"}], "next": None})) == [{"id": "a"}]
assert list(iter_records(lambda c: {"items": [], "next": None})) == []
# A buggy API that hands back the same cursor forever must not hang
loop = {None: {"items": [{"id": 1}], "next": "x"}, "x": {"items": [], "next": "x"}}
try:
list(iter_records(fake_api(loop, [])))
raise AssertionError("expected RuntimeError")
except RuntimeError:
pass
# max_pages guard (cursors never repeat, but there are too many pages)
endless = lambda c: {"items": [], "next": str(int(c or 0) + 1)}
try:
list(iter_records(endless, max_pages=50))
raise AssertionError("expected RuntimeError")
except RuntimeError:
pass
# Error payloads are not silently treated as "no more data"
try:
list(iter_records(lambda c: {"error": "rate limited"}))
raise AssertionError("expected ValueError")
except ValueError:
pass
# Works over real HTTP calls (httpx with a mock transport)
def handler(request: httpx.Request) -> httpx.Response:
cursor = request.url.params.get("cursor")
data = {None: ([{"id": "ORD-1"}], "abc"), "abc": ([{"id": "ORD-2"}], None)}[cursor]
return httpx.Response(200, json={"items": data[0], "next": data[1]})
with httpx.Client(transport=httpx.MockTransport(handler), base_url="https://api.shop.in") as http:
def fetch(cursor):
params = {"cursor": cursor} if cursor else {}
r = http.get("/orders", params=params)
r.raise_for_status()
return r.json()
assert [o["id"] for o in iter_records(fetch)] == ["ORD-1", "ORD-2"]
A common wrong solution (the tests catch it):
def iter_records(fetch_page, max_pages=1000):
cursor = None
seen_ids = set()
for _ in range(max_pages):
page = fetch_page(cursor)
items = page.get("items", [])
if not items: # "empty page means we're done"
return
for item in items:
if item["id"] not in seen_ids:
seen_ids.add(item["id"])
yield item
cursor = page.get("next")
if cursor is None:
return
raise RuntimeError("too many pages")
Alternatives and tradeoffs:
- No de-duplication (yield as-is) uses O(1) extra memory. That's fine if the API guarantees a stable snapshot, but our partner doesn't.
- Bounded de-dup (only remember ids from the last k pages) caps memory for very large exports, at the cost of missing far-apart duplicates. The warehouse load can also de-duplicate downstream with an upsert keyed on
id. - Collecting everything into a list is simpler to write, but it holds the whole export in memory and fetches every page even when the caller only needs the first few records.
Follow-ups:
- "Where would retries for timeouts and 429 responses go, inside
iter_recordsor insidefetch_page?" Insidefetch_page(or the HTTP client), because retrying is about one request.iter_recordsstays about paging logic. A retry wrapper with backoff, honouringRetry-Afteron 429, keeps both pieces small and separately testable. - "The job crashes after page 300 of 500. How would you resume without starting over?" Persist the last successful cursor (and ideally the max
updated_atseen) after each page is processed, and accept astart_cursorargument. Downstream writes must be idempotent (upserts keyed onid), because the page being processed at the crash will be processed again. - "Why does the solution check for a repeated cursor before fetching rather than after?" It records each cursor as it is handed out and refuses to request one it has already used, so the duplicate page is never fetched. Checking afterwards would cost one extra network call and might yield duplicates before noticing.
Scoring: Strong: Generator with correct stop condition (next is None), keeps going over empty pages, de-duplicates ids, guards against repeated cursors and runaway paging, rejects error payloads, and explains why laziness matters. Acceptable: Correct generator for the happy path and empty pages; adds the loop guards once the interviewer describes the repeating-cursor incident. Red flag: Stops at the first empty page, builds a full list before returning, or has no protection against an infinite loop.
JR-H-C02 · A logging filter that redacts PII
Problem:
A KYC service logs lines such as log.info("user %s uploaded PAN %s", email, pan). Compliance says no email address,
PAN or Indian mobile number may ever reach the log files. Write a logging.Filter subclass, RedactingFilter, that
masks them in every record that passes through it:
- emails become
<email>; - PAN numbers (5 capital letters, 4 digits, 1 capital letter, e.g.
ABCDE1234F) become<pan>; - 10-digit mobile numbers starting with 6–9, optionally prefixed by
+91with an optional space or hyphen, become<phone>. Longer digit runs (order references) must not be touched.
PII often arrives in the arguments (%s values), not in the message template, and sometimes inside an exception
message logged with log.exception(...). The filter must never drop a record and must not modify the caller's objects.
A redact(text) helper is part of the reference solution.
Signature: class RedactingFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool: ...
Constraints:
- Attach it to a handler with
handler.addFilter(RedactingFilter()). - Messages may be non-strings (
log.info(12345)) or contain a literal%with no args ("100% done"). - Standard library only.
Examples:
log.info("user %s uploaded PAN %s", "ravi@corp.in", "ABCDE1234F")→INFO user <email> uploaded PAN <pan>log.info("call +91-9123456789 or 9123456789; ref 12345678901")→INFO call <phone> or <phone>; ref 12345678901log.info("order %d total ₹%.2f", 42, 1499.5)→INFO order 42 total ₹1499.50
Hints:
- Where in a
LogRecorddo the%svalues live, and when are they merged into the message? record.getMessage()does themsg % argsmerge for you.- After you've merged and redacted, what must
record.argsbecome so the formatter doesn't merge again? - With
log.exception, the traceback text is produced later by the formatter fromrecord.exc_info.
Reference solution:
import logging
import re
PATTERNS = [
(re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"), "<email>"),
(re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b"), "<pan>"),
(re.compile(r"(?<!\d)(?:\+91[\s-]?)?[6-9]\d{9}(?!\d)"), "<phone>"),
]
def redact(text: str) -> str:
for pattern, label in PATTERNS:
text = pattern.sub(label, text)
return text
class RedactingFilter(logging.Filter):
"""Mask emails, PAN numbers and Indian mobile numbers in every record.
Works on the *formatted* message, so PII passed as %-style args is caught too.
Never drops a record.
"""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage() # applies record.args
record.msg = redact(message)
record.args = None # already merged into msg
if record.exc_info and not record.exc_text:
record.exc_text = logging.Formatter().formatException(record.exc_info)
if record.exc_text:
record.exc_text = redact(record.exc_text)
return True
Complexity: O(L) per record for message length L, times the number of patterns (three regex passes). That's negligible next to the I/O of writing the log line. No extra memory beyond the formatted string.
Tests:
import io
def make_logger(name: str) -> tuple[logging.Logger, io.StringIO]:
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
handler.addFilter(RedactingFilter())
logger = logging.getLogger(name)
logger.handlers[:] = [handler]
logger.setLevel(logging.INFO)
logger.propagate = False
return logger, stream
log, out = make_logger("kyc")
log.info("signup from asha.rao@example.co.in phone 9876543210")
log.info("user %s uploaded PAN %s", "ravi@corp.in", "ABCDE1234F")
log.warning("OTP sent to %s", "+91 9876543210")
log.info("order %d total ₹%.2f", 42, 1499.5)
log.info("call +91-9123456789 or 9123456789; ref 12345678901")
user = {"email": "neha@x.in"}
log.info("profile %s", user)
try:
raise ValueError("invalid email meera@x.in")
except ValueError:
log.exception("KYC failed for %s", "AAAPZ9999Q")
lines = out.getvalue().splitlines()
assert lines[0] == "INFO signup from <email> phone <phone>"
assert lines[1] == "INFO user <email> uploaded PAN <pan>"
assert lines[2] == "WARNING OTP sent to <phone>"
assert lines[3] == "INFO order 42 total ₹1499.50"
assert lines[4] == "INFO call <phone> or <phone>; ref 12345678901"
assert lines[5] == "INFO profile {'email': '<email>'}"
assert lines[6] == "ERROR KYC failed for <pan>"
text = out.getvalue()
assert "meera@x.in" not in text and "<email>" in "\n".join(lines[7:])
assert "ValueError" in text # traceback still logged
assert user == {"email": "neha@x.in"} # caller's objects untouched
# A message that isn't a string and a record with no args still work
log.info(12345)
log.info("100% done")
assert out.getvalue().splitlines()[-2:] == ["INFO 12345", "INFO 100% done"]
A common wrong solution (the tests catch it):
import logging
import re
PATTERNS = [
(re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"), "<email>"),
(re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b"), "<pan>"),
(re.compile(r"(?<!\d)(?:\+91[\s-]?)?[6-9]\d{9}(?!\d)"), "<phone>"),
]
def redact(text: str) -> str:
for pattern, label in PATTERNS:
text = pattern.sub(label, text)
return text
class RedactingFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.msg = redact(str(record.msg)) # only the template; args are merged later
return True
Alternatives and tradeoffs:
- Redact at the source (
log.info("user %s", mask(email))) is precise, but it depends on every developer remembering every time. A filter is the safety net. - Structured logging (JSON logs with named fields) lets you drop or hash known-sensitive fields by name, which is more reliable than regex. Keep the regex filter for free text.
- Filter on the logger vs on the handler: a logger's filters only see records created on that logger, not those propagated from child loggers. Filters on the handler see everything that handler writes.
Follow-ups:
- "Why attach the filter to the handler rather than with
logging.getLogger('kyc').addFilter(...)?" Logger filters are applied only to records logged directly on that logger. A record fromkyc.uploadpropagates tokyc's handlers without passingkyc's logger filters. Handler filters run for every record the handler emits, whichever logger it came from. - "What can a regex-based redactor still miss?" Formats it doesn't know: numbers with internal spaces (
98765 43210), PII in other languages or encodings, data inside nested structures that a third-party library formats differently, or values written to stdout instead of through logging. Treat it as a safety net next to not logging sensitive fields in the first place. - "Your filter mutates
record.msgandrecord.args. Is that safe when two handlers (console and file) share the record?" Yes for this purpose: both handlers see the redacted record, which is what you want, and the second run of the filter is harmless (argsis alreadyNone). It would be a problem only if one handler had to see the raw message. Then you'd redact a copy in a customFormatterinstead.
Scoring: Strong: Redacts the merged message via getMessage(), clears args, handles exception text, never returns False, and explains handler vs logger filters. Acceptable: Works for messages and args but misses the exception traceback, or needs a hint that args are merged later. Red flag: Redacts only record.msg (the template), so PII passed as arguments leaks; or drops records by returning False.
JR-H-C03 · Deep-merge layered config without aliasing
Problem:
A service builds its settings from layers: base.yaml, then prod.yaml, then environment overrides. Write
deep_merge(base, override) that returns a new dict with override layered on top of base:
- if both sides have a dict under the same key, merge them recursively;
- any other override value (lists included) replaces the base value;
- an override value of the sentinel
DELETEremoves the key; - neither input may be changed, and the result must not share any mutable object (nested dicts or lists) with the inputs. The same
baseis reused to build configs for several environments, so a later change to one result must never leak into another.
Signature: def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
Constraints:
- Values are JSON-like: dicts, lists, strings, numbers, booleans, None.
DELETE = object()is defined next to the function.- Standard library only.
Examples:
deep_merge({"db": {"host": "localhost", "port": 5432}}, {"db": {"host": "prod-db"}})→{"db": {"host": "prod-db", "port": 5432}}deep_merge({"features": ["upi", "cod"]}, {"features": ["upi"]})→{"features": ["upi"]}deep_merge({"debug": True, "a": 1}, {"debug": DELETE})→{"a": 1}
Hints:
dict(base)copies only the top level. What do the nested dicts in the copy point to?- Start from
copy.deepcopy(base)and then recurse for keys where both sides are dicts. - Values taken from
overrideneed copying too: think about the lists.
Reference solution:
import copy
from typing import Any
DELETE = object() # sentinel: an override value that removes the key
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Return a NEW dict: `override` layered on top of `base`.
- nested dicts are merged key by key;
- any other value (lists included) in `override` replaces the base value;
- the sentinel DELETE removes the key;
- neither input is mutated, and the result shares no mutable objects with them.
"""
result = copy.deepcopy(base)
for key, value in override.items():
if value is DELETE:
result.pop(key, None)
elif isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
Complexity: O(size of base + size of override) time and space: every value is visited and copied once. Recursion depth equals the nesting depth of the config, which is small.
Tests:
import copy
base = {
"db": {"host": "localhost", "port": 5432, "pool": {"min": 1, "max": 5}},
"features": ["upi", "cod"],
"region": "ap-south-1",
"debug": True,
}
prod = {
"db": {"host": "prod-db.internal", "pool": {"max": 50}},
"features": ["upi"],
"debug": DELETE,
"sentry": {"dsn": "https://x@sentry.example/1"},
}
base_snapshot = copy.deepcopy(base)
prod_snapshot = {k: v for k, v in prod.items() if v is not DELETE}
merged = deep_merge(base, prod)
assert merged == {
"db": {"host": "prod-db.internal", "port": 5432, "pool": {"min": 1, "max": 50}},
"features": ["upi"],
"region": "ap-south-1",
"sentry": {"dsn": "https://x@sentry.example/1"},
}
# Inputs untouched
assert base == base_snapshot
assert {k: v for k, v in prod.items() if v is not DELETE} == prod_snapshot
# No shared mutable objects: mutating the result must not leak back
merged["db"]["pool"]["min"] = 99
merged["features"].append("emi")
merged["sentry"]["dsn"] = "changed"
assert base["db"]["pool"]["min"] == 1 and base["features"] == ["upi", "cod"]
assert prod["features"] == ["upi"] and prod["sentry"]["dsn"] == "https://x@sentry.example/1"
# Untouched nested sections are copies too
only_region = deep_merge(base, {"region": "ap-south-2"})
only_region["db"]["port"] = 1
assert base["db"]["port"] == 5432
# Type changes: a dict replaces a scalar and vice versa
assert deep_merge({"cache": "off"}, {"cache": {"ttl": 60}}) == {"cache": {"ttl": 60}}
assert deep_merge({"cache": {"ttl": 60}}, {"cache": None}) == {"cache": None}
assert deep_merge({}, {"gone": DELETE}) == {}
assert deep_merge({"a": 1}, {}) == {"a": 1}
A common wrong solution (the tests catch it):
DELETE = object()
def deep_merge(base, override):
result = dict(base) # shallow copy
for key, value in override.items():
if value is DELETE:
result.pop(key, None)
elif isinstance(value, dict) and isinstance(result.get(key), dict):
result[key].update(deep_merge(result[key], value)) # mutates base's nested dict
else:
result[key] = value
return result
Alternatives and tradeoffs:
- Copy only on write (build new dicts along the merged path and share untouched subtrees) is cheaper for huge configs, but the result then aliases the inputs. That's acceptable only if everyone treats configs as read-only, which is hard to enforce in Python.
collections.ChainMaplayers dicts for lookups without copying, but it is shallow: nested dicts aren't merged.- Freezing the result (e.g. converting to
types.MappingProxyType/tuples, or loading into a frozen Pydantic settings model) prevents later mutation altogether and turns aliasing bugs into immediate errors.
Follow-ups:
- "Why does the broken version pass a simple equality test on the merged result but still cause a production bug?" Its output is correct, but it achieves it by mutating
base's nested dicts in place (result[key].update(...)on a shallow copy). The first environment's overrides leak intobase, so the second config built from the same base silently contains production hosts or pool sizes. Only a test that checks the inputs afterwards catches it. - "Should lists be merged (concatenated) instead of replaced?" Usually not by default: you can't remove an item via concatenation, and order or duplicates become ambiguous. Replacing is predictable. If a key genuinely needs appending, make it explicit (a special
+featureskey or a documented per-key rule). - "When is
copy.deepcopythe wrong tool?" For objects holding resources (locks, open files, sockets, DB connections), which can't or shouldn't be copied, and for very large structures where the cost matters. For plain JSON-like config it is the simplest correct choice.
Scoring: Strong: Recursive merge on a deep copy, copies override values too, handles DELETE and type changes, and explains the shared-nested-dict bug without prompting. Acceptable: Correct merge results but mutates or aliases one input; fixes it once shown the test that mutates the result. Red flag: Uses dict.update at the top level only (no recursion), or doesn't see why mutating base is a problem.
Hard & Tricky (HT)
JR-HT-O01 · `+=` on a list inside a "constant" tuple
Question: DEFAULT_ROUTE is meant to be a constant: origin airport plus a list of stops. What does this print?
DEFAULT_ROUTE = ("BLR", ["MAA"])
route = DEFAULT_ROUTE
try:
route[1] += ["HYD"]
except TypeError as exc:
print("TypeError:", exc)
print(route)
print(DEFAULT_ROUTE)
stops = route[1]
stops = stops + ["PNQ"]
print(route[1], stops)
frozen = ("DEL", ("JAI",))
backup = frozen
try:
frozen[1] += ("AGR",)
except TypeError as exc:
print("TypeError:", exc)
print(frozen, backup is frozen)
Expected output:
TypeError: 'tuple' object does not support item assignment
('BLR', ['MAA', 'HYD'])
('BLR', ['MAA', 'HYD'])
['MAA', 'HYD'] ['MAA', 'HYD', 'PNQ']
TypeError: 'tuple' object does not support item assignment
('DEL', ('JAI',)) True
The trap: Two tempting answers, both wrong. (1) "It raises TypeError, so nothing changed: ('BLR', ['MAA'])." (2) "Tuples hold lists fine, so += just works with no error." In fact it both raises and mutates the list.
Explanation:
route[1] += ["HYD"] is not one operation. Python runs it as:
tmp = route[1]: fetch the list;tmp = tmp.__iadd__(["HYD"]): for a list,+=extends it in place and returns the same list;route[1] = tmp: store the result back into the tuple, which raisesTypeErrorbecause tuples don't support item assignment.
Step 2 already happened before step 3 failed, so the list now contains HYD even though you saw an exception. route and DEFAULT_ROUTE are two names for one tuple, so the "constant" is changed for every user of it.
stops = stops + ["PNQ"] is different: + builds a new list and rebinds only the local name stops, so the tuple's list is untouched.
With a tuple inside the tuple, += has no in-place version (tuples are immutable), so step 2 builds a new tuple, and step 3 fails before anything is stored. Nothing changes, and backup is frozen is still True.
Takeaway: a tuple freezes which objects it holds, not the objects themselves. x += y means "maybe mutate, then always assign back", and it can do the first half and fail on the second.
Follow-ups:
- "How would you make
DEFAULT_ROUTEgenuinely immutable?" Use immutable contents all the way down:("BLR", ("MAA",)), afrozensetfor unordered data, or a@dataclass(frozen=True)whose fields are tuples. For dict-shaped constants,types.MappingProxyTypegives a read-only view. Code that needs to modify it builds a new value:stops = [*DEFAULT_ROUTE[1], "HYD"]. - "Would
route[1].extend(["HYD"])behave differently?" It mutates the same list but raises no error, because there's no assignment back into the tuple. That's worse in a way: the+=version at least makes noise, while.extendcorrupts the shared default silently. - "Can
("BLR", ["MAA"])be used as a dict key or put in a set?" No. Hashing a tuple hashes its elements, and a list is unhashable:TypeError: unhashable type: 'list'. It's another sign that this 'constant' isn't really immutable.
Scoring: Strong: Predicts both the TypeError and the mutation, explains the fetch / __iadd__ / store-back steps, and contrasts + with += and the tuple-in-tuple case. Acceptable: Gets the mutation after some thought but is unsure about the nested-tuple case; reasons it out when asked whether tuples have an in-place +=. Red flag: Insists the error means nothing changed, or that the tuple itself was modified.
JR-HT-O02 · Class attributes inside a comprehension in the class body
Question: A settings class pre-computes GST-inclusive prices. What does this print?
class PriceConfig:
GST = 0.18
BASE = [100, 250, 400]
WITH_GST = [round(p * (1 + 0.18)) for p in BASE]
LABELS = {p: "low" if p < 200 else "high" for p in BASE}
try:
TOTALS = [round(p * (1 + GST)) for p in BASE]
except NameError as exc:
print("NameError:", exc)
TOTALS = None
print(PriceConfig.WITH_GST)
print(PriceConfig.LABELS)
print(PriceConfig.TOTALS)
Expected output:
NameError: name 'GST' is not defined
[118, 295, 472]
{100: 'low', 250: 'high', 400: 'high'}
None
The trap: "[118, 295, 472] for TOTALS too: GST is defined two lines above, in the same block." The usual second mistake is expecting BASE to fail as well, or the NameError line to be printed last.
Explanation:
A class body runs once, top to bottom, when the class statement executes. That's why the NameError line appears first, before any of the print calls below the class.
The class body has its own namespace, but it is not an enclosing scope for anything nested inside it. Functions, lambdas and comprehensions defined in the body can't see the class's names; LEGB lookup goes local → enclosing function scopes → global → builtins and skips the class. (That's why methods have to say self.GST or cls.GST.)
A comprehension behaves like a nested function, with one exception: its outermost iterable (BASE in for p in BASE) is evaluated in the surrounding scope, the class body, before the comprehension starts. So:
WITH_GSTworks:BASEis the outermost iterable, and the body uses onlypand literals.LABELSworks for the same reason.TOTALSfails:GSTis used inside the comprehension, looked up from the comprehension's scope, which skips the class body. It isn't a global, soNameError.
Python 3.12 made comprehensions faster by inlining them (PEP 709) but deliberately kept this behaviour.
Takeaway: inside a class body, only the first for ... in X of a comprehension can see class attributes; compute derived constants at module level or in a method instead.
Follow-ups:
- "Would
[round(p * (1 + g)) for g in [GST] for p in BASE]fix it?" No. Only the first iterable ([GST]) is evaluated in the class body. The secondfor p in BASEis evaluated inside the comprehension, so it now raisesNameError: name 'BASE' is not defined. The trick works for exactly one name, which is a sign you shouldn't rely on it. - "What are the clean fixes?" Define the constants at module level (
GST = 0.18then use it in the class), computeTOTALSafter the class (PriceConfig.TOTALS = [...]), or make it a method or property that usesself.GSTandself.BASE. For real settings, a Pydantic settings model with a computed field is clearer. - "Does a plain
forloop in the class body have the same problem?" No: aforstatement isn't a new scope, sofor p in BASE: totals.append(round(p * (1 + GST)))works in the class body. The catch is that the loop variablepthen becomes a class attribute (PriceConfig.p == 400).
Scoring: Strong: Correct output including the order of lines; explains that class bodies aren't enclosing scopes and that the outermost iterable is the one exception. Acceptable: Spots that TOTALS fails but can't explain why BASE works; gets there when asked when the iterable is evaluated. Red flag: Expects all three to work, or thinks the comprehension sees class attributes the same way a method sees globals.
JR-HT-O03 · Closures that mutate fine but break on `+=`
Question: A tracker factory returns three small closures. What does this print?
def make_tracker():
count = 0
seen = []
def track_append(order_id):
seen.append(order_id)
return len(seen)
def track_count(order_id):
print("tracking", order_id)
count += 1
return count
def track_extend(order_id):
seen += [order_id]
return len(seen)
return track_append, track_count, track_extend, seen
track_append, track_count, track_extend, seen = make_tracker()
print(track_append("ORD-1"), track_append("ORD-2"))
for fn in (track_count, track_extend):
try:
fn("ORD-3")
except NameError as exc:
print(type(exc).__name__, "-", exc)
print(seen)
Expected output:
1 2
tracking ORD-3
UnboundLocalError - cannot access local variable 'count' where it is not associated with a value
UnboundLocalError - cannot access local variable 'seen' where it is not associated with a value
['ORD-1', 'ORD-2']
The trap: "track_count returns 1 and track_extend works, because list += mutates in place, so seen ends as ['ORD-1', 'ORD-2', 'ORD-3']." A second common slip is expecting the error when the function is defined, so "tracking ORD-3" is left out.
Explanation:
Whether a name is local is decided when the function is compiled, not when the line runs: any assignment to a name anywhere in a function makes it local to the whole function, unless it is declared nonlocal or global.
track_appendonly readsseenand calls a method on it. There's no assignment, soseenis the closure variable frommake_tracker, and appending mutates that shared list:1 2.track_countcontainscount += 1, which is an assignment, socountis local totrack_count. The function starts normally (hencetracking ORD-3), thencount += 1must first read the localcount, which has no value yet, soUnboundLocalError.track_extendis the subtle one.seen += [x]is also an assignment (load,__iadd__, store back), soseenis local totrack_extendtoo. The read fails before__iadd__runs, so no mutation happens andseenis unchanged.UnboundLocalErroris a subclass ofNameError, which is whyexcept NameErrorcatches both.
Fixes: declare nonlocal count in track_count; in track_extend, either nonlocal seen or, better, seen.extend([order_id]), which mutates without assigning.
Takeaway: in a closure, calling methods on a captured object is fine; any assignment to the captured name (=, +=, even on a list) needs nonlocal.
Follow-ups:
- "Why does
seen.append(x)work butseen += [x]doesn't, when both mutate the same list?" Scope is about names, not objects.seen.appendonly looks the name up.seen += ...also rebinds the name (the store-back step), and any rebinding makes the name local unless declarednonlocal. - "Where does the same mechanism bite in everyday code outside closures?" With module-level globals: a function that does
total += amounton a globaltotalraisesUnboundLocalErrorunless it saysglobal total. A function that only reads the global, or doescache[key] = valueon a global dict, works fine. - "Rewrite the counter so no
nonlocalis needed. Is that better?" A small class withself.count += 1, oritertools.count()withnext(counter), avoids rebinding a captured name. A class is clearer once there's more than one piece of state;nonlocalis fine for a tiny closure.
Scoring: Strong: All five lines; explains the compile-time local decision, why += on a list still fails, why seen is unchanged, and that UnboundLocalError is a NameError. Acceptable: Gets track_count right (knows the nonlocal rule) but expects track_extend to work; understands once += is spelled out as read, extend, store back. Red flag: Thinks closures copy variables at creation time, or can't say why track_append works.
JR-HT-O04 · Mock side_effect lists, DEFAULT and reset_mock in a retry test
Question: A test drives a retry helper with a mocked HTTP client. What does this print?
from unittest.mock import DEFAULT, Mock
def order_status(client, order_id, attempts=3):
for _ in range(attempts):
try:
return client.get(f"/orders/{order_id}")["status"]
except TimeoutError as exc:
print("retrying:", exc)
return "UNKNOWN"
client = Mock()
client.get.return_value = {"status": "SHIPPED"}
client.get.side_effect = [TimeoutError("gateway slow"), {"status": "PAID"}]
print(order_status(client, 7))
print(client.get.call_count, client.get.call_args)
try:
order_status(client, 8)
except StopIteration:
print("StopIteration")
client.get.side_effect = lambda path: DEFAULT if path.endswith("9") else {"status": "NEW"}
print(order_status(client, 9), order_status(client, 10))
client.get.side_effect = TimeoutError("bank down")
client.get.reset_mock()
print(order_status(client, 11), client.get.call_count)
Expected output:
retrying: gateway slow
PAID
2 call('/orders/7')
StopIteration
SHIPPED NEW
retrying: bank down
retrying: bank down
retrying: bank down
UNKNOWN 3
The trap: "SHIPPED on the first call, since return_value is set", then "order_status(client, 8) falls back to return_value" once the list runs out, and "reset_mock() clears the side effect, so the last line is SHIPPED 1". All three are wrong.
Explanation:
side_effect takes priority over return_value. Its meaning depends on what you assign:
- An iterable: each call takes the next item. An item that is an exception (instance or class) is raised; anything else is returned. Call 1 raises
TimeoutError(the helper prints and retries), call 2 returns{"status": "PAID"}. That's 2 calls, the last with'/orders/7'. - When the iterable is used up, the next call raises
StopIteration. It does not fall back toreturn_value.StopIterationisn't aTimeoutError, so it escapesorder_statusentirely. In a real test suite this shows up as a bafflingStopIterationfar from the cause: the code made one more call than the test planned for. - A function: it is called with the same arguments and its result is returned, unless it returns the sentinel
mock.DEFAULT, in which casereturn_valueis used. So order 9 getsSHIPPEDand order 10 getsNEW. - An exception: raised on every call, so all three attempts fail and the helper returns
UNKNOWN. reset_mock()clears call history (call_count,call_args) but by default keepsreturn_valueandside_effect. The count restarts at 0 and ends at 3, and the side effect is still active.
Takeaway: use side_effect lists to script sequences such as "fail, then succeed", and remember that running past the end raises StopIteration and that reset_mock() doesn't undo configuration.
Follow-ups:
- "How would you assert that the helper retried exactly three times with the same path?"
assert client.get.call_args_list == [call('/orders/11')] * 3(withfrom unittest.mock import call), orclient.get.assert_called_with('/orders/11')plusclient.get.call_count == 3.call_args_listis the most precise because it checks every call, not just the last. - "How do you clear the configuration too?"
client.get.reset_mock(return_value=True, side_effect=True), or simply create a freshMock()per test, which is cleaner. pytest fixtures make that natural. - "A teammate's test passes even though the code calls
client.fetch(...)instead ofclient.get(...). Why, and what prevents it?" A bareMockinvents any attribute you ask for, soclient.fetchsilently exists.create_autospec(RealClient, instance=True)orMock(spec=RealClient)makes unknown attributes raiseAttributeErrorand checks call signatures.
Scoring: Strong: Every line right; explains side_effect precedence, iterable exhaustion raising StopIteration, the DEFAULT sentinel, and that reset_mock keeps configuration. Acceptable: Gets the first block and the exception side effect; misses StopIteration or the reset_mock detail but reasons correctly once told. Red flag: Believes return_value wins over side_effect, or doesn't know how to make a mock raise an exception.
JR-HT-O05 · Operator precedence in a pandas filter: `&` before `>`
Question: A dashboard wants "orders above ₹500 paid by cash on delivery". What does this print?
import pandas as pd
df = pd.DataFrame({
"city": ["Pune", "Delhi", "Pune", "Chennai"],
"amount": [1200, 300, 4500, 800],
"is_cod": [True, False, False, True],
})
print(df[(df.amount > 500) & df.is_cod].index.tolist())
print(df[df.amount > 500 & df.is_cod].index.tolist())
print((500 & df.is_cod).tolist())
try:
df[df.amount > 500 & df.amount < 2000]
except ValueError as exc:
print("ValueError:", exc)
print(df[(df.amount > 500) & (df.amount < 2000)].index.tolist())
Expected output:
[0, 3]
[0, 1, 2, 3]
[False, False, False, False]
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
[0, 3]
The trap: "The second line is the same as the first, [0, 3]" (reading it the way a person would), or "it raises an error". It runs without complaint and returns every row.
Explanation:
In Python, & and | bind more tightly than comparison operators. pandas overloads them for element-wise AND/OR, but it can't change their precedence.
df.amount > 500 & df.is_codparses asdf.amount > (500 & df.is_cod).500 & Trueis a bitwise AND of 500 and 1. 500 is even, so its lowest bit is 0 and the result is 0;500 & Falseis also 0. pandas returns these as a boolean Series of allFalse, as the third line shows. Thendf.amount > Falsecompares every amount with 0, which is true for every row. The filter silently returns all orders.df.amount > 500 & df.amount < 2000parses asdf.amount > (500 & df.amount) < 2000, which is a chained comparison: Python expandsa > b < cinto(a > b) and (b < c).andcallsbool()on the first Series, and pandas refuses to decide whether a whole Series is "true", so you get theValueError.- With parentheses around each comparison, both filters give
[0, 3](Pune ₹1,200 COD and Chennai ₹800 COD).
This is also why and/or don't work on Series at all: they can't be overloaded, so they always call bool().
Takeaway: in pandas masks, parenthesise every comparison, or build named masks first. A wrong mask doesn't always raise; sometimes it just returns the wrong rows.
Follow-ups:
- "How would you write this filter so the mistake can't happen?" Named masks:
high = df.amount > 500and thendf[high & df.is_cod]. Ordf.query("amount > 500 and is_cod"), whereandworks becausequeryparses its own expression language. For ranges,df.amount.between(500, 2000)(inclusive at both ends by default). - "What does
~do on a column of 0/1 integers instead of booleans?" Bitwise NOT:~1is-2and~0is-1, both truthy. Using such a column as a mask then fails, or misbehaves after a cast. Convert flags tobooldtype on load (df['is_cod'].astype(bool)) before using~,&or|. - "How would a test have caught the silent version?" Assert on the result for a tiny fixture where you know the answer, e.g.
assert filter_orders(df).index.tolist() == [0, 3], and include rows that should be excluded (a small COD order, a large prepaid one). A test with only matching rows can't tell the right mask from 'everything'.
Scoring: Strong: Gets line 2 as all rows and explains it via precedence plus bitwise 500 & True == 0; recognises the chained comparison behind the ValueError. Acceptable: Knows parentheses are required and predicts an error or a wrong result, but can't explain exactly which; follows the precedence explanation once given. Red flag: Expects and to work on Series, or reads line 2 as "COD orders above 500".
JR-HT-O06 · A local statistics.py shadows the standard library, but os.py doesn't
Question: A teammate left two scratch files, statistics.py and os.py, in the same folder as this script. The snippet creates them so it is self-contained. What does it print?
import importlib
import pathlib
import sys
# A teammate left two scratch files next to this script
pathlib.Path("statistics.py").write_text(
'print("loading local statistics.py")\n'
"def mean(values):\n"
" return 'not the real mean'\n"
)
pathlib.Path("os.py").write_text('print("loading local os.py")\n')
importlib.invalidate_caches()
print("os" in sys.modules, "statistics" in sys.modules)
import os
import statistics
print(statistics.mean([10, 20, 60]))
print(os.path.basename(statistics.__file__), os.sep)
try:
from statistics import median
except ImportError as exc:
print(type(exc).__name__, "-", exc.name)
del sys.modules["statistics"]
sys.path.remove(sys.path[0])
import statistics
print(statistics.mean([10, 20, 60]))
Expected output:
True False
loading local statistics.py
not the real mean
statistics.py /
ImportError - statistics
30
The trap: Either "the standard library always wins, so mean returns 30 and nothing local loads", or "both local files are picked up, so loading local os.py prints and os.sep fails". Only statistics is shadowed.
Explanation:
import x looks in two places, in this order:
sys.modules, the cache of modules already imported in this process. Ifxis there, Python reuses it and searches nothing.- Otherwise, the directories on
sys.path, in order. When you run a script,sys.path[0]is the script's own directory, so it is searched before the standard library.
os was already imported while the interpreter started up (the site module needs it), so it is in sys.modules and import os returns the real module; the local os.py is never even looked at. statistics wasn't imported yet, so the search starts at the script's folder, finds the scratch statistics.py, and runs it, printing its message. mean is now the fake one.
from statistics import median fails with ImportError because the local module has no median; exc.name is the module that was searched. In real projects the message often mentions a "partially initialized module ... (most likely due to a circular import)", which sends people hunting for circular imports that don't exist.
Removing the cached module and the script directory from sys.path makes the next import find the real standard library, so mean is 30.
Takeaway: never name your own files after standard-library or installed packages (random.py, email.py, logging.py, pandas.py). When an import behaves strangely, print module.__file__ first.
Follow-ups:
- "A colleague says
import pandasgivesAttributeError: module 'pandas' has no attribute 'DataFrame'. What's your first check?"python -c "import pandas; print(pandas.__file__)"from the same directory. If it points to their ownpandas.py(or apandas/folder) in the project, that's the cause. Rename it and delete any stale__pycache__. If the path is right, check that they're using the venv's interpreter (which python,python -m pip show pandas). - "Is there a way to stop the script's directory being put on
sys.path?" Yes, since Python 3.11:python -P script.pyor the environment variablePYTHONSAFEPATH=1. The cleaner structural fix is asrc/layout with an installed package, so tests and tools import the installed code rather than whatever sits in the current folder. - "How does
python -m app.maindiffer frompython app/main.pyfor imports?" With-m, the current working directory goes at the front ofsys.pathandapp.mainruns as part of the package, sofrom app import configworks. Running the file path putsapp/itself first, which breaks absolute package imports and makes shadowing by files insideapp/more likely.
Scoring: Strong: All six lines; explains the sys.modules cache before the sys.path search, why os is immune here, and that the script directory comes first. Acceptable: Knows local files can shadow stdlib modules but expects os.py to be loaded too; understands once told os is imported at startup. Red flag: Thinks the standard library always takes priority, or has never debugged an import by checking __file__.
JR-HT-C01 · A decorator that enforces type hints at runtime
Problem:
Type hints aren't checked at runtime. For a handful of internal functions that receive data straight from JSON, your
lead wants a small @enforce_types decorator (no Pydantic in this service) that checks arguments and the return value
against the function's hints and raises TypeError naming the offending parameter.
Requirements:
- Check every annotated parameter, however it was passed (positionally, by keyword, or left at its default). Unannotated parameters are not checked.
- Supported hints: plain classes (
int,str,float, your own classes),X | None,Optional[X]and other unions,None,Any, and parameterised generics such aslist[float](check the container type only, not the elements). boolmust not pass forintorfloat(JSONtruemust not become quantity 1), but anintis accepted forfloat, following the usual typing rule.- For
*args: Tcheck each positional extra; for**kwargs: Tcheck each value. - Check the return value if there is a return annotation.
- Hints may be strings / forward references to classes defined after the decorated function.
- Keep the function's name and docstring.
Signature: def enforce_types(func: Callable[P, R]) -> Callable[P, R]:
Constraints:
- Standard library only (
inspect,typing,functools,types). - Calling with the wrong number of arguments must still raise
TypeErroras a normal call would. - The error message must contain the parameter name (e.g.
qty,tags[1],return value).
Examples:
add_item("TEA-250", 2, 149.5)→299.0add_item("TEA-250", qty="2")→TypeError: add_item(): qty must be <class 'int'>, got stradd_item("TEA-250", True, 10.0)→TypeError (bool is not accepted for int)invoice_total(inv: "Invoice") where Invoice is defined later; invoice_total(Invoice(500.0))→500.0
Hints:
zip(annotations, args)misses keyword arguments and defaults. What ininspectmaps every argument to its parameter name?sig.bind(*args, **kwargs)followed byapply_defaults()gives youbound.arguments.isinstance(True, int)isTrue. Handleboolbefore the general check.typing.get_origin/get_argstake apartint | Noneandlist[float].- If you call
get_type_hintsin the decorator itself, what happens with a forward reference to a class that doesn't exist yet?
Reference solution:
import functools
import inspect
import types
from collections.abc import Callable
from typing import Any, ParamSpec, TypeVar, Union, get_args, get_origin, get_type_hints
P = ParamSpec("P")
R = TypeVar("R")
def _matches(value: Any, hint: Any) -> bool:
if hint is Any:
return True
if hint is None or hint is type(None):
return value is None
origin = get_origin(hint)
if origin is Union or origin is types.UnionType:
return any(_matches(value, h) for h in get_args(hint))
if origin is not None: # list[int], dict[str, float]: container type only
return isinstance(value, origin)
if isinstance(value, bool) and hint is not bool:
return False # bool is an int subclass; don't let True pass as 1
if hint is float:
return isinstance(value, (int, float)) # PEP 484: int is acceptable where float is expected
return isinstance(value, hint)
def enforce_types(func: Callable[P, R]) -> Callable[P, R]:
sig = inspect.signature(func)
hints: dict[str, Any] | None = None # resolved lazily: forward refs may not exist yet
def check(name: str, value: Any, hint: Any) -> None:
if not _matches(value, hint):
raise TypeError(f"{func.__name__}(): {name} must be {hint!r}, got {type(value).__name__}")
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
nonlocal hints
if hints is None:
hints = get_type_hints(func)
bound = sig.bind(*args, **kwargs) # raises TypeError for bad arity, like a normal call
bound.apply_defaults()
for name, value in bound.arguments.items():
if name not in hints:
continue
kind = sig.parameters[name].kind
if kind is inspect.Parameter.VAR_POSITIONAL:
for i, item in enumerate(value):
check(f"{name}[{i}]", item, hints[name])
elif kind is inspect.Parameter.VAR_KEYWORD:
for key, item in value.items():
check(f"{name}[{key!r}]", item, hints[name])
else:
check(name, value, hints[name])
result = func(*args, **kwargs)
if "return" in hints:
check("return value", result, hints["return"])
return result
return wrapper
Complexity: Per call: O(number of parameters) checks, plus O(n) for *args/**kwargs with n extras. Union checks are
proportional to the number of union members. inspect.signature is computed once at decoration time, and the type
hints once on the first call.
Tests:
from typing import Any, Optional
def raises(fn, *args, **kwargs) -> str:
try:
fn(*args, **kwargs)
except TypeError as exc:
return str(exc)
raise AssertionError("expected TypeError")
@enforce_types
def add_item(sku: str, qty: int, price: float = 0.0, note: str | None = None) -> float:
"""Add a line item."""
return qty * price
assert add_item("TEA-250", 2, 149.5) == 299.0
assert add_item("TEA-250", qty=3, price=100) == 300 # int is fine for float
assert add_item(sku="TEA-250", qty=1, note=None) == 0.0
assert "qty" in raises(add_item, "TEA-250", True, 10.0) # bool is not an int here
assert "qty" in raises(add_item, "TEA-250", qty="2") # keyword args are checked
assert "price" in raises(add_item, "TEA-250", 1, price=True)
assert "note" in raises(add_item, "TEA-250", 1, note=42)
assert "sku" in raises(add_item, 101, 1)
raises(add_item, "TEA-250") # missing arg: still a TypeError
assert add_item.__name__ == "add_item" and add_item.__doc__ == "Add a line item."
@enforce_types
def bad_total(amounts: list[float]) -> int:
return sum(amounts)
assert bad_total([1, 2]) == 3
assert "return" in raises(bad_total, [1.5, 2.0]) # returns float, promised int
assert "amounts" in raises(bad_total, (1, 2)) # tuple is not a list
@enforce_types
def tag_all(*tags: str, **meta: int) -> None:
pass
tag_all("vip", "diwali", visits=3)
assert "tags[1]" in raises(tag_all, "vip", 7)
assert "meta" in raises(tag_all, "vip", visits="3")
@enforce_types
def flag(enabled: bool, reason: Optional[str] = None, extra: Any = None) -> None:
pass
flag(True, "manual", extra=object())
assert "enabled" in raises(flag, 1)
# Forward reference to a class defined AFTER the decorated function
@enforce_types
def invoice_total(inv: "Invoice") -> float:
return inv.amount
class Invoice:
def __init__(self, amount: float) -> None:
self.amount = amount
assert invoice_total(Invoice(500.0)) == 500.0
assert "inv" in raises(invoice_total, {"amount": 500.0})
@enforce_types
def untyped(a, b: int = 1):
return a
assert untyped("anything") == "anything"
A common wrong solution (the tests catch it):
import functools
def enforce_types(func):
hints = func.__annotations__
@functools.wraps(func)
def wrapper(*args, **kwargs):
for (name, hint), value in zip(hints.items(), args): # positional args only
if isinstance(hint, type) and not isinstance(value, hint):
raise TypeError(f"{func.__name__}(): {name} must be {hint.__name__}")
return func(*args, **kwargs)
return wrapper
Alternatives and tradeoffs:
- Pydantic's
@validate_calldoes this properly, including coercion, nested element validation and good error messages. Prefer it wherever adding Pydantic is acceptable. typeguardchecks hints at runtime, including generic element types; it's handy in tests or at module boundaries.- Validate at the boundary only: parse the JSON into a Pydantic model or dataclass once, and let static type checking (mypy/pyright) cover the internal calls. Runtime checks on every internal call cost CPU and duplicate what the type checker already proves.
Follow-ups:
- "Why resolve the hints lazily, on the first call, instead of when decorating?"
get_type_hintsevaluates string annotations. If"Invoice"is defined further down the module, evaluating at decoration time raisesNameError. By the first call the module has finished importing and the name exists. Caching the result keeps the cost to one evaluation. - "Your decorator accepts
["a", "b"]foramounts: list[float]. Is that acceptable?" It's a documented limitation: checking elements means walking the whole container on every call, which is O(n) and gets tricky for nested or lazy types (iterators can't be checked without consuming them). For boundary data, validate elements once with Pydantic. Inside the codebase, rely on mypy. - "What would you have to change to support
Literal["UPI", "CARD"]?" Detectget_origin(hint) is Literaland checkvalue in get_args(hint), with an exact type check so thatTruedoesn't match a literal1. Without that branch, the generic-origin path would callisinstance(value, Literal), which raisesTypeError.
Scoring: Strong: Uses inspect.signature().bind with defaults, handles unions, None, Any and generics, rejects bool for int, checks *args/**kwargs and the return value, and resolves forward references lazily, explaining each choice. Acceptable: Correct for positional and keyword arguments and simple classes using bind; misses the bool rule or forward references until a test shows it. Red flag: Zips __annotations__ with positional args only, or relies on isinstance(value, hint) for every hint (crashing on int | None or list[float]).
JR-HT-C02 · Signed, expiring session tokens with HMAC
Problem:
An internal tool needs simple stateless session tokens: after login the server issues a token, and later requests present it. Write two functions:
make_token(user_id, expires_at, secret)returns a token: the base64url-encoded JSON payload{"exp": ..., "sub": ...}(no=padding), a., and the base64url HMAC-SHA256 signature of the encoded payload.verify_token(token, secret, now)returns theuser_idif the token is authentic and not expired, and otherwise raisesInvalidToken.
Rules for verify_token:
- The signature must cover the whole payload, so a client can't change the user id or extend
exp. - Compare signatures in constant time.
- A token is expired when
now >= exp. - Any malformed input (wrong number of parts, non-ASCII characters, bad base64, payload that isn't a JSON object, missing or wrongly typed claims) raises
InvalidToken. NoKeyError,binascii.Error,UnicodeEncodeErrororJSONDecodeErrormay leak to the caller. - User ids may contain dots (
asha.rao@corp.in).
now and expires_at are integer Unix timestamps passed in explicitly, so the functions are testable.
Signature: def make_token(user_id: str, expires_at: int, secret: bytes) -> str:
def verify_token(token: str, secret: bytes, now: int) -> str:
Constraints:
- Standard library only (
hmac,hashlib,base64,json). make_tokenmust be deterministic for the same inputs (usesort_keys=Trueand compact separators).- Define
class InvalidToken(Exception).
Examples:
verify_token(make_token("asha.rao@corp.in", NOW + 900, SECRET), SECRET, NOW)→"asha.rao@corp.in"same token verified at now = NOW + 900→InvalidToken (expired)payload re-encoded with a later exp, original signature kept→InvalidToken (bad signature)verify_token("ünï.cödé", SECRET, NOW)→InvalidToken (not a UnicodeEncodeError)
Hints:
- What exactly do you sign? Sign the encoded payload string, not just the user id.
hmac.compare_digestinstead of==, and why?- base64url without padding: add back
'=' * (-len(s) % 4)before decoding. - Verify the signature before trusting or even parsing the payload.
- List every exception that parsing can raise and convert each one to
InvalidToken.
Reference solution:
import base64
import binascii
import hashlib
import hmac
import json
class InvalidToken(Exception):
pass
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _b64d(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _sign(payload_b64: str, secret: bytes) -> str:
return _b64e(hmac.new(secret, payload_b64.encode("ascii"), hashlib.sha256).digest())
def make_token(user_id: str, expires_at: int, secret: bytes) -> str:
payload = json.dumps({"sub": user_id, "exp": expires_at}, separators=(",", ":"), sort_keys=True)
payload_b64 = _b64e(payload.encode("utf-8"))
return f"{payload_b64}.{_sign(payload_b64, secret)}"
def verify_token(token: str, secret: bytes, now: int) -> str:
"""Return the user id if `token` is authentic and unexpired, else raise InvalidToken."""
if not token.isascii():
raise InvalidToken("malformed token")
try:
payload_b64, signature = token.split(".")
except ValueError:
raise InvalidToken("malformed token") from None
expected = _sign(payload_b64, secret)
if not hmac.compare_digest(signature, expected):
raise InvalidToken("bad signature")
try:
claims = json.loads(_b64d(payload_b64))
user_id, exp = claims["sub"], claims["exp"]
except (binascii.Error, ValueError, KeyError, TypeError):
raise InvalidToken("bad payload") from None
if not isinstance(user_id, str) or type(exp) is not int:
raise InvalidToken("bad claims")
if now >= exp:
raise InvalidToken("expired")
return user_id
Complexity: O(n) in the token length for encoding, hashing and comparison; O(n) extra space. HMAC-SHA256 is fast enough to run on every request.
Tests:
SECRET = b"s3cr3t-key-from-env"
NOW = 1_780_000_000
tok = make_token("asha.rao@corp.in", NOW + 900, SECRET)
assert tok.count(".") == 1 and "=" not in tok # dots in the user id don't leak into the format
assert verify_token(tok, SECRET, NOW) == "asha.rao@corp.in"
assert verify_token(tok, SECRET, NOW + 899) == "asha.rao@corp.in"
assert make_token("asha.rao@corp.in", NOW + 900, SECRET) == tok # deterministic
def rejects(token, secret=SECRET, now=NOW):
try:
verify_token(token, secret, now)
except InvalidToken:
return True
return False
assert rejects(tok, now=NOW + 900) # expiry boundary: exp itself is expired
assert rejects(tok, secret=b"other-key") # wrong key
# Tampering with the expiry while keeping the old signature must fail
payload_b64, sig = tok.split(".")
claims = json.loads(base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)))
claims["exp"] = NOW + 10**6
forged_payload = base64.urlsafe_b64encode(
json.dumps(claims, separators=(",", ":"), sort_keys=True).encode()).rstrip(b"=").decode()
assert rejects(f"{forged_payload}.{sig}")
# Changing the user id must fail too
claims["exp"], claims["sub"] = NOW + 900, "admin@corp.in"
forged_payload = base64.urlsafe_b64encode(
json.dumps(claims, separators=(",", ":"), sort_keys=True).encode()).rstrip(b"=").decode()
assert rejects(f"{forged_payload}.{sig}")
# Garbage never leaks a different exception type
for junk in ["", "abc", "a.b.c", tok + "x", "." + sig, "%%%%.%%%%", "ünï.cödé"]:
assert rejects(junk), junk
# Correctly signed but structurally wrong payloads are rejected cleanly
def signed(raw: bytes) -> str:
p = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
s = base64.urlsafe_b64encode(hmac.new(SECRET, p.encode(), hashlib.sha256).digest()).rstrip(b"=").decode()
return f"{p}.{s}"
assert rejects(signed(b"not json"))
assert rejects(signed(b'{"sub": "x"}'))
assert rejects(signed(b'[1, 2]'))
assert rejects(signed(b'{"sub": "x", "exp": "9999999999"}'))
assert verify_token(signed(b'{"sub": "x", "exp": 9999999999}'), SECRET, NOW) == "x"
A common wrong solution (the tests catch it):
import base64
import hashlib
import hmac
import json
class InvalidToken(Exception):
pass
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def make_token(user_id: str, expires_at: int, secret: bytes) -> str:
payload = json.dumps({"sub": user_id, "exp": expires_at}, separators=(",", ":"), sort_keys=True)
sig = _b64e(hmac.new(secret, user_id.encode(), hashlib.sha256).digest()) # signs only the user id
return f"{_b64e(payload.encode())}.{sig}"
def verify_token(token: str, secret: bytes, now: int) -> str:
try:
payload_b64, signature = token.split(".")
claims = json.loads(base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)))
user_id, exp = claims["sub"], claims["exp"]
except Exception:
raise InvalidToken("bad token") from None
expected = _b64e(hmac.new(secret, str(user_id).encode(), hashlib.sha256).digest())
if not hmac.compare_digest(signature, expected) or now >= exp:
raise InvalidToken("rejected")
return user_id
Alternatives and tradeoffs:
- A standard format (JWT via a maintained library such as PyJWT) gives interoperability, key rotation (
kid) and algorithm handling. Hand-rolling is for learning or very small internal tools; in production prefer the library, but pin the accepted algorithm. - Opaque random tokens stored server-side (
secrets.token_urlsafe(32)mapped to a session row) can be revoked instantly and leak nothing. The cost is a database or cache lookup per request. itsdangerous(used by Flask) implements exactly this signed-and-timestamped pattern.
Follow-ups:
- "Why does
hmac.compare_digestmatter? Both versions give the same True/False."==on strings stops at the first differing character, so response time leaks how many leading characters of a guessed signature are right. Over many requests an attacker can recover a valid signature byte by byte.compare_digesttakes the same time however many characters match. - "A user is fired at 11:00 IST but their token expires at 18:00. What can you do with stateless tokens?" Nothing inside the token itself. Options: keep expiries short and use refresh tokens checked against the database; keep a server-side deny-list of revoked token ids (add a
jticlaim) or a per-user 'tokens issued before T are invalid' timestamp; or rotate the secret, which logs everyone out. Revocation is the main trade-off of stateless tokens. - "Where should
secretcome from, and what happens if it leaks?" From a secret manager or environment variable, never from the repo, and long and random (secrets.token_bytes(32)). If it leaks, anyone can mint tokens for any user, so rotate it immediately. Supporting two active secrets (a key id in the token) allows rotation without logging everyone out.
Scoring: Strong: Signs the full encoded payload, verifies before parsing, uses compare_digest, gets the expiry boundary right, maps every malformed input to InvalidToken, and discusses revocation and secret handling. Acceptable: Correct signing and expiry with compare_digest; lets one or two parsing exceptions leak until the garbage-input tests are shown. Red flag: Signs only the user id (so exp can be extended), trusts the payload before checking the signature, or just base64-encodes the JSON with no signature.
JR-HT-C03 · An atomic file-write context manager
Problem:
A nightly job rewrites daily_sales.csv, which a dashboard reads every minute. Last week the job crashed half-way
through writing, and the dashboard showed a truncated file until the next night. Write a context manager
atomic_write(path, mode="w", encoding="utf-8") so that readers see either the complete old file or the
complete new file, never a partial one:
with atomic_write("daily_sales.csv") as f:
f.write(header)
for row in rows:
f.write(row)
- Write to a temporary file, and only when the
withblock finishes successfully move it overpath. - If the block raises (any exception, including
KeyboardInterrupt), leavepathuntouched, delete the temp file, and let the exception propagate. - Leave no temp files behind in either case, and close the file handle.
- Support text mode
"w"and binary mode"wb"; reject any other mode withValueError. - Work whether or not
pathalready exists.
Signature: @contextmanager
def atomic_write(path: str | os.PathLike[str], mode: str = "w", encoding: str = "utf-8") -> Iterator[IO]:
Constraints:
- Standard library only.
- The temp file must be on the same filesystem as
path(think about where you create it). - The exception raised inside the block must reach the caller unchanged.
Examples:
with atomic_write(p) as f: f.write("new")→p contains "new"; the directory contains only pwith atomic_write(p) as f: f.write("Delhi,"); raise RuntimeError("DB connection lost")→RuntimeError propagates; p still has its old content; no temp file leftwith atomic_write(p, "a"): ...→ValueError
Hints:
- With
@contextmanager, an exception in thewithbody is raised at youryield. What must surround theyield? os.replace(src, dst)overwritesdstatomically when both are on the same filesystem./tmpmay be a different one.tempfile.mkstemp(dir=...)gives you a unique file in a directory you choose.- Before renaming, make sure the bytes have reached the disk:
flush()and thenos.fsync().
Reference solution:
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import IO
@contextmanager
def atomic_write(path: str | os.PathLike[str], mode: str = "w",
encoding: str = "utf-8") -> Iterator[IO]:
"""Write to `path` all-or-nothing.
Data goes to a temp file in the SAME directory; only after the with-block
succeeds is it flushed, fsynced and renamed over `path` (os.replace is atomic
on one filesystem). On any exception the temp file is removed, `path` is
untouched, and the exception propagates.
"""
if mode not in ("w", "wb"):
raise ValueError("mode must be 'w' or 'wb'")
target = Path(path)
fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
try:
if mode == "w":
f = os.fdopen(fd, "w", encoding=encoding, newline="")
else:
f = os.fdopen(fd, "wb")
except BaseException:
os.close(fd)
os.unlink(tmp_name)
raise
try:
with f:
yield f
f.flush()
os.fsync(f.fileno())
os.replace(tmp_name, target)
except BaseException:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
raise
Complexity: O(size of the data) time for writing, plus one fsync and one rename. O(1) extra memory, since data streams to the temp file. Disk usage briefly doubles while both files exist.
Tests:
import tempfile as _tf
from pathlib import Path as _P
with _tf.TemporaryDirectory() as d:
report = _P(d) / "daily_sales.csv"
report.write_text("city,amount\nPune,1200\n", encoding="utf-8")
# Failure half-way: original untouched, no temp files left behind
try:
with atomic_write(report) as f:
f.write("city,amount\n")
f.write("Delhi,")
raise RuntimeError("DB connection lost")
except RuntimeError as exc:
assert str(exc) == "DB connection lost" # not swallowed
assert report.read_text(encoding="utf-8") == "city,amount\nPune,1200\n"
assert sorted(p.name for p in _P(d).iterdir()) == ["daily_sales.csv"]
# Success: content replaced, still no temp files
with atomic_write(report) as f:
f.write("city,amount\nचेन्नई,800\n")
assert report.read_text(encoding="utf-8") == "city,amount\nचेन्नई,800\n"
assert sorted(p.name for p in _P(d).iterdir()) == ["daily_sales.csv"]
# New file, binary mode, str path
blob = str(_P(d) / "model.bin")
with atomic_write(blob, "wb") as f:
f.write(b"\x00\x01weights")
assert _P(blob).read_bytes() == b"\x00\x01weights"
# Failure when the target does not exist yet: it must not appear
ghost = _P(d) / "ghost.json"
try:
with atomic_write(ghost) as f:
f.write("{")
raise KeyboardInterrupt
except KeyboardInterrupt:
pass
assert not ghost.exists()
assert sorted(p.name for p in _P(d).iterdir()) == ["daily_sales.csv", "model.bin"]
# Handle is closed afterwards
with atomic_write(report) as f:
f.write("x")
assert f.closed
try:
with atomic_write(report, "a"):
pass
raise AssertionError("append mode accepted")
except ValueError:
pass
assert report.read_text(encoding="utf-8") == "x"
A common wrong solution (the tests catch it):
import os
from contextlib import contextmanager
@contextmanager
def atomic_write(path, mode="w", encoding="utf-8"):
if mode not in ("w", "wb"):
raise ValueError("mode must be 'w' or 'wb'")
tmp = f"{path}.tmp"
f = open(tmp, mode, encoding=None if "b" in mode else encoding)
yield f # no try/finally: an exception skips everything below
f.close()
os.replace(tmp, path)
Alternatives and tradeoffs:
- Write in place with
open(path, "w"): this is what failed. Opening in"w"mode truncates immediately, so readers can see an empty or partial file and a crash leaves it that way. - Write
path + ".tmp"with a fixed name: simpler, but two concurrent runs collide on the same temp file, and a crash leaves.tmpdebris (as the broken solution shows).mkstempgives a unique name. - Write a new versioned file and switch a symlink or pointer (
sales_2026-10-04.csvpluslatest→ it): this keeps history and allows rollback, at the cost of cleanup work. - A full durability guarantee also needs an
fsyncof the directory afteros.replaceon POSIX, so the rename itself survives a power cut. That's worth mentioning for important data.
Follow-ups:
- "Why must the temp file be in the same directory rather than in
/tmp?"os.replaceis an atomic rename only within a single filesystem. If/tmpis a different mount (oftentmpfs), the rename fails withOSError: Invalid cross-device link(EXDEV). Evenshutil.move, which falls back to copy-and-delete, is no longer atomic. - "Why catch
BaseExceptionin the cleanup instead ofException?" So that Ctrl+C (KeyboardInterrupt) orSystemExitduring the write also removes the temp file. Theraisere-raises it, so you clean up without swallowing anything. CatchingBaseExceptionwithout re-raising would be a bug. - "
mkstempcreates the file with permissions 0600. Why might that matter after the rename?" The new file replaces the old one including its permissions, so a file that was group-readable (0644) becomes owner-only, and a dashboard running as another user can suddenly no longer read it. Copy the original mode withos.chmod(tmp, os.stat(path).st_mode)before replacing, if that matters.
Scoring: Strong: Temp file in the same directory, try/except BaseException around the yield that cleans up and re-raises, flush, fsync and os.replace only on success, file always closed; explains the cross-device and permission caveats. Acceptable: Uses a temp file and os.replace correctly, but leaks the temp file on failure or creates it in /tmp; fixes it once the failing test is shown. Red flag: Opens the target with "w" directly, or catches the exception and doesn't re-raise it, hiding failures from the job.
Role-wise questions
JR-DE-C01 · Load a change batch into SQLite with an idempotent upsert
Problem:
An hourly job receives changed orders from an upstream system and must apply them to an orders table in SQLite.
Batches overlap and jobs get re-run, so the same row can arrive several times, and an older version of an
order can arrive after a newer one.
The table already exists:
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
amount_paise INTEGER NOT NULL CHECK (amount_paise >= 0),
updated_at TEXT NOT NULL -- UTC, always 'YYYY-MM-DDTHH:MM:SSZ'
)
Write load_orders(conn, rows):
- New
order_id→ insert. Existing → update only if the incomingupdated_atis strictly later than the stored one; otherwise leave the row alone. Re-running a batch must change nothing. - Before writing anything, check every row's
updated_atmatchesYYYY-MM-DDTHH:MM:SSZexactly and raiseValueErrorotherwise (that fixed format is what makes comparing the text safe). - The batch is all or nothing: if any row fails (a constraint violation, a missing key), roll back every row of this batch and let the exception propagate.
- Use query parameters, never string formatting, to put values into SQL.
- Return how many rows were inserted or updated.
Signature: def load_orders(conn: sqlite3.Connection, rows: list[dict]) -> int:
Constraints: Batches of up to 10^5 rows. SQLite 3.24+ (for ON CONFLICT ... DO UPDATE); Python 3.12 ships a newer one.
Examples:
load_orders(conn, [A1 PLACED @10:00Z, A2 PLACED @10:05Z]) on an empty table→2the same call again→0A1 DELIVERED @11:00Z, then later A1 SHIPPED @10:30Z (arrived late)→1, then 0; A1 stays DELIVEREDa batch whose second row has amount_paise = -5→sqlite3.IntegrityError; the first row is not saved either
Hints:
- SQLite's
INSERT ... ON CONFLICT (order_id) DO UPDATE SET ... WHERE ...can express the whole rule in one statement;excluded.colis the incoming value. executemanyaccepts a list of dicts when the SQL uses named parameters like:order_id.with conn:commits on success and rolls back on an exception.conn.total_changescounts rows changed so far on this connection.
Reference solution:
import re
import sqlite3
STAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
UPSERT = """
INSERT INTO orders (order_id, status, amount_paise, updated_at)
VALUES (:order_id, :status, :amount_paise, :updated_at)
ON CONFLICT (order_id) DO UPDATE SET
status = excluded.status,
amount_paise = excluded.amount_paise,
updated_at = excluded.updated_at
WHERE excluded.updated_at > orders.updated_at
"""
def load_orders(conn: sqlite3.Connection, rows: list[dict]) -> int:
"""Upsert `rows` into `orders` atomically; return the number of rows inserted or updated."""
for row in rows:
stamp = row.get("updated_at")
if not isinstance(stamp, str) or not STAMP.fullmatch(stamp):
raise ValueError(f"bad updated_at for {row.get('order_id')!r}: {stamp!r}")
before = conn.total_changes
with conn: # one transaction: commit on success, roll back on error
conn.executemany(UPSERT, rows)
return conn.total_changes - before
Complexity: O(n log m) for n batch rows against a table of m rows (each upsert is a primary-key B-tree lookup), in a single transaction; O(1) extra Python memory beyond the input list.
Tests:
def fresh():
conn = sqlite3.connect(":memory:")
conn.execute("""CREATE TABLE orders (order_id TEXT PRIMARY KEY, status TEXT NOT NULL,
amount_paise INTEGER NOT NULL CHECK (amount_paise >= 0), updated_at TEXT NOT NULL)""")
return conn
def snapshot(conn):
return conn.execute("SELECT * FROM orders ORDER BY order_id").fetchall()
def order(oid, status, amount, stamp):
return {"order_id": oid, "status": status, "amount_paise": amount, "updated_at": stamp}
conn = fresh()
batch = [order("A1", "PLACED", 49900, "2026-04-01T10:00:00Z"),
order("A2", "PLACED", 19900, "2026-04-01T10:05:00Z")]
assert load_orders(conn, batch) == 2
assert load_orders(conn, batch) == 0 # re-run: no change
assert load_orders(conn, [order("A1", "DELIVERED", 49900, "2026-04-01T11:00:00Z")]) == 1
assert load_orders(conn, [order("A1", "SHIPPED", 49900, "2026-04-01T10:30:00Z")]) == 0 # late, older
assert snapshot(conn) == [("A1", "DELIVERED", 49900, "2026-04-01T11:00:00Z"),
("A2", "PLACED", 19900, "2026-04-01T10:05:00Z")]
# Several versions of one order in one batch, newest first: the newest survives.
assert load_orders(conn, [order("A3", "CANCELLED", 0, "2026-04-01T12:10:00Z"),
order("A3", "PLACED", 9900, "2026-04-01T12:00:00Z")]) == 1
assert conn.execute("SELECT status FROM orders WHERE order_id = 'A3'").fetchone() == ("CANCELLED",)
# All or nothing.
before = snapshot(conn)
try:
load_orders(conn, [order("A4", "PLACED", 100, "2026-04-01T13:00:00Z"),
order("A5", "PLACED", -5, "2026-04-01T13:00:00Z")])
raise AssertionError("expected IntegrityError")
except sqlite3.IntegrityError:
pass
assert snapshot(conn) == before and not conn.in_transaction
# Bad timestamps are rejected before anything is written.
for bad in ("2026-04-01 13:00:00", "2026-04-01T18:30:00+05:30", None):
try:
load_orders(conn, [order("A6", "PLACED", 100, "2026-04-01T13:00:00Z"), order("A7", "PLACED", 100, bad)])
raise AssertionError("expected ValueError")
except ValueError:
pass
assert snapshot(conn) == before
# Values with quotes are stored safely (parameters, not string formatting).
assert load_orders(conn, [order("D'SOUZA-1", "PLACED", 100, "2026-04-01T14:00:00Z")]) == 1
A common wrong solution (the tests catch it):
import re
import sqlite3
STAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
UPSERT = """
INSERT INTO orders (order_id, status, amount_paise, updated_at)
VALUES (:order_id, :status, :amount_paise, :updated_at)
ON CONFLICT (order_id) DO UPDATE SET
status = excluded.status,
amount_paise = excluded.amount_paise,
updated_at = excluded.updated_at
""" # no WHERE: the last row to arrive always wins
def load_orders(conn, rows):
for row in rows:
stamp = row.get("updated_at")
if not isinstance(stamp, str) or not STAMP.fullmatch(stamp):
raise ValueError(f"bad updated_at: {stamp!r}")
before = conn.total_changes
with conn:
conn.executemany(UPSERT, rows)
return conn.total_changes - before
Alternatives and tradeoffs: PostgreSQL has the same INSERT ... ON CONFLICT ... DO UPDATE ... WHERE syntax; warehouses use MERGE. Doing it in
Python (SELECT each row, compare, then INSERT or UPDATE) is slower and racy when two jobs overlap; letting the
database apply the rule in one statement inside one transaction is simpler and safer. For large loads, a common
pattern is to bulk-insert the batch into a staging table and run one set-based upsert from it.
Follow-ups:
- "Why is comparing
updated_atas text safe here but not in general?" Fixed-width, zero-padded, all-UTC ISO strings sort in the same order as the instants they represent. Mix in+05:30offsets, missing seconds or a space instead ofT, and text order no longer matches time order. That's why the function validates the format first. Storing epoch integers or a real timestamp type avoids the issue. - "What exactly does
with conn:do insqlite3?" It commits the transaction if the block succeeds and rolls it back if it raises. It does not close the connection. Without it, a failure halfway throughexecutemanywould leave earlier rows pending in an open transaction, to be committed or lost depending on what the caller does next. - "Two versions of the same order have the same
updated_at. Which wins, and is that OK?" Whichever was stored first: the strict>ignores the second. That's arbitrary; ask upstream for a tiebreaker such as a version number, and use(updated_at, version)in the comparison.
Scoring: Strong: One upsert statement with a WHERE on updated_at, named parameters with executemany, a single transaction,
validation before writing, and a correct count; explains why re-runs and late rows are safe. Acceptable: Correct upsert but always overwrites, or commits per row; fixes it after the late-row or all-or-nothing test fails. Red flag: Builds SQL with f-strings, or does DELETE everything and reinsert for each batch without a transaction.
JR-DE-C02 · Parse a JSON-lines order feed and quarantine bad lines
Problem:
A payments partner drops a JSON-lines file every hour: one JSON object per line. Write a parser that yields good records and collects bad lines into a quarantine list instead of crashing on the first one, so the pipeline can load the good rows and someone can look at the rest.
A line is valid when it is a JSON object with:
order_id: a non-empty string;amount_paise: an integer ≥ 0 (amounts are in paise, so ₹499 is49900). Booleans are not integers here;city: a string. Strip surrounding spaces and title-case it (" pune "→"Pune").
Extra keys are dropped. Blank lines (only whitespace) are skipped silently. For every other bad line, append
(line_number, reason) to rejects, numbering lines from 1 as they appear in the file (blank lines count).
Use exactly these reasons, checking in this order: "invalid json", "not an object",
"missing <field>" (the first missing field in the order above), "bad order_id", "bad amount_paise", "bad city".
Write it as a generator, so a large file is processed one line at a time.
Signature: def parse_orders(lines: Iterable[str], rejects: list[tuple[int, str]]) -> Iterator[dict]:
Constraints: Files can be several GB: do not read the whole file into a list. lines may be an open file object.
Examples:
parse_orders(['{"order_id": "A1", "amount_paise": 49900, "city": " pune "}'], rejects)→yields {'order_id': 'A1', 'amount_paise': 49900, 'city': 'Pune'}; rejects == []a line '{"order_id": "A2", "amount_paise": true, "city": "Goa"}' at line 3→rejects gets (3, 'bad amount_paise')a line 'not json' at line 1→rejects gets (1, 'invalid json')
Hints:
enumerate(lines, start=1)gives you line numbers that count blank lines too.json.loadsraisesjson.JSONDecodeError(aValueError) on bad input, and can return a list or a number, not only a dict.isinstance(True, int)isTrue. How do you reject booleans?
Reference solution:
import json
from collections.abc import Iterable, Iterator
FIELDS = ("order_id", "amount_paise", "city")
def parse_orders(lines: Iterable[str], rejects: list[tuple[int, str]]) -> Iterator[dict]:
"""Yield clean order dicts; append (line_number, reason) for bad lines to `rejects`."""
for line_no, line in enumerate(lines, start=1):
if not line.strip():
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
rejects.append((line_no, "invalid json"))
continue
if not isinstance(data, dict):
rejects.append((line_no, "not an object"))
continue
missing = [f for f in FIELDS if f not in data]
if missing:
rejects.append((line_no, f"missing {missing[0]}"))
continue
order_id, amount, city = data["order_id"], data["amount_paise"], data["city"]
if not isinstance(order_id, str) or not order_id.strip():
rejects.append((line_no, "bad order_id"))
elif isinstance(amount, bool) or not isinstance(amount, int) or amount < 0:
rejects.append((line_no, "bad amount_paise"))
elif not isinstance(city, str):
rejects.append((line_no, "bad city"))
else:
yield {"order_id": order_id, "amount_paise": amount, "city": city.strip().title()}
Complexity: O(total characters) time; O(1) extra memory per good record because it is a generator, plus the size of
rejects (bounded by the number of bad lines).
Tests:
import io
feed = io.StringIO(
'{"order_id": "A1", "amount_paise": 49900, "city": " pune ", "promo": "DIWALI"}\n'
'\n'
'{"order_id": "A2", "amount_paise": true, "city": "Goa"}\n'
'not json\n'
'[1, 2, 3]\n'
'{"order_id": "A3", "city": "Delhi"}\n'
'{"order_id": "", "amount_paise": 100, "city": "Delhi"}\n'
'{"order_id": "A4", "amount_paise": 99.5, "city": "Delhi"}\n'
'{"order_id": "A5", "amount_paise": -1, "city": "Delhi"}\n'
'{"order_id": "A6", "amount_paise": 0, "city": 7}\n'
'{"order_id": "A7", "amount_paise": 0, "city": "navi mumbai"}\n'
)
rejects = []
gen = parse_orders(feed, rejects)
assert iter(gen) is gen # a generator / iterator, not a list
good = list(gen)
assert good == [
{"order_id": "A1", "amount_paise": 49900, "city": "Pune"},
{"order_id": "A7", "amount_paise": 0, "city": "Navi Mumbai"},
], good
assert rejects == [
(3, "bad amount_paise"),
(4, "invalid json"),
(5, "not an object"),
(6, "missing amount_paise"),
(7, "bad order_id"),
(8, "bad amount_paise"),
(9, "bad amount_paise"),
(10, "bad city"),
], rejects
# Lazy: nothing is read until the generator is advanced.
def lines():
yield '{"order_id": "B1", "amount_paise": 1, "city": "Agra"}'
raise RuntimeError("should not read this far")
out = []
first = next(parse_orders(lines(), out))
assert first["order_id"] == "B1" and out == []
A common wrong solution (the tests catch it):
import json
def parse_orders(lines, rejects):
for line_no, line in enumerate(lines, start=1):
if not line.strip():
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
rejects.append((line_no, "invalid json"))
continue
if not isinstance(data, dict):
rejects.append((line_no, "not an object"))
continue
missing = [f for f in ("order_id", "amount_paise", "city") if f not in data]
if missing:
rejects.append((line_no, f"missing {missing[0]}"))
continue
order_id, amount, city = data["order_id"], data["amount_paise"], data["city"]
if not isinstance(order_id, str) or not order_id.strip():
rejects.append((line_no, "bad order_id"))
elif not isinstance(amount, int) or amount < 0: # lets True through
rejects.append((line_no, "bad amount_paise"))
elif not isinstance(city, str):
rejects.append((line_no, "bad city"))
else:
yield {"order_id": order_id, "amount_paise": amount, "city": city.strip().title()}
Alternatives and tradeoffs: A Pydantic model with StrictInt / Field(ge=0) validates the same rules declaratively and gives structured
error messages; it is the better choice when the schema grows. pandas read_json(lines=True) is convenient
for clean files but fails or coerces on bad lines instead of quarantining them. For very large files, write the
rejects to a separate "dead-letter" file instead of keeping them in a list.
Follow-ups:
- "Why is
isinstance(True, int)true, and why does it matter here?"boolis a subclass ofint, soTruepasses anintcheck and would be loaded as an amount of 1 paisa. Check forboolfirst, or usetype(x) is int, or a strict validator. - "The partner sends the file in two parts and the second has a different encoding. How would that show up?" Opening with the wrong encoding raises
UnicodeDecodeErrorwhile iterating, which this function does not catch: the whole run fails mid-file. Open with an explicitencoding="utf-8"and decide whether to fail the file or useerrors="replace"and quarantine lines containing the replacement character. - "How would you alert on a bad feed, rather than silently quarantining 40% of it?" Count good and rejected lines, log both, and fail the run (or page someone) when the reject rate crosses a threshold, for example above 1%.
Scoring: Strong: A real generator, correct line numbering with blank lines, rejects non-object JSON and booleans unprompted,
and suggests a reject-rate threshold. Acceptable: Works on the main cases; misses the boolean or the "not an object" case until a test or hint shows it. Red flag: Reads the whole file with readlines() or wraps everything in a bare except: that hides which rule failed.
JR-DE-O01 · Epoch timestamps in milliseconds read as the wrong unit
Question: Click events from a mobile app arrive with event_ms, a Unix timestamp in milliseconds (what JavaScript's
Date.now() and most Kafka producers emit). What does this print?
from datetime import datetime, timezone
import pandas as pd
event_ms = 1775039400000 # from a mobile app's click event, in milliseconds
print(pd.to_datetime(event_ms))
print(pd.to_datetime(event_ms, unit="ms"))
print(pd.to_datetime(event_ms, unit="ms", utc=True).tz_convert("Asia/Kolkata"))
try:
datetime.fromtimestamp(event_ms, tz=timezone.utc)
except ValueError as exc:
print("ValueError:", exc)
print(datetime.fromtimestamp(event_ms / 1000, tz=timezone.utc))
print(pd.to_datetime(pd.Series([event_ms // 1000]), unit="s").iloc[0])
Expected output:
1970-01-01 00:29:35.039400
2026-04-01 10:30:00
2026-04-01 16:00:00+05:30
ValueError: year 58218 is out of range
2026-04-01 10:30:00+00:00
2026-04-01 10:30:00
The trap:
"The first line is 1 April 2026." Tempting because a big integer "obviously" is a timestamp, and the code runs without any error.
Real-world bug: a pipeline loads a month of click events and every one of them lands on 1 January 1970. The daily partitions for April are empty, the 1970 partition is huge, and the dashboard shows zero traffic. No exception was raised anywhere.
Explanation:
An epoch timestamp is only a number; the unit is a separate fact you have to know.
pd.to_datetime(int)assumes nanoseconds (pandas' internal unit). 1,775,039,400,000 ns is about 1,775 seconds, i.e. 29 minutes and 35 seconds after midnight on 1 January 1970. No error, just a wrong date.unit="ms"gives the right instant: 10:30 UTC on 1 April 2026. The result is naive (no time zone), but epoch time is defined in UTC.utc=Truemakes it timezone-aware in UTC, andtz_convert("Asia/Kolkata")shows it in IST: 16:00, five and a half hours later. Use the IST version for "which business day was this?" questions.- The standard library's
datetime.fromtimestampexpects seconds. Given milliseconds it computes a date about 58,000 years in the future and raisesValueError(this one, at least, fails loudly). Divide by 1000. - Integer division to seconds with
unit="s"gives the same instant, losing the sub-second part.
Takeaway: always state the unit (unit="ms" / "s"), make the result UTC-aware, and sanity-check the date
range after parsing (for example "no events before 2020").
Follow-ups:
- "How can you tell seconds from milliseconds just by looking at the number?" Current timestamps in seconds have 10 digits (about 1.7–1.8 billion in 2026); in milliseconds, 13 digits;
microseconds, 16; nanoseconds, 19. A quick
len(str(x))or a range check catches a wrong unit, but the producer's documentation is the real answer. - "Why use
utc=Trueinstead of leaving the timestamps naive?" A naive value doesn't say which zone it is in. Someone later callstz_localize("Asia/Kolkata")on it, and every event shifts by 5:30. Aware UTC values compare and convert correctly everywhere. - "What check would you add to the pipeline?" After parsing, assert the min and max timestamps fall in an expected window (e.g. within the last few days for a daily job) and fail the run otherwise. It also catches producers that switch units without telling anyone.
Scoring: Strong: All six lines; knows pandas defaults to nanoseconds and fromtimestamp to seconds, converts to UTC-aware and then
IST, and proposes a range check. Acceptable: Spots that the first line is wrong and that unit="ms" fixes it; unsure about the fromtimestamp error or the
IST conversion. Red flag: Fixes the dates by adding a constant offset, or converts to IST by calling tz_localize("Asia/Kolkata") on the UTC
values.
JR-DE-O02 · read_csv turns a real country code into a missing value
Question: An export from a Mumbai spice exporter lists orders by destination country. NA is the ISO code for Namibia,
a real market for them. What does this print on pandas 2.2?
import io
import pandas as pd
csv_text = """order_id,country,qty,coupon
A1,IN,3,DIWALI10
A2,NA,,
A3,IN,5,null
"""
df = pd.read_csv(io.StringIO(csv_text))
print(df.dtypes.astype(str).to_dict())
print(df["country"].isna().sum(), df["coupon"].isna().sum(), df["qty"].sum())
print(df.groupby("country")["qty"].sum().to_dict())
fixed = pd.read_csv(io.StringIO(csv_text), dtype={"qty": "Int64"}, keep_default_na=False,
na_values={"qty": [""], "coupon": ["", "null"]})
print(fixed.dtypes.astype(str).to_dict())
print(fixed["country"].tolist(), fixed["qty"].tolist(), fixed["qty"].sum())
print(fixed.groupby("country")["qty"].sum().to_dict())
Expected output:
{'order_id': 'object', 'country': 'object', 'qty': 'float64', 'coupon': 'object'}
1 2 8.0
{'IN': 8.0}
{'order_id': 'object', 'country': 'object', 'qty': 'Int64', 'coupon': 'object'}
['IN', 'NA', 'IN'] [3, <NA>, 5] 8
{'IN': 8, 'NA': 0}
The trap:
"country is ['IN', 'NA', 'IN'] and qty is an integer column." Tempting because the file plainly contains the
text NA, and pandas usually reads text as text.
Real-world bug: the country-wise export report silently drops Namibia: its orders are "missing country", and
groupby leaves NaN keys out of the result by default. Nobody notices until the Namibian distributor asks why
their volumes are not in the quarterly review.
Explanation:
read_csv treats a list of strings as missing values by default: "", NA, N/A, NaN, NULL, null,
None, n/a, nan, <NA>, #N/A and a few more. So:
- Namibia's
NAbecomesNaN(1 missing country). The empty and thenullcoupon both becomeNaN(2), which is what you'd want there. qtyhas an empty cell. A NumPyint64column can't hold a missing value, so pandas reads the column asfloat64:3.0, NaN, 5.0, sum8.0.groupbydrops rows whose key is NaN (dropna=Trueby default), so the result only hasIN.
The fixed read turns the default list off (keep_default_na=False) and declares per column what counts as missing
(na_values), so NA stays a country. "Int64" (capital I) is pandas' nullable integer dtype: the gap is
<NA> and the other values stay integers, so the sum is the integer 8. Namibia now appears in the group-by, with
0 because sum skips the missing quantity.
Takeaway: the default missing-value list is a guess about your data. For pipeline inputs, say which values mean "missing" in which columns, and give integer columns with gaps a nullable dtype.
Follow-ups:
- "Would you keep
NAfor Namibia by quoting it in the file, as"NA"?" No: the csv parser removes the quotes before the missing-value check, so"NA"is still read as missing.keep_default_na=False(or a per-columnna_values) is the fix. - "What does
groupby(..., dropna=False)change?" Rows with a missing key are kept as their own group (shown as NaN). It's a good habit in data-quality checks, because totals then add up to the ungrouped total. - "How would you catch this kind of silent change in a pipeline?" Check counts after loading: number of rows, nulls per column, and the set of distinct codes compared with a reference list (ISO country codes). A new null in a column that should never be null is a red flag.
Scoring: Strong: All six lines; knows NA is in the default missing list, explains the int → float change and the dropped NaN group,
and describes keep_default_na/na_values and Int64. Acceptable: Gets the float column and the sum; surprised by NA becoming NaN but explains the fix once told. Red flag: Patches it afterwards with fillna("NA") on the country column (which also labels genuinely missing countries
as Namibia), or doesn't know missing values change a column's dtype.
JR-DS-C01 · Target-mean encoding fitted on train only
Problem:
You are predicting whether a loan applicant defaults (defaulted is 0 or 1). The branch column has hundreds
of values, too many for one-hot encoding, so you replace each branch with the mean default rate of that branch
("target-mean encoding").
Write mean_encode(train, test, col, target, smoothing=0.0) that returns two Series, one for train and one
for test:
- The per-category statistics are computed from
trainonly. - With smoothing
m, a category withntraining rows and target sumsis encoded as(s + m * g) / (n + m), wheregis the global mean of the target intrain. Withm = 0this is the plain category mean. - Categories not seen in
train(including missing values) getg. - Returned Series keep the index of the frame they belong to, are named
f"{col}_te", and arefloat64. - Do not modify
trainortest.
Signature: def mean_encode(train: pd.DataFrame, test: pd.DataFrame, col: str, target: str, smoothing: float = 0.0) -> tuple[pd.Series, pd.Series]:
Constraints: Up to 10^6 training rows: no Python loops over rows. smoothing >= 0.
Examples:
train branches A, A, B with defaulted 1, 0, 0; test branches B, C→train → [0.5, 0.5, 0.0]; test → [0.0, 0.3333...] (C is unseen, gets the global mean 1/3)same, smoothing=2→A → (1 + 2/3) / 4 = 0.41666..., B → (0 + 2/3) / 3 = 0.2222...
Hints:
train.groupby(col)[target].agg(['sum', 'count'])gives yousandnper category in one pass.Series.map(stats)looks each value up in a Series indexed by category; what does it return for a key that isn't there?- Which DataFrame is allowed to influence the numbers, and why?
Reference solution:
import pandas as pd
def mean_encode(
train: pd.DataFrame, test: pd.DataFrame, col: str, target: str, smoothing: float = 0.0
) -> tuple[pd.Series, pd.Series]:
"""Encode `col` by the (smoothed) mean of `target`, learned from `train` only."""
if smoothing < 0:
raise ValueError("smoothing must be >= 0")
global_mean = float(train[target].mean())
stats = train.groupby(col)[target].agg(["sum", "count"])
encoding = (stats["sum"] + smoothing * global_mean) / (stats["count"] + smoothing)
def apply(frame: pd.DataFrame) -> pd.Series:
encoded = frame[col].map(encoding).fillna(global_mean).astype("float64")
return encoded.rename(f"{col}_te")
return apply(train), apply(test)
Complexity: O(n + m) time for n training rows and m test rows (one groupby, two hash lookups via map); O(k) extra space for
k categories plus the two output Series.
Tests:
import math
train = pd.DataFrame({"branch": ["A", "A", "B"], "defaulted": [1, 0, 0]}, index=[10, 11, 12])
test = pd.DataFrame({"branch": ["B", "C", None]}, index=[7, 8, 9])
train_copy, test_copy = train.copy(), test.copy()
tr, te = mean_encode(train, test, "branch", "defaulted")
assert tr.tolist() == [0.5, 0.5, 0.0]
assert te.iloc[0] == 0.0
assert math.isclose(te.iloc[1], 1 / 3) and math.isclose(te.iloc[2], 1 / 3) # unseen and missing → global
assert list(tr.index) == [10, 11, 12] and list(te.index) == [7, 8, 9]
assert tr.name == te.name == "branch_te" and te.dtype == "float64"
assert train.equals(train_copy) and test.equals(test_copy)
tr2, te2 = mean_encode(train, test, "branch", "defaulted", smoothing=2)
assert math.isclose(tr2.iloc[0], (1 + 2 / 3) / 4)
assert math.isclose(te2.iloc[0], (0 + 2 / 3) / 3)
# Test rows must not change the encoding, even if test has the target column.
leaky_test = pd.DataFrame({"branch": ["B", "B"], "defaulted": [1, 1]})
_, te3 = mean_encode(train, leaky_test, "branch", "defaulted")
assert te3.tolist() == [0.0, 0.0]
try:
mean_encode(train, test, "branch", "defaulted", smoothing=-1)
raise AssertionError("expected ValueError")
except ValueError:
pass
A common wrong solution (the tests catch it):
import pandas as pd
def mean_encode(train, test, col, target, smoothing=0.0):
if smoothing < 0:
raise ValueError("smoothing must be >= 0")
global_mean = float(train[target].mean())
stats = train.groupby(col)[target].agg(["sum", "count"])
encoding = (stats["sum"] + smoothing * global_mean) / (stats["count"] + smoothing)
tr = train[col].map(encoding).rename(f"{col}_te")
te = test[col].map(encoding).rename(f"{col}_te") # unseen categories stay NaN
return tr, te
Alternatives and tradeoffs: scikit-learn ≥ 1.3 has TargetEncoder, which also uses cross-fitting: each training row is encoded with
statistics computed on the other folds, so a row's own label never leaks into its feature. The plain version
here leaks a little on train (a branch with one applicant gets exactly that applicant's label), which inflates
training scores; cross-fitting or strong smoothing reduces it. One-hot encoding is fine for a few dozen categories;
frequency encoding is a leak-free option when the target isn't needed.
Follow-ups:
- "Why must the statistics come from train only?" The test set stands in for future data whose labels you won't have. Using test labels (or computing stats on train + test) lets the answer leak into the feature, so the validation score is better than what production will see.
- "A branch with one applicant who defaulted gets an encoding of 1.0 on train. What problem does that cause, and how does smoothing help?" The feature effectively is the label for that row, so the model learns to trust it too much. Smoothing pulls small categories towards the global mean: with m = 10, one default gives (1 + 10g) / 11, close to g.
- "How would you use this in production for a new applicant?" Save the fitted
encodingSeries andglobal_meanwith the model (e.g. as JSON), and apply the samemapplusfillnaat inference. Recomputing from live data would change the feature's meaning.
Scoring: Strong: Vectorised groupby + map, handles unseen and missing categories, keeps index and name, fits on train only and
explains why; mentions train-side leakage and cross-fitting or smoothing. Acceptable: Correct plain encoding; forgets the unseen-category fallback or the index until a test shows it. Red flag: Computes statistics on the combined train and test data, or loops over rows with iterrows().
JR-DS-C02 · Per-user conversion summary for an A/B test
Problem:
A food-delivery app tested a new checkout page. The event log has one row per page view: user_id,
variant ("A" or "B"), and converted (1 if that view ended in an order, else 0). Users can view the page
many times.
Return a summary at the user level:
- A user counts as converted if any of their views converted.
- A user who appears in both variants (an assignment bug, say after reinstalling the app) is excluded from both; return how many were excluded.
- The summary is a DataFrame indexed by variant (sorted), with integer columns
usersandconversionsand a float columnrate = conversions / users.
Signature: def ab_summary(events: pd.DataFrame) -> tuple[pd.DataFrame, int]:
Constraints: Up to 10^7 events: use groupby, not Python loops. converted may be stored as int or bool.
Examples:
u1 (A) views twice, converts on the second; u2 (A) views once, no order; u3 (B) converts once→A: users 2, conversions 1, rate 0.5; B: users 1, conversions 1, rate 1.0; excluded 0u4 has one view in A and one in B→u4 counted in neither variant; excluded 1
Hints:
- First reduce to one row per user. Which aggregation turns several 0/1 values into 'converted at least once'?
nuniqueon the variant column per user tells you who saw both.- Only then group by variant.
Reference solution:
import pandas as pd
def ab_summary(events: pd.DataFrame) -> tuple[pd.DataFrame, int]:
"""User-level conversion per variant, excluding users exposed to both variants."""
per_user = events.groupby("user_id").agg(
n_variants=("variant", "nunique"),
variant=("variant", "first"),
converted=("converted", "max"),
)
mixed = per_user["n_variants"] > 1
clean = per_user[~mixed]
summary = clean.groupby("variant").agg(
users=("converted", "size"),
conversions=("converted", "sum"),
).sort_index()
summary = summary.astype({"users": "int64", "conversions": "int64"})
summary["rate"] = summary["conversions"] / summary["users"]
return summary, int(mixed.sum())
Complexity: O(n) expected time for n events (hash-based groupby), O(u) extra space for u users.
Tests:
events = pd.DataFrame({
"user_id": ["u1", "u1", "u2", "u3", "u4", "u4", "u5", "u5", "u5"],
"variant": ["A", "A", "A", "B", "A", "B", "B", "B", "B"],
"converted": [0, 1, 0, 1, 1, 0, 0, 0, 1],
})
summary, excluded = ab_summary(events)
assert excluded == 1
assert list(summary.index) == ["A", "B"]
assert summary.loc["A", "users"] == 2 and summary.loc["A", "conversions"] == 1
assert summary.loc["B", "users"] == 2 and summary.loc["B", "conversions"] == 2
assert summary.loc["A", "rate"] == 0.5 and summary.loc["B", "rate"] == 1.0
assert str(summary["users"].dtype) == "int64" and str(summary["conversions"].dtype) == "int64"
# bool column and variants listed out of order
ev2 = pd.DataFrame({"user_id": ["x", "y", "y"], "variant": ["B", "A", "A"],
"converted": [False, True, True]})
s2, ex2 = ab_summary(ev2)
assert ex2 == 0 and list(s2.index) == ["A", "B"]
assert s2["conversions"].tolist() == [1, 0] and s2["rate"].tolist() == [1.0, 0.0]
A common wrong solution (the tests catch it):
import pandas as pd
def ab_summary(events):
# counts page views, not users
summary = events.groupby("variant").agg(users=("user_id", "size"),
conversions=("converted", "sum")).sort_index()
summary = summary.astype({"users": "int64", "conversions": "int64"})
summary["rate"] = summary["conversions"] / summary["users"]
return summary, 0
Alternatives and tradeoffs: SQL: a CTE with GROUP BY user_id HAVING COUNT(DISTINCT variant) = 1 and MAX(converted), then group by
variant. In pandas you could also drop mixed users with groupby("user_id")["variant"].transform("nunique") == 1
on the raw events first. Either way, the important decision is the unit of analysis: users were randomised, so
rates must be per user.
Follow-ups:
- "Why is the per-view conversion rate misleading here?" Variants were assigned per user, and users who browse a lot (often the undecided ones) contribute many views without converting. A variant that makes people view the page more times would look worse per view even if more users ordered.
- "B's rate is 0.12 and A's is 0.10. Can you ship B?" Not from the rates alone: you need sample sizes and a significance test (a two-proportion z-test or a chi-square test), plus a check that the split is about 50/50 as designed (a "sample ratio mismatch" means the assignment is broken).
- "Excluded users are 8% of the total. What do you do?" Stop and investigate before reading the results: that many cross-over users means the assignment is unstable, and excluding them may bias the comparison (for example, if reinstalls happen more often in one variant).
Scoring: Strong: Reduces to one row per user first, uses max for "converted at least once", excludes cross-over users, and
explains why the unit of analysis matters. Acceptable: Gets the per-user reduction with a hint; forgets the cross-over users or the integer dtypes. Red flag: Reports events.groupby("variant")["converted"].mean() as the conversion rate and sees no problem.
JR-DS-O01 · A left join that inflates revenue
Question: An analyst enriches orders with each customer's city before a revenue-by-city chart. The customer table was
built by appending address updates, so customer C1 (who moved from Pune to Mumbai) appears twice. What does
this print?
import pandas as pd
orders = pd.DataFrame({"order_id": [101, 102, 103],
"customer_id": ["C1", "C2", "C1"],
"amount": [500, 1200, 300]})
customers = pd.DataFrame({"customer_id": ["C1", "C2", "C1"],
"city": ["Pune", "Delhi", "Mumbai"]}) # C1 moved; old row kept
merged = orders.merge(customers, on="customer_id", how="left")
print(len(orders), len(merged))
print(orders["amount"].sum(), merged["amount"].sum())
print(merged.groupby("city")["amount"].sum().to_dict())
try:
orders.merge(customers, on="customer_id", how="left", validate="many_to_one")
except pd.errors.MergeError as exc:
print("MergeError:", exc)
Expected output:
3 5
2000 2800
{'Delhi': 1200, 'Mumbai': 800, 'Pune': 800}
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge
The trap:
"A left join keeps the left table's rows, so merged has 3 rows and the total is still 2000." Tempting because
"left join = one output row per left row" is how many people remember it.
Real-world bug: a revenue dashboard over-reports by exactly the orders of customers with duplicate profile rows; the number looks plausible, and it grows a little every time a customer updates their address.
Explanation:
A join produces one output row for every matching pair of rows. A left join only guarantees that every left
row appears at least once (with NaN if nothing matches). Orders 101 and 103 belong to C1, which matches two
customer rows, so each of them appears twice: 1 + 2 + 2 = 5 rows.
The duplicated order amounts are summed twice: 2000 + 500 + 300 = 2800. The per-city sums show the same orders counted under both Pune and Mumbai (500 + 300 = 800 in each).
validate="many_to_one" tells pandas what you expect: the right side has unique keys. It raises a
MergeError instead of silently multiplying rows. The real fix is upstream: deduplicate customers to one row per
id (for example, the latest address) before joining.
Takeaway: after every join, check the row count (or pass validate=); a changed row count is the cheapest bug
detector you have.
Follow-ups:
- "How would you keep only each customer's latest city?" If there's an
updated_atcolumn:customers.sort_values("updated_at").drop_duplicates("customer_id", keep="last"). Without one, the "latest" is unknowable and that is the real problem to raise with whoever owns the table. - "What do
one_to_one,one_to_manyandmany_to_manycheck?"one_to_one: keys unique on both sides;one_to_many: unique on the left;many_to_one: unique on the right;many_to_many: no check. Use the strictest one that should hold. - "Should the analysis use the customer's city now, or at the time of the order?" Usually at the time of the order: revenue from 101 was earned in Pune. That needs the address history with valid-from/valid-to dates and a join on the order date, not a plain join on the id.
Scoring: Strong: All four lines; explains join cardinality (one row per matching pair) and suggests validate= or a row-count
check, and raises the "city at order time" question. Acceptable: Gets 5 rows after a moment of tracing; knows to deduplicate the right table; unsure what validate does. Red flag: Fixes the total with merged.drop_duplicates() (which keeps both cities' rows, because they differ) or with
.mean(), without understanding where the extra rows came from.
JR-DS-O02 · Indian DD/MM dates through pd.to_datetime
Question: A sales sheet from the Chennai team writes dates the Indian way, DD/MM/YYYY. You want day-of-week features.
What does this print on pandas 2.2?
import pandas as pd
raw = pd.Series(["03/04/2026", "11/04/2026", "25/04/2026"])
try:
pd.to_datetime(raw)
except ValueError as exc:
print("ValueError:", str(exc).split(". You")[0])
first_two = pd.to_datetime(raw[:2])
print(first_two.dt.strftime("%d %b").tolist())
ok = pd.to_datetime(raw, dayfirst=True)
print(ok.dt.strftime("%d %b").tolist())
print(ok.dt.dayofweek.tolist(), ok.dt.day_name().tolist())
print(pd.to_datetime(raw, format="%d/%m/%Y").equals(ok))
Expected output:
ValueError: time data "25/04/2026" doesn't match format "%m/%d/%Y", at position 2
['04 Mar', '04 Nov']
['03 Apr', '11 Apr', '25 Apr']
[4, 5, 5] ['Friday', 'Saturday', 'Saturday']
True
The trap:
"All three parse as April dates." Or: "pandas looks at 25 and realises the column is day-first." Tempting
because a human reading the column immediately sees the format.
Real-world bug: a month's data where every date happens to have a day ≤ 12 parses without any error as the wrong dates, so "day of week" and "month" features are silently wrong, and a weekly seasonality model learns noise. The crash only appears later, on a file that contains the 13th.
Explanation:
In pandas 2.x, to_datetime infers one format from the first value and applies it to the whole column.
03/04/2026 is ambiguous; the default (dayfirst=False) reading is US-style month first, so it picks
%m/%d/%Y. Then 25/04/2026 has no month 25, so the call raises and points at position 2.
With only the first two values, nothing fails: they parse as 4 March and 4 November. This is the dangerous case, because it's silent.
dayfirst=True gives 3, 11 and 25 April. dt.dayofweek counts Monday = 0, so Friday is 4 and Saturday is 5.
An explicit format="%d/%m/%Y" gives the same result and is the most robust choice: no guessing, and anything
that doesn't match fails loudly.
Takeaway: never let a date column's format be guessed; pass format= (or at least dayfirst=True) and check a
few parsed values against the raw strings.
Follow-ups:
- "How would you handle a column with a few unparseable values, like
'TBD'?"pd.to_datetime(col, format="%d/%m/%Y", errors="coerce")turns them intoNaT. Then count the NaTs and decide; silently coercing 30% of a column to NaT is as bad as a wrong format. - "Which date features would you build for daily sales, and which one is easy to get wrong?" Day of week, weekend flag, month, day of month, and holiday or festival flags (Diwali, Pongal and Onam move
every year, so they need a calendar table). Easy to get wrong:
dayofweekstarting at Monday = 0, and "week of year" around year boundaries (dt.isocalendar().week). - "The timestamps are actually
'2026-04-03 23:30'in UTC and the business runs on IST. What changes?" Localise and convert before extracting features:.dt.tz_localize("UTC").dt.tz_convert("Asia/Kolkata"). 23:30 UTC is 05:00 the next day in IST, so the day of week changes.
Scoring: Strong: All five lines, explains the format inference from the first value, notes the silent MM/DD case and Monday = 0,
and recommends an explicit format=. Acceptable: Knows about dayfirst=True and that the default is month-first; unsure whether the call raises or what the
day numbers are. Red flag: Says pandas "figures out" date formats reliably, or only checks that the parse didn't raise.
JR-MLE-C01 · Build a model's feature vector from a JSON request
Problem:
An inference endpoint receives JSON such as {"income": 52000, "age": 31, "tenure_months": 14} and must turn it
into the list of floats the model was trained on, in the training column order (the "schema").
Rules:
- The output follows
schemaorder, never the order of keys in the request. - A feature missing from the request uses
defaults[name]if there is one; otherwise raiseValueError("missing feature: <name>"). - A key that is not in the schema raises
ValueError("unknown feature: <name>"): it is usually a typo such as"incme", and silently ignoring it would score the request with the default instead. - Values must be real numbers:
intorfloat, but notbool, and finite (no NaN or infinity). Otherwise raiseValueError("bad value for <name>"). - Return floats.
If several problems exist, report the first one found when checking unknown keys first, then the schema in order.
Signature: def build_features(payload: dict[str, object], schema: list[str], defaults: dict[str, float]) -> list[float]:
Examples:
build_features({'income': 52000, 'age': 31}, ['age', 'income', 'tenure_months'], {'tenure_months': 0.0})→[31.0, 52000.0, 0.0]build_features({'age': 31, 'incme': 52000}, ['age', 'income'], {})→ValueError: unknown feature: incmebuild_features({'age': True, 'income': 1}, ['age', 'income'], {})→ValueError: bad value for age
Hints:
- Loop over
schema, not overpayload. set(payload) - set(schema)finds unknown keys, but sets have no order; how do you report the first one deterministically?json.loads('NaN')returns a float NaN, so a JSON client can send one.math.isfiniterejects it.
Reference solution:
import math
def build_features(payload: dict[str, object], schema: list[str], defaults: dict[str, float]) -> list[float]:
"""Return the feature vector in training order, validating the request."""
allowed = set(schema)
for key in payload:
if key not in allowed:
raise ValueError(f"unknown feature: {key}")
vector: list[float] = []
for name in schema:
if name in payload:
value = payload[name]
elif name in defaults:
value = defaults[name]
else:
raise ValueError(f"missing feature: {name}")
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError(f"bad value for {name}")
vector.append(float(value))
return vector
Complexity: O(f + k) for f schema features and k request keys, using a set for the unknown-key check.
Tests:
schema = ["age", "income", "tenure_months"]
defaults = {"tenure_months": 0.0}
# Request key order differs from the schema.
assert build_features({"income": 52000, "age": 31, "tenure_months": 14}, schema, defaults) == [31.0, 52000.0, 14.0]
assert build_features({"income": 52000, "age": 31}, schema, defaults) == [31.0, 52000.0, 0.0]
assert all(type(x) is float for x in build_features({"income": 1, "age": 2}, schema, defaults))
def error(payload):
try:
build_features(payload, schema, defaults)
except ValueError as exc:
return str(exc)
return None
assert error({"income": 52000}) == "missing feature: age"
assert error({"age": 31, "incme": 52000}) == "unknown feature: incme"
assert error({"age": True, "income": 1}) == "bad value for age"
assert error({"age": "31", "income": 1}) == "bad value for age"
assert error({"age": 31, "income": float("nan")}) == "bad value for income"
assert error({"age": 31, "income": float("inf")}) == "bad value for income"
assert error({"age": 31, "income": None}) == "bad value for income"
A common wrong solution (the tests catch it):
import math
def build_features(payload, schema, defaults):
for key in payload:
if key not in schema:
raise ValueError(f"unknown feature: {key}")
merged = {**defaults, **payload}
for name in schema:
if name not in merged:
raise ValueError(f"missing feature: {name}")
value = merged[name]
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValueError(f"bad value for {name}")
return [float(v) for v in merged.values()] # dict order, not schema order
Alternatives and tradeoffs: A Pydantic model with one typed field per feature (model_config = ConfigDict(extra="forbid"), StrictFloat
or allow_inf_nan=False) gives the same validation with structured 422 errors in FastAPI, and you then build the
vector with [getattr(m, name) for name in SCHEMA]. The schema list itself should be saved with the model at
training time (for example from X_train.columns), not typed by hand in the service.
Follow-ups:
- "Why not just do
list(payload.values())?" JSON object key order is whatever the client sent. Two clients sending the same data in different orders would get different predictions, with no error anywhere. - "Where should
schemacome from?" From the training run: savelist(X_train.columns)with the model artefact and load it with the model, so the service can never drift from what the model was trained on. - "Is it right to fill a missing feature with a default?" Only if the training data was filled the same way (same default, same rule). Otherwise the model sees a value distribution it never trained on. Defaults belong to the preprocessing step that was fitted with the model.
Scoring: Strong: Iterates in schema order, rejects unknown keys, booleans and NaN without prompting, and says the schema should
be saved with the model. Acceptable: Correct order and missing-feature handling; needs a hint for booleans or NaN. Red flag: Builds the vector from payload.values(), or silently ignores unknown keys.
JR-MLE-C02 · A deterministic train/validation split by user
Problem:
A recommendation model is trained on interaction rows ({"user_id": ..., "item_id": ..., ...}); each user has
many rows. A random row-level split puts the same user in both train and validation, so validation measures how
well the model remembers users it has already seen, not how it does on new ones.
Write split_by_user(rows, val_fraction, salt="") that returns (train, val):
- All rows of a user go to the same side.
- The assignment is deterministic across runs, machines and Python processes: the same user and salt always land on the same side, without storing a lookup table.
- Adding new users to the data never moves existing users to the other side.
- About
val_fractionof the users go to validation;0means none and1means all. - Rows keep their original relative order on each side.
val_fractionoutside[0, 1]raisesValueError. - Changing
saltgives a different (but again deterministic) split, for a fresh validation set.
Signature: def split_by_user(rows: list[dict], val_fraction: float, salt: str = "") -> tuple[list[dict], list[dict]]:
Constraints: Up to 10^7 rows; one pass, no sorting of the whole dataset, no randomness from random or NumPy.
Examples:
split_by_user(rows, 0.2) where rows has 3 rows for each of 1000 users→about 200 users (600 rows) in val, the rest in train; no user in bothsame call twice, or in a new Python process→identical result
Hints:
- You need a function from
user_idto a number in [0, 1) that is stable everywhere. Is the built-inhash()stable across processes for strings? hashlib.sha256(text.encode()).digest()is stable; turn its first 8 bytes into an integer withint.from_bytes.- Compare that number with
val_fraction.
Reference solution:
import hashlib
def _bucket(user_id: object, salt: str) -> float:
"""Map a user id to a stable number in [0, 1)."""
digest = hashlib.sha256(f"{salt}:{user_id}".encode("utf-8")).digest()
return int.from_bytes(digest[:8], "big") / 2**64
def split_by_user(rows: list[dict], val_fraction: float, salt: str = "") -> tuple[list[dict], list[dict]]:
"""Split rows so that every user is entirely in train or entirely in validation."""
if not 0.0 <= val_fraction <= 1.0:
raise ValueError("val_fraction must be between 0 and 1")
train: list[dict] = []
val: list[dict] = []
cache: dict[object, bool] = {}
for row in rows:
uid = row["user_id"]
if uid not in cache:
cache[uid] = _bucket(uid, salt) < val_fraction
(val if cache[uid] else train).append(row)
return train, val
Complexity: O(n) time for n rows (one hash per distinct user thanks to the cache), O(u) extra space for u distinct users plus the output lists.
Tests:
rows = [{"user_id": f"u{u:04d}", "item_id": i} for u in range(1000) for i in range(3)]
train, val = split_by_user(rows, 0.2)
train_users = {r["user_id"] for r in train}
val_users = {r["user_id"] for r in val}
assert not train_users & val_users # no user on both sides
assert len(train) + len(val) == len(rows)
assert 150 <= len(val_users) <= 250, len(val_users) # about 20% of users
assert [r for r in rows if r in val] == val # original order kept
assert split_by_user(rows, 0.2) == (train, val) # deterministic
# New users don't move existing ones.
more = rows + [{"user_id": f"n{u}", "item_id": 0} for u in range(500)]
_, val_more = split_by_user(more, 0.2)
assert {r["user_id"] for r in val_more if r["user_id"].startswith("u")} == val_users
# A bigger fraction only adds users to validation.
_, val_30 = split_by_user(rows, 0.3)
assert val_users <= {r["user_id"] for r in val_30}
assert split_by_user(rows, 0.0)[1] == [] and split_by_user(rows, 1.0)[0] == []
assert {r["user_id"] for r in split_by_user(rows, 0.2, salt="v2")[1]} != val_users
for bad in (-0.1, 1.5):
try:
split_by_user(rows, bad)
raise AssertionError("expected ValueError")
except ValueError:
pass
A common wrong solution (the tests catch it):
def split_by_user(rows, val_fraction, salt=""):
if not 0.0 <= val_fraction <= 1.0:
raise ValueError("val_fraction must be between 0 and 1")
every = round(1 / val_fraction) if val_fraction else 0
train, val = [], []
for i, row in enumerate(rows):
# every n-th row goes to validation: deterministic, but splits users
(val if every and i % every == 0 else train).append(row)
return train, val
Alternatives and tradeoffs: scikit-learn's GroupShuffleSplit or GroupKFold split by group with a seeded random generator; they are fine for
one-off experiments but the assignment changes when the set of users changes. Hash-based bucketing is what
production pipelines (and SQL, e.g. ABS(MOD(FARM_FINGERPRINT(user_id), 10)) < 2 in BigQuery) use because it is
stable as data grows. For time-dependent data, a split by time (train on the past, validate on the future) is
often more important than a split by user.
Follow-ups:
- "Why not
hash(user_id) % 100 < 20?" String hashing is randomised per process (PYTHONHASHSEED), so the same user lands on different sides in different runs or on different workers, unless someone fixes the seed.hashlibdigests are the same everywhere. - "Why is a row-level random split a problem for this model?" Rows of the same user are strongly related. With the user in both sets, the model is graded on users it already memorised, so validation scores are optimistic and you can't tell how it does for new users.
- "What is the salt for?" It lets you draw a new, independent split (for a new experiment, or to avoid overfitting to one fixed validation set) while keeping each split reproducible. Record the salt with the experiment's results.
Scoring: Strong: Hash-based bucketing with hashlib, explains why hash() and row-level random splits fail, keeps order, and
checks the fraction bounds; mentions time-based splits as a further concern. Acceptable: Groups by user correctly but uses a seeded random shuffle of the user list; understands the stability problem
once the "new users arrive" case is pointed out. Red flag: Splits rows randomly and sees no leakage problem, or uses the built-in hash() and claims it is stable.
JR-MLE-O01 · Softmax and log loss written from scratch overflow
Question: A teammate writes softmax and binary log loss by hand for a quick evaluation script. What does this print?
import numpy as np
def softmax_naive(z):
e = np.exp(z)
return e / e.sum()
def softmax(z):
e = np.exp(z - z.max())
return e / e.sum()
logits = np.array([2.0, 1.0, 0.1])
print(softmax_naive(logits).round(3).tolist(), softmax(logits).round(3).tolist())
big = np.array([1000.0, 999.0, 10.0])
with np.errstate(over="ignore", invalid="ignore"):
print(softmax_naive(big).tolist())
print(softmax(big).round(3).tolist())
y = np.array([1, 0, 1])
p = np.array([0.9, 0.2, 0.0]) # model was "certain" the third case was 0
with np.errstate(divide="ignore"):
print(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))
q = np.clip(p, 1e-15, 1 - 1e-15)
print(round(float(-np.mean(y * np.log(q) + (1 - y) * np.log(1 - q))), 2))
Expected output:
[0.659, 0.242, 0.099] [0.659, 0.242, 0.099]
[nan, nan, 0.0]
[0.731, 0.269, 0.0]
inf
11.62
Explanation:
- For small logits both versions agree. Subtracting the maximum doesn't change softmax mathematically:
exp(z - c) / sum(exp(z - c))equalsexp(z) / sum(exp(z))for any constantc, becauseexp(-c)cancels. np.exp(1000)is larger than the biggestfloat64(about 1.8e308), so it becomesinf. Theninf / infisnan, and the third value isexp(10) / inf = 0.0. (Theerrstateonly hides NumPy's RuntimeWarnings, which would otherwise be printed to stderr: a hint that something went wrong.)- The stable version subtracts the max first, so the largest exponent is
exp(0) = 1and nothing overflows. The logits 1000 and 999 differ by 1, exactly like 1 and 0, so the answer is[0.731, 0.269, 0.0]. - Log loss: the model predicted probability 0.0 for an example whose label is 1.
log(0)is-inf, so the mean loss isinf. One over-confident prediction makes the whole metric useless. - Clipping probabilities to
[1e-15, 1 - 1e-15]keeps the loss finite (-log(1e-15)is about 34.5 for that row, averaged over 3 rows with the two small losses gives 11.62). Libraries such as scikit-learn'slog_lossclip for exactly this reason.
Takeaway: when you write numeric code by hand, keep values in a safe range (subtract the max, clip before log),
and treat inf/nan in a metric as a bug signal, not as a number.
Follow-ups:
- "Why do frameworks offer a loss that takes logits instead of probabilities (e.g.
BCEWithLogitsLoss)?" They combine sigmoid/softmax with the log in one numerically stable formula (the log-sum-exp trick), so neither overflow norlog(0)can happen. Computing probabilities first and then taking the log loses that. - "What would you check if the training loss suddenly prints
nan?" Look forinf/nanin inputs (a bad feature, division by zero in preprocessing), a learning rate that makes weights explode, and hand-writtenlog/exp/division.np.isfinite(x).all()on inputs and outputs narrows it down quickly. - "Does the clipping value matter?" It caps how much a single confidently wrong prediction can cost. Any small epsilon keeps the metric finite; what matters is using the same value when comparing models.
Scoring: Strong: All five lines; explains why subtracting the max is safe and fixes overflow, inf / inf = nan, and log(0) in
log loss, and knows libraries clip or work on logits. Acceptable: Gets the overflow to nan and the stable fix; needs a hint for the inf log loss or the clipped value. Red flag: Suggests silencing the warnings or replacing nan with 0 as the fix, without understanding where it came from.
JR-MLE-O02 · Category codes that change between training and serving
Question: A model uses the customer's city as a feature, encoded as an integer with .cat.codes. The serving code runs the
same encoding on each live batch. What does this print?
import pandas as pd
train = pd.Series(["Delhi", "Pune", "Mumbai", "Delhi"])
live = pd.Series(["Pune", "Mumbai", "Pune"])
print(train.astype("category").cat.codes.tolist())
print(live.astype("category").cat.codes.tolist())
cities = train.astype("category").cat.categories
print(list(cities))
print(pd.Categorical(live, categories=cities).codes.tolist())
print(pd.Categorical(["Chennai"], categories=cities).codes.tolist())
print(list(pd.get_dummies(live).columns))
Expected output:
[0, 2, 1, 0]
[1, 0, 1]
['Delhi', 'Mumbai', 'Pune']
[2, 1, 2]
[-1]
['Mumbai', 'Pune']
The trap:
"Pune is 2 in both" or "codes follow the order the cities first appear". Tempting because the encoding code is identical in training and serving, so it feels like it must produce the same numbers.
Real-world bug: at serving time, Pune customers are scored as if they lived in Mumbai and vice versa. Nothing errors, the model's online accuracy quietly drops, and offline evaluation still looks perfect.
Explanation:
astype("category") builds the categories from the values present in that Series, sorted. Training sees
Delhi, Mumbai, Pune → codes 0, 1, 2, so the training codes are [0, 2, 1, 0]. The live batch has no Delhi, so its
categories are Mumbai, Pune → 0, 1: Pune is now 1 instead of 2, and Mumbai 0 instead of 1.
Passing the training categories explicitly (pd.Categorical(live, categories=cities)) gives the right codes
[2, 1, 2]. A city never seen in training gets code -1, which you must handle deliberately (an "unknown"
bucket, or reject the request), not feed to the model as if it were a real category.
get_dummies has the same problem in another form: the live batch produces only the columns for cities it
contains, so the feature matrix has a different number of columns than in training.
Takeaway: any encoding learned from data (category lists, vocabularies, scaler stats) is part of the model and must be saved at training time and reused at serving time.
Follow-ups:
- "How do you make
get_dummiesproduce the training columns at serving time?" Save the training column list andreindex(columns=train_columns, fill_value=0)afterget_dummies, or better, use scikit-learn'sOneHotEncoder(handle_unknown="ignore")fitted on train and saved with the model. - "Would a test have caught this?" A test that encodes a small live batch which is missing one training category and compares the codes or the prediction with the offline pipeline would catch it. A test that reuses the training data at serving time would not.
- "What should happen for the code
-1?" Decide explicitly: map it to an "other" category that existed in training, or return a validation error. Log and count unknown values; a spike usually means an upstream change (a new city launch, or a spelling change).
Scoring: Strong: All six lines; explains sorted categories built per Series, knows -1 means unknown, and says the category list
must be saved with the model. Acceptable: Realises the codes differ between the two batches; unsure about the -1 or the get_dummies columns. Red flag: Thinks sorting both batches the same way fixes it, or that label codes are stable because "Pune is always Pune".
JR-BE-C01 · CRUD endpoints for a product catalogue with validation
Problem:
Build a small FastAPI app for a kirana store's product catalogue. Keep the data in memory, but each app created
by create_app() must have its own store, so tests don't leak into each other.
Endpoints:
POST /productswith a JSON body{"sku", "name", "price_paise", "stock"}:sku: 3–20 characters of letters, digits or-; stored upper-cased, so"atta-5kg"and"ATTA-5KG"are the same product;name: 1–100 characters after stripping spaces (store the stripped value);price_paise: integer > 0;stock: integer ≥ 0, default 0.
Returns 201 with the stored product plus an id (1, 2, 3, … per app). Invalid input → 422 (FastAPI's
default). A SKU that already exists → 409 with {"detail": "sku already exists"}.
- GET /products/{id} → 200 with the product, or 404 {"detail": "product not found"}.
- GET /products → 200 with all products, ordered by id.
- DELETE /products/{id} → 204 with an empty body, or 404 as above.
Signature: def create_app() -> FastAPI:
Constraints: Use Pydantic v2 models for the request and response bodies. No database; a dict per app is fine.
Examples:
POST /products {"sku": "atta-5kg", "name": " Aashirvaad Atta 5kg ", "price_paise": 28500}→201 {"id": 1, "sku": "ATTA-5KG", "name": "Aashirvaad Atta 5kg", "price_paise": 28500, "stock": 0}POST /products {"sku": "ATTA-5KG", ...} again→409 {"detail": "sku already exists"}POST /products {"sku": "x", "name": "", "price_paise": 0}→422
Hints:
- Put the rules on the model:
Field(pattern=..., min_length=..., gt=0). Which rule needs afield_validatorbecause it changes the value? - Create the dict and the id counter inside
create_app, so they belong to that app. status_code=201/204go on the route decorator; a 204 route should returnResponse(status_code=204).
Reference solution:
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field, field_validator
class ProductIn(BaseModel):
sku: str = Field(pattern=r"^[A-Za-z0-9-]{3,20}$")
name: str
price_paise: int = Field(gt=0)
stock: int = Field(default=0, ge=0)
@field_validator("sku")
@classmethod
def upper_sku(cls, value: str) -> str:
return value.upper()
@field_validator("name")
@classmethod
def clean_name(cls, value: str) -> str:
value = value.strip()
if not 1 <= len(value) <= 100:
raise ValueError("name must be 1-100 characters")
return value
class Product(ProductIn):
id: int
def create_app() -> FastAPI:
app = FastAPI()
products: dict[int, Product] = {}
next_id = 1
def get_or_404(product_id: int) -> Product:
product = products.get(product_id)
if product is None:
raise HTTPException(status_code=404, detail="product not found")
return product
@app.post("/products", status_code=201, response_model=Product)
def create_product(body: ProductIn) -> Product:
nonlocal next_id
if any(p.sku == body.sku for p in products.values()):
raise HTTPException(status_code=409, detail="sku already exists")
product = Product(id=next_id, **body.model_dump())
products[next_id] = product
next_id += 1
return product
@app.get("/products", response_model=list[Product])
def list_products() -> list[Product]:
return [products[k] for k in sorted(products)]
@app.get("/products/{product_id}", response_model=Product)
def read_product(product_id: int) -> Product:
return get_or_404(product_id)
@app.delete("/products/{product_id}", status_code=204)
def delete_product(product_id: int) -> Response:
get_or_404(product_id)
del products[product_id]
return Response(status_code=204)
return app
Complexity: Create is O(n) because of the duplicate-SKU scan; an extra sku → id dict makes it O(1). Get and delete are O(1);
list is O(n log n) for the sort (or O(n) if ids are kept in insertion order).
Tests:
from fastapi.testclient import TestClient
client = TestClient(create_app())
r = client.post("/products", json={"sku": "atta-5kg", "name": " Aashirvaad Atta 5kg ", "price_paise": 28500})
assert r.status_code == 201, r.text
assert r.json() == {"id": 1, "sku": "ATTA-5KG", "name": "Aashirvaad Atta 5kg", "price_paise": 28500, "stock": 0}
r = client.post("/products", json={"sku": "ATTA-5KG", "name": "Duplicate", "price_paise": 100})
assert r.status_code == 409 and r.json() == {"detail": "sku already exists"}
for bad in (
{"sku": "x", "name": "Salt", "price_paise": 2000}, # sku too short
{"sku": "SALT 1KG", "name": "Salt", "price_paise": 2000}, # space in sku
{"sku": "SALT-1KG", "name": " ", "price_paise": 2000}, # blank name
{"sku": "SALT-1KG", "name": "Salt", "price_paise": 0}, # price not > 0
{"sku": "SALT-1KG", "name": "Salt", "price_paise": 2000, "stock": -1},
{"sku": "SALT-1KG", "name": "Salt"}, # missing price
):
assert client.post("/products", json=bad).status_code == 422, bad
r = client.post("/products", json={"sku": "salt-1kg", "name": "Tata Salt", "price_paise": 2800, "stock": 40})
assert r.status_code == 201 and r.json()["id"] == 2
assert client.get("/products/2").json()["stock"] == 40
assert [p["id"] for p in client.get("/products").json()] == [1, 2]
r = client.get("/products/99")
assert r.status_code == 404 and r.json() == {"detail": "product not found"}
r = client.delete("/products/1")
assert r.status_code == 204 and r.content == b""
assert client.get("/products/1").status_code == 404
assert client.delete("/products/1").status_code == 404
assert [p["id"] for p in client.get("/products").json()] == [2]
# A second app starts empty: no shared module-level state.
other = TestClient(create_app())
assert other.get("/products").json() == []
assert other.post("/products", json={"sku": "salt-1kg", "name": "Tata Salt", "price_paise": 2800}).json()["id"] == 1
A common wrong solution (the tests catch it):
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field, field_validator
class ProductIn(BaseModel):
sku: str = Field(pattern=r"^[A-Za-z0-9-]{3,20}$")
name: str
price_paise: int = Field(gt=0)
stock: int = Field(default=0, ge=0)
@field_validator("name")
@classmethod
def clean_name(cls, value):
value = value.strip()
if not 1 <= len(value) <= 100:
raise ValueError("name must be 1-100 characters")
return value
class Product(ProductIn):
id: int
PRODUCTS = {} # module-level store shared by every app
def create_app():
app = FastAPI()
@app.post("/products", status_code=201, response_model=Product)
def create_product(body: ProductIn):
if any(p.sku == body.sku for p in PRODUCTS.values()): # case-sensitive
raise HTTPException(status_code=409, detail="sku already exists")
product = Product(id=len(PRODUCTS) + 1, **body.model_dump())
PRODUCTS[product.id] = product
return product
@app.get("/products", response_model=list[Product])
def list_products():
return [PRODUCTS[k] for k in sorted(PRODUCTS)]
@app.get("/products/{product_id}", response_model=Product)
def read_product(product_id: int):
if product_id not in PRODUCTS:
raise HTTPException(status_code=404, detail="product not found")
return PRODUCTS[product_id]
@app.delete("/products/{product_id}", status_code=204)
def delete_product(product_id: int):
if product_id not in PRODUCTS:
raise HTTPException(status_code=404, detail="product not found")
del PRODUCTS[product_id]
return Response(status_code=204)
return app
Alternatives and tradeoffs: In a real service the store is a database table with a UNIQUE constraint on sku: insert and catch the
integrity error to return 409, instead of check-then-insert, which races when two requests arrive together.
Separate models for create, update and response (ProductIn, ProductUpdate, ProductOut) keep server-owned
fields like id out of the request body.
Follow-ups:
- "Why does
id = len(products) + 1break after a delete?" Create 1 and 2, delete 1, and the next product also gets id 2, overwriting an existing product. Use a counter that only goes up (or the database's sequence). - "Two requests create the same SKU at the same moment. What happens with the check-then-insert code in a multi-worker deployment?" Both checks can pass before either inserts, so you get two products with one SKU. Only a unique constraint in a shared database prevents it; the API then maps the integrity error to 409.
- "How would you add
PATCH /products/{id}without wiping fields the client didn't send?" AProductUpdatemodel with every field optional, thenbody.model_dump(exclude_unset=True)to get only the fields the client sent, validated with the same rules, and merged into the stored product.
Scoring: Strong: Validation lives on the Pydantic model, SKU normalised before the duplicate check, correct 201/204/404/409, state
created inside create_app, ids from a counter; mentions a unique constraint for real databases. Acceptable: Endpoints work; misses case-insensitive SKUs or the per-app state until the tests show it; 204 handled after a hint. Red flag: Returns 200 with {"error": ...} for every failure, or validates by hand in each endpoint with if chains and
inconsistent status codes.
JR-BE-C02 · Salted password hashing with PBKDF2
Problem:
Your team's user table stores passwords as plain SHA-256 hex digests. Replace that with salted, slow hashing using only the standard library.
hash_password(password, iterations=600_000)returns a single string"pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>", usinghashlib.pbkdf2_hmac("sha256", ...)with a fresh random 16-byte salt for every call. An empty password raisesValueError.verify_password(password, stored)returnsTrueonly ifpasswordproduces the stored hash, using the iterations and salt from the stored string (so old hashes keep working after you raise the default). It compares in constant time. A malformed or unknown-formatstoredvalue returnsFalse; it never raises.
Signature: def hash_password(password: str, iterations: int = 600_000) -> str: ... / def verify_password(password: str, stored: str) -> bool:
Constraints: Standard library only. Passwords are Unicode: encode them as UTF-8. Tests use a small iterations value to run fast.
Examples:
hash_password('Chai@1234', iterations=1000)→'pbkdf2_sha256$1000$<32 hex chars>$<64 hex chars>'verify_password('Chai@1234', that_string)→Trueverify_password('chai@1234', that_string)→False
Hints:
secrets.token_bytes(16)(oros.urandom(16)) gives a cryptographically random salt;bytes.hex()andbytes.fromhex()convert both ways.- In
verify_password, split the stored string on$and check you got exactly four parts before using them. hmac.compare_digest(a, b)compares without stopping at the first difference.
Reference solution:
import hashlib
import hmac
import secrets
ALGORITHM = "pbkdf2_sha256"
def _derive(password: str, salt: bytes, iterations: int) -> bytes:
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
def hash_password(password: str, iterations: int = 600_000) -> str:
"""Return a self-describing salted PBKDF2-SHA256 hash of `password`."""
if not password:
raise ValueError("password must not be empty")
salt = secrets.token_bytes(16)
digest = _derive(password, salt, iterations)
return f"{ALGORITHM}${iterations}${salt.hex()}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
"""Check `password` against a string produced by `hash_password`."""
parts = stored.split("$")
if len(parts) != 4 or parts[0] != ALGORITHM:
return False
try:
iterations = int(parts[1])
salt = bytes.fromhex(parts[2])
expected = bytes.fromhex(parts[3])
except ValueError:
return False
if iterations < 1 or not salt or not expected:
return False
return hmac.compare_digest(_derive(password, salt, iterations), expected)
Complexity: Deliberately slow: O(iterations) hash computations per call (roughly a tenth of a second or more at 600,000 on one modern CPU core), which is the point: it makes offline guessing expensive. O(1) memory. Budget for it: a login endpoint that hashes on every request needs that CPU time per login.
Tests:
import hashlib
h1 = hash_password("Chai@1234", iterations=1000)
h2 = hash_password("Chai@1234", iterations=1000)
algo, iters, salt_hex, hash_hex = h1.split("$")
assert algo == "pbkdf2_sha256" and iters == "1000"
assert len(salt_hex) == 32 and len(hash_hex) == 64
assert h1 != h2 # fresh salt each time
assert hash_hex == hashlib.pbkdf2_hmac("sha256", b"Chai@1234", bytes.fromhex(salt_hex), 1000).hex()
assert verify_password("Chai@1234", h1) and verify_password("Chai@1234", h2)
assert not verify_password("chai@1234", h1)
assert not verify_password("Chai@1234 ", h1)
# Unicode passwords round-trip.
hu = hash_password("पासवर्ड-2026", iterations=1000)
assert verify_password("पासवर्ड-2026", hu) and not verify_password("पासवर्ड-2025", hu)
# Iterations come from the stored string, not the default.
h_old = hash_password("legacy-pass", iterations=500)
assert verify_password("legacy-pass", h_old)
# Malformed values never raise.
for bad in ("", "5f4dcc3b5aa765d61d8327deb882cf99", "pbkdf2_sha256$abc$00$00",
"pbkdf2_sha256$1000$zz$00", "md5$1000$00$00", h1 + "$extra"):
assert verify_password("Chai@1234", bad) is False, bad
try:
hash_password("")
raise AssertionError("expected ValueError")
except ValueError:
pass
A common wrong solution (the tests catch it):
import hashlib
import hmac
SALT = b"kirana-app-salt!" # one fixed salt for every user
def hash_password(password, iterations=600_000):
if not password:
raise ValueError("password must not be empty")
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), SALT, iterations)
return f"pbkdf2_sha256${iterations}${SALT.hex()}${digest.hex()}"
def verify_password(password, stored):
parts = stored.split("$")
if len(parts) != 4 or parts[0] != "pbkdf2_sha256":
return False
try:
iterations, salt, expected = int(parts[1]), bytes.fromhex(parts[2]), bytes.fromhex(parts[3])
except ValueError:
return False
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(digest, expected)
Alternatives and tradeoffs: Prefer a vetted library in production: argon2-cffi (Argon2id, memory-hard) or bcrypt, usually through a
framework helper such as Django's password hashers or passlib/pwdlib. hashlib.scrypt is also in the standard
library and is memory-hard. The self-describing format (algorithm$params$salt$hash) is what those libraries use,
so you can upgrade parameters later and rehash on the user's next successful login.
Follow-ups:
- "Why is plain SHA-256 a bad password hash, even with a salt?" It's designed to be fast: a GPU computes billions per second, so common passwords fall quickly. A password hash must be slow (and ideally memory-hard) on purpose. The salt only stops precomputed tables and identical passwords producing identical hashes.
- "How do you migrate existing users from SHA-256 to PBKDF2 without asking everyone to reset?" On each successful login (when you have the plain password), verify with the old scheme and immediately store the new hash. Optionally wrap old hashes now: PBKDF2 over the old SHA-256 digest, marked with its own algorithm name, so no weak hashes remain at rest.
- "Why
hmac.compare_digestinstead of==?"==stops at the first differing byte, so response time leaks how many leading bytes matched. With slow hashing the risk here is small, but constant-time comparison is the habit to have for every secret (tokens, API keys, signatures).
Scoring: Strong: Random salt per call, iterations and salt read back from the stored string, compare_digest, False on malformed
input; explains why fast hashes are wrong and how to migrate. Acceptable: Correct hashing and verification; forgets the malformed-input cases or uses the default iterations when
verifying, and fixes it after the tests. Red flag: Uses one global salt, stores passwords encrypted (reversibly) "so support can read them", or uses random for
the salt.
JR-BE-O01 · sqlite3 with f-strings, parameters and rowcount
Question: A small customer service stores data in SQLite. What does this print?
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT)")
cur = conn.execute("INSERT INTO customers (name, city) VALUES (?, ?)", ("Asha Rao", "Pune"))
print(cur.lastrowid, cur.rowcount)
name = "Neil D'Souza"
try:
conn.execute(f"INSERT INTO customers (name, city) VALUES ('{name}', 'Goa')")
except sqlite3.OperationalError as exc:
print("OperationalError:", exc)
conn.execute("INSERT INTO customers (name, city) VALUES (?, ?)", (name, "Goa"))
cur = conn.execute("UPDATE customers SET city = ? WHERE id = ?", ("Mumbai", 99))
print(cur.rowcount)
search = "x' OR '1'='1"
rows = conn.execute(f"SELECT name FROM customers WHERE city = '{search}'").fetchall()
print(rows)
print(conn.execute("SELECT name FROM customers WHERE city = ?", (search,)).fetchall())
print(conn.execute("SELECT * FROM customers WHERE id = ?", (99,)).fetchone())
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM customers WHERE id = ?", (2,)).fetchone()
print(dict(row))
Expected output:
1 1
OperationalError: near "Souza": syntax error
0
[('Asha Rao',), ("Neil D'Souza",)]
[]
None
{'id': 2, 'name': "Neil D'Souza", 'city': 'Goa'}
The trap:
"The update of customer 99 raises an error, and the search for that odd city returns []." Tempting because
"updating something that doesn't exist" feels like an error, and the f-string query reads like a normal search.
Real-world bug: PUT /customers/99 returns 200 "updated" for a customer that doesn't exist, because nobody
checked rowcount. And a search box built with an f-string returns every customer to anyone who types a quote,
which is a data breach, not a bug.
Explanation:
lastrowidis the id SQLite assigned to the inserted row (1);rowcountis how many rows the statement changed (1).- Building SQL with an f-string pastes the value into the SQL text. The apostrophe in
D'Souzaends the string literal early, and SQLite seesSouza'as garbage: a syntax error. Real customer names break your app. - With
?placeholders the value is sent separately from the SQL, so any characters are just data. - An
UPDATE(orDELETE) that matches no rows is not an error: it succeeds androwcountis 0. Your API has to check that and return 404 itself. - The search string
x' OR '1'='1turns the f-string query intoWHERE city = 'x' OR '1'='1', which is true for every row: SQL injection. With a parameter, the database looks for a city literally named that, and finds none. fetchone()returnsNonewhen nothing matches (check it before indexing), andsqlite3.Rowlets you access columns by name and convert a row withdict(row).
Takeaway: never build SQL with string formatting; always use parameters, and check rowcount / None to detect
"not found".
Follow-ups:
- "Can you use a parameter for a table or column name, e.g. a user-chosen sort column?" No: parameters are only for values. Check the name against an allow-list (
{"name", "city", "created_at"}) and only then put it into the SQL text. - "How does an ORM like SQLAlchemy relate to this?" It builds parameterised SQL for you, so normal ORM queries are safe. Raw SQL through the ORM (
text(...)) still needs bound parameters (:name), not f-strings. - "What would your
PUT /customers/{id}endpoint do withrowcount == 0?" Return 404 Not Found (or, if your API supports create-by-PUT, insert and return 201). Never report success when nothing was written.
Scoring: Strong: All seven lines; explains the quote breaking the f-string, SQL injection, that zero-row updates aren't errors, and
uses parameters plus rowcount checks. Acceptable: Knows about parameters and injection; expects the missing update to raise, but understands rowcount once shown. Red flag: "Fixes" the D'Souza problem by escaping quotes by hand or stripping apostrophes from names.
JR-BE-O02 · When a FastAPI yield dependency runs, and how often
Question: An order service opens a database session per request with a yield dependency. The auth dependency also needs the
session. What does this print?
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
events = []
app = FastAPI()
def get_db():
events.append("open")
try:
yield {"orders": {1: "₹499 thali"}}
finally:
events.append("close")
def current_user(db: dict = Depends(get_db)) -> str:
events.append("auth")
return "asha"
@app.get("/orders/{order_id}")
def read_order(order_id: int, user: str = Depends(current_user), db: dict = Depends(get_db)):
events.append("handler")
if order_id not in db["orders"]:
raise HTTPException(status_code=404, detail="order not found")
return {"order": db["orders"][order_id], "user": user}
client = TestClient(app)
print(client.get("/orders/1").json())
print(events)
events.clear()
r = client.get("/orders/7")
print(r.status_code, events)
Expected output:
{'order': '₹499 thali', 'user': 'asha'}
['open', 'auth', 'handler', 'close']
404 ['open', 'auth', 'handler', 'close']
Explanation:
get_dbis used twice in one request: directly by the route and indirectly throughcurrent_user. FastAPI caches a dependency's result within a request, soget_dbruns once and both receive the same session. That is what you want for a database session: auth and the handler see the same transaction.- A
yielddependency is a setup/teardown pair: the code beforeyieldruns before the handler, the value is injected, and the code afteryieldruns when the request is finished. Withtry/finally, the cleanup runs even when the handler raises, as the 404 request shows. - Dependencies are resolved before the handler, in order:
get_db, thencurrent_user, then the handler. - Nothing is shared across requests: each request opens and closes its own session.
Takeaway: per-request resources (DB sessions, clients that must be closed) belong in a yield dependency with
try/finally; FastAPI gives every dependency in the request the same instance.
Follow-ups:
- "When would you want a dependency to run again instead of using the cached value?" Rarely, for example a dependency that must produce a fresh value each time it's used.
Depends(get_db, use_cache=False)turns off the per-request cache for that use. - "Where should you commit the transaction, and what happens on an error?" A common pattern is commit after
yieldif no exception occurred and roll back inexcept, then close infinally. Then the handler doesn't need to remember to commit, and a failing request never commits half its work. - "How would you replace
get_dbwith an in-memory fake in tests?"app.dependency_overrides[get_db] = fake_get_dbbefore creating theTestClient, and clear it afterwards. Both the route andcurrent_userthen receive the fake, because they depend on the same function.
Scoring: Strong: All three lines; explains the per-request cache (one open despite two uses), the order of resolution, and that
finally runs after an HTTPException. Acceptable: Gets the order right but expects open twice; understands the cache once told. Red flag: Opens a global database connection at import time and shares it across all requests without thinking about
cleanup or concurrent use.
JR-FS-C01 · A "Download as CSV" export that is safe to open in Excel
Problem:
The admin dashboard of a coaching institute has a "Download as CSV" button for the enquiries table. The rows come
from user-submitted forms. Write the function that produces the file's text (the endpoint will return it with
media_type="text/csv").
columnsis a list of(key, header)pairs: the header row uses the headers, and each data row takes the values for those keys in that order. A key missing from a row gives an empty cell.- Formatting:
None→ empty;True/False→Yes/No;date/datetime→isoformat(); numbers →str(); strings as they are, except as below. - Formula injection: a spreadsheet treats a cell starting with
=,+,-or@(also a tab or carriage return) as a formula, so a visitor who types=HYPERLINK("http://evil.example","Click")into the enquiry form gets a live link in your staff's spreadsheet. Prefix such strings with a single quote'. Numbers (including negative ones) are not changed. - Commas, quotes and newlines inside values must survive a round trip through a CSV reader.
- Start the text with a UTF-8 byte-order mark (
"") so Excel on Windows shows₹and Hindi names correctly, and use thecsvmodule's default\r\nline endings.
Signature: def export_csv(rows: list[dict], columns: list[tuple[str, str]]) -> str:
Examples:
export_csv([{'name': 'Asha', 'fee': 4500, 'paid': True}], [('name', 'Name'), ('fee', 'Fee (₹)'), ('paid', 'Paid')])→'\ufeffName,Fee (₹),Paid\r\nAsha,4500,Yes\r\n'a row whose name is '=1+1'→the cell is "'=1+1"a row whose fee is -500→the cell is -500 (numbers are never prefixed)
Hints:
csv.writer(io.StringIO())handles quoting for you;getvalue()returns the text.- Check
boolbeforeint:isinstance(True, int)isTrue. str.startswithaccepts a tuple of prefixes.
Reference solution:
import csv
import io
from datetime import date
FORMULA_START = ("=", "+", "-", "@", "\t", "\r")
def _cell(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, date): # datetime is a subclass of date
return value.isoformat()
if isinstance(value, str):
return "'" + value if value.startswith(FORMULA_START) else value
return str(value)
def export_csv(rows: list[dict], columns: list[tuple[str, str]]) -> str:
"""Render rows as CSV text that opens safely in spreadsheet apps."""
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow([header for _, header in columns])
for row in rows:
writer.writerow([_cell(row.get(key)) for key, _ in columns])
return "" + buffer.getvalue()
Complexity: O(total size of the output) time and memory, since the whole file is built in a StringIO.
Tests:
from datetime import date, datetime
from decimal import Decimal
cols = [("name", "Name"), ("fee", "Fee (₹)"), ("paid", "Paid")]
assert export_csv([{"name": "Asha", "fee": 4500, "paid": True}], cols) == "Name,Fee (₹),Paid\r\nAsha,4500,Yes\r\n"
assert export_csv([], cols) == "Name,Fee (₹),Paid\r\n"
rows = [
{"name": "=HYPERLINK(\"http://evil.example\",\"Click\")", "fee": -500, "paid": False,
"note": "+91 98765 43210", "city": "@home", "joined": date(2026, 4, 1)},
{"name": "Rao, Priya", "fee": Decimal("1999.50"), "paid": None,
"note": "Said \"call after 6\"\nprefers Hindi", "city": "-", "joined": datetime(2026, 4, 2, 18, 30)},
{"name": "राहुल", "fee": 0},
]
cols2 = [("name", "Name"), ("fee", "Fee"), ("paid", "Paid"), ("note", "Note"), ("city", "City"), ("joined", "Joined")]
text = export_csv(rows, cols2)
assert text.startswith("Name,Fee,Paid,Note,City,Joined\r\n")
parsed = list(csv.reader(io.StringIO(text[1:], newline="")))
assert parsed[0] == ["Name", "Fee", "Paid", "Note", "City", "Joined"]
assert parsed[1] == ["'=HYPERLINK(\"http://evil.example\",\"Click\")", "-500", "No",
"'+91 98765 43210", "'@home", "2026-04-01"]
assert parsed[2] == ["Rao, Priya", "1999.50", "", "Said \"call after 6\"\nprefers Hindi", "'-",
"2026-04-02T18:30:00"]
assert parsed[3] == ["राहुल", "0", "", "", "", ""]
assert len(parsed) == 4
A common wrong solution (the tests catch it):
import csv
import io
from datetime import date
def _cell(value):
if value is None:
return ""
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, date):
return value.isoformat()
if isinstance(value, str):
return "'" + value if value.startswith("=") else value # only guards '='
return str(value)
def export_csv(rows, columns):
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow([header for _, header in columns])
for row in rows:
writer.writerow([_cell(row.get(key)) for key, _ in columns])
return "" + buffer.getvalue()
Alternatives and tradeoffs: pandas.DataFrame.to_csv is convenient but won't sanitise formulas for you, and it formats None/NaN and
booleans its own way. For large exports, stream the response: a generator that writes one row at a time to a small
buffer and yields it, passed to FastAPI's StreamingResponse, so memory stays flat. If staff need real formatting
(currency, column widths), generate .xlsx with a library such as openpyxl, writing values as text cells.
Follow-ups:
- "Why only prefix strings and not negative numbers?" A number cell is data and can't execute anything; prefixing
-500would turn it into text and break sums in the spreadsheet. The danger is user-controlled text that a spreadsheet interprets as a formula. - "The file is downloaded with a name like
enquiries.csv. What header sets that?"Content-Disposition: attachment; filename="enquiries.csv". Set it on the response; if the filename includes user input (a batch name), sanitise it too. - "Why not just
','.join(str(v) for v in values)?" A value containing a comma, quote or newline (Rao, Priya, an address, a note) would shift columns or split rows. Thecsvmodule quotes such fields and doubles embedded quotes, so a reader gets the original value back.
Scoring: Strong: Uses csv.writer, orders by columns, checks bool before numbers, sanitises all formula prefixes for strings
only, adds the BOM, and explains why formula injection matters. Acceptable: Correct CSV and formatting; sanitises only = or forgets the BOM until the tests show it. Red flag: Joins values with commas by hand, or strips "dangerous" characters from user data instead of escaping at export.
JR-FS-C02 · Validate an uploaded document and make its filename safe
Problem:
A loan-application form lets users upload ID proof (PAN card, Aadhaar, salary slip) as an image or PDF. The browser
sends a filename and the file's bytes. Write check_upload(filename, data, max_bytes) that either returns a safe
name to store the file under, or raises ValueError with one of these exact messages, checked in this order:
"empty file"ifdatais empty;"file too large"if it's longer thanmax_bytes."file type not allowed"unless the extension (the part from the last dot, compared case-insensitively) is.png,.jpg,.jpegor.pdf. A name with no dot is not allowed."content does not match extension"unlessdatastarts with the right signature ("magic bytes"): PNGb"\x89PNG\r\n\x1a\n", JPEGb"\xff\xd8\xff", PDFb"%PDF-". Users rename.exefiles to.pdf.
The safe name:
- keeps only the last path component; the browser may send
C:\Users\Asha\Pictures\pan.jpg, and an attacker may send../../app/config.pdf, so treat both/and\as separators; - replaces every character other than ASCII letters, digits,
.,_and-with_; - strips leading dots and underscores, so the result can never be
..-something or a hidden file; - keeps at most 50 characters of the stem, uses
"file"if nothing is left, and adds the extension in lower case.
Signature: def check_upload(filename: str, data: bytes, max_bytes: int = 2_000_000) -> str:
Examples:
check_upload('Aadhaar Card (front).JPG', jpeg_bytes)→'Aadhaar_Card__front_.jpg'check_upload('C:\\Users\\Asha\\Pictures\\pan.jpg', jpeg_bytes)→'pan.jpg'check_upload('salary.pdf', png_bytes)→ValueError: content does not match extensioncheck_upload('invoice.pdf.exe', pdf_bytes)→ValueError: file type not allowed
Hints:
re.split(r'[\\/]', name)[-1]gives the last component for both kinds of separator;os.path.basenameonly knows the current OS's separator.str.rpartition('.')splits at the last dot. What doesos.path.splitext('.pdf')return?bytes.startswithworks like thestrversion.
Reference solution:
import re
SIGNATURES = {
".png": b"\x89PNG\r\n\x1a\n",
".jpg": b"\xff\xd8\xff",
".jpeg": b"\xff\xd8\xff",
".pdf": b"%PDF-",
}
UNSAFE = re.compile(r"[^A-Za-z0-9._-]")
def check_upload(filename: str, data: bytes, max_bytes: int = 2_000_000) -> str:
"""Validate an uploaded file and return a safe storage name, or raise ValueError."""
if not data:
raise ValueError("empty file")
if len(data) > max_bytes:
raise ValueError("file too large")
name = re.split(r"[\\/]", filename)[-1]
stem, dot, ext = name.rpartition(".")
if not dot:
raise ValueError("file type not allowed")
ext = "." + ext.lower()
if ext not in SIGNATURES:
raise ValueError("file type not allowed")
if not data.startswith(SIGNATURES[ext]):
raise ValueError("content does not match extension")
stem = UNSAFE.sub("_", stem).lstrip("._")[:50] or "file"
return stem + ext
Complexity: O(len(filename)) for the name handling plus O(1) for the signature check; len(data) is O(1) on bytes. The bigger
concern in a real app is memory: check the size while streaming the upload, not after reading it all.
Tests:
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
JPG = b"\xff\xd8\xff\xe0" + b"\x00" * 20
PDF = b"%PDF-1.7\n" + b"\x00" * 20
assert check_upload("Aadhaar Card (front).JPG", JPG) == "Aadhaar_Card__front_.jpg"
assert check_upload("C:\\Users\\Asha\\Pictures\\pan.jpg", JPG) == "pan.jpg"
assert check_upload("../../app/config.pdf", PDF) == "config.pdf"
assert check_upload("..\\..\\static\\logo.png", PNG) == "logo.png"
assert check_upload("....pdf", PDF) == "file.pdf"
assert check_upload(".pdf", PDF) == "file.pdf"
assert check_upload("आधार.pdf", PDF) == "file.pdf"
assert check_upload("salary.slip.march.pdf", PDF) == "salary.slip.march.pdf"
assert check_upload("x" * 80 + ".png", PNG) == "x" * 50 + ".png"
assert check_upload("scan.jpeg", JPG) == "scan.jpeg"
def error(*args):
try:
check_upload(*args)
except ValueError as exc:
return str(exc)
return None
assert error("a.pdf", b"") == "empty file"
assert error("a.pdf", PDF, 10) == "file too large"
assert error("invoice.pdf.exe", PDF) == "file type not allowed"
assert error("README", PDF) == "file type not allowed"
assert error("pan.png\x00.exe", PNG) == "file type not allowed"
assert error("salary.pdf", PNG) == "content does not match extension"
assert error("photo.jpg", b"MZ\x90\x00 pretend exe") == "content does not match extension"
A common wrong solution (the tests catch it):
import os
import re
SIGNATURES = {".png": b"\x89PNG\r\n\x1a\n", ".jpg": b"\xff\xd8\xff", ".jpeg": b"\xff\xd8\xff", ".pdf": b"%PDF-"}
def check_upload(filename, data, max_bytes=2_000_000):
if not data:
raise ValueError("empty file")
if len(data) > max_bytes:
raise ValueError("file too large")
name = os.path.basename(filename) # only splits on "/" on Linux
stem, dot, ext = name.rpartition(".")
if not dot:
raise ValueError("file type not allowed")
ext = "." + ext.lower()
if ext not in SIGNATURES:
raise ValueError("file type not allowed")
if not data.startswith(SIGNATURES[ext]):
raise ValueError("content does not match extension")
stem = re.sub(r"[^A-Za-z0-9._-]", "_", stem).lstrip("._")[:50] or "file"
return stem + ext
Alternatives and tradeoffs: Many apps ignore the client's name entirely and store files under a generated id (uuid4().hex + ext), keeping the
original name only as display metadata in the database; that removes most filename risks. Libraries such as
python-magic or filetype detect many more formats from content. In FastAPI, UploadFile gives you
filename, content_type (sent by the client, so don't trust it) and a file object to read in chunks.
Follow-ups:
- "Why not trust the
Content-Typethe browser sends?" The client sets it, so an attacker can send any value. Use it as a hint at most, and decide from the extension plus the file's own bytes. - "The checks pass. Is the file now safe to show to other users?" Not fully: a valid PDF can still contain malicious content, and an image can be huge in pixels. Serve uploads
from a separate domain or with
Content-Disposition: attachment, never execute or render them as HTML, scan if required, and store them outside the web root (or in object storage). - "Why strip leading dots instead of just checking for
..?" Blocklisting..misses variants (...., encoded forms, a leading dot that creates a hidden file). Taking only the last component and keeping a small allow-list of characters is simpler and safer than listing bad inputs.
Scoring: Strong: Checks in the stated order, splits on both separators, uses the last dot, compares magic bytes, sanitises with an
allow-list; explains why the client's name and content type can't be trusted and suggests generated names. Acceptable: Correct type and content checks; uses os.path.basename and fixes the Windows-path case after the test fails. Red flag: Only checks filename.endswith(".pdf"), or saves the file under the client's filename as given.
JR-FS-O01 · Building and parsing URLs for a menu search page
Question: A food-ordering site builds search links on the server and parses form posts by hand in one legacy view. What does this print?
from urllib.parse import parse_qs, quote, quote_plus, urlencode
filters = {"q": "paneer tikka", "veg": True, "cuisine": ["north", "south"]}
print(urlencode(filters))
print(urlencode(filters, doseq=True))
dish = "dal makhani/naan"
print(quote(dish), quote(dish, safe=""), quote_plus("dal & naan"))
body = "name=&city=Pune&city=Thane¬e=50%25+off"
print(parse_qs(body))
print(parse_qs(body, keep_blank_values=True)["name"])
print(parse_qs("q=a+b%2Bc")["q"])
Expected output:
q=paneer+tikka&veg=True&cuisine=%5B%27north%27%2C+%27south%27%5D
q=paneer+tikka&veg=True&cuisine=north&cuisine=south
dal%20makhani/naan dal%20makhani%2Fnaan dal+%26+naan
{'city': ['Pune', 'Thane'], 'note': ['50% off']}
['']
['a b+c']
The trap:
"urlencode writes cuisine=north&cuisine=south and veg=true, and parse_qs returns name as an empty
string." Tempting because that's what a browser form would produce, and urlencode sounds like it knows about forms.
Real-world bug: the "North + South" filter link sends cuisine=['north', 'south'] as one literal string, so the
backend finds no matching dishes; and a profile form where the user clears their name is parsed as "name not sent",
so the old name is silently kept.
Explanation:
urlencodeconverts every value withstr(). A list becomes its Python repr,"['north', 'south']", then percent-encoded.TruebecomesTrue(capital T), which a JavaScript or Java backend expectingtruemay reject. Spaces become+, form-encoding style.doseq=Trueexpands a sequence into repeated keys, the standard way to send several values.quoteis for path segments and leaves/unescaped by default (safe="/"), so a dish name containing/would create an extra path segment.safe=""encodes it as%2F.quote_plusis the form/query variant: space →+,&→%26.parse_qsreturns a list for every key (keys can repeat), decodes%25to%and+to a space, and drops keys with blank values unless you passkeep_blank_values=True.+means space in a query string; a literal plus must be sent as%2B, soa+b%2Bcdecodes toa b+c.
Takeaway: in query strings, decide how lists, booleans and blank values are represented, and use
doseq=True / keep_blank_values=True when that's what you mean.
Follow-ups:
- "When would you use
parse_qslinstead ofparse_qs?" When order or duplicates matter:parse_qslreturns a list of(key, value)pairs in the original order, which a dict of lists can't preserve across keys. - "Why does a phone number like
+919876543210arrive as' 919876543210'on the server?" It was put into a query string without encoding, and+decodes to a space. Build URLs withurlencode/quote_plus(orURLSearchParamsin the browser), never by string concatenation. - "Do you need these functions in a FastAPI app?" For parsing incoming requests, no: FastAPI and Starlette decode query strings and form bodies for you. You still
need them when building outgoing URLs (redirects, links in emails, calls to other APIs);
httpxdoes it for you withparams=.
Scoring: Strong: All six lines; knows doseq, str() of booleans, quote's default safe /, list values and dropped blanks in
parse_qs, and + vs %2B. Acceptable: Gets the encoding lines roughly right; surprised by the dropped blank name or the repr of the list. Red flag: Builds query strings with f-strings and "&".join(...) without encoding.
JR-FS-O02 · How FastAPI parses query strings from a filter form
Question: The product listing page builds its URL from a filter form. What does this print?
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/products")
def products(page: int = 1, in_stock: bool = False, brand: str | None = None,
tags: list[str] = Query(default=[])):
return {"page": page, "in_stock": in_stock, "brand": brand, "tags": tags}
client = TestClient(app)
print(client.get("/products?page=2&in_stock=yes").json())
print(client.get("/products?in_stock=0&brand=Tata&brand=Amul").json())
print(client.get("/products?tags=organic&tags=vegan").json()["tags"])
print(client.get("/products?tags=organic,vegan").json()["tags"])
r = client.get("/products?page=two")
print(r.status_code, r.json()["detail"][0]["msg"])
r = client.get("/products?in_stock=maybe")
print(r.status_code, r.json()["detail"][0]["type"])
print(client.get("/products?page=").status_code)
Expected output:
{'page': 2, 'in_stock': True, 'brand': None, 'tags': []}
{'page': 1, 'in_stock': False, 'brand': 'Amul', 'tags': []}
['organic', 'vegan']
['organic,vegan']
422 Input should be a valid integer, unable to parse string as an integer
422 bool_parsing
422
Explanation:
Everything in a query string is text; FastAPI converts it using the parameter's type hint.
page=2becomes the int2. Forbool, Pydantic acceptstrue/false,1/0,yes/no,on/off(and a few more), soyesisTrueand0isFalse;maybeis a validation error of typebool_parsing.- A parameter typed as a single
strtakes one value. When the key repeats (brand=Tata&brand=Amul), FastAPI keeps the last one,Amul, and silently ignores the other. - To accept several values, declare a list with
Query(): repeated keys (tags=organic&tags=vegan, which is what an HTML multi-select or a set of same-named checkboxes submits) become a list. A comma-separated value is one string,'organic,vegan'; splitting it is up to you. - Values that can't be converted give a 422 with a readable message.
page=is present but empty, and an empty string isn't a valid integer, so it's a 422 too, not the default1.
Takeaway: declare query parameter types precisely (including lists), and make the frontend send exactly what those types expect; for example, drop empty fields from the form before building the URL.
Follow-ups:
- "How would you validate
page >= 1and capper_pageat 100?"page: int = Query(1, ge=1)andper_page: int = Query(20, ge=1, le=100): invalid values get a 422 with the constraint in the message, before your code runs. - "Why might a list filter be better as repeated keys than as a comma-separated string?" It's what HTML forms and
URLSearchParams.appendproduce, it needs no escaping rules for values that contain commas, and FastAPI parses it with no extra code. Comma-separated is shorter in the URL; if you choose it, split and strip on the server and document it. - "How should the frontend show a 422 from this endpoint?" Use
detail[i]["loc"](for example['query', 'page']) to point at the form field and show a friendly message, not the raw Pydantic text. Better still, validate on the client too so most bad values never reach the API.
Scoring: Strong: All seven lines; knows Pydantic's bool strings, that a repeated key keeps the last value for a str, that lists
need Query(), and that an empty value is a 422. Acceptable: Gets the int and list behaviour; unsure about yes/0 for bool or the repeated brand. Red flag: Expects "0" to be truthy because it's a non-empty string, or parses request.query_params by hand instead of
using typed parameters.
Code-review exercises
JR-CR-01 · Grouping delivery orders by city
Context:
PR #412 · dashboard/grouping.py · author: Karthik (8 months in the team)
The ops dashboard for a quick-commerce app shows, for each city the company operates in, the list of order IDs placed today. Cities with no orders must still appear with an empty list so the dashboard renders a "0 orders" tile. City names in the order feed come from a free-text address field, so they arrive in mixed case with stray spaces. Orders from cities outside the operating list (test orders, bad data) should be ignored.
Author's note: "Used dict.fromkeys so every city starts with an empty list in one line. Tested
locally with Bengaluru orders and the counts looked right."
Review the function as you would a real pull request.
Code under review:
def group_orders_by_city(orders, cities):
"""Return {city: [order_id, ...]} for every city in `cities`.
Cities with no orders map to []. Orders from other cities are ignored.
"""
grouped = dict.fromkeys(cities, [])
for order in orders:
city = order["city"].strip().title()
grouped[city].append(order["order_id"])
return grouped
What a strong reviewer raises:
- High:
dict.fromkeys(cities, [])evaluates[]once and stores that same list object as the value of every key. Everyappendgoes into the one shared list, so every city shows every order. With the three sample orders the buggy function returns{'Bengaluru': ['BLR-101', 'PUN-201', 'BLR-102'], 'Pune': ['BLR-101', 'PUN-201', 'BLR-102'], 'Chennai': ['BLR-101', 'PUN-201', 'BLR-102']}. The author's local test used only Bengaluru orders and only looked at Bengaluru, which is the one case where the output looks right. Fix: Build a fresh list per key with a dict comprehension,{city: [] for city in cities}. Alternatives:collections.defaultdict(list)(but then empty cities must still be added for the "0 orders" tile).dict.fromkeysis fine only with immutable defaults such as0,Noneor"". - Medium: An order from a city not in
citiesraisesKeyError(for exampleKeyError: 'Hyderabad'), so one test order or typo takes down the whole dashboard refresh. The docstring says such orders are ignored, so the code contradicts its own contract. Fix: Check membership before appending (if city in grouped:). In production, also count or log the skipped rows (logger.info("skipped %d orders from unknown cities", n)) so bad data is visible instead of silently vanishing. - Low:
.title()is a fragile normaliser:"navi mumbai"→"Navi Mumbai"works, but spellings such as"Bangalore"vs"Bengaluru"or"Gurgaon"vs"Gurugram"still miss, and the function has no tests for normalisation at all. Fix: Normalise against a small alias map ({"bangalore": "Bengaluru", ...}) keyed oncity.strip().casefold(), and add a parametrised pytest covering spacing, case and aliases.
Tests that expose the bug:
orders = [
{"order_id": "BLR-101", "city": "Bengaluru"},
{"order_id": "PUN-201", "city": " pune "},
{"order_id": "BLR-102", "city": "bengaluru"},
]
result = group_orders_by_city(orders, ["Bengaluru", "Pune", "Chennai"])
assert result == {"Bengaluru": ["BLR-101", "BLR-102"], "Pune": ["PUN-201"], "Chennai": []}, result
# The lists must be independent: touching one city must not change another.
result["Chennai"].append("CHN-301")
assert result["Pune"] == ["PUN-201"]
# Orders from cities outside the operating list are ignored, not a crash.
stray = [{"order_id": "TEST-1", "city": "Hyderabad"}, {"order_id": "PUN-202", "city": "Pune"}]
assert group_orders_by_city(stray, ["Pune"]) == {"Pune": ["PUN-202"]}
assert group_orders_by_city([], ["Pune", "Chennai"]) == {"Pune": [], "Chennai": []}
Fixed version:
def group_orders_by_city(orders, cities):
"""Return {city: [order_id, ...]} for every city in `cities`.
Cities with no orders map to []. Orders from other cities are ignored.
"""
grouped = {city: [] for city in cities} # a new list per city
for order in orders:
city = order["city"].strip().title()
if city in grouped: # skip cities we don't operate in
grouped[city].append(order["order_id"])
return grouped
Follow-ups:
- "When is
dict.fromkeys(keys, value)perfectly safe?" When the value is immutable (0,None,False, a string, a tuple of immutables). Sharing one immutable object is harmless because nobody can change it in place;counts[k] += 1rebinds the key to a new int instead of mutating the shared one. - "The same bug in another disguise: what does
grid = [[0] * 3] * 2; grid[0][0] = 9; print(grid)print?"[[9, 0, 0], [9, 0, 0]]. The outer* 2copies the reference to one inner list. Fix with[[0] * 3 for _ in range(2)]. - "What single test would have caught the shared-list bug before review?" Any test with orders from two different cities that asserts the full returned dict, or one that
appends to one city's list and checks another is unchanged (
result["Pune"] is not result["Chennai"]). Testing only one city can never expose aliasing between keys. - "How would you debug this if you saw it in production, with no reviewer around?" Print or log
id(grouped["Pune"]) == id(grouped["Chennai"]), or checkgrouped["Pune"] is grouped["Chennai"].Truemeans the values are the same object, which points straight at how the dict was built.
Scoring: Strong: Spots the shared list within a couple of minutes, explains that [] is evaluated once and the same
object is stored for every key, gives the comprehension fix, and also flags the KeyError on unknown
cities against the docstring. Proposes a test with two cities. Acceptable: Finds the shared-list bug after being asked "what would Pune show?", fixes it correctly, but misses the
KeyError or needs a hint for it. Red flag: Approves because "it worked for Bengaluru", comments only on naming or style, or "fixes" the aliasing
with dict.fromkeys(cities, list()) (still one shared list).
JR-CR-02 · Alert threshold checks built in a loop
Context:
PR #87 · alerts/checks.py · author: Sneha
The payments on-call team gets paged when a metric crosses its threshold. Thresholds used to be
hard-coded if statements; this PR moves them into a config dict and builds one check function per
metric, so adding a metric is a one-line config change. The scheduler calls breached() every minute
with the latest readings and pages for every metric it returns.
The PR includes this test, which passes:
def test_refund_check():
checks = build_checks({"refund_count": 50})
assert checks["refund_count"](51) is True
assert checks["refund_count"](10) is False
Author's note: "Pure refactor, behaviour unchanged. CI is green."
Code under review:
THRESHOLDS = {
"upi_failure_rate": 0.05, # fraction of UPI payments failing
"p95_latency_ms": 800,
"refund_count": 50, # refunds in the last minute
}
def build_checks(thresholds):
"""Return {metric: check} where check(value) is True if value is above the limit."""
checks = {}
for metric, limit in thresholds.items():
checks[metric] = lambda value: value > limit
return checks
def breached(checks, readings):
"""Names of metrics whose reading is above the threshold, sorted."""
return sorted(metric for metric, value in readings.items() if checks[metric](value))
What a strong reviewer raises:
- High: Late binding. Each lambda refers to the variable
limitinbuild_checks's scope, not to the valuelimithad when the lambda was created. The lookup happens when the check is called, after the loop has finished, so every check compares against the last value,50. Consequences with the real config:upi_failure_rate0.09 > 50 isFalse, so a 9% UPI failure rate never pages; andp95_latency_ms120 > 50 isTrue, so a healthy service pages all night. The PR's own test passes because it builds a single check, and with one item the "last value" is the right value. Fix: Bind the value at creation time. Cleanest: a small factory (_above(limit)), because each call to the factory gets its ownlimit. Also acceptable:lambda value, limit=limit: value > limit(default values are evaluated at definition time) orfunctools.partial(operator.lt, limit). - Medium: The test only covers one metric, so it cannot catch any bug that involves more than one closure. A
"pure refactor" claim needs a test that runs the old and new behaviour over the whole config. Fix: Add a parametrised test over every
(metric, limit)pair checking a value just above and just below the limit, plus onebreached()test with mixed readings. - Medium:
breached()raisesKeyErrorfor any reading that has no configured threshold. When the metrics team adds a new metric to the readings feed before anyone adds a threshold, the whole alert loop crashes and no alerts fire, including real ones. Fix: Skip metrics without a check (if metric in checks) and log a warning once, so one unknown metric can't disable alerting for everything else. - Low: Lambdas all have
__name__ == "<lambda>", so tracebacks and log lines don't say which metric's check ran. Fix: Log the metric name with each result (the scheduler already has it as the dict key). A named inner function such as_above.<locals>.checkat least gives tracebacks a name you can search for.
Tests that expose the bug:
checks = build_checks(THRESHOLDS)
# 9% of UPI payments failing is an incident and must page.
assert checks["upi_failure_rate"](0.09) is True
assert checks["upi_failure_rate"](0.01) is False
# 120 ms latency is healthy and must not page.
assert checks["p95_latency_ms"](120) is False
assert checks["p95_latency_ms"](900) is True
readings = {"upi_failure_rate": 0.09, "p95_latency_ms": 900, "refund_count": 12}
assert breached(checks, readings) == ["p95_latency_ms", "upi_failure_rate"]
# A metric that has no configured threshold is skipped, not a crash.
assert breached(checks, {"new_metric": 5, "refund_count": 60}) == ["refund_count"]
Fixed version:
THRESHOLDS = {
"upi_failure_rate": 0.05, # fraction of UPI payments failing
"p95_latency_ms": 800,
"refund_count": 50, # refunds in the last minute
}
def _above(limit):
"""Return a check bound to this call's `limit`."""
def check(value):
return value > limit
return check
def build_checks(thresholds):
"""Return {metric: check} where check(value) is True if value is above the limit."""
return {metric: _above(limit) for metric, limit in thresholds.items()}
def breached(checks, readings):
"""Names of metrics whose reading is above the threshold, sorted."""
return sorted(
metric
for metric, value in readings.items()
if metric in checks and checks[metric](value)
)
Follow-ups:
- "Why did
p95_latency_mswith 900 still give the right answer in the buggy version?" Because 900 > 50 is alsoTrue: the wrong limit happens to agree with the right one for that reading. That's why spot checks on large values miss this bug. You need a value between the real limit and the leaked one (120 for latency, 0.09 for the UPI rate). - "Someone suggests
functools.partial(operator.gt, limit). Is that correct?" No.partial(operator.gt, limit)(value)computesoperator.gt(limit, value), which islimit > value, the opposite check. The correct one ispartial(operator.lt, limit), meaninglimit < value. Readability matters: the factory is harder to get backwards. - "What is the downside of the
limit=limitdefault-argument fix?"limitbecomes part of the function's public signature: a caller can accidentally pass a second argument and override it (check(0.09, 1)), and type checkers andhelp()show a parameter that callers should never use. The factory keeps it private. - "Would a list comprehension
[lambda v: v > limit for limit in limits]have the same problem?" Yes. The comprehension has its own scope, but all the lambdas still close over the onelimitvariable in that scope, which ends at the last value.
Scoring: Strong: Spots that every lambda closes over the same limit and explains it as lookup at call time, not
definition time. Names the concrete impact (missed UPI page, false latency pages) and explains why the
single-metric test was green. Fixes with a factory or default argument and asks for a multi-metric test. Acceptable: Recognises the late-binding pattern and fixes it, but needs a prompt to explain why CI passed or to see
the KeyError in breached(). Red flag: Approves because CI is green and it is "a pure refactor", or believes each lambda "remembers" its own
limit, and can't explain why after running [c(0.09) for c in checks.values()].
JR-CR-03 · A retry decorator for the SMS gateway
Context:
PR #233 · notify/retry.py · author: Arjun
The OTP service calls a third-party SMS gateway that sometimes returns 502s or times out for a few
seconds. The client already raises TransientError for 5xx responses and timeouts, and lets other
errors (ValueError for a malformed phone number, PermissionError for a revoked API key) propagate.
This PR adds a reusable @retry decorator so callers can write:
@retry(times=3, delay=0.5)
def send_otp(phone: str, code: str) -> str:
... # returns the gateway's message ID
The spec agreed in standup: try up to times times in total; wait a little longer after each
failure; if every attempt fails, the caller must see the error (the login screen shows "Couldn't send
OTP, try again"). Callers store the returned message ID for delivery tracking.
Code under review:
import logging
import time
logger = logging.getLogger("notify")
class TransientError(Exception):
"""5xx response or timeout from the SMS gateway."""
def retry(times=3, delay=0.5):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(1, times):
try:
return func(*args, **kwargs)
except Exception as exc:
logger.warning("attempt %d failed: %s", attempt, exc)
time.sleep(delay * attempt)
return wrapper
return decorator
What a strong reviewer raises:
- High: Swallowed failure. When the last attempt fails, the
exceptblock logs, sleeps, and the loop ends.wrapperthen falls off the end and returnsNone. The caller getsNoneinstead of an exception, so the login flow says "OTP sent", storesNoneas the message ID, and the user waits for an SMS that never comes. Nothing reaches error monitoring because no exception escaped. Fix: Re-raise on the final attempt (if attempt == times: raise). A bareraiseinsideexceptre-raises the original exception with its traceback intact. - High: Off by one.
range(1, times)yields1 .. times-1, sotimes=3makes only 2 attempts (andtimes=1makes zero attempts and returnsNonewithout calling the function at all). A call that would have succeeded on the third try is reported as a failure. In the tests,flaky_sendreturnsNoneafter two calls. Fix:range(1, times + 1), and rejecttimes < 1up front so a config typo can't turn the decorator into a silent no-op. - Medium:
except Exceptionretries everything. AValueErrorfor a malformed number or a revoked API key will fail identically every time, so retrying just multiplies load on the gateway and delays the error. It also hides real bugs: aTypeErrorin our own code is retried three times and logged as "attempt failed". Fix: Retry only what is known to be transient: aretry_on=(TransientError,)parameter, defaulting to the gateway's transient error type. Everything else propagates on the first attempt. - Medium: No
functools.wraps: every decorated function now reports__name__ == "wrapper"and loses its docstring. Log lines, metrics keyed by function name, and pytest/Sentry output all showwrapper. Fix: Decorate the inner function with@functools.wraps(func), and includefunc.__name__andattempt/timesin the warning so on-call can tell which call is failing. - Low: It sleeps even after the final attempt, adding
delay * timesseconds of pointless waiting before returning, on a user-facing login path. Fix: Only sleep when another attempt will follow (the fixed version re-raises before sleeping).
Tests that expose the bug:
logging.disable(logging.CRITICAL) # keep test output quiet
calls = []
@retry(times=3, delay=0)
def flaky_send(phone):
calls.append(phone)
if len(calls) < 3:
raise TransientError("502 Bad Gateway")
return "msg-7f3a"
# Succeeds on the third (last allowed) attempt.
assert flaky_send("+919800000001") == "msg-7f3a"
assert len(calls) == 3
# When every attempt fails, the caller must see the error, after exactly `times` attempts.
attempts = []
@retry(times=3, delay=0)
def gateway_down(phone):
attempts.append(phone)
raise TransientError("timeout")
try:
result = gateway_down("+919800000002")
except TransientError:
pass
else:
raise AssertionError(f"expected TransientError, got return value {result!r}")
assert len(attempts) == 3
# A bad phone number will never succeed: no retries.
bad = []
@retry(times=3, delay=0)
def invalid_number(phone):
bad.append(phone)
raise ValueError("phone must be +91 followed by 10 digits")
try:
invalid_number("98400")
except ValueError:
pass
assert len(bad) == 1
# Logs, metrics and pytest reports use the real function name.
assert flaky_send.__name__ == "flaky_send"
Fixed version:
import functools
import logging
import time
logger = logging.getLogger("notify")
class TransientError(Exception):
"""5xx response or timeout from the SMS gateway."""
def retry(times=3, delay=0.5, retry_on=(TransientError,)):
if times < 1:
raise ValueError("times must be at least 1")
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except retry_on as exc:
if attempt == times:
raise # out of attempts: let the caller see it
logger.warning("%s attempt %d/%d failed: %s",
func.__name__, attempt, times, exc)
time.sleep(delay * attempt)
return wrapper
return decorator
Follow-ups:
- "The gateway call timed out, but the gateway actually sent the SMS. What happens when we retry, and how do you prevent it?" The user gets two OTPs, and with some providers the first one becomes invalid, which confuses them. A timeout means "unknown outcome", not "failed". Prevent it with an idempotency key (the same request ID on every retry, so the gateway dedups), or by retrying only errors that guarantee nothing happened (connection refused, 503 before processing).
- "Why does
logging.disableappear in the tests, and what would print without it?" Thenotifylogger has no handler configured, so Python's last-resort handler prints WARNING and above to stderr: each failed attempt would print a line such asattempt 1 failed: 502 Bad Gateway. Tests should either silence logging or assert on it with pytest'scaplogfixture. - "How would you test this without real
time.sleepcalls slowing the suite?" Passdelay=0as here, or patch the sleep:monkeypatch.setattr(time, "sleep", fake_sleep)and assert on the delays it recorded ([0.5, 1.0]for three attempts). Makingsleepan injectable parameter ofretryis another clean option. - "Why
delay * attemptrather than a fixed delay? What would you add for a gateway used by many servers?" Increasing waits give the gateway time to recover. With many clients, add jitter (a random part of the delay) so all servers don't retry in lockstep and hammer the gateway at the same instant. Exponential backoff (delay * 2 ** (attempt - 1)) with a cap is the usual pattern; libraries such astenacityimplement it.
Scoring: Strong: Finds both high-severity bugs (the silent None and the off-by-one) by reading the loop carefully, and
explains the user-visible effect of each. Raises the except Exception scope and the missing
functools.wraps. Fixes with a bare raise on the last attempt, and mentions idempotency or tests
without real sleeps. Acceptable: Finds the swallowed exception and the missing wraps, and finds the off-by-one when asked to trace
times=3. Fix is correct but retries all exceptions until prompted. Red flag: Comments only on naming or log format. Thinks returning None after failures is "graceful". Fixes by
raise exc after the loop (in Python 3 that raises UnboundLocalError, because the as exc name is
deleted when the except block ends). Or can't explain why a decorator with arguments needs three
nested functions.
JR-CR-04 · Hourly incremental load of an orders table
Context:
PR #58 · etl/orders_incremental.py · author: Meera (data engineering)
The analytics copy of the orders table used to be rebuilt from scratch every night. This PR switches to
an hourly incremental load. Every hour the upstream team drops a change file with orders whose
updated_at changed recently. To be safe they re-send the previous hour's rows too, so files overlap.
Files for quiet hours (roughly 02:00–05:00 IST) are often empty. The job keeps a watermark, the
latest updated_at it has loaded, and stores it between runs.
incremental_load should upsert: new orders are inserted, and changed orders replace their old row
(an order moves PLACED → DELIVERED or CANCELLED). The target must stay one row per order_id.
Author's note: "Tested with yesterday's 11:00 file: the new orders show up and the row count matches.
The > watermark filter skips the overlapping rows."
Code under review:
import pandas as pd
def incremental_load(target, batch, watermark):
"""Upsert rows from an hourly change file into the target table.
target: current copy of the orders table (one row per order_id)
batch: the latest change file; may repeat rows already loaded
watermark: latest updated_at already loaded
Returns (new_target, new_watermark).
"""
changed = batch[batch["updated_at"] > watermark]
merged = pd.concat([target, changed]).drop_duplicates(subset="order_id")
merged = merged.sort_values("order_id").reset_index(drop=True)
return merged, changed["updated_at"].max()
What a strong reviewer raises:
- High: Updates are thrown away.
drop_duplicateskeeps the first occurrence by default. Because the old target rows come first inpd.concat([target, changed]), the stale row wins and the new version is dropped. New orders are inserted (they have no duplicate), so the author's check, "new orders show up and the row count matches", passes. But order 2 staysPLACEDafter the 11:00 file saysDELIVERED. Every status change after the first load is lost, and nothing errors. Fix:drop_duplicates(subset="order_id", keep="last"), so the batch row wins. Sortchangedbyupdated_atfirst (with a stable sort) so that if one order appears twice in a file, its latest version is the one kept. - High: An empty file poisons the watermark. For a quiet hour
changedis empty, andchanged["updated_at"].max()returnsNaT. Every comparison withNaTisFalse, so from the next run onbatch["updated_at"] > NaTselects nothing, forever. The job keeps "succeeding" with zero rows loaded. In the tests the watermark after the 03:00 file isNaT, and the later cancellation of order 1 and the new order 4 never arrive. Fix: Ifchangedis empty, return the target and the old watermark unchanged. Also never let the watermark move backwards:max(watermark, changed["updated_at"].max()). A sanity check (assert pd.notna(new_watermark)) before saving the watermark would have turned this into a loud failure. - Medium: Late rows at the watermark are skipped. Many rows share a timestamp (two orders updated at 10:40).
If one of them was committed after the extract ran, it arrives in a later file with
updated_atequal to the watermark, and> watermarkfilters it out. Order 4 in the tests is lost even with a valid watermark. Fix: Use>= watermark. That re-reads the rows at the boundary every run, which is harmless because the load is an idempotent upsert (keep="last"). The general rule: make the write idempotent, then overlap the read window rather than trying to make it exact. - Low: Concatenating an empty change file changes column types. An empty CSV is read with
objectcolumns, and afterpd.concatthe target'sorder_idsilently becomesobjectinstead ofint64. Later joins against anint64key then match nothing or raise. Fix: The early return for empty batches avoids it here. More generally, read files with explicitdtype={"order_id": "int64"}andparse_dates=["updated_at"], and assert the schema before loading.
Tests that expose the bug:
ts = pd.Timestamp
COLS = ["order_id", "status", "updated_at"]
def frame(rows):
df = pd.DataFrame(rows, columns=COLS)
df["updated_at"] = pd.to_datetime(df["updated_at"])
return df
target = frame([(1, "PLACED", "2026-10-01 09:00"), (2, "PLACED", "2026-10-01 09:30")])
wm = ts("2026-10-01 09:30")
# 11:00 file: order 2 delivered, order 3 placed; the upstream also re-sends order 1 unchanged.
batch_11 = frame([(1, "PLACED", "2026-10-01 09:00"),
(2, "DELIVERED", "2026-10-01 10:40"),
(3, "PLACED", "2026-10-01 10:40")])
target, wm = incremental_load(target, batch_11, wm)
assert target["status"].tolist() == ["PLACED", "DELIVERED", "PLACED"], target
assert wm == ts("2026-10-01 10:40")
# 03:00 file is empty (quiet hour). Nothing changes, the watermark must survive.
target, wm = incremental_load(target, frame([]), wm)
assert wm == ts("2026-10-01 10:40"), wm
assert target["order_id"].dtype == "int64", target.dtypes
# Next file: order 4 was committed late with the same timestamp as the watermark,
# and order 1 was cancelled.
batch_04 = frame([(4, "PLACED", "2026-10-01 10:40"),
(1, "CANCELLED", "2026-10-02 04:10")])
target, wm = incremental_load(target, batch_04, wm)
assert target["order_id"].tolist() == [1, 2, 3, 4], target
assert target["status"].tolist() == ["CANCELLED", "DELIVERED", "PLACED", "PLACED"], target
assert wm == ts("2026-10-02 04:10")
Fixed version:
import pandas as pd
def incremental_load(target, batch, watermark):
"""Upsert rows from an hourly change file into the target table.
target: current copy of the orders table (one row per order_id)
batch: the latest change file; may repeat rows already loaded
watermark: latest updated_at already loaded
Returns (new_target, new_watermark).
"""
# >= : re-read rows at the watermark; the upsert below makes that harmless.
changed = batch[batch["updated_at"] >= watermark]
if changed.empty:
return target, watermark # quiet hour: keep the old watermark
changed = changed.sort_values("updated_at", kind="stable")
merged = pd.concat([target, changed]).drop_duplicates(subset="order_id", keep="last")
merged = merged.sort_values("order_id").reset_index(drop=True)
return merged, max(watermark, changed["updated_at"].max())
Follow-ups:
- "Why does
batch['updated_at'] > NaTselect nothing, and how would you detect a NaT watermark in a pipeline?"NaTbehaves likeNaN: every comparison with it isFalse(pd.NaT == pd.NaTisFalsetoo; only!=isTrue). Detect it withpd.isna(watermark)before saving state, and alert when a run loads zero rows several hours in a row during business hours. - "The upstream starts sending
updated_atas timezone-aware IST while the stored watermark is naive. What happens?" The comparison raisesTypeError: Invalid comparison between dtype=datetime64[ns, Asia/Kolkata] and Timestamp. At least it's loud. Pick one convention (usually UTC, timezone-aware) at the boundary: convert withtz_localize/tz_convertwhen reading, and store the watermark the same way. - "The job crashes after writing the new target but before saving the watermark. Is the next run safe?" Yes, because the upsert is idempotent: the next run re-reads from the old watermark and rewrites the same rows with the same values. That's why "save the watermark last" plus idempotent writes is the standard ordering. If the write were an append, the rerun would create duplicates.
- "In production
targetis a database table, not a DataFrame. What replacesconcat+drop_duplicates?" An upsert in the database:INSERT ... ON CONFLICT (order_id) DO UPDATEin PostgreSQL, orMERGEin warehouses, ideally with a guard such asWHERE excluded.updated_at >= orders.updated_atso an older replay can't overwrite a newer row. Load the batch into a staging table first. - "How would you test this function properly with pytest?" Small hand-built DataFrames for each scenario: an update to an existing order, an empty file, an overlapping replay, a late row at the watermark, and the same order twice in one file. Assert on the full table and the watermark after a sequence of runs, because most of these bugs only appear on the second or third run.
Scoring: Strong: Finds the keep="first" bug by asking "what happens to an order that changes?", and the NaT
watermark by asking "what if the file is empty?". Explains why the author's row-count check passed.
Proposes keep="last", an early return, and >= with an idempotent upsert, and thinks in sequences of
runs, not a single call. Acceptable: Finds the drop_duplicates bug and the empty-file case with one hint ("think about a quiet hour"), but
treats the > vs >= boundary as a nitpick or misses it. Red flag: Reviews only one run on the happy path. Accepts "row count matches" as proof of correctness, or
proposes rebuilding the full table every hour without being asked about scale or cost.
JR-CR-05 · Customer profile API with partial updates
Context:
PR #301 · profiles/ (schemas.py, db.py, auth.py, routes.py) · author: Vikram
A lending app is adding a "My profile" screen. Customers log in on the app and get an API key, sent as
the X-API-Key header. This PR adds two endpoints:
GET /customers/{customer_id}: view your own profile.PATCH /customers/{customer_id}: update some ofname,city,phone. The app sends only the fields the customer edited, for example{"city": "Bengaluru"}.
The customer table also stores the PAN (Permanent Account Number), collected for KYC. The product spec says the PAN is never shown or returned by the profile screen. Customers may only see or change their own profile.
The four modules are shown in one block, separated by comments. The dict CUSTOMERS stands in for the
database table. Author's note: "Validation is all in Pydantic: phone must be +91 and 10 digits, name
can't be empty. Manually tested both endpoints in Swagger with my own test account."
Code under review:
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
# ---------------- schemas.py ----------------
class CustomerOut(BaseModel):
id: int
name: str
city: str
phone: str
class CustomerUpdate(BaseModel):
"""PATCH body: every field optional, send only what changes."""
name: str | None = Field(default=None, min_length=1)
city: str | None = Field(default=None, min_length=1)
phone: str | None = Field(default=None, pattern=r"^\+91\d{10}$")
# ---------------- db.py (in-memory stand-in for the real table) ----------------
CUSTOMERS = {
1: {"id": 1, "name": "Ananya Iyer", "city": "Chennai", "phone": "+919840012345", "pan": "ABCPI1234K"},
2: {"id": 2, "name": "Rohit Verma", "city": "Indore", "phone": "+919826054321", "pan": "BXZPV5678L"},
}
API_KEYS = {"key-ananya": 1, "key-rohit": 2} # issued at login; maps key -> customer id
# ---------------- auth.py ----------------
def current_customer_id(x_api_key: str = Header()) -> int:
customer_id = API_KEYS.get(x_api_key)
if customer_id is None:
raise HTTPException(status_code=401, detail="invalid API key")
return customer_id
# ---------------- routes.py ----------------
app = FastAPI()
@app.get("/customers/{customer_id}")
def get_customer(customer_id: int, caller: int = Depends(current_customer_id)):
if customer_id != caller:
raise HTTPException(status_code=403, detail="not your profile")
return CUSTOMERS[customer_id]
@app.patch("/customers/{customer_id}")
def update_customer(customer_id: int, body: CustomerUpdate,
caller: int = Depends(current_customer_id)):
customer = CUSTOMERS.get(customer_id)
if customer is None:
raise HTTPException(status_code=404, detail="customer not found")
customer.update(body.model_dump())
return customer
What a strong reviewer raises:
- High: Anyone can edit anyone's profile (IDOR, insecure direct object reference).
GETcheckscustomer_id != caller, butPATCHresolvescallerand then never uses it. With her own key, Ananya canPATCH /customers/2and change Rohit's phone number. On a lending app, changing someone else's phone redirects their OTPs, which is the first step of an account takeover. Testing "with my own test account" can't find this, because you only ever request your own ID. Fix: Move the ownership check into a shared dependency (require_owner) used by every/customers/{customer_id}route, so a new route can't forget it. Add a test that uses one customer's key on another customer's ID for each route. - High: PATCH wipes fields the client didn't send.
body.model_dump()includes every field, withNonefor the ones that weren't sent.{"city": "Bengaluru"}therefore writesname=None, phone=None. The response in the tests is{'id': 1, 'name': None, 'city': 'Bengaluru', 'phone': None, 'pan': 'ABCPI1234K'}. The customer loses their phone number, so they can't receive OTPs at all. Fix:body.model_dump(exclude_unset=True)returns only the fields present in the request JSON. That is exactly PATCH semantics. - High: The PAN leaks. Both routes return the raw stored dict, so the JSON response includes
pan. That breaks the product spec and exposes regulated personal data to logs, browser caches and anyone holding the key.CustomerOutexists but isn't used anywhere. Fix: Declareresponse_model=CustomerOuton both routes. FastAPI then validates the return value and sends only the declared fields. Treat "return the DB row" as a review smell in general. - Medium: Explicit
nullpasses validation. The fields are typedstr | Noneso they can be omitted, but that also accepts{"name": null}, andmin_lengthdoesn't apply toNone. Even withexclude_unset=True, an explicit null would writename=None. The author's claim that "name can't be empty" is only half true. Fix: Afield_validatorthat rejectsNone. Validators don't run for omitted fields that fall back to their default, so "omitted" is still allowed while "sent as null" returns 422. Test both. - Low: Wrong status codes. A missing
X-API-Keyheader returns 422 (FastAPI treats the required header as a validation error), not 401, so the mobile app's "session expired, log in again" handler never fires. Also,PATCHon an unknown ID returns 404 while an existing ID that belongs to someone else returns 200. That difference lets an attacker list which customer IDs exist. Fix: Declare the header as optional (Header(default=None)) and raise 401 yourself when it's missing or unknown. Do the ownership check before the lookup, so any ID that isn't yours gets the same 403.
Tests that expose the bug:
from fastapi.testclient import TestClient
client = TestClient(app)
ANANYA = {"X-API-Key": "key-ananya"}
# Partial update: only the city changes, everything else is kept.
r = client.patch("/customers/1", json={"city": "Bengaluru"}, headers=ANANYA)
assert r.status_code == 200, r.text
assert r.json() == {"id": 1, "name": "Ananya Iyer", "city": "Bengaluru", "phone": "+919840012345"}, r.json()
# Responses never include the PAN.
r = client.get("/customers/1", headers=ANANYA)
assert r.status_code == 200 and "pan" not in r.json(), r.json()
# A customer can't edit someone else's profile.
r = client.patch("/customers/2", json={"city": "Goa"}, headers=ANANYA)
assert r.status_code == 403, r.status_code
assert CUSTOMERS[2]["city"] == "Indore"
# Explicit null for a required field is rejected, and nothing is written.
r = client.patch("/customers/1", json={"name": None}, headers=ANANYA)
assert r.status_code == 422, r.status_code
assert CUSTOMERS[1]["name"] == "Ananya Iyer"
# Validation still works.
assert client.patch("/customers/1", json={"phone": "98400"}, headers=ANANYA).status_code == 422
# No key at all is an authentication failure (401), not a validation error.
assert client.get("/customers/1").status_code == 401
assert client.get("/customers/1", headers={"X-API-Key": "stolen"}).status_code == 401
Fixed version:
from fastapi import Depends, FastAPI, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
# ---------------- schemas.py ----------------
class CustomerOut(BaseModel):
id: int
name: str
city: str
phone: str
class CustomerUpdate(BaseModel):
"""PATCH body: every field optional, send only what changes."""
name: str | None = Field(default=None, min_length=1)
city: str | None = Field(default=None, min_length=1)
phone: str | None = Field(default=None, pattern=r"^\+91\d{10}$")
@field_validator("name", "city", "phone")
@classmethod
def not_null(cls, value):
# Runs only for fields the client actually sent: omitted is fine, explicit null is not.
if value is None:
raise ValueError("may be omitted, but not set to null")
return value
# ---------------- db.py (in-memory stand-in for the real table) ----------------
CUSTOMERS = {
1: {"id": 1, "name": "Ananya Iyer", "city": "Chennai", "phone": "+919840012345", "pan": "ABCPI1234K"},
2: {"id": 2, "name": "Rohit Verma", "city": "Indore", "phone": "+919826054321", "pan": "BXZPV5678L"},
}
API_KEYS = {"key-ananya": 1, "key-rohit": 2} # issued at login; maps key -> customer id
# ---------------- auth.py ----------------
def current_customer_id(x_api_key: str | None = Header(default=None)) -> int:
customer_id = API_KEYS.get(x_api_key) if x_api_key else None
if customer_id is None:
raise HTTPException(status_code=401, detail="missing or invalid API key")
return customer_id
def require_owner(customer_id: int, caller: int = Depends(current_customer_id)) -> int:
"""Shared by every /customers/{customer_id} route, so no route can forget the check."""
if customer_id != caller:
raise HTTPException(status_code=403, detail="not your profile")
return customer_id
# ---------------- routes.py ----------------
app = FastAPI()
@app.get("/customers/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id: int = Depends(require_owner)):
return CUSTOMERS[customer_id]
@app.patch("/customers/{customer_id}", response_model=CustomerOut)
def update_customer(body: CustomerUpdate, customer_id: int = Depends(require_owner)):
customer = CUSTOMERS[customer_id]
customer.update(body.model_dump(exclude_unset=True))
return customer
Follow-ups:
- "The client sends
{"city": "Pune", "pan": "XXXXX0000X"}. Can they overwrite their PAN through this endpoint after the fix?" No. Pydantic's defaultextra="ignore"drops unknown keys, sopannever reachesmodel_dump()and the stored PAN is unchanged. Many teams prefermodel_config = ConfigDict(extra="forbid")on input models, so the client gets a 422 instead of a silent ignore. Writing the request dict straight into the row without a model ("mass assignment") is how this goes wrong. - "What's the difference between
exclude_unset=Trueandexclude_none=Truehere?"exclude_unsetdrops fields not present in the request.exclude_nonedrops fields whose value isNone, whether sent or not. For PATCH you wantexclude_unset, and you decide separately what an explicitnullmeans: reject it (as here), or treat it as "clear this field" for a genuinely nullable column such asalternate_phone. - "Why is the ownership check better as a dependency than as an
ifin each route?" It can't be forgotten: routes declareDepends(require_owner)and get back an ID that is already authorised. The rule lives in one place, so when admins or support staff need access, you change one function. It is also easy to test on its own. - "Is an API key that maps to a customer ID enough for production?" It's a reasonable stand-in. In production you'd usually issue short-lived signed tokens (for example JWTs or opaque session tokens) with expiry and revocation, send them only over HTTPS, store only a hash of long-lived keys, and rate-limit the login and OTP endpoints. A junior isn't expected to design that, but should know the key must expire and must never be logged.
- "Which tests would you ask the author to add before approving?" One per issue, using FastAPI's
TestClient: a partial PATCH asserting untouched fields are kept;"pan" not in response.json()for both routes; another customer's key gets 403 on every route; an explicitnullgets 422 and nothing changes; no header gets 401.
Scoring: Strong: Finds the missing ownership check on PATCH, the model_dump() wiping fields, and the PAN leak. Explains
the real-world impact of each (account takeover through a changed phone number, data loss, regulated
data exposure). Fixes with a shared dependency, exclude_unset=True and response_model, raises the
explicit-null case, and proposes cross-customer tests. Acceptable: Finds two of the three high-severity issues on their own (usually the PATCH wipe and the PAN leak) and
the authorisation gap after a prompt such as "what stops Ananya from calling /customers/2?". Fixes are
correct but stay route by route. Red flag: Trusts "tested in Swagger" as evidence. Focuses on code style or the in-memory dict. Doesn't see why
returning the stored row is a problem. Treats authentication (who you are) as if it were authorisation
(what you may access).
Theory questions
JR-TH-ASY-01 · A minimal asyncio program
Question: Walk me through a minimal asyncio program that fetches two things concurrently. What do async def, await, asyncio.gather and asyncio.run each do?
Answer:
async defdefines a coroutine function. Calling it returns a coroutine object; nothing in the body runs until it's awaited or scheduled as a task.await xruns the awaitable and pauses the current coroutine until it's done. While paused, the event loop runs other tasks.asyncio.gather(c1, c2)schedules the coroutines as tasks, runs them concurrently, and returns their results in argument order, not completion order. "users" finished first but is second in the list.asyncio.run(main())creates an event loop, runsmain()to completion and closes the loop. Call it once, at the program's entry point.
Everything runs on one thread. Concurrency comes from switching tasks at await points while they wait on I/O, so the total time is roughly the longest wait (0.05 s here), not the sum.
Good fit: many network or database calls with async libraries. Not a fit: CPU-heavy work, which blocks the single thread.
import asyncio
async def fetch(name, delay):
print(f"start {name}")
await asyncio.sleep(delay) # stands in for a network call
print(f"end {name}")
return f"{name} ok"
async def main():
results = await asyncio.gather(fetch("orders", 0.05), fetch("users", 0.01))
print(results)
asyncio.run(main())
Output:
start orders
start users
end users
end orders
['orders ok', 'users ok']
Follow-ups:
- "Can you use
awaitinside a normaldeffunction?" No, it's aSyntaxError. Only insideasync def(Jupyter and the asyncio REPL also allow top-levelawait). - "What if
fetchusedtime.sleepinstead ofasyncio.sleep?"time.sleepblocks the whole thread, so the event loop can't switch tasks: the fetches run one after another and the total becomes the sum.
Scoring: Strong: Explains each piece accurately, notes gather's result order and that it's single-threaded concurrency at await points. Acceptable: Writes a working example but is fuzzy on what await does to the event loop. Red flag: Thinks async def runs the function in a background thread, or that gather returns results in completion order.
JR-TH-ASY-02 · Two awaits in a row are not concurrent
Question: An async endpoint does a = await fetch(1) and then b = await fetch(2). Do the two calls overlap? Compare with asyncio.gather.
Answer:
Trap: "Each await starts the call in the background, so they overlap." await waits for that coroutine to finish before the next line runs. Two awaits in a row are sequential: the output shows start 1, end 1, start 2, end 2, and the total time is the sum.
Being inside async def isn't enough. You have to schedule both before waiting on either:
await asyncio.gather(fetch(1), fetch(2)),t1 = asyncio.create_task(...)andt2 = asyncio.create_task(...), then await them,- or an
asyncio.TaskGroup(3.11+).
Then both start before either ends (start 1, start 2, end 2, end 1), and the total is about the longest call.
The real-world version: a FastAPI endpoint that awaits five independent service calls one after another and is no faster than the sync version. Only independent calls should run concurrently; if you need the user before you can fetch their orders, those stay sequential.
import asyncio
async def fetch(n, delay):
print(f"start {n}")
await asyncio.sleep(delay)
print(f"end {n}")
return n
async def sequential():
a = await fetch(1, 0.02)
b = await fetch(2, 0.01)
return [a, b]
async def concurrent():
return await asyncio.gather(fetch(1, 0.02), fetch(2, 0.01))
print(asyncio.run(sequential()))
print(asyncio.run(concurrent()))
Output:
start 1
end 1
start 2
end 2
[1, 2]
start 1
start 2
end 2
end 1
[1, 2]
Follow-ups:
- "Rewrite
concurrent()withcreate_task."t1 = asyncio.create_task(fetch(1, 0.02)),t2 = asyncio.create_task(fetch(2, 0.01)), thenreturn [await t1, await t2]. Both are already running before the first await. - "And with a TaskGroup?"
async with asyncio.TaskGroup() as tg:create both tasks withtg.create_task(...); after the block, readt1.result()andt2.result(). If one fails, the other is cancelled.
Scoring: Strong: Says the awaits run sequentially, reads the trace correctly, and gives gather / create_task / TaskGroup with the 'only independent calls' caveat. Acceptable: Unsure at first but gets it from the printed start/end order. Red flag: Insists sequential awaits run in parallel because the function is async.
JR-TH-ASY-03 · Limiting concurrency with a Semaphore
Question: You need to call an API for 10,000 SKUs with asyncio, but the provider allows at most 10 requests in flight. asyncio.gather on 10,000 coroutines would start them all at once. How do you limit it?
Answer:
Use an asyncio.Semaphore(n). At most n coroutines can be inside async with sem: at any moment; the rest wait at that line. In the snippet the peak number of in-flight calls is exactly 3, and gather still returns the results in input order.
Create the semaphore inside the running program (e.g. in main), share it with all the tasks, and hold it only around the actual request, not around the processing of the response.
Alternatives:
- A fixed pool of worker tasks pulling from an
asyncio.Queue. That's better for very large inputs, because you don't create 10,000 coroutine objects up front. httpx.AsyncClient(limits=httpx.Limits(max_connections=10))caps connections at the client level.
A semaphore limits concurrency, not rate. If the limit is "100 requests per minute", you also need a time-based limiter.
Why is the global in_flight counter safe without a lock? Everything runs on one thread, and there's no await between reading and writing it, so no other task can run in between.
import asyncio
in_flight = 0
peak = 0
async def call_api(sem, i):
global in_flight, peak
async with sem:
in_flight += 1
peak = max(peak, in_flight)
await asyncio.sleep(0.01)
in_flight -= 1
return i * 10
async def main():
sem = asyncio.Semaphore(3)
results = await asyncio.gather(*(call_api(sem, i) for i in range(10)))
print(results)
print("peak concurrent calls:", peak)
asyncio.run(main())
Output:
[0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
peak concurrent calls: 3
Follow-ups:
- "Semaphore(10) vs '10 requests per second': same thing?" No. A fast API could serve 10 concurrent requests many times per second. Use a token bucket, or space out the starts.
- "What if one request hangs forever?" It holds a semaphore slot forever. Always set a timeout (client timeout or
asyncio.timeout) so slots are released. - "Would
threading.Semaphorework here?" No. It blocks the whole thread, and with it the event loop. Use the asyncio version.
Scoring: Strong: Uses an asyncio Semaphore correctly, mentions queue-based workers or client limits, and distinguishes concurrency from rate. Acceptable: Knows a semaphore is the answer but places it awkwardly (e.g. around gather). Red flag: Proposes time.sleep between calls, or batching with sequential awaits only.
JR-TH-ASY-04 · gather does not cancel the others on failure
Question: You charge two orders with asyncio.gather(charge("A1"), charge("A2")). A1 fails. Does A2 still get charged?
Answer:
Trap: "When one task in gather fails, the others are cancelled." Not with plain gather (the default return_exceptions=False). The first exception is raised to whoever awaited gather, but the other awaitables are not cancelled; they keep running. A2 still gets charged after your code has already handled "card declined", so a step you think was aborted actually happened.
If main() returned right away, asyncio.run would cancel the leftover task at shutdown, which is a different, timing-dependent outcome.
The options:
return_exceptions=True:gatherwaits for everything and puts exceptions in the result list as values; check each withisinstance(r, Exception). Good for independent jobs, such as sending 50 notifications.asyncio.TaskGroup(3.11+): if one task fails, the others are cancelled and the errors are raised together as anExceptionGroup(catch withexcept*). Good for "all or nothing" steps.
Pick by meaning, not by habit.
import asyncio
async def charge(order_id, delay, fail=False):
await asyncio.sleep(delay)
if fail:
raise ValueError(f"card declined for {order_id}")
print(f"charged {order_id}")
return order_id
async def main():
try:
await asyncio.gather(charge("A1", 0.01, fail=True), charge("A2", 0.03))
except ValueError as exc:
print("gather raised:", exc)
await asyncio.sleep(0.05) # give A2 time to finish
results = await asyncio.gather(charge("B1", 0.01, fail=True), charge("B2", 0.02),
return_exceptions=True)
print(results)
asyncio.run(main())
Output:
gather raised: card declined for A1
charged A2
charged B2
[ValueError('card declined for B1'), 'B2']
Follow-ups:
- "How do you handle errors from a TaskGroup?"
except* ValueError as eg:then loop overeg.exceptions. You can have severalexcept*clauses for different types. - "What if the
gather(...)itself is cancelled, e.g. by a timeout?" Then all of its unfinished children are cancelled too. - "For payments, is cancelling A2 enough to be safe?" No. Cancellation can arrive after the provider already processed the charge. Use idempotency keys and reconcile with the provider's status.
Scoring: Strong: Knows plain gather doesn't cancel siblings, explains return_exceptions=True vs TaskGroup, and picks by semantics. Acceptable: Knows return_exceptions but assumes the default cancels the rest. Red flag: Thinks the exception is silently swallowed, or that the whole program stops.
JR-TH-ASY-05 · Timeouts in asyncio
Question: How do you put a timeout on a slow async call, or on a whole block of calls? What happens to the work that timed out?
Answer:
Two tools:
await asyncio.wait_for(coro, timeout=...)for a single awaitable.async with asyncio.timeout(seconds):(3.11+) around a block of several awaits, e.g. a whole endpoint's budget.
On timeout, asyncio cancels the inner work: CancelledError is raised inside it at the await where it's waiting, so its finally blocks and async with cleanups run (that's why "cleanup" prints before the handler). Then the caller gets TimeoutError, which since 3.11 is the built-in TimeoutError.
The work is abandoned, not paused. If the remote side already processed your request, it may still have happened, so make retries idempotent.
Practical points:
- HTTP clients have their own timeouts.
httpxdefaults to 5 seconds; set them explicitly per call. - Don't swallow
CancelledError. It inherits fromBaseException(since 3.8), soexcept Exceptiondoesn't catch it, but a bareexcept:does, and then timeouts and shutdown stop working.
import asyncio
async def slow_gst_lookup(gstin):
try:
await asyncio.sleep(1)
return "valid"
finally:
print(f"cleanup for {gstin}")
async def main():
try:
result = await asyncio.wait_for(slow_gst_lookup("27ABCDE1234F1Z5"), timeout=0.05)
except TimeoutError:
result = "unknown (timed out)"
print(result)
try:
async with asyncio.timeout(0.05):
await slow_gst_lookup("29PQRSX5678K1Z2")
except TimeoutError:
print("whole block timed out")
asyncio.run(main())
Output:
cleanup for 27ABCDE1234F1Z5
unknown (timed out)
cleanup for 29PQRSX5678K1Z2
whole block timed out
Follow-ups:
- "You want to stop waiting but let the work finish in the background. How?" Start it as a task and use
asyncio.wait({task}, timeout=...), which doesn't cancel, or wrap it inasyncio.shield(). Keep a reference to the task. - "How would you retry a call that times out?" A loop with a per-attempt timeout, a capped number of attempts and exponential backoff with jitter, and only for idempotent operations.
- "
asyncio.timeoutvswait_for: which for a whole request handler?"asyncio.timeout: one deadline across several awaits without wrapping each call.
Scoring: Strong: Names wait_for and asyncio.timeout, explains cancellation with cleanup then TimeoutError, and warns about idempotency and swallowing CancelledError. Acceptable: Uses wait_for correctly but thinks the timed-out coroutine keeps running. Red flag: Uses time.sleep / threads to implement timeouts, or catches the timeout with except Exception around unrelated code.
JR-TH-ASY-06 · asyncio.run inside a running event loop
Question: A sync helper get_price_sync() calls asyncio.run(get_price(sku)). It works from a script but fails when called from a FastAPI async def endpoint or a Jupyter cell. Why?
Answer:
Trap: "asyncio.run is the universal way to call async code from anywhere." It creates a new event loop and is meant for the program's entry point. A thread can run only one event loop at a time. Inside an async def endpoint, or in Jupyter (which already runs a loop), the thread's loop is already running, so asyncio.run raises RuntimeError: asyncio.run() cannot be called from a running event loop. Because the coroutine you passed never ran, you also get a "coroutine was never awaited" RuntimeWarning on stderr.
Even if it were allowed, blocking the running loop until an inner loop finishes would freeze every other request on that loop.
Fixes:
- In async code, make the caller async and
awaitthe coroutine directly. Jupyter supports top-levelawait. - Expose both a sync and an async version of the helper instead of hiding
asyncio.runinside a library function. - From a worker thread with no running loop (e.g. a FastAPI sync
defendpoint, which runs in a thread pool),asyncio.runworks, though it creates a loop per call. To hand work to the main loop from another thread, useasyncio.run_coroutine_threadsafe(coro, loop).
Avoid "nest_asyncio"-style patches in production code.
import asyncio
async def get_price(sku):
await asyncio.sleep(0)
return 499
def get_price_sync(sku):
return asyncio.run(get_price(sku)) # fine from plain sync code
print(get_price_sync("SKU-1"))
async def handler():
try:
return get_price_sync("SKU-2") # sync helper called inside a running loop
except RuntimeError as exc:
print(exc)
return await get_price("SKU-2") # the fix: just await it
print(asyncio.run(handler()))
Output:
499
asyncio.run() cannot be called from a running event loop
499
Follow-ups:
- "The reverse problem: calling a slow sync function from async code?"
await asyncio.to_thread(fn, *args)so it runs in a thread and doesn't block the loop. - "Why does Jupyter let you
awaitat the top level?" IPython runs each cell inside an already running event loop (autoawait), which is also whyasyncio.runfails there.
Scoring: Strong: Explains one running loop per thread, that asyncio.run is for entry points, and gives await / separate APIs / run_coroutine_threadsafe. Acceptable: Knows the error and the 'just await it' fix, but not the thread-based options. Red flag: Suggests catching the RuntimeError and calling loop.run_until_complete on the running loop.
JR-TH-BE-01 · Path, query or body in a CRUD API
Question: In a REST API for orders, when does data go in the URL path, the query string, or the request body? Sketch the CRUD endpoints.
Answer:
- Path: identifies which resource.
/orders/{order_id}, or/customers/{id}/ordersfor a nested collection. - Query string: optional modifiers on a read, usually a collection: filtering, sorting and pagination, as in
GET /orders?status=paid&sort=-created_at&limit=20. - Body (JSON): the resource's data when you create or update it, validated against a schema.
CRUD for orders:
| Action | Request | Success response |
|---|---|---|
| Create | POST /orders with a JSON body |
201 Created, the new order, and a Location: /orders/123 header |
| List | GET /orders?status=paid&limit=20 |
200 with a page of orders |
| Read | GET /orders/123 |
200, or 404 if it doesn't exist |
| Replace / update | PUT /orders/123 (full) or PATCH /orders/123 (partial) |
200 with the updated order |
| Delete | DELETE /orders/123 |
204 No Content |
Conventions: plural nouns for resources and HTTP methods for actions (POST /orders, not /createOrder); GET never changes anything.
Keep secrets and personal data (tokens, phone numbers, PAN) out of URLs: paths and query strings end up in server logs, browser history and proxy logs. Send them in headers or the body.
In FastAPI the rule is mechanical: a parameter named in the path template is a path parameter, other simple-typed parameters are query parameters, and a Pydantic model parameter is the JSON body.
Follow-ups:
- "Search with many filters makes a very long query string. Is it OK to use
POST /orders/searchwith a JSON body?" Yes, it's a common pragmatic choice for complex searches. You lose easy caching and shareable URLs of a GET, so use it when the filter really doesn't fit in a URL. - "Should
GET /orders/123for another customer's order return 403 or 404?" Often 404, so you don't reveal that the order exists. 403 is fine where existence isn't sensitive. Either way, check ownership on every request. - "Why return the created resource and a
Locationheader from POST?" The client learns the server-generated ID and fields (timestamps, status) without a second request, andLocationtells it where the resource lives.
Scoring: Strong: Gives the path/query/body rule, a full CRUD table with methods and status codes, noun-based URLs, safe GET, and keeps sensitive data out of URLs. Acceptable: Correct CRUD methods and the path vs query distinction, with some status codes missing. Red flag: Puts actions in URLs (/deleteOrder?id=), changes data with GET, or puts tokens in query strings without concern.
JR-TH-BE-02 · Pydantic silently ignores unknown fields
Question:
A signup endpoint takes this body model:
class SignupIn(BaseModel):
name: str
email: str
newsletter: bool = False
The mobile app sends {"name": "Priya", "email": "priya@example.in", "newsltter": true}. What does the endpoint receive? Does the client get an error?
Answer:
Trap: "422, because newsltter isn't a field."
By default Pydantic models use extra="ignore": unknown keys are dropped without an error. The typo'd key disappears, newsletter takes its default False, and the request succeeds. Users who opted in are silently not subscribed, and nobody notices until marketing asks why sign-ups dropped.
Choose the behaviour on purpose:
model_config = ConfigDict(extra="forbid")rejects unknown keys with anextra_forbiddenerror (a 422 in FastAPI), so client typos fail in testing instead of in the data. This is a good default for request bodies you own.extra="ignore"(the default) is useful when clients may send fields your version doesn't know yet, at the cost of hiding mistakes.- Either way, declare the fields the client may set, and never build database rows from the raw request dict. Ignoring or forbidding unknown keys is what stops a client from sending
"is_admin": trueand having it saved.
A related default: Pydantic's lax mode also coerces some values (the string "29" into an int field, "yes" into True). Use strict=True on a model or field when the exact JSON type matters.
from pydantic import BaseModel, ConfigDict, ValidationError
class SignupIn(BaseModel):
name: str
email: str
newsletter: bool = False
payload = {"name": "Priya", "email": "priya@example.in", "newsltter": True} # typo
print(SignupIn.model_validate(payload))
class StrictSignupIn(SignupIn):
model_config = ConfigDict(extra="forbid")
try:
StrictSignupIn.model_validate(payload)
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"])
Output:
name='Priya' email='priya@example.in' newsletter=False
('newsltter',) extra_forbidden
Follow-ups:
- "Would making
newsletterrequired (no default) have caught the typo?" Yes: the request would fail withmissingfornewsletter. But optional fields with defaults are common, soextra="forbid"is the more general guard. - "What does
extra="allow"do?" It keeps the unknown keys on the model (inmodel_extra, and inmodel_dump()). Useful for pass-through data, but dangerous if the dump is written to the database. - "Should responses also use
extra="forbid"?" Response models are about what you send, so the useful rule is the opposite: a separate output model that lists only the fields clients may see, so internal fields are never serialised.
Scoring: Strong: Knows unknown fields are ignored by default, explains the silent data loss, uses extra="forbid" deliberately, and links field allow-listing to mass-assignment safety; mentions lax coercion. Acceptable: Knows extra keys are ignored and how to forbid them, without the security angle. Red flag: Believes Pydantic rejects any unexpected input by default, or passes request.json() straight into an ORM model.
JR-TH-BE-03 · Protecting a login endpoint from brute force
Question: Your login endpoint takes an email and password. How do you protect it against password guessing and credential stuffing?
Answer:
Attackers either try many passwords against one account (brute force) or try leaked email/password pairs from other sites across many accounts (credential stuffing). Layer several defences:
- Rate-limit failed attempts per account and per client IP. For example, at most 5 failures per account in 15 minutes, then reject with 429 Too Many Requests and a
Retry-Afterheader. The snippet shows a sliding-window limiter with an injected clock, which also makes it easy to test. In production the counters must live in shared storage (Redis), not in one process's memory, or each worker counts separately. - Prefer temporary throttling to permanent lockout. Locking an account after N failures lets anyone lock out a real user by typing wrong passwords. Use delays or short lockouts, plus a CAPTCHA or an OTP challenge after repeated failures.
- Don't reveal which part was wrong. Return the same message ("Invalid email or password") and status for an unknown email and a wrong password. Keep response times similar too, for example by still running the password check against a dummy hash. Otherwise attackers can find out which emails have accounts. Apply the same rule to "forgot password" responses.
- Store passwords with a slow, salted hash (argon2, bcrypt, scrypt or PBKDF2) and compare in constant time.
- Offer or require MFA (OTP, authenticator app) for sensitive accounts, and check new passwords against known-breached lists.
- Log and alert on spikes in failures, and never log the submitted passwords.
from collections import defaultdict, deque
class LoginLimiter:
"""At most `limit` failed attempts per key in any `window` seconds."""
def __init__(self, limit, window, clock):
self.limit, self.window, self.clock = limit, window, clock
self.failures = defaultdict(deque)
def allowed(self, key):
q, now = self.failures[key], self.clock()
while q and q[0] <= now - self.window:
q.popleft() # forget failures outside the window
return len(q) < self.limit
def record_failure(self, key):
self.failures[key].append(self.clock())
now = [0] # fake clock, in seconds
limiter = LoginLimiter(limit=3, window=300, clock=lambda: now[0])
key = "account:asha@example.in"
for t in [0, 10, 20, 30]:
now[0] = t
ok = limiter.allowed(key)
print(f"t={t:>3}s allowed={ok}")
if ok:
limiter.record_failure(key) # wrong password each time
now[0] = 301
print("t=301s allowed=", limiter.allowed(key), sep="")
Output:
t= 0s allowed=True
t= 10s allowed=True
t= 20s allowed=True
t= 30s allowed=False
t=301s allowed=True
Follow-ups:
- "Why limit by account and by IP?" Per-account limits stop many guesses against one user; per-IP limits slow one machine trying many accounts. Credential stuffing from many IPs needs both, plus signals like failure spikes across the site.
- "What should happen to the failure counter after a successful login?" Reset it for that account, so a user who mistyped twice and then logged in starts fresh next time.
- "Why does the limiter take a
clockargument?" So tests can control time and check the window without sleeping. In production you'd passtime.monotonic.
Scoring: Strong: Distinguishes brute force from credential stuffing, rate-limits per account and IP with 429 and shared storage, avoids lockout abuse, prevents user enumeration (message and timing), and mentions hashing, MFA and alerting. Acceptable: Proposes rate limiting or lockout and a generic error message, without the lockout-abuse or shared-storage points. Red flag: Returns "user not found" vs "wrong password", locks accounts permanently, or logs submitted passwords.
JR-TH-BE-04 · JWTs are signed, not encrypted
Question: A teammate puts the user's role, phone number and PAN in the JWT the API issues at login, saying "it's fine, the token is encrypted". Is it? What does the signature actually protect?
Answer:
Trap: "A JWT is encrypted, so its contents are secret."
A standard JWT (a JWS) is header.payload.signature. Header and payload are just base64url-encoded JSON; anyone holding the token can decode them without any key (the snippet does it in two lines). So the phone number and PAN are readable by the browser, by any JavaScript running on the page, and by anything that logs the token. Keep personal data and secrets out of it: put a user ID and the minimum claims needed.
The signature (HMAC-SHA256 with a server secret for HS256, or a private key for RS256) guarantees integrity: if someone edits "role": "viewer" to "admin", the signature no longer matches. But it only protects you if the server checks it properly:
- Verify the signature on every request, and pin the algorithm:
jwt.decode(token, key, algorithms=["HS256"])in PyJWT. Never acceptalg: none, and never decode with verification turned off. - Check the claims:
exp(expiry), andiss/audif you use them. - Keep the secret secret and strong; with
HS256anyone who has the secret can mint tokens.
Design consequences:
- A JWT stays valid until it expires, even after logout or a role change. Use short-lived access tokens (minutes) with refresh tokens, or a server-side denylist, if revocation matters.
- Where the browser stores it matters:
localStorageis readable by any XSS; anHttpOnlycookie isn't (but then you need CSRF protection).
If the contents really must be hidden, that's JWE (encrypted JWT), but usually the answer is simply not to put sensitive data in the token.
import base64
import hashlib
import hmac
import json
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64url(json.dumps({"sub": "user_42", "role": "viewer", "exp": 1775000000}).encode())
signature = b64url(hmac.new(b"server-secret", f"{header}.{payload}".encode(),
hashlib.sha256).digest())
token = f"{header}.{payload}.{signature}"
# Anyone holding the token can read the payload: no key needed
part = token.split(".")[1]
print(json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))))
# But changing it breaks the signature
forged = b64url(json.dumps({"sub": "user_42", "role": "admin", "exp": 1775000000}).encode())
expected = b64url(hmac.new(b"server-secret", f"{header}.{forged}".encode(),
hashlib.sha256).digest())
print("forged token valid?", hmac.compare_digest(expected, signature))
Output:
{'sub': 'user_42', 'role': 'viewer', 'exp': 1775000000}
forged token valid? False
Follow-ups:
- "An admin demotes a user, but the user keeps admin access for another 50 minutes. Why?" The role is baked into a token valid for an hour, and the server trusts the token without checking the database. Shorten access-token lifetime, re-check roles for sensitive actions, or keep a denylist or token version per user.
- "What's the risk of
jwt.decode(token, options={"verify_signature": False})in an auth path?" Anyone can craft a token with any claims and it's accepted. That option is only for inspecting tokens in debugging tools, never for authorisation. - "HS256 vs RS256: when would you choose RS256?" When other services need to verify tokens but must not be able to issue them: they get the public key, and only the auth service holds the private key. With HS256 every verifier holds the shared secret and could mint tokens.
Scoring: Strong: Explains base64url encoding vs encryption, what the signature guarantees, verification with a pinned algorithm and expiry, revocation limits, and storage trade-offs. Acceptable: Knows the payload is readable and signed, not encrypted, and that the signature must be verified, without algorithm pinning or revocation. Red flag: Believes the payload is secret, or trusts claims without verifying the signature.
JR-TH-BE-05 · Making a two-step wallet transfer atomic
Question: A wallet-transfer endpoint runs two UPDATE statements, a credit and a debit. What can go wrong without a transaction, and how do you use transactions correctly from Python?
Answer:
If the first statement succeeds and the second fails (a constraint violation, a crash, a lost connection, a bug), money has been created or destroyed: Ravi got ₹5,000 that Asha never paid. A transaction makes the pair atomic: either both changes are committed, or neither is.
In Python's DB-API (sqlite3, psycopg), a transaction is opened for you automatically (sqlite3 opens it before the first data-changing statement; psycopg before the first statement of any kind). Nothing is permanent until commit(), and rollback() undoes everything since the last commit. The usual pattern is a context manager:
with conn:in sqlite3 commits if the block succeeds and rolls back if it raises, as in the snippet. The second transfer fails on theCHECK (balance >= 0)constraint, and Asha's credit from the first statement is undone too.- psycopg 3:
with conn.transaction():. SQLAlchemy:with Session.begin():.
Practical rules:
- One unit of work, one transaction. Group exactly the statements that must succeed together.
- Keep transactions short: no HTTP calls, emails or slow computations inside them. Open transactions hold locks and block other requests.
- Let the database enforce invariants (
CHECK,NOT NULL, foreign keys, unique constraints) as the last line of defence, and turn a constraint failure into a clear 4xx (e.g. "insufficient balance") instead of a 500. - Concurrent requests: two simultaneous transfers can both read the old balance. Do the arithmetic in SQL (
SET balance = balance - ? WHERE user = ? AND balance >= ?and check the affected row count) or lock the row (SELECT ... FOR UPDATEin Postgres).
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE wallet (user TEXT PRIMARY KEY, balance INTEGER CHECK (balance >= 0))")
conn.executemany("INSERT INTO wallet VALUES (?, ?)", [("asha", 1000), ("ravi", 200)])
conn.commit()
def transfer(src, dst, amount):
with conn: # commits on success, rolls back if an exception escapes
conn.execute("UPDATE wallet SET balance = balance + ? WHERE user = ?", (amount, dst))
conn.execute("UPDATE wallet SET balance = balance - ? WHERE user = ?", (amount, src))
transfer("asha", "ravi", 300)
print(conn.execute("SELECT * FROM wallet ORDER BY user").fetchall())
try:
transfer("ravi", "asha", 5000) # second UPDATE violates the CHECK
except sqlite3.IntegrityError as e:
print("failed:", e)
print(conn.execute("SELECT * FROM wallet ORDER BY user").fetchall())
Output:
[('asha', 700), ('ravi', 500)]
failed: CHECK constraint failed: balance >= 0
[('asha', 700), ('ravi', 500)]
Follow-ups:
- "What would the balances be after the failed transfer if
transferdidn't usewith conn:and the caller later calledconn.commit()?" The credit to Asha would already have run, so committing would save Asha +5,000 while Ravi's debit never happened: money created from nothing. - "The transfer also has to send an SMS. Where does that go?" After the commit. If it runs inside the transaction and the commit then fails, the user gets an SMS for a transfer that never happened. For reliability, record a 'notification pending' row in the same transaction and send it from a worker.
- "How would you return the failure to the API client?" Catch the specific error (or check the balance with a conditional update) and return a 4xx such as 409 or 422 with a clear message like 'insufficient balance', rather than letting it become a 500.
Scoring: Strong: Explains atomicity with the partial-failure scenario, uses commit/rollback via context managers in sqlite3/psycopg/SQLAlchemy, keeps transactions short, relies on constraints, and flags concurrent updates. Acceptable: Knows to wrap both statements in a transaction with commit and rollback, without the concurrency or side-effect points. Red flag: Commits after each statement, or thinks catching the exception is enough without a rollback.
JR-TH-BE-06 · Placeholders can't bind column names in ORDER BY
Question: To fix SQL injection in GET /orders?sort_by=amount, a teammate changed f"SELECT ... ORDER BY {sort_by}" to "SELECT ... ORDER BY ?" with (sort_by,). The tests run without errors. Is the endpoint fixed?
Answer:
Trap: "Yes, parameterised queries fix everything."
Placeholders bind values, not identifiers. ORDER BY ? with "amount" means "order by the constant string 'amount'", which is the same for every row, so the results come back unsorted, silently. In the snippet the "sorted" result is just insertion order. SQLite accepts this without complaint; other databases may raise an error or behave the same way. Either way, it doesn't sort by the column.
The same limit applies to table names, column names in SELECT, and the ASC/DESC keyword: none of them can be bound.
The fix is an allow-list: map the API's sort names to known SQL column names, reject anything else with 400, and validate the direction against {"asc", "desc"}. Only allow-listed strings are ever put into the SQL text. With SQLAlchemy you'd map to column objects, e.g. getattr(Order, name) after checking the name against the allowed set.
The original f-string version was injectable. Sort and filter parameters are classic injection points because developers assume they're harmless. And values must still always be bound: WHERE customer = ?, never f"WHERE customer = '{name}'", where name = "x' OR '1'='1" returns every row.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, amount INTEGER)")
conn.executemany("INSERT INTO orders VALUES (?, ?, ?)",
[(1, "Asha", 900), (2, "Ravi", 150), (3, "Meera", 400)])
sort_by = "amount" # from ?sort_by=amount
rows = conn.execute("SELECT id, amount FROM orders ORDER BY ?", (sort_by,)).fetchall()
print("ORDER BY ? :", rows)
ALLOWED = {"amount": "amount", "customer": "customer", "id": "id"}
column = ALLOWED[sort_by] # KeyError -> return 400 for anything else
rows = conn.execute(f"SELECT id, amount FROM orders ORDER BY {column}").fetchall()
print("allow-list :", rows)
Output:
ORDER BY ? : [(1, 900), (2, 150), (3, 400)]
allow-list : [(2, 150), (3, 400), (1, 900)]
Follow-ups:
- "Why didn't the tests catch the unsorted results?" They probably only checked the status code or the number of rows. A sorting test needs data inserted out of order and an assertion on the returned order (ascending and descending).
- "How would you support
sort=-amountfor descending order safely?" Strip a leading-to set the direction, look up the rest in the allow-list, and buildORDER BY {column} {'DESC' if desc else 'ASC'}from fixed strings only. Add a unique tie-breaker (, id) so equal amounts have a stable order across pages. - "In FastAPI, how can the framework enforce the allow-list?" Type the parameter as a
Literal["amount", "customer", "id"]or anEnum; anything else gets a 422 before your code runs. You still map the value to SQL explicitly.
Scoring: Strong: Explains values vs identifiers, why ORDER BY ? silently doesn't sort, fixes it with an allow-list and validated direction, and keeps binding for values, with a test that checks order. Acceptable: Knows column names can't be parameterised and suggests validating the input, without explaining the silent unsorted result. Red flag: Thinks binding fixed it, or "sanitises" by stripping quotes and keeps the f-string with raw input.
JR-TH-CLO-01 · A tax-calculator factory
Question: make_tax(rate) defines an inner function apply(amount) that uses rate, and returns it. After gst_18 = make_tax(0.18), make_tax has finished. How does gst_18(1000) still know the rate?
Answer:
apply is a closure: a function that refers to a variable from the enclosing function's scope. When the inner function uses rate, Python keeps that variable alive in a cell attached to the function object (gst_18.__closure__), even after make_tax has returned. Each call to make_tax creates a new scope and a new cell, so gst_18 and gst_5 each have their own rate.
Typical uses at work: small function factories (validators, formatters, per-tenant pricing), callbacks that need a bit of context, and decorators, whose wrapper is a closure over the decorated function.
def make_tax(rate):
def apply(amount):
return round(amount * (1 + rate), 2)
return apply
gst_18 = make_tax(0.18)
gst_5 = make_tax(0.05)
print(gst_18(1000), gst_5(1000))
print(gst_18.__code__.co_freevars, gst_18.__closure__[0].cell_contents)
Output:
1180.0 1050.0
('rate',) 0.18
Follow-ups:
- "Could you write the same thing without a closure?" Yes:
functools.partial(apply_tax, rate=0.18)with a top-levelapply_tax(amount, rate), or a small class with arateattribute and a__call__method. - "Does a nested function that uses no outer variables have a closure?" No. Its
__closure__isNone. It's just a function defined inside another function.
Scoring: Strong: Explains the captured variable kept alive after the outer function returns, notes each call gets its own scope, and gives real uses. Acceptable: Describes "the inner function remembers the variable" without the per-call detail. Red flag: Thinks rate is a global, or that it's lost when make_tax returns.
JR-TH-CLO-02 · Why a list-based counter works without `nonlocal`
Question: A counter closure stores its state as calls = [0] and the inner function does calls[0] += 1. There's no nonlocal. Does it work? Would nonlocal be better?
Answer:
Trap: "It fails with UnboundLocalError, like a counter that uses a plain int." It works and returns 1, 2, 3.
The scoping rule is about rebinding a name. calls[0] += 1 reads the name calls (found in the enclosing scope) and then assigns to an item of that list. The name calls itself is never assigned in the inner function, so it isn't made local. calls += 1 on an int would rebind the name, and that's what needs nonlocal.
The list trick was the workaround before Python 3 added nonlocal. Today nonlocal calls with a plain int states the intent more clearly. If the state grows beyond one or two values, use a small class or a dataclass instead.
def make_counter():
calls = [0]
def inc():
calls[0] += 1 # mutates the list; never rebinds `calls`
return calls[0]
return inc
def make_counter_nonlocal():
calls = 0
def inc():
nonlocal calls # needed because we rebind the name
calls += 1
return calls
return inc
c = make_counter()
print(c(), c(), c())
d = make_counter_nonlocal()
print(d(), d())
Output:
1 2 3
1 2
Follow-ups:
- "Would a dict,
state = {"n": 0}withstate["n"] += 1, work too?" Yes, for the same reason: it's item assignment on an object, not rebinding the name. - "Do two counters made by
make_counter()share their count?" No. Each call tomake_countercreates a new list, so each closure has its own state.
Scoring: Strong: Says it works and explains rebinding a name versus mutating an object, then prefers nonlocal or a class for clarity. Acceptable: Says it works but explains it vaguely ("lists are mutable"). Red flag: Insists it raises an error, or adds global to fix a non-existent problem.
JR-TH-CLO-03 · `functools.partial` versus a lambda for pre-filling arguments
Question: You need a one-argument function that converts USD to INR with today's rate. You can write lambda amt: convert(amt, rate) or partial(convert, rate=rate). How do they differ if rate is updated later in the same scope?
Answer:
- The lambda is a closure. It looks
rateup when it's called, so ifrateis rebound afterwards it uses the new value (late binding). partialevaluates its arguments when it's created and stores the values (p.args,p.keywords), so it keeps the rate it was given (early binding).
Which one is right depends on what you want: a snapshot of the rate when the job started, or always the latest value. Other practical differences:
partialobjects are introspectable (p.func,p.keywords) and can be pickled if the underlying function is a top-level function, so they work withmultiprocessingand many task queues. Lambdas can't be pickled by the standardpickle.- Keyword arguments in a
partialcan still be overridden at call time:p(10, rate=90.0). - A lambda can reorder or transform arguments;
partialonly pre-fills them.
from functools import partial
def convert(amount, rate):
return round(amount * rate, 2)
rate = 83.0
usd_to_inr_lambda = lambda amt: convert(amt, rate)
usd_to_inr_partial = partial(convert, rate=rate)
rate = 84.5 # rate updated later in the same scope
print(usd_to_inr_lambda(10), usd_to_inr_partial(10))
print(usd_to_inr_partial.func.__name__, usd_to_inr_partial.keywords)
Output:
845.0 830.0
convert {'rate': 83.0}
Follow-ups:
- "How would you make the lambda keep 83.0 instead?" Bind the value as a default argument,
lambda amt, rate=rate: convert(amt, rate), because defaults are evaluated when the lambda is created. - "Does
partialcopy a mutable argument such as a list?" No. It stores a reference, so later mutations of that list are visible. Only rebinding the outer name is not.
Scoring: Strong: Explains late versus early binding with the result, plus at least one practical difference (pickling, introspection, overriding keywords). Acceptable: Gets the result right but can't say why the two differ. Red flag: Thinks they are interchangeable in every case.
JR-TH-CLO-04 · Per-city fee calculators built in a loop
Question: You build fee_calculators[city] = lambda amount: amount * rate while looping over {"Delhi": 0.05, "Mumbai": 0.08}.items(). What does the Delhi calculator return for ₹1000, and what's the cleanest fix?
Answer:
Trap: "50.0, since the lambda was created while rate was 0.05." It returns 80.0. Every calculator uses Mumbai's rate.
The lambdas don't capture the value of rate. They all refer to the same variable rate in the enclosing scope and read it when called. By then the loop has finished and rate holds its last value, 0.08.
Fixes that bind the value at creation time:
- a default argument:
lambda amount, rate=rate: amount * rate. Short, but a caller can accidentally overriderateby passing a second argument. functools.partial(apply_rate, rate=rate)with a named top-level function. Clear, and picklable.- a factory function:
make_calc(rate)returns the inner function, so each call gets its own scope.
In a code review I'd prefer the factory or partial, because the default-argument trick reads like a mistake.
rates = {"Delhi": 0.05, "Mumbai": 0.08}
fee_calculators = {}
for city, rate in rates.items():
fee_calculators[city] = lambda amount: amount * rate
print({c: f(1000) for c, f in fee_calculators.items()})
def make_calc(rate):
return lambda amount: amount * rate
fixed = {city: make_calc(rate) for city, rate in rates.items()}
print({c: f(1000) for c, f in fixed.items()})
Output:
{'Delhi': 80.0, 'Mumbai': 80.0}
{'Delhi': 50.0, 'Mumbai': 80.0}
Follow-ups:
- "Would building the lambdas in a dict comprehension instead of a
forloop fix it?" No. The comprehension has its own scope, but there is still oneratevariable for the whole comprehension, so all the lambdas see its final value. - "How would a test catch this?" Assert each city separately, for example
fee_calculators["Delhi"](1000) == 50.0. A test that only checks the last city passes even with the bug.
Scoring: Strong: Predicts 80.0, explains that the closure looks the variable up at call time, and gives a fix with its trade-offs. Acceptable: Knows the default-argument fix but explains the cause vaguely. Red flag: Expects 50.0 and can't explain the result when shown.
JR-TH-CLO-05 · Inspecting what a closure captured
Question: A fraud-flag function returned by make_flagger(city) behaves oddly in production. How can you see exactly what it captured and what global names it depends on, without reading all the code that built it?
Answer:
Python exposes a closure's state on the function object:
f.__code__.co_freevars: the names of captured variables.f.__closure__: a tuple of cells in the same order;cell.cell_contentsis each current value.inspect.getclosurevars(f): a convenient summary, withnonlocals(captured values),globals(module names the function references, with their current values),builtins, andunboundnames.
That quickly shows things like "this flagger captured city='Patna', not the city we expected", or "it reads the global THRESHOLD, which a config reload changed".
For testing, it's often better to design the closure so its inputs are explicit: pass the threshold in as a factory argument instead of reading a global. Then the test builds the function with known values and doesn't depend on module state.
import inspect
THRESHOLD = 500
def make_flagger(city):
blocked = {"Ranchi"}
def flag(amount):
return city in blocked or amount > THRESHOLD
return flag
f = make_flagger("Patna")
cv = inspect.getclosurevars(f)
print(f.__code__.co_freevars)
print(cv.nonlocals)
print(cv.globals)
print(f(600), f(100))
Output:
('blocked', 'city')
{'blocked': {'Ranchi'}, 'city': 'Patna'}
{'THRESHOLD': 500}
True False
Follow-ups:
- "Why does
THRESHOLDshow up underglobalsand notnonlocals?" It's defined at module level, not in an enclosing function, so it isn't captured in a cell. The function looks it up in the module each time it runs. - "Can you change a captured value from outside?" Technically yes, by assigning to
cell.cell_contents, but that's for debugging only. In code, use a class with attributes if the state needs to change.
Scoring: Strong: Knows __closure__/co_freevars or inspect.getclosurevars, distinguishes captured variables from globals, and suggests making inputs explicit. Acceptable: Would add print statements or use a debugger, and knows closures store variables somewhere on the function. Red flag: Has no way to inspect a function's captured state and no strategy beyond guessing.
JR-TH-CLO-06 · Lambdas from a list comprehension versus a generator expression
Question: fns = [lambda: i for i in range(3)] gives [2, 2, 2] when you call each one. If you change the brackets to parentheses and call each lambda while looping over the generator, what do you get, and why?
Answer:
Trap: "Also [2, 2, 2]. It's the same late-binding bug." You get [0, 1, 2].
Every lambda still refers to the single variable i and reads it when called. The difference is when it is called:
- The list comprehension runs to completion first, so by the time you call any lambda,
iis 2. - The generator expression is lazy. It produces one lambda, pauses, and your loop calls that lambda before the generator resumes and changes
i. Each call happens to see the "right" value.
So the code isn't fixed, it's just accidentally correct: list() the generator first (or store the lambdas and call them later) and you're back to [2, 2, 2]. When code's correctness depends on when it's evaluated, bind values explicitly with lambda i=i: i or partial.
fns = [lambda: i for i in range(3)]
print([f() for f in fns])
gen = (lambda: i for i in range(3))
print([f() for f in gen])
stored = list(lambda: i for i in range(3))
print([f() for f in stored])
Output:
[2, 2, 2]
[0, 1, 2]
[2, 2, 2]
Follow-ups:
- "Where could this bite in real code?" Building callbacks or tasks lazily and then later deciding to collect them first, for example passing the generator to a function that calls
list()or schedules everything before running any. The behaviour silently changes. - "Which fix makes it correct regardless of evaluation order?" Bind at creation:
lambda i=i: i,functools.partial(fn, i), or a factory functionmake(i)that returns the lambda.
Scoring: Strong: Predicts [0, 1, 2], explains it as late binding plus lazy evaluation, and shows that collecting the lambdas first brings the bug back. Acceptable: Predicts the result after a hint and connects it to laziness. Red flag: Can't explain why the two versions differ.
JR-TH-CMP-01 · List, dict and set comprehensions over orders
Question: Given a list of order dicts, how would you use comprehensions to get (a) the IDs of orders worth ₹500 or more, (b) a dict from ID to amount, and (c) the distinct cities?
Answer:
A comprehension is expression for item in iterable, optionally followed by an if filter, wrapped in the brackets for the result type:
- list
[...]:[o["id"] for o in orders if o["amount"] >= 500] - dict
{key: value ...}:{o["id"]: o["amount"] for o in orders} - set
{...}with no colon:{o["city"] for o in orders}. Duplicates disappear, and a set has no reliable order for strings, so sort it before displaying.
Compared with a for loop that calls .append(), a comprehension is shorter, says "build a collection" at a glance, and is usually a little faster. Use one when the logic fits on a line or two; switch back to a loop when it doesn't.
orders = [
{"id": "A1", "city": "Pune", "amount": 1200},
{"id": "A2", "city": "Delhi", "amount": 300},
{"id": "A3", "city": "Pune", "amount": 800},
]
big = [o["id"] for o in orders if o["amount"] >= 500]
by_id = {o["id"]: o["amount"] for o in orders}
cities = {o["city"] for o in orders}
print(big)
print(by_id)
print(sorted(cities))
Output:
['A1', 'A3']
{'A1': 1200, 'A2': 300, 'A3': 800}
['Delhi', 'Pune']
Follow-ups:
- "What does
{}on its own create?" An empty dict. An empty set must be writtenset(). - "How would you sum the amounts without building a list?" Pass a generator expression to
sum:sum(o["amount"] for o in orders).
Scoring: Strong: Writes all three correctly, notes that set order isn't guaranteed, and knows when to fall back to a loop. Acceptable: Writes the list and dict versions but stumbles on the set syntax. Red flag: Can't turn a simple filtering loop into a comprehension.
JR-TH-CMP-02 · Where the `if` goes in a comprehension
Question: You want to replace negative amounts with 0 and keep everything else. A candidate writes [a for a in amounts if a > 0 else 0]. What's wrong, and what are the two different places an if can go?
Answer:
Trap: "The if ... else goes at the end, like a filter." That's a SyntaxError.
There are two different constructs:
- A filter goes after the
forand has noelse:[a for a in amounts if a > 0]. It decides whether an item is included, so the result can be shorter than the input. - A conditional expression (
x if cond else y) goes before thefor, as part of the output expression:[a if a > 0 else 0 for a in amounts]. It decides what to output for every item, so the result has the same length as the input.
You can combine them: [a if a > 0 else 0 for a in amounts if a is not None]. Here "replace negatives with 0" is the second form.
amounts = [120, -40, 75, -10]
print([a for a in amounts if a > 0])
print([a if a > 0 else 0 for a in amounts])
try:
compile("[a for a in amounts if a > 0 else 0]", "<demo>", "eval")
except SyntaxError as e:
print("SyntaxError:", e.msg)
Output:
[120, 75]
[120, 0, 75, 0]
SyntaxError: invalid syntax
Follow-ups:
- "How would you do the same on a pandas column?" Use vectorised operations instead of a comprehension:
s.clip(lower=0)ors.where(s > 0, 0). - "Can you have two
iffilters?" Yes:[a for a in xs if a > 0 if a < 1000]. They're combined with AND, butif 0 < a < 1000reads better.
Scoring: Strong: Explains filter versus conditional expression, including the length difference, and writes the correct version. Acceptable: Fixes the syntax but explains the difference only vaguely. Red flag: Can't produce the correct version.
JR-TH-CMP-03 · When a comprehension is the wrong tool
Question: When should you write a plain for loop instead of a comprehension? Give concrete cases you'd flag in a code review.
Answer:
A comprehension is for building a collection from an expression. Use a loop when:
- The point is a side effect.
[send_email(u) for u in users]builds a throwaway list of return values (Nones) just to run a function. Write aforloop. - You need statements:
try/exceptper item, logging,break/continue, or several steps with intermediate variables. Squeezing these into helper calls or walrus tricks hurts readability. - It's too complex. More than two
forclauses, or nested conditional expressions. If a reader has to untangle it, a loop with clear names wins. - You're updating several things at once, such as appending to two lists or updating counters along with the output.
- The data is huge and used once. A list comprehension holds everything in memory; a generator expression or a loop that streams is better.
For transform-and-filter of moderate size, a comprehension is the idiomatic choice. In pandas or NumPy, prefer vectorised operations to either.
result = [print(f"emailing {name}") for name in ["Asha", "Ravi"]]
print(result)
Output:
emailing Asha
emailing Ravi
[None, None]
Follow-ups:
- "Is
list(map(send_email, users))better than the side-effect comprehension?" No. It has the same problem: a list built only to force the calls. A plain loop states the intent. - "How would you handle a parse that might fail per row?" A loop with
try/exceptthat logs or collects bad rows. Or a small helper that returnsNoneon failure, combined with a filter, if that stays readable.
Scoring: Strong: Gives several concrete cases, especially side effects and per-item error handling, and keeps comprehensions for building collections. Acceptable: Says "when it's too complex" without other cases. Red flag: Uses comprehensions purely for side effects, or says they should always replace loops.
JR-TH-CMP-04 · A dict comprehension that silently drops rows
Question: You build a lookup with by_phone = {r["phone"]: r for r in rows} from 3 customer rows. Two rows share a phone number. How many entries does the dict have, and which row wins?
Answer:
Trap: "Python raises an error on a duplicate key" or "the first row is kept." The dict has 2 entries, and the last row with that phone wins. Earlier rows are silently overwritten, exactly like assigning d[key] = value in a loop.
This is a common data-loss bug when building lookups from CSV or API data. Nothing fails, and the counts just don't match later.
What to do depends on what duplicates mean:
- Duplicates are an error: compare
len(by_phone)withlen(rows)and raise, or find duplicates withcollections.Counter. - Keep all of them: group with
defaultdict(list)(grouped[phone].append(row)). - Keep a specific one (the first, or the newest by timestamp): make the rule explicit, for example sort first and then build the dict, or check
if phone not in din a loop.
The pandas equivalent: df.set_index("phone") keeps duplicates in the index, and df.drop_duplicates("phone", keep="last") makes the choice explicit.
from collections import defaultdict
rows = [
{"phone": "98400", "name": "Kiran", "plan": "basic"},
{"phone": "98111", "name": "Meena", "plan": "pro"},
{"phone": "98400", "name": "Kiran K", "plan": "pro"},
]
by_phone = {r["phone"]: r for r in rows}
print(len(rows), len(by_phone))
print(by_phone["98400"]["name"])
grouped = defaultdict(list)
for r in rows:
grouped[r["phone"]].append(r["name"])
print(dict(grouped))
Output:
3 2
Kiran K
{'98400': ['Kiran', 'Kiran K'], '98111': ['Meena']}
Follow-ups:
- "Which position does the overwritten key keep in the dict?" Its original position. Insertion order is set when a key is first added; updating the value doesn't move it.
- "How would you write a test that guards against this?" Assert that
len(lookup) == len(rows)for inputs that should be unique, and add a case with a duplicate that asserts your chosen behaviour (an error, or which row wins).
Scoring: Strong: Says 2 entries with the last row winning, calls it silent data loss, and picks a strategy based on what duplicates mean. Acceptable: Knows the last row wins but offers no detection or alternative. Red flag: Expects an error or three entries.
JR-TH-CMP-05 · Calling an expensive function twice in a comprehension
Question: [clean(r) for r in raw_names if clean(r)] keeps only names that clean to something non-empty, but clean is expensive (it calls a normalisation service). How many times is it called, and how do you avoid the double work?
Answer:
The if filter runs first for every item, and the output expression runs again for every item that passes. So clean runs once per item plus once more per kept item: for 3 names with 2 kept, that's 5 calls instead of 3.
Ways to call it once per item:
- Nest a generator expression:
[n for n in (clean(r) for r in raw_names) if n]. The inner generator callscleanonce per item and the outer comprehension filters. Works on every Python version and reads well. - Assignment expression (3.8+):
[n for r in raw_names if (n := clean(r))]. Shorter, butnis bound in the enclosing scope, so it remains defined after the comprehension. Some teams avoid:=for this reason. - A plain loop with a local variable, when there's also error handling.
If the same raw values repeat often, also consider caching clean (for example functools.lru_cache, if its arguments are hashable and the result doesn't change).
calls = 0
def clean(raw):
global calls
calls += 1
return raw.strip().title() or None
raw_names = [" asha ", "", " ravi"]
names = [clean(r) for r in raw_names if clean(r)]
print(names, calls)
calls = 0
names = [n for n in (clean(r) for r in raw_names) if n]
print(names, calls)
calls = 0
names = [n for r in raw_names if (n := clean(r))]
print(names, calls, n)
Output:
['Asha', 'Ravi'] 5
['Asha', 'Ravi'] 3
['Asha', 'Ravi'] 3 Ravi
Follow-ups:
- "Would
filter(None, map(clean, raw_names))work?" Yes.mapcallscleanonce per item andfilter(None, ...)drops falsy results. Wrap it inlist()if you need a list. - "Is the double call only a performance problem?" No. If
cleanhas side effects or isn't deterministic (it logs, hits a service, or depends on time), the filtered value and the stored value can differ.
Scoring: Strong: Counts the calls correctly, gives the nested-generator or walrus fix, and notes the walrus scope leak and the risk with non-deterministic functions. Acceptable: Spots the double call and suggests a loop, without the generator or walrus versions. Red flag: Thinks Python caches the first call automatically.
JR-TH-CMP-06 · What a generator expression reads now and what it reads later
Question: big = (x for x in data if x > threshold). Before consuming big, the code sets threshold = 800 and data = []. What does list(big) return if data was [100, 450, 900] and threshold was 400?
Answer:
Trap: "[450, 900], because the generator was created with those values" or "[], because data is now empty." It returns [900].
A generator expression evaluates only its outermost iterable immediately: iter(data) is called when the expression is created, so it holds on to the original list object. Rebinding the name data later doesn't matter.
Everything else (the condition, the output expression, any inner for clauses) runs lazily as items are pulled, and looks up names at that moment. So x > threshold uses 800, the value at consumption time.
The safe habit: don't change variables a generator depends on between creating and consuming it. If you need a snapshot, materialise with a list comprehension, or bind values in a function's parameters (def big_items(data, threshold): return (x for x in data if x > threshold)).
data = [100, 450, 900]
threshold = 400
big = (x for x in data if x > threshold)
threshold = 800 # changed before the generator is consumed
data = [] # rebinding the source name
print(list(big))
Output:
[900]
Follow-ups:
- "What if the code did
data.clear()instead ofdata = []?" Then the generator's iterator would see the emptied list, because clearing mutates the same object it is iterating. It would return[]. - "Why does Python evaluate the outermost iterable early?" So errors such as iterating over
Noneshow up where the generator is created, not at some distant point where it's consumed.
Scoring: Strong: Predicts [900] and explains eager outermost iterable versus lazy condition, plus the rebinding versus mutation distinction. Acceptable: Gets the threshold part but is unsure about data. Red flag: Treats a generator expression like a list built at creation time.
JR-TH-COL-01 · Counting order statuses with Counter
Question: You have a day's list of order statuses ("delivered", "pending", ...). How do you count how many of each there are, and what does collections.Counter give you beyond a plain dict?
Answer:
Counter is a dict subclass that maps each item to its count. Counter(iterable) counts in one pass, replacing the hand-written if key in d: d[key] += 1 else: d[key] = 1 loop.
What it adds over a dict:
- A missing key reads as
0instead of raisingKeyError, and reading it does not insert it (counts["refunded"]is0, and"refunded" in countsstaysFalse). most_common(n)returns thenhighest counts as(item, count)pairs; ties keep first-seen order.update(iterable_or_mapping)adds more counts, and+combines two counters, which is handy for merging two days' or two files' counts.total()(3.10+) sums all the counts.
Takeaway: whenever you write "count how many of each", reach for Counter first.
from collections import Counter
statuses = ["delivered", "pending", "delivered", "cancelled",
"delivered", "pending", "returned"]
counts = Counter(statuses)
print(counts["delivered"], counts["refunded"])
print(counts.most_common(2))
next_day = Counter(["pending", "pending", "delivered"])
print(counts + next_day)
Output:
3 0
[('delivered', 3), ('pending', 2)]
Counter({'delivered': 4, 'pending': 4, 'cancelled': 1, 'returned': 1})
Follow-ups:
- "How would you get the two least common statuses?"
counts.most_common()[:-3:-1](the documented idiom: slice the full list from the end), orsorted(counts.items(), key=lambda kv: kv[1])[:2]. - "How would you count words in a 2 GB log file?" Iterate the file line by line and call
counts.update(line.split()), so only one line and the counter are in memory at a time. - "Pandas equivalent?"
df["status"].value_counts(), which also sorts by count descending.
Scoring: Strong: Uses Counter(statuses) straight away, knows missing keys give 0, and uses most_common and +/update correctly. Acceptable: Writes a correct manual dict loop and recognises Counter once it's mentioned. Red flag: Counts with list.count() inside a loop over every status (O(n²)) and can't name a better structure.
JR-TH-COL-02 · itertools.groupby is not SQL GROUP BY
Question: A teammate totals order amounts per city with itertools.groupby, expecting it to work like SQL GROUP BY. What does this print, and what's wrong?
Answer:
Trap: "groupby collects every row with the same key, like SQL GROUP BY." It only groups consecutive items with equal keys. Each time the key changes it starts a new group, so on unsorted input "Pune" appears as three separate groups and "Delhi" as two.
The dict comprehension then overwrites the key on every group, so the result is the last group's sum: {'Pune': 50, 'Delhi': 80} instead of {'Pune': 800, 'Delhi': 200}. No error, just wrong totals.
Fixes:
- Sort by the same key first, then
groupby(O(n log n)). - Or don't use
groupbyat all: accumulate into adefaultdict(int)orCounterin one O(n) pass, which doesn't care about order.
groupby is the right tool when the data is already ordered by the key (a query with ORDER BY, a log sorted by user), because it streams without holding everything in memory. One more catch: each group is a view onto the shared iterator, so it is consumed as soon as you move to the next group. Use list(grp) right away if you need it later.
from itertools import groupby
orders = [("Pune", 450), ("Delhi", 120), ("Pune", 300), ("Delhi", 80), ("Pune", 50)]
by_city = {city: sum(amt for _, amt in grp)
for city, grp in groupby(orders, key=lambda o: o[0])}
print(by_city)
orders.sort(key=lambda o: o[0])
by_city = {city: sum(amt for _, amt in grp)
for city, grp in groupby(orders, key=lambda o: o[0])}
print(by_city)
Output:
{'Pune': 50, 'Delhi': 80}
{'Delhi': 200, 'Pune': 800}
Follow-ups:
- "What do you get if you do
groups = list(groupby(data))and then read each group?" Empty lists. Advancing thegroupbyobject exhausts the previous group's iterator, so only the keys survive. Materialise each group ([(k, list(g)) for k, g in groupby(data)]) while iterating. - "How would you do this in pandas?"
df.groupby("city")["amount"].sum(). Pandas groups all matching rows regardless of order (and sorts the keys by default). - "When would you still choose
itertools.groupbyover a dict?" For huge, already-sorted streams where you want to emit each group as soon as it ends, without keeping all groups in memory.
Scoring: Strong: Predicts the wrong totals, explains 'consecutive only', and gives both fixes (sort first, or a dict accumulator). Acceptable: Knows the input must be sorted but only after tracing the output with a hint. Red flag: Insists the output is {'Pune': 800, 'Delhi': 200} even after walking through the groups.
JR-TH-COL-03 · Batching a stream for a bulk API
Question: You need to push 1 lakh customer records to a bulk API that accepts at most 500 records per request. The records come from a generator reading a database cursor. How do you split them into batches without loading everything into memory?
Answer:
Use itertools.batched(iterable, n) (Python 3.12+). It lazily yields tuples of up to n items; the last batch can be shorter. Only one batch is in memory at a time, and it works on any iterable, including generators.
Before 3.12 (or to get lists), build the same thing with islice: call iter() once, then keep taking islice(it, size) from that same iterator until a chunk comes back empty. The iter() call matters: islice(some_list, 3) creates a fresh iterator each time and would return the first chunk forever.
Why not records[i:i + 500]? Slicing needs a list (no len() or slicing on a generator), so you'd have to materialise all 1 lakh records first.
In a real incremental load you'd also log which batch (or ID range) you're on, retry a failed batch with backoff, and make the write idempotent (upsert by key) so rerunning after a crash doesn't duplicate rows.
from itertools import batched, islice
def read_rows():
for i in range(1, 8):
yield {"order_id": i}
for batch in batched(read_rows(), 3):
print([row["order_id"] for row in batch])
def chunks(iterable, size):
it = iter(iterable)
while chunk := list(islice(it, size)):
yield chunk
print(list(chunks(range(1, 8), 3)))
Output:
[1, 2, 3]
[4, 5, 6]
[7]
[[1, 2, 3], [4, 5, 6], [7]]
Follow-ups:
- "Batch 37 fails with a 503. What do you do?" Retry that batch with exponential backoff; if it still fails, log the batch's IDs and either stop or park it for a rerun. Because the API call is an upsert, retrying a partly applied batch is safe.
- "Any 3.13 change to
batched?"batched(..., strict=True)raisesValueErrorif the final batch is short, for cases where every batch must be full. - "How do you report progress without knowing the total?" Count batches and records as you go (
enumerate(batched(...), start=1)) and log every N batches; a generator has no length.
Scoring: Strong: Names batched (or the iter + islice loop), explains why it is memory-safe on generators, and mentions retries or idempotency. Acceptable: Gives the list-slicing version and adapts it to islice with a hint. Red flag: Calls list(generator) first, or writes an islice loop that never advances.
JR-TH-COL-04 · Counter subtraction hides the shortfall
Question: For a kirana store's stock check you subtract two Counters, stock - ordered, to find which items are short. What does this print, and why is - the wrong tool here?
Answer:
Trap: "- subtracts counts like normal arithmetic, so oversold items show up as negative." Counter's binary - (and +) keep only results that are greater than zero. dal (0) and ghee (-3) are silently dropped, leaving Counter({'rice': 2}). The shortage you were looking for has vanished.
The method subtract() is different: it works in place and keeps zero and negative counts. Copy the stock, subtract the orders, then filter qty < 0 to get ['ghee'].
Related rules: unary +c strips zero and negative entries, and & / | give the minimum / maximum of each count, again keeping only positives.
Where this bites in real work: reconciling expected vs loaded row counts per partition, or stock vs orders. The "missing" rows are exactly the ones - throws away. A test with an oversold item would have caught it.
from collections import Counter
stock = Counter({"rice": 5, "dal": 2, "ghee": 1})
ordered = Counter({"rice": 3, "dal": 2, "ghee": 4})
print(stock - ordered)
remaining = stock.copy()
remaining.subtract(ordered)
print(remaining)
print([item for item, qty in remaining.items() if qty < 0])
Output:
Counter({'rice': 2})
Counter({'rice': 2, 'dal': 0, 'ghee': -3})
['ghee']
Follow-ups:
- "What are
c1 & c2andc1 | c2?" Intersection and union: the min and the max of each count. Like+and-, they drop non-positive results. - "How would you write a test for this check?" Include a case where
orderedexceedsstockfor one item and assert that item is reported as short, plus an exact-match item (0) to pin down the boundary.
Scoring: Strong: Predicts Counter({'rice': 2}), explains the 'positive counts only' rule, and fixes it with subtract(). Acceptable: Expects negatives at first but accepts the real output and finds subtract() with a hint. Red flag: Blames the data, or proposes catching an exception that never happens.
JR-TH-COL-05 · functools.cached_property: when it helps and when it lies
Question: What does functools.cached_property do? How is it different from @property and from @lru_cache on a method, and when does it bite you?
Answer:
@property runs the getter on every access. cached_property runs it on the first access and stores the result in the instance's __dict__ under the same name. Because it's a non-data descriptor, later lookups find that instance attribute and never call the function again. The cache is per instance and goes away with the instance.
Things that bite:
- Stale values. If the inputs change, the cached value doesn't. In the snippet, appending 650 still gives 350. Invalidate with
del obj.total, or only use it on objects that don't change after creation. - It's writable:
obj.total = 0simply overwrites the cache. - It needs an instance
__dict__, so it won't work on a class that uses__slots__without__dict__. - Since 3.12 there's no internal lock, so two threads can compute it at the same time. That's harmless for pure computations; guard it yourself if the computation has side effects.
@lru_cache on a method caches at class level, keyed on self. That needs self to be hashable, and the cache holds strong references to every instance it saw, so they're never freed (a common memory leak). For "compute an expensive derived value once per object", cached_property is the right tool.
from functools import cached_property
class Report:
def __init__(self, amounts):
self.amounts = amounts
@cached_property
def total(self):
print("computing total")
return sum(self.amounts)
r = Report([100, 250])
print(r.total)
print(r.total)
r.amounts.append(650)
print(r.total) # stale: still the cached value
del r.total # invalidate
print(r.total)
Output:
computing total
350
350
350
computing total
1000
Follow-ups:
- "How would you keep it correct if
amountscan change?" Makeamountsprivate and change it through a method that also doesself.__dict__.pop("total", None), or drop the cache and use a plain@propertyif the computation is cheap. - "Does it work on a frozen dataclass?" Yes. It writes straight into the instance
__dict__, bypassing__setattr__, so the frozen check doesn't block it (the class still needs a__dict__). - "Where would you use it in a data pipeline?" Things like a parsed schema, a loaded lookup table or summary stats on an object that's built once and then only read.
Scoring: Strong: Explains first-access caching in the instance dict, shows the stale-value problem and del to reset, and contrasts it with lru_cache keeping instances alive. Acceptable: Knows it computes once and is per instance, but misses staleness or the lru_cache memory issue until prompted. Red flag: Thinks it recomputes when attributes change, or that it's the same as @property.
JR-TH-COL-06 · A nested defaultdict that won't pickle
Question: Per-city counts are kept in defaultdict(lambda: defaultdict(int)). Everything works until you cache the result to disk with pickle, or return it from a ProcessPoolExecutor worker. What happens, and why?
Answer:
Trap: "A defaultdict is just a dict, so it pickles like one." Pickling a defaultdict also pickles its default_factory, and pickle stores functions by reference (module plus qualified name), not by their code. A lambda's name is <lambda>, which can't be looked up again, so pickle.dumps raises PicklingError. Nested functions fail the same way ("Can't pickle local object ...").
defaultdict(int) and a factory that's a named module-level function (like make_counts) pickle fine, because int and make_counts can be found by name when loading.
It shows up wherever data crosses a process boundary: ProcessPoolExecutor return values, multiprocessing queues, joblib/disk caches. Often the error appears deep inside the pool's traceback, far from the line that built the dict.
Fixes:
- Use a named module-level factory.
- Or, usually better, convert to plain dicts at the boundary:
{k: dict(v) for k, v in d.items()}. Consumers then can't accidentally create keys just by reading them, and the result is also ready for JSON.
import pickle
from collections import defaultdict
def make_counts():
return defaultdict(int)
ok = defaultdict(make_counts)
ok["Mumbai"]["orders"] += 1
print(pickle.loads(pickle.dumps(ok))["Mumbai"]["orders"])
bad = defaultdict(lambda: defaultdict(int))
bad["Mumbai"]["orders"] += 1
try:
pickle.dumps(bad)
except Exception as exc:
print(type(exc).__name__)
plain = {city: dict(c) for city, c in bad.items()}
print(pickle.loads(pickle.dumps(plain)))
Output:
1
PicklingError
{'Mumbai': {'orders': 1}}
Follow-ups:
- "Would
json.dumps(d)fail on the same object?" No. JSON only sees a dict subclass and writes its contents; the factory is ignored. The problem is specific to pickle. - "Why does
picklestore functions by name at all?" Function code isn't pickled, so loading needs to import the function from its module. That's also why the code must be importable (same module path) in the process that loads it. - "Another common thing that won't pickle across processes?" Open file handles, DB connections, locks, and lambdas or nested functions passed as the task itself.
Scoring: Strong: Predicts a pickling error, explains that functions are pickled by name, and offers both fixes (named factory, convert to plain dict). Acceptable: Knows lambdas don't pickle once the error is shown, and finds one fix. Red flag: Suggests copy.deepcopy or wrapping in try/except to make the error go away.
JR-TH-CON-01 · Threads vs processes
Question: What's the difference between a thread and a process in Python, and what does the GIL have to do with choosing between them?
Answer:
A process has its own interpreter and memory. Processes don't share Python objects; they exchange data by pickling it (return values, queues, pipes). They cost more to start (time and memory per process), but a crash in one doesn't take the others down.
Threads live inside one process and share its memory. They're cheap, and sharing data is easy, which also makes it easy to corrupt.
In standard CPython, the GIL (global interpreter lock) lets only one thread execute Python bytecode at a time. So:
- I/O-bound work (API calls, DB queries, downloads): threads help, because a thread waiting on the network or disk releases the GIL.
asynciois another option. - CPU-bound pure-Python work (parsing, loops doing arithmetic): threads don't speed it up; use processes (
ProcessPoolExecutor).
Libraries such as NumPy release the GIL inside heavy C operations, so they can benefit from threads. Python 3.13+ also ships an optional free-threaded build without the GIL, but the default build still has one.
Follow-ups:
- "Resizing 10,000 images: threads or processes?" It's CPU-bound, so processes are the safe default (one per core).
- "Why not always use processes?" Startup and memory cost, pickling overhead for every argument and result, and no shared objects.
- "Does the GIL make shared data thread-safe?" No. It protects the interpreter's internals, not your read-modify-write sequences; you still need locks.
Scoring: Strong: Clear memory-model difference, correct GIL consequence, and the I/O-bound vs CPU-bound rule with an example. Acceptable: Knows threads share memory and processes don't, and mentions the GIL vaguely. Red flag: Thinks threads make CPU-heavy Python code run on all cores, or that processes share variables.
JR-TH-CON-02 · Exceptions inside a thread pool
Question: You send SMS notifications with a ThreadPoolExecutor, using pool.submit(...) for each number. One number is invalid and the task raises ValueError. What happens to your program?
Answer:
Trap: "The program crashes with a traceback when the task raises." The pool catches the exception and stores it on that task's Future. Leaving the with block waits for all tasks and finishes normally ("batch finished"). Nothing is printed and nothing is raised.
The exception only resurfaces when you call future.result(), which re-raises it (or future.exception(), which returns it). If you never look at the futures, as in fire-and-forget submission, failures disappear silently. The real bug: notifications or uploads that quietly never happen, and nobody notices until a customer complains.
With pool.map, the exception is raised when your loop reaches that item. Iteration stops there, and the remaining results are never read, although those tasks still ran.
Fix: always collect results. Loop over as_completed(futures) (or the list), call .result() inside try/except, log failures with logger.exception(...), and count or retry them.
from concurrent.futures import ThreadPoolExecutor
def send_sms(phone):
if not phone.startswith("+91"):
raise ValueError(f"bad number {phone}")
return f"sent {phone}"
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(send_sms, p) for p in ["+919800000001", "98000", "+919800000003"]]
print("batch finished")
for f in futures:
try:
print(f.result())
except ValueError as exc:
print("failed:", exc)
Output:
batch finished
sent +919800000001
failed: bad number 98000
sent +919800000003
Follow-ups:
- "With plain
threading.Thread, where does an uncaught exception go?"threading.excepthookprints the traceback to stderr, the thread dies, and the main thread carries on with no way to see the error unless you capture it yourself. - "How do you know which input failed?" Keep a dict
{future: phone}when submitting, or include the input in the exception message.
Scoring: Strong: Says the exception is stored on the Future and re-raised by .result(), explains the silent-failure risk, and shows the collect-and-log fix. Acceptable: Expects a crash at first, then understands futures once .result() is mentioned. Red flag: Believes the whole pool stops at the first error, or wraps submit() in try/except and calls that handled.
JR-TH-CON-03 · Calling an API for many inputs with a thread pool
Question: You need to call an exchange-rate API for 200 cities. How do you do it concurrently with concurrent.futures, and when do you use map versus submit + as_completed?
Answer:
It's I/O-bound, so use ThreadPoolExecutor. Use it in a with block so the pool waits for all tasks and shuts down cleanly.
pool.map(fn, items)returns results in input order. Delhi comes first even though it finished last. It's the simplest choice when you want every result in order; the first exception is raised when your loop reaches that item.pool.submit(fn, item)returns aFutureper call.as_completed(futures)yields them as they finish, so you can log progress, save partial results and handle errors per task. Keep a{future: city}dict to know which input each result belongs to. Completion order varies from run to run; the snippet sorts it before printing.
Pick max_workers from what the API and your connection pool allow (often 5–20), not as high as possible. The default is min(32, os.cpu_count() + 4). Also set a timeout on every HTTP call: requests has no default timeout, so one hung connection can block a worker forever.
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_rate(city):
time.sleep({"Delhi": 0.03, "Pune": 0.01, "Kochi": 0.02}[city]) # stands in for an HTTP call
return city, len(city)
cities = ["Delhi", "Pune", "Kochi"]
with ThreadPoolExecutor(max_workers=3) as pool:
print(list(pool.map(fetch_rate, cities)))
with ThreadPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(fetch_rate, c): c for c in cities}
done = [f.result()[0] for f in as_completed(futures)]
print(sorted(done))
Output:
[('Delhi', 5), ('Pune', 4), ('Kochi', 5)]
['Delhi', 'Kochi', 'Pune']
Follow-ups:
- "Would
ProcessPoolExecutorbe faster here?" No. The work is waiting on the network, so threads are enough; processes only add startup and pickling overhead. - "How do you put an overall time limit on the batch?"
as_completed(futures, timeout=60)raisesTimeoutErrorif not everything finishes in time. Per-request timeouts still matter, because pool threads can't be killed. - "How would this look with asyncio?"
httpx.AsyncClientplusasyncio.gatherand aSemaphoreto limit concurrency. It's lighter per request when there are thousands of calls.
Scoring: Strong: Chooses threads with a reason, explains ordered map vs completion-order as_completed, and mentions sensible workers and timeouts. Acceptable: Writes a working map version but is unsure how as_completed differs. Red flag: Uses processes for HTTP calls 'to beat the GIL', or starts 200 raw threads with no limit.
JR-TH-CON-04 · Worker processes don't share your globals
Question: A data-cleaning job uses ProcessPoolExecutor. Each worker appends the record it handled to a module-level list processed so the parent can log them later. Why is processed empty in the parent?
Answer:
Trap: "The workers share the module's globals, so their appends show up in the parent." Each worker is a separate process with its own memory. With fork (the default on Linux in 3.12) it starts as a copy of the parent; with spawn (macOS, Windows) it re-imports the module. Either way, processed.append changes the worker's copy, which is never sent back.
Only the function's arguments and return value cross the process boundary, by pickling. Fix: return everything you need from the worker and aggregate in the parent. For large outputs, have each worker write to its own file or table. multiprocessing.Manager().list() gives a shared proxy, but every operation is a round-trip between processes and it's slow.
Threads would share the list (and its appends would show up) because they share memory. That's the key difference.
The if __name__ == "__main__": guard matters too: under spawn, workers import the main module, and the guard stops each of them from creating its own pool.
from concurrent.futures import ProcessPoolExecutor
processed = []
def clean(record):
processed.append(record) # runs in a worker process
return record.strip().title()
if __name__ == "__main__":
raw = [" asha ", "vikram", " lata"]
with ProcessPoolExecutor(max_workers=2) as pool:
results = list(pool.map(clean, raw))
print(results)
print(processed)
Output:
['Asha', 'Vikram', 'Lata']
[]
Follow-ups:
- "Same code with
ThreadPoolExecutor: what doesprocessedcontain?" All three records (a singleappendis safe in CPython), but in whatever order the threads ran. - "Each worker needs a 2 GB lookup table. Pass it as an argument?" No, it would be pickled for every task. Load it once per worker with
initializer=, or use a shared file or DB the workers read from. - "Why must the worker function be defined at module level?" It's pickled by name, so the worker process must be able to import it; lambdas and nested functions fail.
Scoring: Strong: Predicts [], explains separate memory and that only pickled return values come back, and gives the 'return, don't mutate globals' fix. Acceptable: Gets it after being reminded processes have separate memory. Red flag: Suggests adding a global statement or a lock to make the append visible.
JR-TH-CON-05 · Race conditions, locks and deadlocks
Question: What is a race condition? How can one happen with Python threads despite the GIL, how does a Lock fix it, and what is a deadlock?
Answer:
A race condition is when the result depends on the timing of threads touching shared data. balance += 1 is a read, an add and a write. A thread can be switched out between the read and the write, so two threads both read 10, both write 11, and one credit is lost. The GIL doesn't prevent this: it guarantees one bytecode at a time, not that your sequence of steps runs as one unit. Check-then-act code (if seat_free: book(seat)) has the same problem.
Fix: protect the critical section with a threading.Lock, using with lock: so it's released even on an exception. With the lock the snippet always prints 400000. Keep critical sections short and never do network calls while holding a lock. Better still, avoid shared mutable state: give each thread its own result, or communicate through queue.Queue.
Without the lock, this exact snippet may or may not lose updates on a given run. That's what makes races dangerous: they pass tests and fail under production load.
A deadlock is two threads each holding one lock and waiting forever for the other's. Avoid it by:
- always acquiring locks in the same global order,
- holding one lock at a time where possible,
- using
lock.acquire(timeout=...)to detect it, - using an
RLockwhen the same thread must re-enter.
import threading
balance = 0
lock = threading.Lock()
def credit(times):
global balance
for _ in range(times):
with lock:
balance += 1
threads = [threading.Thread(target=credit, args=(100_000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(balance)
Output:
400000
Follow-ups:
- "Is
list.appendfrom several threads safe?" A singleappendis atomic in CPython, so the list won't be corrupted. A sequence like 'check length, then append' still needs a lock. - "Lock vs RLock?" An
RLockcan be acquired again by the thread that already holds it (a counter tracks this). A plainLockwould deadlock that thread. - "How would you make this design lock-free?" Each thread computes a local total and returns it (e.g. via an executor), and the main thread sums the results.
Scoring: Strong: Explains read-modify-write interleaving, why the GIL doesn't help, with lock, and deadlock with lock ordering. Acceptable: Knows to use a Lock and roughly what a race is, but can't explain why += isn't atomic. Red flag: Claims the GIL makes += safe, or holds a lock around an entire HTTP call 'to be safe' without seeing the cost.
JR-TH-CON-06 · max_workers=1000 is not a speed-up
Question: To speed up 5,000 calls to a partner's REST API, a teammate sets ThreadPoolExecutor(max_workers=1000): "more threads, more speed." What actually happens?
Answer:
Trap: "Throughput grows with the number of threads." Beyond a point it doesn't, and things start breaking.
- Rate limits: the partner answers HTTP 429 (Too Many Requests) or blocks you, and naive retries add even more load.
- Connection pools: a
requestssession keeps at most 10 connections per host by default. Extra threads either wait or open throwaway connections, and urllib3 logs "Connection pool is full, discarding connection". - Your own limits: the DB connection pool, open-file limits (
ulimit -n) and memory for 1,000 thread stacks. - Overhead: CPU time goes to context switches and GIL hand-offs instead of useful work.
What to do instead:
- Measure: raise the worker count until throughput stops improving (often 10–50 for a single host).
- Respect the documented rate limit with a semaphore or token bucket.
- Set timeouts and retry with exponential backoff, honouring
Retry-After. - Size the HTTP connection pool to match the workers.
For many thousands of concurrent calls, asyncio with httpx.AsyncClient and a Semaphore has much lower per-request overhead than threads.
Follow-ups:
- "How do you choose
max_workersthen?" Start near the API's allowed concurrency or your connection-pool size, measure throughput and error rate, and stop increasing when either gets worse. - "What is
Retry-After?" A response header (with 429 or 503) telling you how long to wait before retrying. A good client sleeps at least that long. - "Is a concurrency limit the same as a rate limit?" No. 10 in-flight requests can still be 200 requests per second if the API is fast. Rate limits need a time-based limiter.
Scoring: Strong: Names concrete limits (429s, connection pool size, resources), proposes measuring plus backoff and rate limiting, and mentions asyncio for very high fan-out. Acceptable: Says 'too many threads is bad' and suggests a smaller number, but without the reasons. Red flag: Agrees that 1000 threads means roughly 1000× faster.
JR-TH-CTX-01 · Why with open() instead of open() and close()
Question: Why do we write with open(path) as f: instead of f = open(path) and calling f.close() at the end? Name two other things you use with for.
Answer:
With manual close(), any exception or early return between open and close skips the close. The file handle leaks, and for files opened for writing, buffered data may not be flushed to disk. with guarantees cleanup: the file is closed when the block ends, however it ends (normal finish, return, break, or an exception). It is equivalent to a try/finally, but shorter and impossible to forget.
Other everyday uses: threading.Lock() (released even on error), tempfile.TemporaryDirectory() (deleted afterwards), httpx.Client() (connections closed), database transactions, unittest.mock.patch(...) (patch undone), and pytest.raises(...) in tests.
Follow-ups:
- "Write the
try/finallyequivalent of awith open(...)block."f = open(path), thentry:usef,finally: f.close(). Theopenstays outside thetry, because if opening fails there is nothing to close. - "Can you open two files in one
withstatement?" Yes:with open(src) as fin, open(dst, "w") as fout:, or the parenthesised multi-line form supported since 3.10.
Scoring: Strong: Explains that exceptions and early returns skip a manual close, mentions flushing writes, and names two other context managers. Acceptable: Says "it closes the file automatically" and names one other use; reaches the exception case with a prompt. Red flag: Thinks with is just style, or that Python always closes files immediately anyway so it doesn't matter.
JR-TH-CTX-02 · Does with sqlite3.connect() close the connection?
Question: You write with sqlite3.connect("shop.db") as conn: and run some inserts inside. Is the connection closed when the block ends?
Answer:
Trap: "Yes, like a file." A sqlite3.Connection used as a context manager manages a transaction, not the connection. On a clean exit it commits; if the block raises, it rolls back. The connection stays open and usable afterwards. You still have to call conn.close(), or wrap it in contextlib.closing(sqlite3.connect(...)).
The lesson generalises: what __exit__ does is decided by each library, so check the docs. Some close (open, httpx.Client), some release (Lock), some commit or roll back (sqlite3, many DB drivers' transaction objects), some restore state (mock.patch). Assuming "with means close" leaks connections in long-running services.
import sqlite3
with sqlite3.connect(":memory:") as conn:
conn.execute("CREATE TABLE orders (id INTEGER, amount INTEGER)")
conn.execute("INSERT INTO orders VALUES (1, 499)")
# The with block has ended. Is the connection closed?
print(conn.execute("SELECT COUNT(*) FROM orders").fetchone())
try:
with conn:
conn.execute("INSERT INTO orders VALUES (2, 999)")
raise ValueError("payment failed")
except ValueError:
pass
print(conn.execute("SELECT COUNT(*) FROM orders").fetchone())
conn.close()
try:
conn.execute("SELECT 1")
except sqlite3.ProgrammingError as e:
print("ProgrammingError:", e)
Output:
(1,)
(1,)
ProgrammingError: Cannot operate on a closed database.
Follow-ups:
- "Why is the count still 1 after the second block?" The block raised, so the connection's
__exit__rolled back the insert of order 2. TheValueErrorstill propagated, which is why the outertrywas needed. - "How do you get both behaviours, commit/rollback and close?" Nest them:
with closing(sqlite3.connect(path)) as conn:and inside itwith conn:for each transaction.
Scoring: Strong: Says the connection stays open, explains commit-on-success and rollback-on-error, and gives closing() or an explicit close(). Acceptable: Unsure at first but reasons from the output that it stays open; knows to call close(). Red flag: Insists every context manager closes its object, or does not know what commit and rollback mean.
JR-TH-CTX-03 · An optional context manager with nullcontext
Question: An export function writes to a file when given a path, and to an already-open stream (stdout, or a buffer passed in by the caller) otherwise. How do you write a single with block for both cases? What goes wrong with with (open(path, "w") if path else sys.stdout) as out:?
Answer:
Choose the context manager first, then use one with:
- path given:
cm = open(path, "w", encoding="utf-8"). The function opened it, so it should close it. - otherwise:
cm = contextlib.nullcontext(stream).nullcontextdoes nothing on enter or exit and just hands back the object you gave it as theastarget.
The one-liner with sys.stdout has a subtle bug: file objects are their own context managers, and their __exit__ closes them. Using sys.stdout (or a caller's buffer) directly in with closes a stream your function doesn't own, so the next print() fails with ValueError: I/O operation on closed file. The rule: only use with on resources you acquired; for borrowed ones, wrap them in nullcontext.
The same pattern covers "lock only when running with threads" (lock if threaded else nullcontext()) and optional mock.patch in tests.
import contextlib
import io
def export(rows, out_file=None):
# open a file only when asked; otherwise write to the caller's stream
if out_file is None:
cm = contextlib.nullcontext(console)
else:
cm = open(out_file, "w", encoding="utf-8")
with cm as out:
for row in rows:
out.write(row + "\n")
console = io.StringIO() # stands in for sys.stdout
export(["id,city", "1,Pune"])
print(repr(console.getvalue()), "closed:", console.closed)
export(["id,city", "2,Kochi"], "orders.csv")
with open("orders.csv", encoding="utf-8") as f:
print(repr(f.read()))
shared = io.StringIO()
with shared as out: # using the stream itself as the context manager
out.write("hello\n")
print("closed:", shared.closed)
Output:
'id,city\n1,Pune\n' closed: False
'id,city\n2,Kochi\n'
closed: True
Follow-ups:
- "What would you pass as the stream in production instead of the
StringIO?"sys.stdout, or better, take the stream as a parameter (out=sys.stdout) so tests can pass aStringIOand check what was written. - "Is there an alternative that avoids the branching altogether?" Have the function accept an open stream only and let callers do
with open(path, "w", encoding="utf-8") as f: export(rows, f). The caller owns the file and the function just writes.
Scoring: Strong: Uses nullcontext, explains that with on a file object closes it and why closing a borrowed stream is wrong, and states the "close only what you opened" rule. Acceptable: Writes an if/else with two with blocks (duplicated body) and recognises the stdout-closing problem when shown. Red flag: Sees no problem with closing sys.stdout, or drops with and leaves the file unclosed.
JR-TH-CTX-04 · What runs after the exception inside suppress() or pytest.raises()
Question: Inside with contextlib.suppress(KeyError): there are three lines, and the first one raises KeyError. Do the other two run? How does the same rule bite people using pytest.raises?
Answer:
Trap: "suppress ignores the failing line and carries on with the next one." No. An exception ends the with body immediately; __exit__ then decides whether the exception propagates. suppress swallows it, and execution continues after the whole with block. The remaining lines in the body are skipped, so in the example neither del cart["rice"] nor the print runs. It is not "On Error Resume Next".
Same rule in tests:
with pytest.raises(ValueError):
parse_amount("abc")
assert audit_log == [] # never executed
The assert after the raising call never runs, so the test passes whatever audit_log contains. Keep only the call that should raise inside pytest.raises, and put follow-up assertions after the block. Likewise, put one operation per suppress, and use it only when the exception genuinely means "nothing to do" (e.g. deleting a file that may not exist).
from contextlib import suppress
cart = {"rice": 2, "dal": 1}
with suppress(KeyError):
del cart["ghee"] # raises KeyError
del cart["rice"] # never runs
print("removed items") # never runs
print(cart)
Output:
{'rice': 2, 'dal': 1}
Follow-ups:
- "Rewrite the cart code so both deletions are attempted." One
suppressper operation, orcart.pop("ghee", None)andcart.pop("rice", None), which need no exception handling at all. - "How do you assert on the exception's message in pytest?"
with pytest.raises(ValueError, match="invalid amount") as exc_info:and then, after the block, inspectexc_info.value.
Scoring: Strong: Says the rest of the body is skipped, execution resumes after the block, and spots the dead assertion inside pytest.raises. Acceptable: Gets the suppress case after a moment's thought; needs a hint to connect it to pytest.raises. Red flag: Believes suppress resumes on the next line, or writes tests with several assertions inside pytest.raises.
JR-TH-CTX-05 · Class-based context managers vs @contextmanager
Question: When would you write a context manager as a class with __enter__ and __exit__ instead of using @contextmanager? What exactly must each method do?
Answer:
__enter__(self) runs at the start; its return value is what as binds. It's usually self, but not always: a Lock returns True, and open() returns the file. __exit__(self, exc_type, exc, tb) runs at the end; the three arguments are None on a clean exit. Returning a truthy value suppresses the exception, so return None/False unless suppression is the explicit purpose.
Prefer a class when:
- The object is reused or long-lived. A
@contextmanagerobject wraps one generator and can be entered only once; a secondwithon the same object fails. A class can reset its state in__enter__and be entered many times (a pipeline-stage tracker, a reusable lock wrapper, a test fixture object). - It carries state and methods used inside the block (
client.get(...)), or the resource needs other behaviour besides being a context manager. - You need the exception details in a structured way, or want to subclass it.
For a quick one-off setup/teardown (temporarily changing a directory, a setting, or a timer), @contextmanager is shorter and fine.
from contextlib import contextmanager
class Stage:
"""Reusable: every with-block re-runs __enter__."""
def __init__(self, name):
self.name = name
self.runs = 0
def __enter__(self):
self.runs += 1
print(f"enter {self.name} run {self.runs}")
return self
def __exit__(self, exc_type, exc, tb):
print(f"exit {self.name} exc={exc_type.__name__ if exc_type else None}")
return False
t = Stage("load")
with t:
pass
with t:
pass
@contextmanager
def section(name):
print(f"enter {name}")
yield
print(f"exit {name}")
s = section("load")
with s:
pass
try:
with s:
pass
except Exception as e:
print("second use failed:", type(e).__name__)
Output:
enter load run 1
exit load exc=None
enter load run 2
exit load exc=None
enter load
exit load
second use failed: AttributeError
Follow-ups:
- "How do you make a reusable context manager with
@contextmanager?" Call the function again for each block:with section("load"):creates a fresh generator every time. The problem only appears when one returned object is stored and reused. - "What should
__exit__return if it only logs the exception?"NoneorFalse, so the exception continues to propagate after being logged.
Scoring: Strong: States that __enter__'s return value is what as binds, explains the three __exit__ arguments and the meaning of a truthy return, and gives a real reason to prefer a class (reuse, state, methods). Acceptable: Writes both methods correctly but is vague on when to choose a class. Red flag: Thinks as always binds the context manager object itself, or returns True from __exit__ by default.
JR-TH-CTX-06 · A with block inside a generator
Question: A generator does with open(path) as f: and yields lines from inside the with. The caller stores the generator in a variable, reads the first matching line, and breaks. When is the file closed?
Answer:
Trap: "At the break, because the with block is finished." The generator is only paused at its yield; the with block inside it has not ended, so the file is still open. It is closed only when the generator is finished or closed:
- the caller exhausts it,
- someone calls
gen.close()(this raisesGeneratorExitat the pausedyield, so thewithexits and cleanup runs), or - the generator object is garbage-collected. In CPython that happens as soon as the last reference goes away, which is why the bug hides when the generator is a temporary in a
forloop, but here a variable keeps it alive.
In a long-running service or a loop over thousands of files, this shows up as "too many open files" or a lock held far longer than expected. Fixes: consume the generator fully, call close() explicitly, wrap it with contextlib.closing(gen) in a with, or read what you need inside the function and return a list.
class Resource:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"open {self.name}")
return self
def __exit__(self, *exc):
print(f"close {self.name}")
def read_rows(name):
with Resource(name):
for row in ["r1", "r2", "r3"]:
yield row
rows = read_rows("sales.csv")
for row in rows:
print("got", row)
break
print("after loop")
rows.close()
print("after close")
Output:
open sales.csv
got r1
after loop
close sales.csv
after close
Follow-ups:
- "Write the
closingversion."with contextlib.closing(read_rows("sales.csv")) as rows:then loop andbreak; leaving thewithcallsrows.close(). - "Would a
try/finallyinside the generator behave differently from thewith?" No. Thefinallyalso runs only when the generator is closed, exhausted or collected. The issue is the paused generator, not the choice of cleanup syntax.
Scoring: Strong: Explains the paused generator, names close()/GeneratorExit, notes the CPython garbage-collection timing that hides the bug, and offers closing() or full consumption. Acceptable: Realises the file is still open after the break once the generator is described as paused; fix needs a hint. Red flag: Is sure the break closes the file, or does not know generators can be paused mid-function.
JR-TH-DE-01 · CSV vs Parquet for pipeline storage
Question: Your team stores daily order extracts as CSV. A senior suggests switching to Parquet. What's the difference, and why do data teams usually prefer Parquet for pipeline storage?
Answer:
CSV is row-oriented plain text. Everything is a string, so every reader has to guess types again (dates, IDs with leading zeros, nullable integers). Quoting, delimiters and encodings vary between tools, and to read one column you still parse the whole file.
Parquet is a binary, columnar format:
- The schema travels with the data. Column names and types (int64, decimal, timestamp with time zone, string) are stored in the file, so a reader gets the same types back without guessing.
- Columnar layout. Values of one column are stored together, so reading
["order_id", "amount"]out of 40 columns reads only those two (column pruning). - Compression. Similar values sit next to each other, so codecs such as Snappy or Zstandard shrink files a lot; several times smaller than the CSV is common.
- Statistics. Each row group stores min/max per column, so engines can skip chunks that can't match a filter such as
order_date = '2026-04-01'(predicate pushdown). - It's the common format of the ecosystem: pandas (
to_parquet/read_parquetwith pyarrow), Spark, DuckDB, Polars, BigQuery, Athena.
Where CSV still fits: small files exchanged with people or Excel, quick debugging, and append-only logs. Parquet isn't human-readable, and a file can't be appended to in place (you write new files instead), so it's a storage format, not a format for humans to edit.
Follow-ups:
- "How do you organise Parquet files for a table that gets a new day of data every day?" Partition by date in the folder path, e.g.
orders/order_date=2026-04-01/part-0.parquet. Readers that filter on the date only open the matching folders, and a rerun for one day can replace just that folder. - "Does Parquet stop the type-guessing problem completely?" Only if the first writer gets the types right. If
pd.read_csvalready turned branch codes into integers, Parquet stores those integers faithfully. Set the types when you first read the raw data. - "Thousands of tiny Parquet files: what's the problem?" Each file has metadata and open overhead, so reads slow down and object storage costs more per request. Compact them into fewer, larger files, usually in the hundreds of MB.
Scoring: Strong: Explains columnar storage, embedded schema and types, compression, column pruning and predicate pushdown, and when CSV is still the better choice. Acceptable: Knows Parquet is smaller, faster and keeps types, without explaining columnar reads or statistics. Red flag: Thinks Parquet is just "compressed CSV", or can't name any problem with CSV types.
JR-TH-DE-02 · to_sql with if_exists="replace" drops the table
Question: A daily job refreshes a reporting table with df.to_sql("daily_sales", conn, if_exists="replace", index=False). The DBA created that table with a primary key, NOT NULL columns and an index. What does replace do to them?
Answer:
Trap: "replace swaps the rows and leaves the table definition alone."
if_exists="replace" drops the table and creates a new one from the DataFrame. The new table's column types are whatever pandas infers from the dtypes, and it has none of the original's primary key, NOT NULL or CHECK constraints, indexes, defaults, comments, grants or dependent views (in databases that enforce dependencies, the drop may fail instead). The snippet shows the index and primary key disappearing and revenue NUMERIC NOT NULL becoming a plain INTEGER.
Consequences show up later, not at load time:
- duplicate keys can now be inserted, because the primary key is gone;
- queries that relied on the index get slow;
- other users lose access if grants were dropped;
- one day's frame with only nulls in a column, or ints instead of decimals, creates the table with different types.
Safer patterns:
- Keep the table; replace the rows in one transaction:
DELETE FROM daily_sales WHERE sale_date = ?(orTRUNCATEfor a full refresh), thento_sql(..., if_exists="append"). - Load a staging table, then merge or swap: write to
daily_sales_staging, validate it, thenINSERT ... ON CONFLICT/MERGEinto the real table. - Create tables with explicit DDL (in a migration), not from whatever the first DataFrame looked like. If pandas must create a table, pass
dtype=to control column types.
Also note if_exists="append" on its own isn't idempotent: rerunning the job inserts the same day twice unless a key or a prior delete prevents it.
import sqlite3
import pandas as pd
conn = sqlite3.connect(":memory:")
conn.execute("""CREATE TABLE daily_sales (
sale_date TEXT PRIMARY KEY,
store_id TEXT NOT NULL,
revenue NUMERIC NOT NULL)""")
conn.execute("CREATE INDEX idx_store ON daily_sales (store_id)")
def schema():
rows = conn.execute("SELECT type, name FROM sqlite_master ORDER BY type, name")
return [f"{t}:{n}" for t, n in rows]
print("before:", schema())
today = pd.DataFrame({"sale_date": ["2026-04-01"], "store_id": ["BLR-01"], "revenue": [125000]})
today.to_sql("daily_sales", conn, if_exists="replace", index=False)
print("after: ", schema())
print(conn.execute("SELECT sql FROM sqlite_master WHERE name = 'daily_sales'").fetchone()[0])
Output:
before: ['index:idx_store', 'index:sqlite_autoindex_daily_sales_1', 'table:daily_sales']
after: ['table:daily_sales']
CREATE TABLE "daily_sales" (
"sale_date" TEXT,
"store_id" TEXT,
"revenue" INTEGER
)
Follow-ups:
- "How would you make the daily refresh safe to rerun?" In one transaction, delete that day's rows and append the new frame (or upsert on
sale_date, store_id). A failure rolls back to the previous state, and a rerun produces the same result. - "What does
if_exists="fail"(the default) do, and when is it useful?" It raisesValueErrorif the table exists. It's a good default for one-off exports, where overwriting an existing table by accident would be worse than an error. - "Why might
to_sqlbe slow for a million rows, and what helps?" By default it sends rows in batches of generic INSERT statements. Usechunksize=andmethod="multi", or the database's bulk loader (PostgresCOPY, a warehouse's load-from-file), often by writing a file first.
Scoring: Strong: Knows replace means DROP + CREATE with inferred types, lists the lost constraints and indexes and their later consequences, and proposes delete-and-append in a transaction or staging plus merge, with explicit DDL. Acceptable: Knows the table is recreated and constraints are lost, and suggests using append after clearing the rows. Red flag: Thinks replace only replaces the data, or proposes append alone as the fix without handling reruns.
JR-TH-DE-03 · Aggregating a CSV larger than memory
Question: A 20 GB transactions CSV needs total revenue per city, but the machine has 8 GB of RAM. How do you do it in Python?
Answer:
Don't load the whole file. Stream it and aggregate as you go, keeping only the running result in memory.
pandas in chunks. pd.read_csv(path, chunksize=500_000) returns an iterator of DataFrames. Aggregate each chunk, then combine the partial results (the snippet uses a 2-row chunk size to show the idea):
- sums and counts combine by adding them;
- for a mean, keep the sum and the count and divide at the end; averaging the chunk means is wrong when chunk sizes differ;
- a median or distinct count can't be combined from per-chunk results that simply; you need a different approach (an approximate algorithm, or a tool that handles it).
Cut memory per chunk: usecols=["city", "amount"] reads only the needed columns; dtype={"city": "category", "amount": "float32"} (or int32) shrinks each column.
Without pandas: csv.DictReader over the open file is already a generator. Iterate row by row and update a dict or collections.Counter. That's slower per row, but memory stays flat.
Mistakes to avoid:
- appending every chunk to a list and calling
pd.concatat the end (you've rebuilt the whole file in memory); - reading everything and then filtering.
Tools built for this: DuckDB (SELECT city, SUM(amount) FROM 'tx.csv' GROUP BY city) or a Polars lazy scan process larger-than-memory files efficiently. If the data is read repeatedly, convert it to Parquet once.
import io
import pandas as pd
csv_text = """order_id,city,amount
1,Pune,250
2,Delhi,400
3,Pune,150
4,Chennai,300
5,Delhi,100
"""
totals = {}
for chunk in pd.read_csv(io.StringIO(csv_text), chunksize=2,
usecols=["city", "amount"]):
print("chunk rows:", len(chunk))
for city, amt in chunk.groupby("city")["amount"].sum().items():
totals[city] = totals.get(city, 0) + int(amt)
print(dict(sorted(totals.items())))
Output:
chunk rows: 2
chunk rows: 2
chunk rows: 1
{'Chennai': 300, 'Delhi': 500, 'Pune': 400}
Follow-ups:
- "Why is the mean of per-chunk means wrong?" Chunks have different numbers of rows per city (and the last chunk is smaller), so each chunk mean has a different weight. Keep sum and count per city and divide once at the end.
- "The job also needs to drop duplicate
order_ids across the whole file. Does chunking still work?" Not chunk by chunk, because duplicates can sit in different chunks. Keep a set of seen IDs (fine for millions of integers), or push the work to a tool like DuckDB or a database that can deduplicate out of core. - "How would you show the memory use of a DataFrame?"
df.memory_usage(deep=True).sum()ordf.info(memory_usage="deep").deep=Truematters for object (string) columns.
Scoring: Strong: Streams with chunksize or the csv module, combines partial aggregates correctly (sum and count for means), trims columns and dtypes, avoids concatenating chunks, and knows DuckDB/Polars or Parquet as options. Acceptable: Uses chunksize and adds up per-chunk sums, without discussing which aggregates combine safely or dtype savings. Red flag: Suggests buying a bigger machine or reading the file normally and hoping, or concatenates all chunks back into one DataFrame.
JR-TH-DE-04 · A crashed job leaves a half-written output file
Question: A nightly job writes sales.csv with open(path, "w"), and a dashboard reads that file every few minutes. One night the job's container is killed halfway through. What does the dashboard see, and how should the job write its output?
Answer:
Trap: "If the job fails, the file just won't be updated."
open(path, "w") truncates the existing file immediately. From then on, anyone reading the path sees whatever has been written so far. If the process dies midway, you're left with neither yesterday's good file nor today's complete one, just a partial file. The dashboard happily shows two stores instead of all of them, and nothing errors. (The same happens to a reader that opens the file while a healthy job is still writing.)
Write atomically: write to a temporary file in the same directory, then os.replace(tmp, path). A rename within one filesystem is atomic: readers see either the complete old file or the complete new one, never a mixture. If the job crashes before the rename, yesterday's file is untouched (second line of output). Clean up leftover .tmp files, and for extra safety call f.flush() and os.fsync(f.fileno()) before renaming so the data is on disk.
The same idea at larger scale:
- Object storage (S3/GCS): a single object upload becomes visible only once it completes, but a folder of many files doesn't. Write to a new prefix, then publish it with a marker file such as
_SUCCESSor by updating a pointer, and have readers check for it. - Databases: load into a staging table and swap or merge inside a transaction.
- Readers: check completeness (marker file, expected row count, a footer or trailer record) instead of trusting that a file exists.
import os
import tempfile
from pathlib import Path
def write_report(path, rows, crash_after=None):
with open(path, "w") as f:
for i, row in enumerate(rows):
if i == crash_after:
raise RuntimeError("worker killed")
f.write(row + "\n")
def write_report_atomic(path, rows, crash_after=None):
tmp = path.with_name(path.name + ".tmp")
write_report(tmp, rows, crash_after)
os.replace(tmp, path) # atomic rename on the same filesystem
rows = ["store,revenue", "BLR-01,125000", "PUN-02,98000", "DEL-03,143000"]
with tempfile.TemporaryDirectory() as d:
out = Path(d) / "sales.csv"
try:
write_report(out, rows, crash_after=2)
except RuntimeError:
pass
print("direct write after crash:", out.read_text().splitlines())
out.unlink()
write_report_atomic(out, rows) # yesterday's good run
try:
write_report_atomic(out, rows[:2] + ["HYD-04,1"], crash_after=2)
except RuntimeError:
pass
print("atomic write after crash:", out.read_text().splitlines())
Output:
direct write after crash: ['store,revenue', 'BLR-01,125000']
atomic write after crash: ['store,revenue', 'BLR-01,125000', 'PUN-02,98000', 'DEL-03,143000']
Follow-ups:
- "Why must the temporary file be in the same directory as the target?"
os.replaceis only atomic within one filesystem. Across filesystems (e.g./tmpon a different mount) it fails withOSError(EXDEV), and a copy-then-delete workaround is no longer atomic. - "Why
os.replaceinstead ofos.rename?" On Windowsos.renamefails if the target exists;os.replaceoverwrites it on every platform. On POSIX both behave the same. - "Two copies of the job run at the same time and both use
sales.csv.tmp. What breaks?" They write into the same temp file and corrupt each other. Use a unique temp name (tempfile.NamedTemporaryFile(dir=..., delete=False)or a run ID in the name), and ideally prevent overlapping runs with a lock.
Scoring: Strong: Explains that "w" truncates immediately and readers see partial data, proposes temp file in the same directory plus os.replace, and extends the idea to marker files or staging tables. Acceptable: Recognises the partial-file problem and suggests writing elsewhere and renaming, without knowing why the same filesystem matters. Red flag: Believes a failed job leaves the old file intact, or proposes wrapping the write in try/except and carrying on.
JR-TH-DE-05 · UTC timestamps and IST business days
Question: Order timestamps are stored in UTC. Your "daily sales" report for Indian business days groups by the date part of created_at. What's wrong, and how should a pipeline handle time zones?
Answer:
An IST day (UTC+05:30) runs from 18:30 UTC the previous day to 18:30 UTC. Grouping by the UTC date shifts every report by five and a half hours: orders placed between 00:00 and 05:30 IST are counted on the previous day. In the snippet the 01:45 IST order on 10 March lands on 9 March by UTC date. Late-night food and grocery orders make this a visible error, and month-end totals stop matching finance.
Rules that keep pipelines correct:
- Store and move timestamps as timezone-aware UTC. Convert to a business time zone only when you derive business things: report dates, daily partitions, "orders before 10 a.m." features.
- Never use naive datetimes for real events.
datetime.now()returns the machine's local time with no zone attached. Servers usually run in UTC and laptops in IST, so the same code gives different answers. Usedatetime.now(timezone.utc). - In pandas:
tz_localize("UTC")labels naive values whose zone you know;tz_convert("Asia/Kolkata")changes the zone of aware values; then.dt.dategives the IST business date. - Name the zone of every date column and partition (
order_date_ist, or document thatdt=is IST), and schedule "yesterday's" job after midnight IST, not after midnight UTC. - India has no daylight saving, so a fixed +05:30 offset is correct for IST (the snippet uses one). For other markets use
zoneinfo.ZoneInfo("Europe/London"), never a fixed offset, because of DST.
from collections import Counter
from datetime import datetime, timedelta, timezone
IST = timezone(timedelta(hours=5, minutes=30), "IST")
# Order timestamps, stored in UTC as they should be
orders_utc = [
datetime(2026, 3, 9, 20, 15, tzinfo=timezone.utc), # 01:45 IST on 10 March
datetime(2026, 3, 10, 2, 0, tzinfo=timezone.utc), # 07:30 IST on 10 March
datetime(2026, 3, 10, 19, 0, tzinfo=timezone.utc), # 00:30 IST on 11 March
]
print("by UTC date:", dict(Counter(str(ts.date()) for ts in orders_utc)))
print("by IST date:", dict(Counter(str(ts.astimezone(IST).date()) for ts in orders_utc)))
Output:
by UTC date: {'2026-03-09': 1, '2026-03-10': 2}
by IST date: {'2026-03-10': 2, '2026-03-11': 1}
Follow-ups:
- "A source system sends naive timestamps like
2026-03-10 09:15:00. What do you do?" Find out which zone they're in (ask the source owner or check known events), thentz_localizewith that zone at ingestion, convert to UTC and document it. Guessing turns every downstream metric into a guess. - "Comparing an aware and a naive datetime: what happens?" Ordering comparisons (
<) raiseTypeError: can't compare offset-naive and offset-aware datetimes;==just returnsFalse. Mixed columns are a sign that one source isn't being normalised. - "Write the SQL filter for 'orders on 10 March IST' against a UTC column, without converting every row."
created_at >= '2026-03-09 18:30:00+00' AND created_at < '2026-03-10 18:30:00+00': compute the UTC bounds once. A half-open range keeps the index usable and avoids double-counting the boundary instant.
Scoring: Strong: Explains the 18:30 UTC boundary and its effect, stores UTC and converts only for business dates, avoids naive now(), knows tz_localize vs tz_convert, and labels partition time zones. Acceptable: Knows to convert to IST before taking the date and to store UTC, but is vague about naive datetimes and partitions. Red flag: Stores local IST times without a zone, or thinks the date part is the same in every time zone.
JR-TH-DE-06 · A renamed upstream column that fails silently
Question: Your daily job concatenates the day's files with pd.concat and sums amount. On Tuesday the upstream team renamed amount to amount_inr. The job ran green, but the revenue dashboard dropped by half. Why didn't anything fail, and how do you make the pipeline fail loudly?
Answer:
Trap: "If there's no exception, the data is fine."
pd.concat aligns frames by column name, keeping the union of columns (an outer join on columns). Tuesday's rows get NaN under amount, and Monday's rows get NaN under the new amount_inr. Then Series.sum() skips NaN by default, so the total quietly covers only the old files: ₹1,200 instead of ₹2,400 in the snippet. Every step did exactly what it's documented to do.
Other silent paths like this: pd.to_numeric(..., errors="coerce") turning bad values into NaN; a left merge on keys with mismatched dtypes ("101" vs 101) matching nothing; read_csv inferring a new type.
Make the pipeline check its assumptions at the boundary:
- Schema check on read. Assert the expected columns and dtypes before processing:
missing = EXPECTED - set(df.columns), then raise if anything is missing. Passingusecols=[...]toread_csvraises when a listed column is missing. - Data-quality checks before publishing: no nulls in required columns, row count close to the source's, totals reconciled with a control total from the source, and values within a sane range compared with recent days (e.g. revenue within ±50% of the same weekday last week).
- Fail and alert instead of loading. A late dashboard is better than a wrong one people make decisions on.
- Agree a contract with upstream (column names, types, a changelog), and use a validation library such as pandera or Pydantic models for records if checks multiply.
Note that pd.concat(..., join="inner") is not a fix: it would silently drop amount from every file instead.
import pandas as pd
monday = pd.DataFrame({"order_id": [1, 2], "amount": [500, 700]})
# Upstream renamed the column on Tuesday
tuesday = pd.DataFrame({"order_id": [3, 4], "amount_inr": [300, 900]})
combined = pd.concat([monday, tuesday], ignore_index=True)
print(combined)
print("revenue:", combined["amount"].sum())
Output:
order_id amount amount_inr
0 1 500.0 NaN
1 2 700.0 NaN
2 3 NaN 300.0
3 4 NaN 900.0
revenue: 1200.0
Follow-ups:
- "How would you make the sum itself refuse to ignore missing values?"
combined["amount"].sum(min_count=len(combined))returns NaN if any value is missing, or checkcombined["amount"].isna().any()and raise. Explicit checks with a clear error message are better than relying on NaN propagating. - "Why did the amount column change from int to float?" NaN is a float, and NumPy's int64 has no missing value, so pandas upcast the column to float64. An unexpected dtype change like this is itself a useful schema-check signal.
- "The rename was intentional. How should the pipeline handle both names during the transition?" Map known aliases explicitly at read time (
df.rename(columns={"amount_inr": "amount"})), log which version arrived, and remove the alias once upstream has fully switched, rather than accepting any column set.
Scoring: Strong: Explains concat's union-of-columns alignment and NaN-skipping sums, adds schema checks on read and data-quality checks (row counts, null rates, reconciliation, ranges) before publishing, and prefers failing loudly. Acceptable: Identifies the NaN columns from the rename and suggests checking for expected columns, without broader data-quality checks. Red flag: Fixes it only by renaming the column this one time, or suggests fillna(0) on the sum.
JR-TH-DEC-01 · Decorators you already use
Question: Name a few decorators you have used at work, from the standard library or a framework, and say what each one does to the function or class below it.
Answer:
A decorator is a callable that takes a function (or class) and returns a replacement; @d above def f means f = d(f). Common ones a Junior should know:
@property,@classmethod,@staticmethod: change how a method is accessed or what it receives (self,cls, or nothing).@dataclass: inspects the class annotations and generates__init__,__repr__and__eq__.@functools.cache/@functools.lru_cache: remember results per argument tuple.@functools.wraps(func): used inside your own decorators to copy the original name and docstring onto the wrapper.- Framework ones:
@app.get("/orders")in FastAPI registers a route;@pytest.fixtureand@pytest.mark.parametrizeregister test setup and test cases.
The common idea: the decorator either wraps the function with extra behaviour (caching, logging, retries) or registers it somewhere (routes, fixtures) and returns it.
Follow-ups:
- "Is
@app.get("/orders")wrapping the function or registering it?" Mainly registering: FastAPI records the path, method and function in the app's router at import time. The function you wrote is what runs per request. - "What does
@dataclassproduce if you print an instance?" The generated__repr__, e.g.Order(order_id=1, amount=499), instead of the default<__main__.Order object at 0x...>.
Scoring: Strong: Gives the f = d(f) equivalence and three or more correct examples, separating "wraps behaviour" from "registers the function". Acceptable: Two correct examples with roughly right descriptions; needs a prompt for the f = d(f) desugaring. Red flag: Cannot name any decorator beyond @staticmethod, or thinks decorators are comments or type hints with no runtime effect.
JR-TH-DEC-02 · What a wrapper does to __name__ and __doc__
Question: You write a simple logging decorator with an inner wrapper(*args, **kwargs) and apply it to calc_shipping. What do calc_shipping.__name__ and calc_shipping.__doc__ give now, and why should you care?
Answer:
Trap: "They are unchanged, because the decorator just adds logging." After decoration the name calc_shipping is bound to the wrapper function, so __name__ is "wrapper" and __doc__ is None (the wrapper has no docstring).
It matters because many tools read those attributes: log lines that print func.__name__, help(), Sphinx docs, pytest and profiler output, and frameworks such as FastAPI that build OpenAPI docs from the function. Every endpoint decorated the same way would show up as "wrapper".
The fix is @functools.wraps(func) on the wrapper. It copies __name__, __qualname__, __doc__, __module__ and __dict__, and sets __wrapped__ to the original so tools (and inspect.signature) can reach the real function.
import functools
def log_calls(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
def log_calls_wrapped(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_calls
def calc_shipping(amount):
"""Shipping charge in rupees for an order."""
return 40 if amount < 499 else 0
@log_calls_wrapped
def calc_cod_fee(amount):
"""Cash-on-delivery fee in rupees."""
return 25
print(calc_shipping.__name__, calc_shipping.__doc__)
print(calc_cod_fee.__name__, calc_cod_fee.__doc__)
print(calc_cod_fee.__wrapped__.__name__)
Output:
wrapper None
calc_cod_fee Cash-on-delivery fee in rupees.
calc_cod_fee
Follow-ups:
- "Does
functools.wrapschange what the wrapper does when called?" No. It only copies metadata; behaviour is whatever the wrapper body does. - "How would a test reach the undecorated function, for example to skip a cache or retry?" Through
func.__wrapped__, whichwrapssets. Withoutwrapsthere is no standard way back to the original.
Scoring: Strong: Says "wrapper" and None without hesitation, names functools.wraps, and gives one concrete consequence (logs, docs, FastAPI, debugging). Acceptable: Knows the name changes once asked to think about what the name is bound to; knows wraps fixes it. Red flag: Insists the metadata is preserved automatically, or has never heard of functools.wraps after writing decorators.
JR-TH-DEC-03 · Why patching a decorator in a test does nothing
Question: send_sms is decorated with @retry, which makes tests slow and noisy. A teammate tries to switch retries off with mock.patch("notify.retry", lambda f: f) inside the test. The function still retries three times. Why, and what would you do instead?
Answer:
Decoration happens once, when the def statement runs, i.e. when the module is imported. At that moment send_sms = retry(send_sms) binds the name to the wrapper. Patching the name retry later only changes what future decorations would use; the wrapper already exists and holds its own reference to the original function. So the patch has no effect on send_sms.
Options that do work:
- Test the undecorated function through
send_sms.__wrapped__(set byfunctools.wraps) when the test is about the function's own logic. - Patch what the wrapper uses at call time, e.g.
time.sleepinside the retry loop, so retries are instant, or the gateway client so the first call succeeds. - Make the decorator configurable at call time: read the attempt count or delay from a setting when the wrapper runs, not when it is created.
- Patch before the module is imported (rarely worth the fragility).
Separately, keep at least one test that exercises the decorator itself on a tiny dummy function: that it retries ConnectionError exactly N times and lets other errors through.
import functools
from unittest import mock
def retry(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, 4):
try:
return func(*args, **kwargs)
except ConnectionError:
print(f"attempt {attempt} failed")
raise ConnectionError("gave up after 3 attempts")
return wrapper
@retry # applied once, when this def runs (import time)
def send_sms(phone):
raise ConnectionError("gateway down")
# A test tries to switch retries off by replacing the decorator:
with mock.patch(f"{__name__}.retry", lambda f: f):
try:
send_sms("98xxxxxx01")
except ConnectionError as e:
print("patched decorator:", e)
# Calling the original function directly skips the wrapper:
try:
send_sms.__wrapped__("98xxxxxx01")
except ConnectionError as e:
print("__wrapped__:", e)
Output:
attempt 1 failed
attempt 2 failed
attempt 3 failed
patched decorator: gave up after 3 attempts
__wrapped__: gateway down
Follow-ups:
- "Why does
__wrapped__exist here at all?"functools.wrapssets it to the original function. Withoutwrapsthere is no standard way to reach the undecorated function. - "The same team asks why their FastAPI route registered at import time ignores a setting changed inside a test. Same cause?" Yes. Anything a decorator does at definition time (registering a route, reading a config value) has already happened when the test runs. Read settings at request time or build the app in a fixture.
Scoring: Strong: Explains that decoration happens at import time and the wrapper is already bound, and gives two working alternatives (__wrapped__, patching sleep or the client, call-time configuration). Acceptable: Reaches "the decorator already ran" with a hint about when @retry executes; suggests __wrapped__ or patching the client. Red flag: Thinks decorators run on every call, or proposes deleting the decorator to make tests pass.
JR-TH-DEC-04 · Order of stacked decorators
Question: A function has two decorators stacked on it. In what order are they applied, and in what order does their code run when the function is called? Why does the order matter for something like @cache and @require_login?
Answer:
Trap: "Top to bottom in both cases." @bold over @italic over def greet means greet = bold(italic(greet)). Application is bottom-up: italic decorates first (at definition time), then bold decorates the result. At call time the outermost wrapper runs first: bold's "before" code, then italic's, then the function, and the "after" parts unwind in reverse, like nested function calls.
Order changes behaviour. Say @require_login checks the current session (not a function argument) and @cache sits outside it: the first logged-in call's result is cached under the report's arguments, and later calls with the same arguments are served from the cache before the login check ever runs, so a logged-out user gets the data. Put checks that must run on every call (auth, validation) outermost, and decorators like caching or retries inside them, unless you have a reason otherwise. In FastAPI and Flask the route decorator must be the outermost, or it registers the undecorated function.
def bold(func):
print("decorating with bold")
def wrapper():
print("bold: before")
result = "<b>" + func() + "</b>"
print("bold: after")
return result
return wrapper
def italic(func):
print("decorating with italic")
def wrapper():
print("italic: before")
result = "<i>" + func() + "</i>"
print("italic: after")
return result
return wrapper
@bold
@italic
def greet():
return "Namaste"
print(greet())
Output:
decorating with italic
decorating with bold
bold: before
italic: before
italic: after
bold: after
<b><i>Namaste</i></b>
Follow-ups:
- "Write the stacked version as plain assignment."
greet = bold(italic(greet)). - "Where would you put a
@retryrelative to a@log_callsdecorator if you want one log line per attempt?"@log_callsinside (closer to the function) so each retry attempt passes through it; outside, it logs only the single outer call.
Scoring: Strong: Bottom-up application, top-down call order with reverse unwinding, and a concrete case where order changes behaviour (cache vs auth, route decorator outermost). Acceptable: Gets the call order right but has to reason through the application order; sees the cache/auth issue after a hint. Red flag: Thinks order never matters, or thinks only the top decorator takes effect.
JR-TH-DEC-05 · What lru_cache remembers, and when that hurts
Question: You add @functools.lru_cache to a function that looks up the GST rate for a state, and it gets much faster. What can go wrong with caching like this, and when should you not use it?
Answer:
lru_cache stores each return value keyed by the call's arguments and skips the function body on a hit. Things to watch:
- Stale data. Results never expire. A function that reads a database, a file, a config dict or the clock keeps returning its first answer until
func.cache_clear()or a restart. Use a cache with a TTL for data that changes. - Side effects disappear on hits. Logging, metrics or writes inside the function happen only on misses.
- Arguments must be hashable. A list or dict argument raises
TypeError: unhashable type. Pass a tuple, or cache on an ID. - The same object comes back every time, so a caller that mutates a cached list or dict changes it for everyone. Return immutable values.
- Memory.
maxsize=None(or@functools.cache) never evicts, so a function called with many distinct arguments, such as user IDs, grows forever. On methods,selfis part of the key, so cached instances are never freed.
Good fits: pure functions of small, hashable inputs whose answer doesn't change while the process runs (parsing a fixed config, lookup tables, recursive computations).
from functools import lru_cache
RATES = {"KA": 18}
@lru_cache(maxsize=128)
def gst_rate(state):
print(f"lookup {state}") # side effect: happens only on a miss
return RATES[state]
print(gst_rate("KA"))
print(gst_rate("KA")) # cache hit: no "lookup" line
RATES["KA"] = 12 # the source data changes
print(gst_rate("KA")) # stale answer
gst_rate.cache_clear()
print(gst_rate("KA"))
print(gst_rate.cache_info())
try:
gst_rate(["KA", "MH"])
except TypeError as e:
print("TypeError:", e)
Output:
lookup KA
18
18
18
lookup KA
12
CacheInfo(hits=0, misses=1, maxsize=128, currsize=1)
TypeError: unhashable type: 'list'
Follow-ups:
- "How do you see whether the cache is helping?"
func.cache_info()shows hits, misses and size; a hit ratio near zero means the arguments rarely repeat and the cache is just using memory. - "Is
lru_cacheshared across processes, for example four Gunicorn workers?" No. Each process has its own in-memory cache, so each worker warms its own copy. A shared cache needs something like Redis.
Scoring: Strong: Covers staleness, skipped side effects, hashable arguments, shared results and memory growth, and states when caching is appropriate. Acceptable: Names two or three of these, and spots the stale rate once walked through the output. Red flag: Believes caching is always safe, or cannot explain why the third call still prints 18.
JR-TH-DEC-06 · A parameterised decorator used without parentheses
Question: retry(attempts=3) is a decorator factory: it returns the real decorator. A teammate writes @retry instead of @retry() above fetch_rates. Does Python raise an error? What happens when fetch_rates("Pune") is called?
Answer:
Trap: "Python raises an error at definition time" or "it retries with the default 3". Neither. @retry means fetch_rates = retry(fetch_rates), so the function is passed as attempts, and retry returns its inner decorator. No error yet.
Calling fetch_rates("Pune") now calls decorator("Pune"), which treats the string as the function to wrap and returns a new wrapper. The real fetch_rates body never runs, nothing is fetched, and the caller gets a function object back instead of a dict. The error appears later and somewhere else, e.g. TypeError: 'function' object is not subscriptable when the caller does result["rate"].
How to catch it: a unit test that checks the return value, not just "no exception"; a type hint on the factory (attempts: int), so mypy reports that a function is being passed where an int is expected; or make the factory support both forms by checking if callable(attempts) and decorating directly in that case.
import functools
def retry(attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for i in range(1, attempts + 1):
try:
return func(*args, **kwargs)
except ConnectionError:
print(f"attempt {i} failed")
raise ConnectionError(f"gave up after {attempts} attempts")
return wrapper
return decorator
@retry # forgot the parentheses
def fetch_rates(city):
print(f"fetching rates for {city}")
return {"city": city, "rate": 7.5}
result = fetch_rates("Pune")
print(type(result).__name__, result.__name__)
print(fetch_rates.__name__)
Output:
function wrapper
decorator
Follow-ups:
- "Why does
fetch_rates.__name__printdecoratoreven thoughwrapsis used?"wrapsis applied towrapper, which was never created forfetch_rates. The name is bound to the bare innerdecoratorfunction returned byretry(fetch_rates). - "Sketch a factory that supports both
@retryand@retry(attempts=5)."def retry(func=None, *, attempts=3):builddecorator;return decorator(func) if func is not None else decorator. Makingattemptskeyword-only stops a function being passed into it by accident.
Scoring: Strong: Traces retry(fetch_rates) correctly, says the original never runs and a function is returned, and proposes a test or a dual-mode factory. Acceptable: Sees that the function is passed as attempts after tracing the desugaring with the interviewer; reaches the consequence with a hint. Red flag: Assumes the decorator falls back to its defaults, or cannot desugar @retry vs @retry().
JR-TH-DM-01 · `is` versus `==`, and when each is correct
Question: What is the difference between is and ==, and when should you use is in real code?
Answer:
== asks "do these two objects have equal values?" and calls the left object's __eq__. is asks "are these two names bound to the same object?" and compares identities, which a class cannot override.
Two lists with the same contents are equal but are different objects. Assigning c = a creates a second name for one object, so a is c is True.
Use is only for singletons and sentinels: x is None, x is not None, and your own MISSING = object() markers. Use == for everything else, especially numbers and strings. amount is 500 or status is "paid" may appear to work in a quick test only because CPython sometimes reuses small ints and short strings. That is an implementation detail, and Python 3.8+ warns about it (SyntaxWarning: "is" with a literal).
a = [101, 102]
b = [101, 102]
c = a
print(a == b, a is b)
print(a is c)
result = None
print(result is None)
Output:
True False
True
True
Follow-ups:
- "Why is
x is Nonepreferred overx == None?"Noneis a singleton, so identity is the exact question. A class can override__eq__to return something odd (a pandas Series returns an element-wise Series), andisis also slightly faster. - "How do you check whether two variables point to the same list?"
a is b, or compareid(a) == id(b)while both are alive. If it'sTrue, a change made through one name is visible through the other.
Scoring: Strong: Separates value equality from identity, uses is only for None and sentinels, and knows why is on numbers and strings is unreliable. Acceptable: Gets the definitions right but can't say when is is the right tool beyond None. Red flag: Uses is to compare strings or numbers, or thinks the two operators are interchangeable.
JR-TH-DM-02 · `if not discount:` when the discount is ₹0
Question: A pricing function uses if not discount: discount = 10 to apply a default 10% discount when none is passed. A caller passes discount=0. What happens, and how should the check be written?
Answer:
Trap: "not discount means 'no discount was passed'." It actually means "the value is falsy", so an explicit 0 is replaced by 10 and the customer gets a discount they were never meant to get.
Python's truthiness rules treat these as false: None, False, 0, 0.0, "", and empty containers ([], {}, set()). Everything else is true, including "0" and [0]. A class can define its own truthiness with __bool__ (or __len__).
When None means "not provided", test exactly that: if discount is None:. Keep if not items: for cases where "empty" and "missing" really should be handled the same way.
def final_price(price, discount=None):
if not discount: # bug: 0 is treated as "not given"
discount = 10
return price - price * discount / 100
def final_price_fixed(price, discount=None):
if discount is None:
discount = 10
return price - price * discount / 100
print(final_price(1000, 0))
print(final_price_fixed(1000, 0))
print([bool(v) for v in (0, 0.0, "", [], {}, None, "0", [0])])
Output:
900.0
1000.0
[False, False, False, False, False, False, True, True]
Follow-ups:
- "Where else does this bug show up?" Anywhere 0 or an empty string is valid: quantity 0, a balance of ₹0, an empty search string, a page number 0,
port = port or 8080. The same applies tovalue = value or default. - "How does a pandas column behave with
if df["qty"]:?" It raisesValueError: The truth value of a Series is ambiguous. You have to say what you mean:.any(),.all(), or.empty.
Scoring: Strong: Spots that 0 is overwritten, lists the falsy values, and switches to is None with a reason. Acceptable: Fixes it with is None after a hint but can't list the falsy values. Red flag: Says the code is fine because "0 means no discount anyway".
JR-TH-DM-03 · Making your own class work with `len`, `in` and `for`
Question: You have a Cart class that wraps a list of SKUs. How do you make len(cart), "SKU-2" in cart, for sku in cart and if cart: work on it?
Answer:
Built-ins and operators call special (dunder) methods on the object's type, so you implement the protocol rather than inherit from list:
len(cart)calls__len__, which must return a non-negativeint.x in cartcalls__contains__. If it's missing, Python falls back to iterating with__iter__and comparing with==.for sku in cartcallsiter(cart), which uses__iter__(return an iterator, for exampleiter(self._items), or write it as a generator).if cart:calls__bool__. If that's missing it uses__len__(zero is false). If neither exists, every instance is truthy.
Delegating to an internal list keeps the class small and lets you control what callers can do. Subclassing list exposes every list method, including ones that bypass your validation.
class Cart:
def __init__(self, items):
self._items = list(items)
def __len__(self):
return len(self._items)
def __contains__(self, sku):
return sku in self._items
def __iter__(self):
return iter(self._items)
cart = Cart(["SKU-1", "SKU-2"])
print(len(cart), "SKU-2" in cart, list(cart))
empty = Cart([])
print(bool(cart), bool(empty))
Output:
2 True ['SKU-1', 'SKU-2']
True False
Follow-ups:
- "Which method would you add so
cart[0]works?"__getitem__(self, index). Delegating to the list also gives you slices and negative indexes for free, because the list handles thesliceobject. - "What should
__repr__return for this class?" Something unambiguous for debugging, such asCart(['SKU-1', 'SKU-2']), so logs and the REPL show the contents instead of<Cart object at 0x...>.
Scoring: Strong: Names the right method for each operation and knows the __bool__ to __len__ fallback and why delegation beats subclassing list. Acceptable: Knows __len__ and __iter__ but not the fallbacks. Red flag: Thinks these only work if the class inherits from list.
JR-TH-DM-04 · `isinstance(True, int)` in a quantity validator
Question: A validator accepts an order quantity if isinstance(qty, int). A JSON payload arrives with "qty": true. Does it pass? And why do people say to prefer isinstance over type(x) == dict?
Answer:
Trap: "True is a bool, not an int, so it's rejected." It passes. bool is a subclass of int (True == 1, False == 0, True + True == 2), and isinstance returns True for subclasses. The order goes through with quantity 1.
isinstance is still usually the right tool, precisely because it respects subclasses: type(d) == dict rejects an OrderedDict or a defaultdict, which are perfectly good dicts. Use an exact type() check only when you really mean "this class and not its subclasses".
For the validator, exclude bool explicitly (isinstance(qty, int) and not isinstance(qty, bool)), or let a validation library do it. Pydantic in strict mode, for example, rejects true for an int field.
from collections import OrderedDict
def is_quantity(v):
return isinstance(v, int)
print(is_quantity(5), is_quantity(True))
print(True + True, isinstance(True, int))
d = OrderedDict(a=1)
print(type(d) == dict, isinstance(d, dict))
def is_quantity_strict(v):
return isinstance(v, int) and not isinstance(v, bool)
print(is_quantity_strict(True))
Output:
True True
2 True
False True
False
Follow-ups:
- "How would you check for "any number" without listing every type?"
isinstance(x, numbers.Number), ornumbers.Realto exclude complex. Remember thatboolwill still pass unless you exclude it. - "Can
isinstancetake several types at once?" Yes, a tuple:isinstance(x, (int, float)). Since 3.10 a union also works:isinstance(x, int | float).
Scoring: Strong: Knows bool subclasses int, explains why isinstance is generally preferred, and fixes the validator. Acceptable: Prefers isinstance correctly but misses the bool case until shown. Red flag: Recommends type(x) == ... everywhere, or doesn't know that isinstance accepts subclasses.
JR-TH-DM-05 · Special methods are looked up on the type
Question: In a test you try to fake the size of a Batch object with b.__len__ = lambda: 99. Does len(b) return 99? Explain what's going on.
Answer:
No. Built-ins and operators (len(), +, in, iter(), with) look up special methods on the object's type, not on the instance. Assigning b.__len__ only stores an ordinary attribute in the instance's __dict__. Calling b.__len__() explicitly uses it, but len(b) goes straight to type(b).__len__.
CPython does this for speed (the type's methods are cached in slots) and for consistency: len(SomeClass) must use the metaclass's __len__, not the class's own method.
Practical consequences:
- To change how an operator behaves, change the class (a subclass, or patch
Batch.__len__, which affects every instance). - In tests,
unittest.mock.MagicMocksupports configuring dunders (m.__len__.return_value = 99) because it creates a fresh subclass per mock, so the method lands on the type.
class Batch:
def __init__(self, rows):
self.rows = rows
def __len__(self):
return len(self.rows)
b = Batch([1, 2, 3])
b.__len__ = lambda: 99
print(len(b), b.__len__())
Batch.__len__ = lambda self: 42
print(len(b))
Output:
3 99
42
Follow-ups:
- "Does the same rule apply to normal methods like
b.summary()?" No. Normal attribute access checks the instance__dict__first (for non-data descriptors such as functions), so an instance attribute namedsummaryshadows the method. - "Why is patching
Batch.__len__risky in a test?" It changes every instance and leaks into later tests unless you undo it. Usemock.patch.object(Batch, '__len__', ...)as a context manager or fixture so it's restored.
Scoring: Strong: Says the lookup goes through the type, shows the instance attribute is still callable directly, and gives a correct way to fake it in tests. Acceptable: Correctly predicts len(b) is 3 but can't explain why. Red flag: Expects 99 and can't reason about where Python looks for methods.
JR-TH-DM-06 · Equality and hashing of plain classes versus dataclasses
Question: Two Order("A1", 500) objects: are they equal if Order is a plain class? What changes when you add @dataclass, and can you then put orders in a set to deduplicate them?
Answer:
Trap: "Objects with the same attributes are equal, and dataclasses are hashable like tuples." Neither is true by default.
- A plain class inherits
object.__eq__, which compares identity. Two separately built orders with identical data are not equal, and each one hashes by identity, so a set never deduplicates them. @dataclassgenerates__eq__, which compares the fields in order (as tuples) when both objects are the same class. Becauseeq=Trueand the class is not frozen, the dataclass also sets__hash__ = None. Equal objects must have equal hashes, and a mutable object's hash could change while it sits in a set. So{Order("A1", 500)}raisesTypeError: unhashable type: 'Order'.@dataclass(frozen=True)makes instances read-only and generates a field-based__hash__, so duplicates collapse in a set and the objects work as dict keys.
Rule: if you define equality by value, define hashing to match, or make the type unhashable.
from dataclasses import dataclass
class PlainOrder:
def __init__(self, order_id, amount):
self.order_id = order_id
self.amount = amount
@dataclass
class Order:
order_id: str
amount: int
print(PlainOrder("A1", 500) == PlainOrder("A1", 500))
print(Order("A1", 500) == Order("A1", 500))
print(Order.__hash__)
try:
{Order("A1", 500)}
except TypeError as e:
print("TypeError:", e)
@dataclass(frozen=True)
class FrozenOrder:
order_id: str
amount: int
print(len({FrozenOrder("A1", 500), FrozenOrder("A1", 500)}))
Output:
False
True
None
TypeError: unhashable type: 'Order'
1
Follow-ups:
- "What if you hand-write
__eq__on a plain class and forget__hash__?" Python sets__hash__toNonefor you, exactly as the dataclass does, so instances become unhashable. Define__hash__from the same fields that__eq__uses if the object is effectively immutable. - "Why not just use
unsafe_hash=Trueon a mutable dataclass?" If a field changes while the object is in a set or used as a dict key, it sits in the wrong hash bucket and lookups silently fail. Freeze the class instead, or deduplicate on a key such asorder_id.
Scoring: Strong: Knows default equality is identity, explains why a non-frozen dataclass is unhashable, and offers frozen=True or a key-based dedupe. Acceptable: Knows dataclasses add __eq__ but is surprised by the TypeError. Red flag: Assumes plain objects compare by attributes, or suggests hashing on mutable fields with no caveat.
JR-TH-DS-01 · First checks on a freshly loaded dataset
Question: You've just loaded an orders dataset into pandas for an analysis. What do you check before computing anything, and which pandas calls do you use?
Answer:
A quick health check finds most data problems before they turn into wrong conclusions:
- Size:
df.shape. Does the row count match what the source says? - Types:
df.dtypesordf.info(). Numbers read asobjectusually mean stray text ("₹1,200","NA"); an integer column shown asfloat64means it has missing values. - Missing values:
df.isna().sum(), per column. Decide later whether to drop, fill or flag them, but know they're there. - Duplicates:
df.duplicated().sum()for exact copies, anddf["order_id"].duplicated().sum()for repeated keys. - Categories:
df["city"].value_counts(dropna=False). It catches spelling and case variants (Delhivsdelhi), unexpected values and missing ones. - Ranges and outliers:
df.describe(). Look at min and max (negative amounts? ages of 200?) and compare the mean with the median (50%). In the snippet one ₹52,000 order pulls the mean to ₹11,046 while the median is ₹1,200; for skewed data like order values or salaries, report the median.
Then check the obvious business rules (dates in the expected range, amounts positive, one row per order). The snippet's tiny file has a duplicated order, a missing amount, a case variant and an outlier, which is typical of real exports.
import io
import pandas as pd
csv_text = """order_id,city,amount,delivered
1,Pune,450,yes
2,Pune,1200,yes
2,Pune,1200,yes
3,Delhi,,no
4,delhi,52000,yes
5,Chennai,380,yes
"""
df = pd.read_csv(io.StringIO(csv_text))
print(df.shape)
print(df.dtypes.to_dict())
print(df.isna().sum().to_dict())
print("duplicate rows:", df.duplicated().sum())
print(df["city"].value_counts().to_dict())
print(df["amount"].describe()[["mean", "50%", "max"]].to_dict())
Output:
(6, 4)
{'order_id': dtype('int64'), 'city': dtype('O'), 'amount': dtype('float64'), 'delivered': dtype('O')}
{'order_id': 0, 'city': 0, 'amount': 1, 'delivered': 0}
duplicate rows: 1
{'Pune': 3, 'Delhi': 1, 'delhi': 1, 'Chennai': 1}
{'mean': 11046.0, '50%': 1200.0, 'max': 52000.0}
Follow-ups:
- "Why is
amountfloat64when every value in the file is a whole number?" One value is missing, NaN is a float, and NumPy's int64 has no missing value, so pandas stores the column as float64. The nullableInt64dtype keeps integers with<NA>. - "
describe()skipped thecitycolumn. How do you include it?"df.describe(include="all")(orinclude="object") adds count, unique, top and freq for non-numeric columns. - "How would you fix the
Delhi/delhisplit?" Normalise early:df["city"] = df["city"].str.strip().str.title(), then re-runvalue_countsto confirm. For messy names (Bengaluru/Bangalore), keep an explicit mapping.
Scoring: Strong: Covers shape, dtypes, missing values, duplicates (rows and keys), category counts and ranges with mean vs median, using the right pandas calls, and says why each matters. Acceptable: Mentions info(), describe() and missing values, but misses duplicates or category inconsistencies. Red flag: Goes straight to modelling or aggregates without looking at the data, or reports the mean of a heavily skewed column without comment.
JR-TH-DS-02 · groupby silently drops rows with a missing key
Question: You compute revenue per city with df.groupby("city")["amount"].sum() and share the table. Finance says the city totals don't add up to the overall revenue. How can that happen?
Answer:
Trap: "Group totals always add up to the overall total."
groupby has dropna=True by default: rows whose group key is NaN or None are left out of every group. In the snippet, ₹800 of ₹1,500 belongs to orders with no city, so the per-city table sums to ₹700. Nothing warns you.
Fixes:
df.groupby("city", dropna=False)keeps aNaNgroup;- or fill the key first with an explicit label:
df["city"].fillna("Unknown"), which reads better in a report; - and, as a habit, reconcile: check that the group totals equal
df["amount"].sum()before sharing.
The same default appears elsewhere: value_counts() hides missing values unless you pass dropna=False, and pivot_table drops NaN keys too. Missing keys are often the most interesting rows (orders from a broken app version, unmapped pincodes), so make them visible.
import numpy as np
import pandas as pd
df = pd.DataFrame({
"city": ["Pune", "Delhi", None, "Pune", np.nan],
"amount": [100, 200, 300, 400, 500],
})
by_city = df.groupby("city")["amount"].sum()
print(by_city)
print("total:", df["amount"].sum(), "sum of groups:", by_city.sum())
print(df.groupby("city", dropna=False)["amount"].sum())
Output:
city
Delhi 200
Pune 500
Name: amount, dtype: int64
total: 1500 sum of groups: 700
city
Delhi 200
Pune 500
NaN 800
Name: amount, dtype: int64
Follow-ups:
- "
Noneandnp.nanboth appear in the key column. Do they form separate groups withdropna=False?" No. pandas treats both as missing, so they fall into one NaN group (₹300 + ₹500 = ₹800 in the snippet). - "What about missing values in the amount column, not the key?" The row stays in its group, but
sum()skips the NaN, so the total is computed from fewer values. Checkisna()on value columns too. - "How would you write the reconciliation check in a script?"
assert by_city.sum() == df["amount"].sum(), ornp.isclosefor floats, with a message that says how much is missing, run before the table is published.
Scoring: Strong: Names groupby's dropna=True default, fixes it with dropna=False or an explicit "Unknown" label, mentions value_counts/pivot_table, and makes reconciliation a habit. Acceptable: Figures out that rows with missing cities are dropped and fills them, without knowing the parameter. Red flag: Blames floating-point rounding, or assumes finance's total is wrong.
JR-TH-DS-03 · Handling missing values when preparing features
Question: Several features in your loan-applicant dataset have missing values (income, employer type, years at current address). How do you decide what to do with them?
Answer:
First understand the missingness. How much is missing per column, and why? Missing at random (a form field skipped now and then) is different from missing that means something: "no previous loan" stored as blank, income missing mostly for self-employed applicants. Check whether missingness relates to the target; if it does, it's information.
Options, from simplest:
- Drop rows when very few are affected and it looks random. Dropping many rows biases the sample and can't be done at prediction time anyway; you still have to score that applicant.
- Drop the column when it's mostly empty and not useful.
- Impute numbers with the median (robust to skew such as income) or the mean. Group-wise medians (median income per city or occupation, via
groupby(...).transform("median")) are often better than one global value. - Impute categories with an explicit
"Unknown"level rather than the mode, so the model can learn from it. - Add a missing-indicator column (
income_missing = income.isna()) alongside the imputed value, so the model can still tell that the value was filled in. - Let the model handle it. Gradient-boosted trees (LightGBM, XGBoost, scikit-learn's
HistGradientBoosting*) accept NaN natively.
Two rules that matter more than the method:
- Learn fill values from the training data only, then reuse those stored values for validation, test and live requests. A scikit-learn
PipelinewithSimpleImputerdoes this automatically. Computing medians on the full dataset leaks test information; computing them per request fails for single-row batches. - Don't fill with 0 by reflex. Zero income means something different from unknown income.
Follow-ups:
- "Why the median rather than the mean for income?" Income is right-skewed; a few very high earners pull the mean up, so the mean is an unrepresentative fill value. The median isn't affected by extreme values.
- "How do you check that your imputation didn't hurt?" Compare validation metrics with and without it (and with the indicator), and check that the filled column's distribution doesn't develop an unrealistic spike that the model latches onto.
- "Missing values appear in production for a feature that was never missing in training. What happens?" Depending on the model, it errors or silently mis-scores. Validate inputs at serving time, apply the same stored imputation, and monitor null rates per feature so a broken upstream field shows up quickly.
Scoring: Strong: Starts from why data is missing, chooses between dropping, median or group imputation, Unknown categories, indicators and native NaN handling, and insists on fitting fill values on training data only. Acceptable: Knows dropping vs mean/median/mode imputation and gives sensible choices, but doesn't mention indicators or train-only fitting. Red flag: Always uses dropna() or fillna(0), or computes fill values on the whole dataset including test.
JR-TH-DS-04 · The average of group averages
Question: Your manager asks for the average delivery time across all orders. You have a table of per-city average delivery times, so you take the mean of that column. What's wrong?
Answer:
Trap: "The mean of the city averages is the overall average."
It's only equal when every city has the same number of orders. Averaging the averages gives each city equal weight, while the question asks for each order to count equally. In the snippet, Mumbai has four orders averaging 30 minutes and Indore one order of 70 minutes: the mean of city means is 50, but the true average per order is 38. Small groups get the same say as big ones, and the result can move in either direction.
How to get it right:
- compute from the raw rows:
df["minutes"].mean(); - or, if you only have aggregates, keep sums and counts (
agg(["sum", "count"])) and divide the total sum by the total count; equivalently, a weighted mean of the group means, weighted by count; - when you publish per-group averages, publish the counts next to them, so nobody averages them again.
The same mistake appears with rates (averaging per-store conversion rates), percentages across months, and averaging per-chunk results in batch jobs. Sometimes "average city" is exactly what you want (e.g. comparing city operations); the point is to choose the weighting on purpose and say which one you used.
import pandas as pd
deliveries = pd.DataFrame({
"city": ["Mumbai"] * 4 + ["Indore"],
"minutes": [30, 34, 26, 30, 70],
})
per_city = deliveries.groupby("city")["minutes"].mean()
print(per_city.to_dict())
print("mean of city means:", per_city.mean())
print("true mean per order:", deliveries["minutes"].mean())
stats = deliveries.groupby("city")["minutes"].agg(["sum", "count"])
print("from sums and counts:", stats["sum"].sum() / stats["count"].sum())
Output:
{'Indore': 70.0, 'Mumbai': 30.0}
mean of city means: 50.0
true mean per order: 38.0
from sums and counts: 38.0
Follow-ups:
- "Can the overall rate go the opposite way from every group's rate?" Yes, that's Simpson's paradox: when group sizes differ a lot, a treatment can look better in every group yet worse overall (or the reverse). Always look at group sizes before comparing aggregates.
- "How would you compute the weighted mean with NumPy?"
np.average(group_means, weights=group_counts), which equals total sum divided by total count. - "Can you combine per-city medians into an overall median the same way?" No. Medians don't combine from group medians, even with weights; you need the raw values (or a histogram or approximate quantile sketch).
Scoring: Strong: Explains equal weighting per group vs per order, fixes it with raw data or sums and counts, recommends publishing counts, and generalises to rates and Simpson's paradox. Acceptable: Spots that group sizes differ and computes a weighted average, without generalising. Red flag: Thinks the two numbers are always equal, or can't explain why 50 and 38 differ.
JR-TH-DS-05 · Point-in-time customer features without leakage
Question: For a repeat-purchase model, every order row needs the feature "this customer's average order value so far". A teammate wrote df.groupby("customer")["amount"].transform("mean"). What's wrong with it, and how do you build the feature correctly?
Answer:
transform("mean") gives every row the customer's average over all their orders, including the current order and future ones. Asha's first order on 5 January gets ₹500, an average that partly comes from her 20 January order. At prediction time the future doesn't exist, so the model trains on information it will never have. Offline metrics look great and production performance drops. This is target or time leakage through a feature.
Build point-in-time features: each row may only use what was known before that event.
- Sort by entity and time:
sort_values(["customer", "date"]). - Within each customer,
shift()the values down by one so the current row is excluded, then take an expanding (all past) or rolling (recent window) statistic. - A customer's first order has no history: the value is NaN. Keep a "previous order count" feature (
cumcount()) so the model can tell "new customer" apart from "low spender", and fill or leave the NaN deliberately.
In the snippet, prev_avg for Asha's third order is ₹300, the average of her first two orders, while leaky_avg is ₹500 on every row.
Practical notes:
- For speed on big data, avoid the lambda:
(g.cumsum() - df["amount"]) / g.cumcount()gives the same running mean using built-in vectorised operations. - Decide how to handle several orders with the same timestamp; neither should see the other.
- At serving time, compute the feature from the stored history before the current request, with the same definition. Otherwise you've created training/serving skew.
import pandas as pd
orders = pd.DataFrame({
"customer": ["Asha", "Ravi", "Asha", "Asha", "Ravi"],
"date": pd.to_datetime(["2026-01-05", "2026-01-06", "2026-01-10",
"2026-01-20", "2026-01-25"]),
"amount": [200, 500, 400, 900, 100],
})
orders = orders.sort_values(["customer", "date"])
g = orders.groupby("customer")["amount"]
orders["prev_avg"] = g.transform(lambda s: s.shift().expanding().mean())
orders["prev_count"] = g.cumcount()
orders["leaky_avg"] = g.transform("mean")
print(orders.to_string(index=False))
Output:
customer date amount prev_avg prev_count leaky_avg
Asha 2026-01-05 200 NaN 0 500.0
Asha 2026-01-10 400 200.0 1 500.0
Asha 2026-01-20 900 300.0 2 500.0
Ravi 2026-01-06 500 NaN 0 300.0
Ravi 2026-01-25 100 500.0 1 300.0
Follow-ups:
- "Why
groupby(...).shift()rather thandf["amount"].shift()?" A plain shift moves values across customer boundaries, so Ravi's first order would get Asha's last amount. Shifting within the group keeps each customer's history separate. - "How would you make it 'average over the last 30 days' instead of all history?" Use a time-based rolling window per customer on a date index, e.g.
rolling("30D", closed="left"), whereclosed="left"excludes the current row's timestamp. - "How would you catch this kind of leakage in review?" For each feature, ask 'could I compute this at the moment of prediction?' Also spot-check a customer's first row, which must have no history, and be suspicious of a single feature with huge importance or an unrealistically high validation score.
Scoring: Strong: Identifies the use of current and future rows as leakage, builds the feature with sort + group-wise shift + expanding/rolling, handles first orders with a count feature, and mentions serving-time consistency. Acceptable: Sees the leakage and uses shift within groups, but misses first-order handling or ties. Red flag: Thinks the full-history mean is fine because "it's the customer's real average", or shifts without grouping.
JR-TH-DS-06 · rolling(7) is seven rows, not seven days
Question: You build a "sales in the last 7 days" feature with daily_sales.rolling(7).sum(), where daily_sales has one row per day the store had sales. Is that the last 7 days?
Answer:
Trap: "Yes, one row per day, so 7 rows = 7 days."
An integer window counts rows, not time. If days are missing (the store was shut, a holiday, or the extract skipped a day), seven rows can span ten or more calendar days. The feature then mixes different time spans from row to row, and nothing errors. The snippet uses a 3-row window: on 5 April, rolling(3) adds 1, 2 and 5 April (300), but the last three calendar days (3–5 April) only had 100 of sales.
Two fixes, depending on what a missing day means:
- Time-based window. On a
DatetimeIndex(sorted),rolling("7D")includes exactly the rows whose timestamps fall in the last 7 days, however many rows that is. Note that time-based windows default tomin_periods=1, so early rows get partial sums instead of NaN. - Make the gaps explicit. If no row means zero sales, reindex to a full daily calendar first (
asfreq("D", fill_value=0)orreindex(pd.date_range(...))); then row-based windows really are days. If no row means unknown (data missing), fill with NaN instead, not 0.
Also remember a rolling window includes the current row. For a feature that must use only the past (predicting today's sales), shift it or use closed="left".
import pandas as pd
# Daily sales for one store; no rows on days it was shut (3 and 4 April)
sales = pd.Series(
[100, 100, 100, 100, 100, 100, 100],
index=pd.to_datetime(["2026-04-01", "2026-04-02", "2026-04-05", "2026-04-06",
"2026-04-07", "2026-04-08", "2026-04-09"]),
)
print(sales.rolling(3).sum().tolist())
print(sales.rolling("3D").sum().tolist())
print(sales.asfreq("D", fill_value=0).rolling(3).sum().tail(5).tolist())
Output:
[nan, nan, 300.0, 300.0, 300.0, 300.0, 300.0]
[100.0, 200.0, 100.0, 200.0, 300.0, 300.0, 300.0]
[100.0, 200.0, 300.0, 300.0, 300.0]
Follow-ups:
- "Why does
rolling("3D")give 100 for 1 April instead of NaN?" For time-based windowsmin_periodsdefaults to 1, so a partial window still produces a value. Passmin_periods=if early rows should be NaN, or add a 'days of history' feature. - "The data has many stores in one DataFrame. How do you compute the window per store?" Group first:
df.set_index("date").groupby("store_id")["sales"].rolling("7D").sum(), after sorting by store and date. A plain rolling call would let windows run across store boundaries. - "What does
rolling("7D")require of the index?" A DatetimeIndex (oron=a datetime column) whose values are monotonic; otherwise pandas raisesValueError: index values must be monotonic. Sort by date first (ascending, so the window looks back in time).
Scoring: Strong: Explains that integer windows count rows, shows how gaps distort the feature, and fixes it with time-based windows or an explicit calendar (choosing 0 vs NaN deliberately), noting the current row is included. Acceptable: Realises missing days break the assumption and suggests filling the calendar, without knowing time-based windows. Red flag: Assumes rows equal days without checking, or fills every gap with 0 without asking what a missing day means.
JR-TH-EXC-01 · Reading a traceback
Question: A script crashes with a 15-line traceback. How do you read it? Which lines do you look at first?
Answer:
Read from the bottom up. The last line gives the exception type and message, e.g. KeyError: 'email': that's what went wrong. The frame just above it (file, line number, function, source line) is where it was raised. Frames above that show the chain of calls that led there, oldest call at the top ("most recent call last").
Then walk upwards until you reach the first frame in your own code: the bug is usually there (bad input passed to a library), not inside pandas or requests. Lines like "During handling of the above exception, another exception occurred" or "The above exception was the direct cause of the following exception" mean two exceptions are chained; the root cause is usually the first one printed.
Follow-ups:
- "The bottom frame is inside
site-packages/pandas/.... Is pandas buggy?" Almost never. Move up to the last frame in your own files and check what you passed in: a wrong column name, dtype or argument. - "How do you get the same traceback into your logs instead of the console?" Call
logger.exception("message")inside theexceptblock; it logs at ERROR level and appends the traceback.
Scoring: Strong: Bottom-up reading, type and message vs location, finds the first frame in own code, and recognises chained-exception lines. Acceptable: Reads the last line and the frame above it; unsure about chained exceptions. Red flag: Reads only the first line, or copies the whole traceback into a search engine without being able to say where the error happened.
JR-TH-EXC-02 · except ValueError or TypeError
Question: A parser has except ValueError or TypeError: return 0. It returns 0 for "abc". What does it do when it receives None?
Answer:
Trap: "It returns 0, because the clause catches either exception." The expression after except is evaluated first, like any expression. ValueError or TypeError evaluates to ValueError, because a class is truthy and or returns the first truthy operand. So the clause is just except ValueError:. int(None) raises TypeError, which isn't caught, and the function crashes.
To catch several types, use a tuple: except (ValueError, TypeError):. Use separate except clauses when the handling differs. The bug passes code review easily because it reads like English, and it passes tests that only cover the first exception type, so test each error path the handler is meant to cover.
def parse_qty(raw):
try:
return int(raw)
except ValueError or TypeError: # looks like "either"; it isn't
return 0
print(ValueError or TypeError)
print(parse_qty("abc"))
print(parse_qty(None))
Output:
<class 'ValueError'>
0
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'
Follow-ups:
- "What does
except (ValueError, TypeError) as e:bindeto?" The exception that was actually raised, whichever of the two types it is;type(e)tells you which. - "Would
except ValueError and TypeError:be any better?" No.andreturns the last operand when all are truthy, so it becomesexcept TypeError:and missesValueErrorinstead.
Scoring: Strong: Evaluates ValueError or TypeError to ValueError, predicts the uncaught TypeError, and fixes it with a tuple. Acceptable: Unsure but gets there when asked what ValueError or TypeError evaluates to; knows the tuple form. Red flag: Believes or in an except clause means "either", or fixes it with a bare except:.
JR-TH-EXC-03 · Check first or try first (LBYL vs EAFP)
Question: To read an optional config file, one teammate writes if os.path.exists(path): open(path), another writes try: open(path) except FileNotFoundError: .... Which do you prefer, and why? When is checking first the better choice?
Answer:
The first style is LBYL ("look before you leap"), the second is EAFP ("easier to ask forgiveness than permission"), the usual Python idiom.
For files, prefer EAFP. Between the exists() check and the open(), another process can delete or replace the file (a race condition, also called TOCTOU), so the check doesn't make the open safe: you need the try anyway. The check also misses other failures such as PermissionError or a path that is a directory.
For plain data, use whichever is clearer: d.get(key, default) or key in d are fine and often more readable than catching KeyError. Check first when failure is the normal case in a hot loop (raising is slower than an if), when validating user input to produce a friendly error message, or when the operation has side effects you can't undo halfway. Whichever you choose, catch the specific exception, never a bare except:.
Follow-ups:
- "How would you load an optional JSON config with defaults?"
try: with open(path, encoding="utf-8") as f: cfg = json.load(f), thenexcept FileNotFoundError: cfg = {}, then merge defaults. Let a malformed file (json.JSONDecodeError) fail loudly rather than silently using defaults. - "Is
if key in d: return d[key]subject to the same race?" Only if another thread modifies the dict at the same time. In single-threaded code it's safe; across threads, use oned.get(key)call instead of check-then-read, or a lock.
Scoring: Strong: Names EAFP and LBYL, explains the race between check and use, catches a specific exception, and gives sensible cases for checking first. Acceptable: Prefers try/except for files but can't explain the race until prompted. Red flag: Thinks exists() makes the open() safe, or catches every exception with a bare except:.
JR-TH-EXC-04 · A try block that is too wide
Question: notify(uid) looks up a user and sends an email in one try block with except KeyError: return "unknown user". Support reports that user 101 exists but gets "unknown user 101". What's happening, and how do you fix it?
Answer:
Trap: "The user really is missing from the dict." The except KeyError catches a KeyError raised anywhere inside the try, including deep inside send_email, where a mistyped template name raises KeyError: 'welcom'. The handler can't tell the two apart, so a bug in the email code is reported as a missing user, and the real error is hidden.
Fix: keep the try around only the operation whose failure you are handling, and move the rest into else (or after the block). Then the user lookup's KeyError is handled, while the template bug raises with its real traceback and gets fixed. More generally: one try per failure you mean to handle, the narrowest exception type, and never translate an exception into a business message unless you know where it came from. A custom UserNotFound exception raised by the lookup makes it unambiguous.
users = {101: {"name": "Priya", "email": "priya@example.com"}}
templates = {"welcome": "Hi {name}"}
def send_email(user, template):
body = templates[template] # KeyError if template is missing
return f"to={user['email']} body={body.format(**user)}"
def notify_wide(uid):
try:
user = users[uid]
return send_email(user, "welcom") # typo in template name
except KeyError:
return f"unknown user {uid}"
def notify_narrow(uid):
try:
user = users[uid]
except KeyError:
return f"unknown user {uid}"
else:
return send_email(user, "welcom")
print(notify_wide(101))
print(notify_narrow(101))
Output:
unknown user 101
KeyError: 'welcom'
Follow-ups:
- "Could
users.get(uid)replace thetryentirely?" Yes:user = users.get(uid),if user is None: return .... With notryaroundsend_email, its errors propagate naturally. - "What other common exceptions get swallowed by wide
tryblocks this way?"AttributeError(hides typos in attribute names),TypeErrorandValueError(hide bad arguments deep in a call), and of courseException.
Scoring: Strong: Spots that the KeyError comes from send_email, narrows the try (with else or .get), and explains why the wide block hides bugs. Acceptable: Finds the cause by reading the code with a hint about where else KeyError can come from; fix is right. Red flag: Fixes it by catching Exception instead, or by adding more KeyError handlers.
JR-TH-EXC-05 · Logging exceptions without losing information
Question: A batch job has except Exception as e: logger.error(f"failed: {e}"). The on-call engineer sees a log line failed: 'email' and nothing else. What went wrong, and how should exceptions be logged?
Answer:
str(e) is only the message, and for many exceptions it's useless on its own: a KeyError's message is just the key ('email'), and an exception raised with no arguments has an empty message. The type and the traceback (where it happened) are lost.
Better:
- Inside the
exceptblock uselogger.exception("failed to process order %s", order_id): it logs at ERROR level and appends the full traceback. (logger.error(..., exc_info=True)does the same, andexc_info=Trueworks with any level.) - Add context you'll need to reproduce it: the record ID, file name, batch number. Use
%sarguments rather than f-strings so the message template stays constant and is only formatted if emitted. - If you're not handling the error, either log it or re-raise it, not both at every layer, otherwise one failure produces five identical tracebacks.
- Decide deliberately whether the job should continue (log, count the failure, move on to the next record) or stop (re-raise).
repr(e) includes the type, which is better than str(e) when you only have room for one line.
errors = [KeyError("email"), ValueError(), FileNotFoundError(2, "No such file", "rates.csv")]
for e in errors:
print(type(e).__name__)
print(" str: ", repr(str(e)))
print(" repr:", repr(e))
Output:
KeyError
str: "'email'"
repr: KeyError('email')
ValueError
str: ''
repr: ValueError()
FileNotFoundError
str: "[Errno 2] No such file: 'rates.csv'"
repr: FileNotFoundError(2, 'No such file')
Follow-ups:
- "What does
logger.exceptiondo if you call it outside anexceptblock?" There is no active exception, so it logs the message followed byNoneType: Noneinstead of a traceback. It's meant for use inside handlers. - "For a job processing 1 lakh rows, how do you avoid one bad row stopping everything without hiding failures?" Catch per row, log with the row ID, append it to a rejects list or file, and count failures; at the end, fail the job (non-zero exit) if failures exceed a threshold.
Scoring: Strong: Explains why str(e) loses the type and traceback, uses logger.exception with context, and mentions log-or-raise and the continue-vs-stop decision. Acceptable: Knows logger.exception or exc_info=True; context and log-or-raise come with prompting. Red flag: Thinks printing e is enough, or uses print / traceback.print_exc() in a service instead of logging.
JR-TH-EXC-06 · An exception raised inside an except block
Question: The except ZeroDivisionError: handler in charge() has a typo in its logging call and raises NameError. Which exception does the caller catch, and is the original ZeroDivisionError lost?
Answer:
Trap: "The caller still gets the ZeroDivisionError" or "the original is gone". The caller catches the new exception, the NameError, because that is what's propagating. The original isn't lost: Python attaches it automatically as __context__ of the new one (implicit chaining), and the traceback prints both, joined by "During handling of the above exception, another exception occurred:". __cause__ stays None because nobody wrote raise ... from ....
Consequences: an except ZeroDivisionError higher up won't match any more; a typo in error-handling code changes which exception escapes. That's why handlers should be small and tested too (a test that forces the error path would have caught this). When you deliberately convert an error, use raise PaymentError(...) from e, which sets __cause__ and prints "The above exception was the direct cause...", making the intent clear.
import logging
def charge(order):
try:
return 100 / order["qty"]
except ZeroDivisionError:
logging.error("bad order %s", order_id) # typo: order_id is not defined
try:
charge({"id": 7, "qty": 0})
except Exception as e:
print("caller caught:", type(e).__name__, "-", e)
print("context:", type(e.__context__).__name__)
print("cause:", e.__cause__)
Output:
caller caught: NameError - name 'order_id' is not defined
context: ZeroDivisionError
cause: None
Follow-ups:
- "What does
raise NewError() from Nonedo?" It suppresses the context in the printed traceback (sets__suppress_context__), useful when the original error is an irrelevant implementation detail. Use it sparingly, since it hides information. - "How would you test the error path of
charge?" Call it withqty=0in a test and assert on the result or the log (pytest'scaplogfixture). That test would fail with theNameErrorimmediately.
Scoring: Strong: Says the caller gets NameError, explains __context__ and the "During handling" message, contrasts it with from / __cause__, and suggests testing error paths. Acceptable: Says NameError and knows both appear in the traceback; fuzzy on __context__ vs __cause__. Red flag: Thinks the original exception wins, or that Python discards it entirely.
JR-TH-FN-01 · Positional, keyword and default arguments
Question: Explain positional arguments, keyword arguments and default values using create_invoice(customer, amount, currency="INR"). Why can't you write def f(currency="INR", amount):?
Answer:
- Positional arguments are matched by order:
create_invoice("Asha", 1500)setscustomerandamount. - Keyword arguments are matched by name, so order doesn't matter:
create_invoice(amount=900, customer="Ravi"). They make calls with several values much easier to read. - A default value makes a parameter optional:
currencyis"INR"unless the caller passes something else.
In a call, positional arguments must come before keyword arguments. In a definition, parameters without defaults must come before parameters with defaults. Otherwise Python couldn't tell which positional value fills which slot, so def f(currency="INR", amount): is a SyntaxError when the module is compiled.
Default values are evaluated once, when the def runs, so use None rather than a mutable default such as [].
def create_invoice(customer, amount, currency="INR"):
return f"{customer}: {amount} {currency}"
print(create_invoice("Asha", 1500))
print(create_invoice("Asha", 20, "USD"))
print(create_invoice(amount=900, customer="Ravi"))
try:
compile("def f(currency='INR', amount): pass", "<demo>", "exec")
except SyntaxError as e:
print("SyntaxError:", e.msg)
Output:
Asha: 1500 INR
Asha: 20 USD
Ravi: 900 INR
SyntaxError: parameter without a default follows parameter with a default
Follow-ups:
- "What happens with
create_invoice("Asha", amount=10, 20)?"SyntaxError: positional argument follows keyword argument. Python rejects it before running anything. - "How can you force callers to pass
currencyby name?" Put it after a bare*:def create_invoice(customer, amount, *, currency="INR"). Everything after*is keyword-only.
Scoring: Strong: Clearly separates the three ideas, explains the ordering rule and why it exists, and mentions defaults being evaluated once. Acceptable: Gets the definitions right but not the reason for the ordering rule. Red flag: Confuses parameters with arguments, or doesn't know keyword arguments can be passed in any order.
JR-TH-FN-02 · A coupon function that sometimes returns `None`
Question: apply_coupon(total, coupon) returns the discounted total for FEST10, but for FLAT100 the branch says total - 100 without return. What does the caller get, and where does the error finally show up?
Answer:
Trap: "The last expression is returned automatically, like in Ruby or Scala." Python returns a value only through return. A function that reaches its end, or a branch that computes a value without returning it, returns None.
So apply_coupon(1000, "FLAT100") returns None, and so does any coupon that matches no branch. Nothing fails there; the error appears later and far away, for example TypeError: unsupported operand type(s) for +: 'NoneType' and 'float' when the totals are summed, or a None written into the database.
Prevention:
- Make every path return explicitly, including a final
return totalfor unknown coupons, or raise an error for invalid input. - Add a return type hint (
-> float). A type checker such as mypy then reports the missing return. - Write tests for each branch, not just the happy path.
def apply_coupon(total, coupon):
if coupon == "FEST10":
return total * 0.9
elif coupon == "FLAT100":
total - 100 # forgot `return`
print(apply_coupon(1000, "FEST10"))
print(apply_coupon(1000, "FLAT100"))
print(apply_coupon(1000, "NONE"))
Output:
900.0
None
None
Follow-ups:
- "What does a bare
returnreturn?"None, the same as falling off the end of the function. - "Which built-in methods return
Noneon purpose?" Methods that change an object in place, such aslist.sort(),list.append()anddict.update(), sox = items.sort()setsxtoNone.
Scoring: Strong: Says None immediately, explains that the failure shows up later, and gives prevention through explicit returns, type hints and branch tests. Acceptable: Says None but offers only "add the return". Red flag: Expects 900 or thinks Python raises an error at the missing return.
JR-TH-FN-03 · `got multiple values for argument`
Question: A call send_sms(*args, message="Your OTP") fails with TypeError: send_sms() got multiple values for argument 'message'. What does the error mean, and how does this typically happen?
Answer:
Python binds arguments in two steps: first positional values fill parameters left to right, then keyword values are matched by name. If a keyword names a parameter that a positional value already filled, Python can't choose, so it raises this TypeError.
Here args holds two items, which fill phone and message by position. Then message="Your OTP" targets message a second time.
Common causes:
- Unpacking
*argsor**kwargsthat contains more than you thought (an extra item in the list, or a dict that includes a key you also pass explicitly). - A wrapper or decorator that adds an argument by name while also forwarding the caller's positional arguments.
- A method called with an explicit
selfvalue, or a parameter order that changed in a refactor while callers weren't updated.
Fix by being explicit at the call site (pass by keyword), or make parameters keyword-only with * so they can't be filled by position.
def send_sms(phone, message, sender="BANK"):
return f"{sender} -> {phone}: {message}"
args = ["9876543210", "OTP is 4821"]
print(send_sms(*args))
try:
send_sms(*args, message="Your OTP")
except TypeError as e:
print("TypeError:", e)
Output:
BANK -> 9876543210: OTP is 4821
TypeError: send_sms() got multiple values for argument 'message'
Follow-ups:
- "What does
send_sms(**{"phone": "98"}, phone="99")raise?"TypeError: ... got multiple values for keyword argument 'phone'. Two keyword sources name the same parameter. - "How do you debug which values went where?"
inspect.signature(send_sms).bind(*args, **kwargs)shows the binding (or raises the same error) without calling the function.
Scoring: Strong: Explains positional-then-keyword binding, identifies the double fill, and names realistic causes such as unpacking and wrappers. Acceptable: Understands the message but can't explain how unpacking caused it. Red flag: Can't interpret the error, or "fixes" it by renaming parameters at random.
JR-TH-FN-04 · `**options` silently swallows a typo
Question: connect(host, timeout=5, **options) is called as connect("db.internal", timout=30). Does Python complain about the misspelled timout? What timeout is used?
Answer:
Trap: "Python raises TypeError: unexpected keyword argument." That only happens when the function has no **kwargs. Here **options collects every unknown keyword, so the typo lands in options, timeout stays at its default 5, and nothing warns you. In production that's a 30-second timeout you think you set but never did.
**kwargs is right when you genuinely forward arbitrary options (a wrapper around httpx.Client(**kwargs)), but it removes Python's built-in protection against misspelled names.
Safer designs:
- Declare the options you support as keyword-only parameters:
def connect(host, *, timeout=5, retries=3). A typo is then an immediateTypeError. - If you must accept
**options, validate the keys against an allowed set and raise for unknown ones. - Use a typed config object (a dataclass or a Pydantic model with
extra="forbid").
def connect(host, timeout=5, **options):
return {"host": host, "timeout": timeout, **options}
print(connect("db.internal", timout=30)) # typo goes into **options
def connect_strict(host, *, timeout=5, retries=3):
return {"host": host, "timeout": timeout, "retries": retries}
try:
connect_strict("db.internal", timout=30)
except TypeError as e:
print("TypeError:", e)
Output:
{'host': 'db.internal', 'timeout': 5, 'timout': 30}
TypeError: connect_strict() got an unexpected keyword argument 'timout'
Follow-ups:
- "How do editors and type checkers treat
**kwargsversus named parameters?" Named parameters get autocomplete and type checking.**kwargs: Anygives neither, so mistakes are found only at runtime, or not at all. - "When is passing
**kwargsthrough still a good idea?" In thin wrappers and decorators that shouldn't need to know the wrapped function's options. The wrapped function then does the validation.
Scoring: Strong: Says no error is raised and the default 5 is used, explains why, and proposes keyword-only parameters or key validation. Acceptable: Predicts the result but has no design fix beyond "be careful". Red flag: Believes Python always rejects unknown keyword arguments.
JR-TH-FN-05 · Recursion on deeply nested data
Question: A recursive function walks a chain of nested category records (each with a child). It works in tests but raises RecursionError on a production record that is 5,000 levels deep. Why, and how do you fix it properly?
Answer:
Every Python call adds a frame to the call stack, and CPython limits recursion depth to protect the interpreter from crashing: sys.getrecursionlimit() is 1000 by default. Python also doesn't do tail-call optimisation, so even return 1 + depth(child) uses one frame per level. Test data was shallow; production data wasn't.
Proper fixes:
- Rewrite iteratively. A linked chain becomes a
whileloop. A tree becomes a loop over an explicit stack (a list) or acollections.dequefor breadth-first. Memory then grows on the heap instead of the call stack, and there's no hard depth limit. - Validate or cap depth if very deep input is a sign of bad or malicious data (deeply nested JSON from a client, for example).
Raising the limit with sys.setrecursionlimit is a band-aid: it can crash the process when the C stack runs out, and the next, deeper record fails again.
import sys
def depth(node):
return 0 if node is None else 1 + depth(node["child"])
def depth_iter(node):
d = 0
while node is not None:
d, node = d + 1, node["child"]
return d
chain = None
for _ in range(5000):
chain = {"child": chain}
print(sys.getrecursionlimit())
try:
depth(chain)
except RecursionError as e:
print("RecursionError:", e)
print(depth_iter(chain))
Output:
1000
RecursionError: maximum recursion depth exceeded
5000
Follow-ups:
- "Is recursion ever the right choice in Python?" Yes, when depth is naturally small and bounded, such as walking a config tree or a parsed expression. It's often the clearest code. Just know the depth limit.
- "Does
functools.lru_cachehelp with recursion depth?" It avoids repeated work (for example in Fibonacci) but not deep stacks. The first call still recurses all the way down.
Scoring: Strong: Explains the stack limit and the lack of tail-call optimisation, rewrites with a loop or explicit stack, and calls raising the limit a band-aid. Acceptable: Knows about the limit and suggests raising it, then reaches the iterative version with a hint. Red flag: Can't explain RecursionError, or would ship with sys.setrecursionlimit(10**6).
JR-TH-FN-06 · Arguments to `log.debug` are evaluated even when debug is off
Question: Production runs at WARNING level. A hot path has log.debug(f"summary: {expensive_summary()}"). A colleague says switching to log.debug("summary: %s", expensive_summary()) makes it free when debug is off. Are they right?
Answer:
Trap: "%s style is lazy, so nothing runs." Only the string formatting is lazy. Python evaluates all arguments before calling any function, so expensive_summary() runs in both versions, and its result is then thrown away because the logger is disabled for DEBUG.
What each form costs when DEBUG is off:
- f-string: calls
expensive_summary()and builds the formatted string. %swith a call as the argument: callsexpensive_summary(), skips formatting. Cheaper, not free.%swith a plain variable: almost free, because the logger checks its level and skips formatting.
When the argument itself is costly (a df.describe(), a big json.dumps), guard it: if log.isEnabledFor(logging.DEBUG): log.debug(...). The %s form is still worth using, because tools that group log messages see the same template every time.
import logging, sys
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s", stream=sys.stdout)
log = logging.getLogger("billing")
def expensive_summary():
print("computing summary...")
return "42 rows"
log.debug(f"summary: {expensive_summary()}")
log.debug("summary: %s", expensive_summary())
if log.isEnabledFor(logging.DEBUG):
log.debug("summary: %s", expensive_summary())
log.warning("done")
Output:
computing summary...
computing summary...
WARNING done
Follow-ups:
- "Is there any way to defer the call without an
if?" Pass an object whose__str__does the work, for example a small class wrapping a function.%sformatting callsstr()only when the record is actually emitted. - "Where else does eager argument evaluation surprise people?"
dict.get(key, build_default())andd.setdefault(key, expensive())always call the default builder, even when the key exists.
Scoring: Strong: Explains that arguments are evaluated before the call, separates deferred formatting from deferred computation, and gives the isEnabledFor guard. Acceptable: Knows %s defers formatting but believes the call is skipped, then corrects it when shown. Red flag: Doesn't know that arguments are evaluated before the call.
JR-TH-FS-01 · Post/Redirect/Get after a form submission
Question: What is the Post/Redirect/Get pattern, and what problem does it solve?
Answer:
After a form is submitted with POST and processed successfully, the server doesn't render the result page directly. It responds with a redirect (303 See Other) to a page the browser then loads with GET, e.g. /orders/123.
Why:
- Refresh doesn't resubmit. If the POST response itself is the "Order placed" page, pressing refresh (or back, then forward) asks "Resubmit form?" and sends the POST again: a duplicate order or payment. After PRG, refresh just repeats the harmless GET.
- The result has a real URL that can be bookmarked or shared.
Details that matter:
- Use 303. It tells the browser to follow with GET. 307 and 308 keep the original method and body, which would POST again. Starlette/FastAPI's
RedirectResponsedefaults to 307, so passstatus_code=303explicitly (the snippet shows both). - On validation errors, don't redirect. Re-render the form with the errors and the user's input (status 200 or 422), so they can fix it.
- Show a one-time message ("Order placed") with a flash message stored in the session, or read it from the target page.
- PRG doesn't stop double clicks on the submit button. Also disable the button on submit and make the server reject duplicates (an idempotency key or a unique constraint).
from urllib.parse import parse_qs
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.testclient import TestClient
app = FastAPI()
orders = []
@app.post("/orders")
async def place_order(request: Request):
# parse the form body by hand so the example needs no python-multipart
form = parse_qs((await request.body()).decode())
orders.append(form["item"][0])
return RedirectResponse(f"/orders/{len(orders)}", status_code=303)
@app.get("/orders/{order_id}")
def show_order(order_id: int):
return {"order_id": order_id, "item": orders[order_id - 1]}
client = TestClient(app)
r = client.post("/orders", data={"item": "masala dosa"}, follow_redirects=False)
print(r.status_code, r.headers["location"])
r = client.post("/orders", data={"item": "filter coffee"}) # follows it, like a browser
print(r.request.method, r.json())
print(RedirectResponse("/x").status_code) # Starlette's default is 307, not 303
Output:
303 /orders/1
GET {'order_id': 2, 'item': 'filter coffee'}
307
Follow-ups:
- "Does PRG matter for a single-page app that submits with
fetchand JSON?" Not in the same way: a refresh reloads the app, not the POST. But duplicate submission still matters (double clicks, retries on flaky mobile networks), so use idempotency keys or disable the button while the request is in flight. - "Why is a 302 redirect usually also followed with GET?" Historically browsers changed POST to GET on 301/302 even though the original spec didn't say to. 303 was added to state that intent explicitly, so prefer 303 after a POST.
- "In real FastAPI form handling you'd write
item: str = Form(). What does that need?" Thepython-multipartpackage, which FastAPI uses to parse form bodies. Without it FastAPI raises an error asking you to install it.
Scoring: Strong: Explains the redirect-to-GET flow, the refresh/resubmit problem it solves, uses 303 and knows RedirectResponse defaults to 307, re-renders on validation errors, and adds server-side duplicate protection. Acceptable: Describes redirecting after a POST to avoid duplicate submissions, without status-code details. Red flag: Renders success pages directly from POST handlers without seeing a problem, or redirects with 307 after a POST.
JR-TH-FS-02 · bool("false") and the unchecked checkbox
Question: A settings page posts its form to a Flask-style handler that does user.newsletter = bool(request.form.get("newsletter")). A JavaScript toggle sends newsletter=false. What gets saved? And what does the server receive for an unchecked HTML checkbox named sms_alerts?
Answer:
Trap: "False, because the client sent false."
Form and query values are strings. bool() of any non-empty string is True, so "false", "0", "no" and "off" all become True. The user switched the newsletter off and the code switched it on.
And an unchecked checkbox isn't sent at all: the field is missing from the form body. A checked one sends its value, which defaults to the string "on". So request.form.get("sms_alerts") is None when unchecked (bool(None) is False, correct only by luck), and code that does request.form["sms_alerts"] raises KeyError (a 400 in Flask).
Fixes:
- Parse explicitly: accept a known set of true values (
"1","true","on","yes") and treat everything else, including a missing key, as false, asparse_boolin the snippet does. Better, reject values that are neither clearly true nor clearly false. - Or let a schema do it: Pydantic and FastAPI parse
"false","0"and"off"toFalsefor aboolfield and reject nonsense with 422. - For partial updates (PATCH), a missing key should mean "don't change", not "set to false". Then a missing checkbox and an explicit false must be handled differently, so HTML forms often add a hidden field with the same name and value
"off"before the checkbox.
from urllib.parse import parse_qs
# Body of a submitted HTML form. The "sms_alerts" checkbox was left unchecked,
# so the browser didn't send it at all; a JS client sent newsletter=false.
form = {k: v[0] for k, v in parse_qs("name=Priya&newsletter=false").items()}
print(form)
print(bool(form.get("newsletter"))) # naive
print(bool(form.get("sms_alerts"))) # missing -> None -> False
def parse_bool(value: str | None) -> bool:
return (value or "").strip().lower() in {"1", "true", "on", "yes"}
print(parse_bool(form.get("newsletter")), parse_bool(form.get("sms_alerts")))
Output:
{'name': 'Priya', 'newsletter': 'false'}
True
False
False False
Follow-ups:
- "What does
bool("0")return, and why?"True. Truthiness of a string depends only on whether it's empty, not on what it says. - "A query string has
?in_stock=(empty). What should the API do?" Decide deliberately: treat it as missing (use the default) or reject it with 400/422. Silently treating it as false hides client bugs. - "Why does the hidden-field trick work?" Both fields share a name; when the box is checked, the form sends both values and the server takes the last one (
on); when unchecked, only the hiddenoffis sent. The server always receives a value.
Scoring: Strong: Knows form values are strings and bool() tests emptiness, knows unchecked checkboxes are omitted, parses explicitly or via a schema, and distinguishes missing from false for partial updates. Acceptable: Spots bool("false") is True and fixes the parsing, but doesn't know checkboxes are omitted when unchecked. Red flag: Expects bool("false") to be False, or fixes it with value != "false" (which turns "0" and "off" into True).
JR-TH-FS-03 · Session cookie flags and what each one protects
Question: Your login sets a session cookie. What do HttpOnly, Secure, SameSite and Max-Age do, and what values would you choose?
Answer:
HttpOnly: JavaScript can't read the cookie (document.cookiedoesn't show it). If an attacker gets script onto your page (XSS), they can't simply copy the session ID and use it from their own machine. They can still act through the user's browser while the page is open, so this limits XSS damage rather than preventing XSS. Set it on session cookies.Secure: the browser sends the cookie only over HTTPS, so it doesn't leak on plain-HTTP requests (public Wi-Fi, a mistypedhttp://link). Set it everywhere except local development.SameSite: whether the cookie is sent on requests that come from other sites:Lax(a good default; Chrome and Edge also treat cookies withoutSameSiteas Lax, but other browsers don't, so set it explicitly): sent on top-level navigations such as clicking a link to your site, but not on cross-site form POSTs,fetchcalls or iframes. That blocks most CSRF.Strict: never sent cross-site. Safest, but a user clicking a link from email or WhatsApp arrives logged out.None: always sent, needed for embedded or third-party use, and requiresSecure.Max-Age/Expires: without them the cookie is a session cookie, removed when the browser session ends. With them it persists for that long. Match it to how long a login should last, and expire sessions on the server side too.- Also
PathandDomainscope it. LeaveDomainunset unless subdomains genuinely need the cookie.
The value itself: an unguessable random ID (secrets.token_urlsafe(32)) that maps to server-side session data, or a signed value. Never something like user_id=42 that a user can edit. Issue a new session ID at login to prevent session fixation, and clear it server-side on logout.
The snippet builds such a header with the standard library.
from http.cookies import SimpleCookie
cookie = SimpleCookie()
cookie["session_id"] = "a8f3c2e1"
cookie["session_id"]["httponly"] = True
cookie["session_id"]["secure"] = True
cookie["session_id"]["samesite"] = "Lax"
cookie["session_id"]["path"] = "/"
cookie["session_id"]["max-age"] = 3600
print(cookie.output())
Output:
Set-Cookie: session_id=a8f3c2e1; HttpOnly; Max-Age=3600; Path=/; SameSite=Lax; Secure
Follow-ups:
- "With
HttpOnly, how does your frontend know whether the user is logged in?" It asks the server, e.g.GET /api/mereturns the user or 401. The cookie is sent automatically; the JavaScript never needs to read it. - "The frontend is on
app.example.inand the API onapi.example.in. DoesSameSite=Laxblock the frontend's calls?" No. SameSite is about sites (scheme plus registrable domain), and both are onexample.in, so it's same-site. You still need CORS configured with credentials, because they are different origins. - "In FastAPI, how do you set this cookie?"
response.set_cookie("session_id", value, httponly=True, secure=True, samesite="lax", max_age=3600).
Scoring: Strong: Explains each flag and the attack it addresses (XSS theft, network leakage, CSRF), chooses HttpOnly + Secure + Lax with a sensible lifetime, and covers random session IDs and rotation at login. Acceptable: Correctly explains HttpOnly and Secure and roughly what SameSite is for, with less precision on Lax vs Strict. Red flag: Stores the user ID or role directly in an unsigned cookie, or thinks HttpOnly prevents XSS.
JR-TH-FS-04 · CORS does not stop CSRF
Question: Your app uses cookie-based sessions. A teammate says: "We're safe from CSRF. CORS only allows our own frontend's origin." Are they right?
Answer:
Trap: "Yes, CORS blocks requests from other sites."
CORS controls whether a script on another origin can read the response. It doesn't stop the browser from sending the request. A malicious page can contain an auto-submitting form:
<form action="https://shop.example.in/account/email" method="POST">
<input name="email" value="attacker@evil.example">
</form>
<script>document.forms[0].submit()</script>
A form-encoded POST is a "simple" request: there is no preflight, the browser sends it, and if the session cookie goes along the server changes the email. That's CSRF (cross-site request forgery). The attacker never needs to read the response, so CORS is irrelevant.
Real defences (use more than one):
SameSite=LaxorStrictsession cookies. The cookie isn't attached to cross-site POSTs, which stops the attack above.Laxstill sends cookies on top-level cross-site GETs, so GET must never change state.- CSRF tokens for cookie-authenticated forms: a random token tied to the session, embedded in the form or sent as a header, and checked on every state-changing request. Django's CSRF middleware and Flask-WTF do this for you.
- Check
Origin(orReferer) on state-changing requests and reject foreign origins. - For JSON APIs: require
Content-Type: application/jsonor a custom header, which forces a preflight that a foreign origin fails. The server must then reject form-encoded bodies rather than parse them too.
Requests authenticated with an Authorization: Bearer header aren't exposed to CSRF, because browsers don't add that header automatically. The token then lives in JavaScript, though, so XSS becomes the bigger risk.
Follow-ups:
- "Why is a state-changing GET like
/cart/clear?confirm=1dangerous even withSameSite=Lax?" Lax still sends cookies on top-level cross-site navigation. A link or redirect to that URL on another site clears the user's cart. Use POST (or DELETE) for anything that changes state. - "A sibling subdomain,
blog.example.in, gets compromised. Does SameSite protectshop.example.in?" No. Subdomains of the same registrable domain are same-site, so cookies are sent. CSRF tokens or Origin checks still protect you. - "What does a CORS
Access-Control-Allow-Origin: *with credentials do?" Browsers refuse it: with credentials, the server must name the exact origin. Reflecting any incoming Origin back with credentials allowed lets any site read your users' data, a separate and serious misconfiguration.
Scoring: Strong: Separates reading responses (CORS) from sending requests (CSRF), shows a simple cross-site form POST, and layers SameSite, CSRF tokens, Origin checks and no state-changing GETs; knows bearer-header auth changes the picture. Acceptable: Knows CORS doesn't prevent CSRF and names CSRF tokens or SameSite, without explaining simple requests. Red flag: Treats CORS as a security boundary for the server, or relies on "only POST is allowed" as CSRF protection.
JR-TH-FS-05 · Designing API responses for a frontend
Question: Your React frontend shows user profiles and wallet balances from a FastAPI backend. How do you design the JSON responses, especially internal fields, money and dates?
Answer:
Explicit output schemas. Return a dedicated response model (UserOut, used as response_model= or as the return type), not the ORM object or a raw dict. Only the listed fields are serialised, so password_hash, internal flags or a PAN never leak because someone added a column. Separate models for input (UserCreate), partial updates (UserUpdate) and output keep each contract clear.
Money: never float. Use Decimal in Python (or integer paise in the database), and send it as a string such as "1250.50" (Pydantic v2 does this by default for Decimal in JSON, as the snippet shows) or as an integer number of paise. Document which. Format for display only in the UI: Intl.NumberFormat("en-IN", {style: "currency", currency: "INR"}) gives ₹1,250.50 with Indian digit grouping (₹1,25,000.00).
Dates and times: timezone-aware UTC in ISO 8601 ("2026-03-10T04:30:00Z"); the browser converts to the user's time zone (10:00 IST here). Send plain dates (date of birth, delivery date) as "2026-03-10" with no time, so they never shift across time zones.
Consistency the frontend can rely on:
- one naming style (snake_case, or camelCase via a Pydantic
alias_generator); - one error shape (FastAPI's
{"detail": ...}); - a pagination envelope (
items,next_cursor); - a clear difference between
null(no value) and an omitted field.
Evolve without breaking clients: adding fields is safe; renaming or removing them needs a version or a deprecation period, because shipped mobile apps keep calling the old contract.
from datetime import datetime, timezone
from decimal import Decimal
from pydantic import BaseModel
class UserRow(BaseModel): # what the DB layer returns
id: int
email: str
password_hash: str
wallet_balance: Decimal
created_at: datetime
class UserOut(BaseModel): # what the frontend is allowed to see
id: int
email: str
wallet_balance: Decimal
created_at: datetime
row = UserRow(id=7, email="priya@example.in", password_hash="pbkdf2$...",
wallet_balance=Decimal("1250.50"),
created_at=datetime(2026, 3, 10, 4, 30, tzinfo=timezone.utc))
print(UserOut.model_validate(row.model_dump()).model_dump_json())
Output:
{"id":7,"email":"priya@example.in","wallet_balance":"1250.50","created_at":"2026-03-10T04:30:00Z"}
Follow-ups:
- "Why not send money as a JSON number like
1250.5?" The frontend parses it as a binary float, so sums like 0.1 + 0.2 drift, and the trailing zero (display precision) is lost. Strings or integer paise keep exact values; convert deliberately on each side. - "What does FastAPI do if the handler returns a
UserRowbutresponse_model=UserOut?" It validates the return value againstUserOutand serialises onlyUserOut's fields, sopassword_hashis filtered out. That's why declaring the response model matters even if the handler returns a richer object. - "A teammate returns
datetime.now()in responses. What goes wrong?" It's naive local time of whatever server ran it, serialised without an offset; the browser then treats it as the user's local time, so times shift by hours. Usedatetime.now(timezone.utc).
Scoring: Strong: Uses explicit output models to prevent leaks, represents money as Decimal/string or paise with UI-side ₹ formatting, uses aware UTC ISO timestamps and plain dates, and keeps naming, errors and pagination consistent with a plan for evolving the contract. Acceptable: Uses response models and ISO dates, and knows not to use floats for money, with fewer details on consistency or versioning. Red flag: Returns ORM objects or dicts straight from the database, or sends money as floats and naive local datetimes.
JR-TH-FS-06 · A CDN that serves one user's data to another
Question: To speed up the site, the team put a CDN in front of the whole app with a "cache everything" rule. The next day a user reports seeing another person's name and wallet balance on the account page, which loads GET /api/me. How did that happen, and how do you prevent it?
Answer:
Trap: "CDNs only cache static files like images and JS, so API responses can't be shared."
A shared cache (CDN, reverse proxy) stores responses according to its rules and the response headers, and by default its cache key is the URL, not the user's cookie. /api/me is the same URL for everyone. The first user's response gets cached, and every later visitor receives that copy without the request ever reaching your server. With "cache everything" rules, or responses that carry no Cache-Control header at all, this happens easily.
Prevention:
- Mark personalised responses explicitly:
Cache-Control: private, no-store.privateforbids shared caches from storing the response;no-storealso keeps it out of the browser's cache, which matters for sensitive pages after logout on a shared computer. The snippet sets this as a default for every/api/route in a middleware, so a new endpoint can't forget it. - Opt in to caching only for truly public data:
public, max-age=3600for a city list, and long-livedimmutablecaching for static assets with content-hashed file names (app.3f9c1b.js). - Don't rely on
Vary: CookieorVary: Authorizationalone; CDNs handleVaryinconsistently, and it fragments the cache. Prefer bypass rules for authenticated paths (/api/*, any request with a session cookie). - Never cache responses that set cookies (
Set-Cookie), or one user's session can be handed to others. - After an incident: purge the cache, fix the headers or rules, and treat it as a data leak (log who could have seen what).
The underlying habit: be explicit about caching on every response, and review CDN rules as security configuration, not just performance tuning.
from fastapi import FastAPI, Request, Response
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def no_store_by_default(request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/"):
response.headers.setdefault("Cache-Control", "private, no-store")
return response
@app.get("/api/me")
def me():
return {"name": "Priya", "wallet_inr": "1250.50"}
@app.get("/api/cities")
def cities(response: Response):
response.headers["Cache-Control"] = "public, max-age=3600" # same for everyone
return ["Pune", "Delhi", "Chennai"]
client = TestClient(app)
for path in ["/api/me", "/api/cities"]:
print(path, "->", client.get(path).headers["cache-control"])
Output:
/api/me -> private, no-store
/api/cities -> public, max-age=3600
Follow-ups:
- "What's the difference between
no-cacheandno-store?"no-cachelets caches store the response but requires them to revalidate with the server (e.g. via ETag) before every reuse;no-storeforbids storing it at all. For sensitive personal data useno-store. - "Why does the middleware use
setdefaultinstead of always overwriting the header?" So an endpoint that deliberately sets its own policy (like/api/cities) keeps it, while every endpoint that says nothing gets the safe default. - "How would you test for this regression?" An automated test that calls every authenticated API route and asserts
Cache-Controlcontainsprivateorno-store, plus a check after CDN rule changes that two different users get their own data through the CDN URL.
Scoring: Strong: Explains URL-keyed shared caches and missing headers, sets private, no-store by default for personalised responses with explicit opt-in caching for public data and hashed static assets, mentions Set-Cookie and incident handling. Acceptable: Knows personalised responses need Cache-Control: private or no-store and that the CDN cached by URL, without the default-safe approach. Red flag: Believes the CDN can't cache API responses, or "fixes" it by adding a random query parameter on the client.
JR-TH-GEN-01 · What `yield` does
Question: What is the difference between a function that uses return and one that uses yield? Walk through what happens when you call read_amounts(lines) and then next() on the result.
Answer:
A function containing yield is a generator function. Calling it doesn't run the body; it returns a generator object. Each next() runs the body until the next yield, hands out that value, and pauses with all local variables intact. The next next() resumes right after the yield. When the body finishes, next() raises StopIteration. A for loop calls next() for you and stops quietly at StopIteration.
A normal function with return runs to completion once and returns a single value; its local variables are gone afterwards.
Why it matters: a generator produces items one at a time, on demand, so you can process a 2 GB log file or a paginated API without holding everything in memory.
def read_amounts(lines):
for line in lines:
print(f"parsing {line!r}")
yield int(line)
gen = read_amounts(["250", "1200"])
print(type(gen).__name__)
print(next(gen))
print(next(gen))
try:
next(gen)
except StopIteration:
print("StopIteration: no more amounts")
Output:
generator
parsing '250'
250
parsing '1200'
1200
StopIteration: no more amounts
Follow-ups:
- "Why is nothing printed when
read_amounts(...)is called?" Calling a generator function only creates the generator object. The body starts running on the firstnext(). - "How do you get all the values at once?"
list(gen). That gives up the memory benefit, so do it only when the data is small or you need it more than once.
Scoring: Strong: Explains the generator object, pausing and resuming with local state, StopIteration, and the memory benefit. Acceptable: Knows generators produce values one at a time but is unclear about when the body runs. Red flag: Thinks yield is the same as return, or that calling the function runs it immediately.
JR-TH-GEN-02 · `if refunds:` on a generator is always true
Question: pending_refunds(rows) returns a generator expression. The caller writes if refunds: notify_finance(). There are no pending refunds today. Is finance notified? How do you check whether a generator has any items?
Answer:
Trap: "An empty generator is falsy, like an empty list." A generator object defines neither __bool__ nor __len__, so it's always truthy. Finance is notified every day. len(gen) raises TypeError, because a generator doesn't know how many items it will produce until it has produced them.
How to check correctly:
- If the data is small, materialise it:
refunds = list(...); thenif refunds:andlen()work normally. - To check without materialising, pull the first item:
first = next(gen, None). Remember that this item is now consumed, so chain it back if you still need it (itertools.chain([first], gen)). - If all you need is "is there any?", use
any(True for _ in gen)orany(cond(r) for r in rows)on the source data.
The same applies to map, filter, zip and other iterators.
def pending_refunds(rows):
return (r for r in rows if r["status"] == "pending")
refunds = pending_refunds([{"id": 1, "status": "done"}])
if refunds:
print("there are pending refunds") # runs even though there are none
print(list(refunds))
try:
len(pending_refunds([]))
except TypeError as e:
print("TypeError:", e)
first = next(pending_refunds([{"id": 1, "status": "done"}]), None)
print(first)
Output:
there are pending refunds
[]
TypeError: object of type 'generator' has no len()
None
Follow-ups:
- "Would changing the return type hint to
Iterator[dict]have helped?" It documents the risk, and mypy has an optionaltruthy-iterablecheck that flags this pattern, but Python itself doesn't care at runtime. The fix is in the code, not the hint. - "What's the danger of
next(gen, None)whenNonecan be a real item?" You can't tell an empty generator from aNoneitem. Use a sentinel object,_MISSING = object(), as the default.
Scoring: Strong: Knows generators are always truthy, len() fails, and gives next(gen, default) or materialising, noting that next consumes an item. Acceptable: Recognises the bug when shown but only offers list(). Red flag: Believes an empty generator is falsy.
JR-TH-GEN-03 · A reusable iterable versus a one-shot iterator
Question: You need a DateRange(start, days) object that reports can loop over more than once. How do you implement it, and what goes wrong if __iter__ returns self?
Answer:
The iterator protocol has two parts:
- an iterable has
__iter__(), which returns an iterator; - an iterator has
__next__(), which returns the next item or raisesStopIteration, and an__iter__()that returns itself.
For a reusable object, make DateRange an iterable whose __iter__ creates a fresh iterator each time. The simplest way is to write __iter__ as a generator function (with yield). Every for loop then starts from start.
If the class is its own iterator (__iter__ returns self and __next__ advances internal state), it's one-shot: the first loop uses up the state, and the second loop silently produces nothing. That's right for things like a file or a network stream, but surprising for a value object like a date range.
This is the same split as list (reusable iterable) versus iter(list) (one-shot iterator).
from datetime import date, timedelta
class DateRange:
def __init__(self, start, days):
self.start, self.days = start, days
def __iter__(self):
for i in range(self.days):
yield self.start + timedelta(days=i)
class DateRangeIterator:
def __init__(self, start, days):
self.current, self.remaining = start, days
def __iter__(self):
return self
def __next__(self):
if self.remaining == 0:
raise StopIteration
value = self.current
self.current += timedelta(days=1)
self.remaining -= 1
return value
r = DateRange(date(2026, 3, 30), 3)
print([d.isoformat() for d in r])
print(len(list(r)))
it = DateRangeIterator(date(2026, 3, 30), 3)
print(len(list(it)), len(list(it)))
Output:
['2026-03-30', '2026-03-31', '2026-04-01']
3
3 0
Follow-ups:
- "How does a
forloop use these methods?" It callsiter(obj)once to get an iterator, then callsnext()on it repeatedly untilStopIteration, which it catches to end the loop. - "How would you type-hint
DateRange.__iter__?"def __iter__(self) -> Iterator[date]:(fromcollections.abc). A generator function satisfies that return type.
Scoring: Strong: Separates iterable from iterator, implements __iter__ as a generator for reusability, and explains the one-shot failure. Acceptable: Writes a working class-based iterator but doesn't see why it can be looped over only once. Red flag: Can't describe what a for loop calls under the hood.
JR-TH-GEN-04 · `in` on an iterator consumes it
Question: it = iter([10, 20, 30, 40, 50]). You check 30 in it, then call list(it). What do you get? What if the value isn't in the iterator?
Answer:
Trap: "True, then the full list [10, 20, 30, 40, 50]; in is just a check." You get True and then [40, 50].
For an iterator, in has no way to look without moving: it calls next() repeatedly until it finds a match, and every item it passes is consumed. It stops just after the match. If the value isn't there, it consumes everything and the iterator is empty.
This bites with map/filter/zip objects, generators, csv.reader and file objects: an innocent if header in reader: or a debug print(list(rows)) eats rows, and later code silently processes fewer of them.
If you need membership tests and iteration, materialise first (items = list(it)) or, for repeated lookups, build a set.
it = iter([10, 20, 30, 40, 50])
print(30 in it)
print(list(it))
ids = iter(["A1", "B2", "C3"])
print("Z9" in ids, list(ids))
Output:
True
[40, 50]
False []
Follow-ups:
- "Which other common operations also consume an iterator?"
list(),sum(),max(),sorted(),any()/all()(up to the first deciding item),len(list(it))and unpacking such asa, *rest = it. - "Does
30 in some_listhave the same problem?" No. A list is an iterable, not an iterator.inuseslist.__contains__, which scans without consuming anything.
Scoring: Strong: Predicts both outputs, explains that in advances the iterator, and lists real cases such as file and CSV readers. Acceptable: Gets the first case after thinking and is unsure about the missing value. Red flag: Expects the full list afterwards.
JR-TH-GEN-05 · A streaming CSV pipeline built from generators
Question: You need to load a 5 GB orders CSV into an API that accepts batches of 500, skipping rows with zero amount and recording rows that fail to parse. How would you structure it with generators, and where are the pitfalls?
Answer:
Build small generator stages, each taking an iterable and yielding items:
read_rows(f):yield from csv.DictReader(f). Reads one line at a time.parse(rows, errors): converts types insidetry/exceptand records bad rows (ID and reason) instead of crashing or silently dropping them.non_zero(rows): a filter.itertools.batched(pipeline, 500)(3.12+) groups items into tuples for the API.
Nothing runs until the final loop pulls items, and memory stays at roughly one batch whatever the file size.
Pitfalls to mention:
- Keep the file open for the whole pipeline: consume it inside the
with open(...)block. Returning the generator out of thewithcloses the file before it's read. - Single pass: you can't loop over the pipeline twice; recreate it.
- Error reporting: errors are known only after the pipeline has been consumed, so read the error list at the end.
- Partial failure: if the API fails on batch 37, earlier batches are already sent. Log progress (last row or batch number) so a rerun can resume, or make the API call idempotent.
import csv, io
from itertools import batched
RAW = """order_id,city,amount
A1,Pune,1200
A2,Delhi,oops
A3,Pune,800
A4,Kochi,0
A5,Delhi,450
"""
def read_rows(f):
yield from csv.DictReader(f)
def parse(rows, errors):
for row in rows:
try:
yield {**row, "amount": int(row["amount"])}
except ValueError:
errors.append(row["order_id"])
def non_zero(rows):
return (r for r in rows if r["amount"] > 0)
errors = []
pipeline = non_zero(parse(read_rows(io.StringIO(RAW)), errors))
for batch in batched(pipeline, 2):
print([r["order_id"] for r in batch])
print("bad rows:", errors)
Output:
['A1', 'A3']
['A5']
bad rows: ['A2']
Follow-ups:
- "What would you do before Python 3.12, without
itertools.batched?" Useitertools.islicein a loop:while batch := list(islice(it, size)): ..., whereit = iter(pipeline). - "When would you use pandas
read_csv(chunksize=...)instead?" When each chunk needs DataFrame operations (vectorised cleaning, joins). Each chunk is a DataFrame, and memory stays bounded by the chunk size.
Scoring: Strong: Designs clear stages with batching, collects parse errors, and raises the open-file, single-pass and partial-failure pitfalls. Acceptable: Uses generators for reading and batching but handles errors by crashing or skipping silently. Red flag: Reads the whole file into a list first, or doesn't see why that fails at 5 GB.
JR-TH-GEN-06 · Argument validation inside a generator runs late
Question: read_batches(rows, size) starts with if size <= 0: raise ValueError(...) and then yields batches. A caller passes size=0. When is the ValueError raised, and why does that matter?
Answer:
Trap: "Immediately, when read_batches is called." No code in a generator function runs until the first next(), including the validation at the top. The call returns a generator object without complaint, and the ValueError appears later, wherever the generator is first consumed. That might be in another function, inside a framework, or after the generator was handed to a worker, so the traceback points away from the bad call.
The fix is to split it into two functions:
- a regular function that validates its arguments and returns the generator, and
- a private generator function that does the yielding.
Now read_batches_checked([1, 2, 3], 0) fails at the call site. The same applies to opening files or connections at the top of a generator: they also happen only on first iteration.
def read_batches(rows, size):
if size <= 0:
raise ValueError("size must be positive")
for i in range(0, len(rows), size):
yield rows[i:i + size]
gen = read_batches([1, 2, 3], 0) # no error yet
print("created", type(gen).__name__)
try:
next(gen)
except ValueError as e:
print("ValueError on first next():", e)
def read_batches_checked(rows, size):
if size <= 0:
raise ValueError("size must be positive")
return _batches(rows, size)
def _batches(rows, size):
for i in range(0, len(rows), size):
yield rows[i:i + size]
try:
read_batches_checked([1, 2, 3], 0)
except ValueError as e:
print("ValueError at call:", e)
print(list(read_batches_checked([1, 2, 3], 2)))
Output:
created generator
ValueError on first next(): size must be positive
ValueError at call: size must be positive
[[1, 2], [3]]
Follow-ups:
- "How would you test that validation happens at call time?"
with pytest.raises(ValueError): read_batches_checked(rows, 0)without iterating. With the original version that test fails, because nothing is raised until iteration. - "If a generator is never iterated, does its body run at all?" No. Not even the code before the first
yield. That's also why a generator-based 'setup' function can silently do nothing.
Scoring: Strong: Says the error appears on first next(), explains why that hurts debugging, and gives the wrapper-function fix. Acceptable: Knows generator bodies are lazy but doesn't connect it to where the error surfaces. Red flag: Expects the error at call time and can't explain the delay.
JR-TH-HSH-01 · Why a tuple can be a key and a list can't
Question: Why can a tuple be a dict key or set element, but a list can't? Can every tuple be a key?
Answer:
Dicts and sets find keys by their hash, and that hash must not change while the key is stored. A list is mutable: after an append its hash would have to change and the dict could no longer find it. So Python makes lists unhashable, and using one raises TypeError: unhashable type: 'list'.
A tuple is immutable, so its hash can be computed from its elements. But it's only hashable if all its elements are: ("Chennai", ["2025-01-05"]) fails with the same error.
Typical uses are composite keys like (city, date). Convert first when you need to: tuple(my_list), frozenset(my_set). Hashable: int, float, str, bytes, None, tuple (of hashables), frozenset. Not hashable: list, dict, set.
visits = {}
visits[("Chennai", "2025-01-05")] = 42
print(visits)
for key in (["Chennai", "2025-01-05"], ("Chennai", ["2025-01-05"])):
try:
visits[key] = 1
except TypeError as exc:
print(exc)
Output:
{('Chennai', '2025-01-05'): 42}
unhashable type: 'list'
unhashable type: 'list'
Follow-ups:
- "You want to count how often each combination of tags appears, where tags is a list per order. How?" Turn each list into something hashable that ignores order:
Counter(frozenset(tags) for tags in orders), ortuple(sorted(tags))if duplicates matter. - "Are instances of your own classes hashable?" By default yes, by identity. If you define
__eq__without__hash__, they become unhashable.
Scoring: Strong: Links hashability to immutability, knows tuples are hashable only if their contents are, and suggests tuple/frozenset conversions. Acceptable: Knows lists can't be keys and tuples can, but hesitates on a tuple containing a list. Red flag: Thinks lists can't be keys 'because they're slow', or has never seen unhashable type.
JR-TH-HSH-02 · De-duplicating while keeping order
Question: You need to remove duplicate emails from a signup list but keep the first-seen order for a report. A colleague writes list(set(emails)). Is that OK?
Answer:
Trap: "Yes, sets keep insertion order now, like dicts." Dicts have kept insertion order since 3.7; sets don't. Set iteration order depends on hash values and the table size. String hashes are randomised per process, so list(set(emails)) can come out in a different order on each run. Small ints often happen to come out sorted, which hides the bug in tests.
Order-preserving options, both O(n):
list(dict.fromkeys(emails)): dict keys keep first-seen order.- A seen-set loop: check
if e not in seen, thenseen.add(e)and append. Use this when you also need to normalise first (e.strip().lower()).
If sorted order is what you actually want, sorted(set(emails)) is deterministic. In pandas, use drop_duplicates(keep="first").
emails = ["neha@x.in", "amit@x.in", "neha@x.in", "zoya@x.in", "amit@x.in"]
unique_in_order = list(dict.fromkeys(emails))
print(unique_in_order)
seen = set()
unique_loop = []
for e in emails:
if e not in seen:
seen.add(e)
unique_loop.append(e)
print(unique_loop == unique_in_order)
print(sorted(set(emails)))
Output:
['neha@x.in', 'amit@x.in', 'zoya@x.in']
True
['amit@x.in', 'neha@x.in', 'zoya@x.in']
Follow-ups:
- "De-duplicate case-insensitively but keep the original spelling of the first occurrence?" Seen-set loop on
e.casefold(), appending the originale. - "How would you de-duplicate a list of dicts?" Dicts aren't hashable. Key on a tuple of the identifying fields, e.g.
{(d["email"],): d for d in rows}(last wins), or a seen-set ofd["email"](first wins).
Scoring: Strong: Says set order isn't insertion order (and varies for strings), and gives dict.fromkeys or a seen-set loop. Acceptable: Knows sets are 'unordered' and finds a loop-based fix. Red flag: Believes list(set(x)) keeps order, or sorts and calls that 'original order'.
JR-TH-HSH-03 · Deleting from a dict while looping over it
Question: You loop over a dict of user carts and delete entries with zero items. Why does it fail, and what are the correct ways to do it?
Answer:
Iterating over a dict (or .keys(), .items(), .values()) walks the live hash table. If the dict's size changes during the loop (a key added or deleted), Python raises RuntimeError: dictionary changed size during iteration, because the iterator's position can no longer be trusted.
Look at the output: by the time it raised, u1 had already been deleted, so the dict is left half-processed. The error doesn't roll anything back.
Fixes:
- Iterate over a snapshot:
for user in list(carts):. - Or build a new dict with a comprehension, which is usually the clearest:
{u: n for u, n in carts.items() if n}.
Assigning a new value to an existing key during iteration is fine, because the size doesn't change. Sets raise the same error. Lists don't raise at all; removing from a list while looping silently skips the next element, which is worse.
carts = {"u1": 0, "u2": 3, "u3": 0}
try:
for user, items in carts.items():
if items == 0:
del carts[user]
except RuntimeError as exc:
print(exc)
print(carts)
carts = {"u1": 0, "u2": 3, "u3": 0}
for user in list(carts):
if carts[user] == 0:
del carts[user]
print(carts)
carts = {"u1": 0, "u2": 3, "u3": 0}
print({u: n for u, n in carts.items() if n})
Output:
dictionary changed size during iteration
{'u2': 3, 'u3': 0}
{'u2': 3}
{'u2': 3}
Follow-ups:
- "Is
carts[user] = 0for existing keys safe inside the loop?" Yes. Updating values doesn't change the size or the table layout. - "What happens if you remove items from a list while iterating over it?" No error, but the element after each removed one is skipped. Iterate over a copy or build a new list.
- "Can another thread trigger the same error?" Yes, if it modifies the dict while you iterate. Take a snapshot under a lock or avoid sharing the dict.
Scoring: Strong: Names the exact error, notes the partial deletion, and gives the snapshot and comprehension fixes. Acceptable: Knows you 'can't modify while iterating' and gives one fix. Red flag: Wraps the loop in try/except RuntimeError and moves on.
JR-TH-HSH-04 · Float prices as dict keys
Question: A pricing script maps prices to discount labels with float keys, {0.3: "10% off"}, and looks up a computed price 0.1 + 0.2. What happens?
Answer:
Trap: "0.1 + 0.2 is 0.3, so the lookup works." Floats are binary approximations. 0.1 + 0.2 is 0.30000000000000004, which isn't equal to 0.3, so in returns False and d[price] would raise KeyError. Dict lookup is exact equality; there's no tolerance.
Where it bites: money in floats used as keys or join columns, GPS coordinates as keys, merging pandas DataFrames on float columns.
Fixes:
- Store money as integer paise:
round(price * 100). Useround, notint, becauseinttruncates. - Or use
Decimalbuilt from strings.Decimal(0.1)inherits the float error. Equal Decimals hash equal, soDecimal("0.30")andDecimal("0.3")are the same key. - For measurements, bucket values (
round(x, 2)) before using them as keys, and compare withmath.iscloserather than==.
from decimal import Decimal
discount_by_price = {0.3: "10% off"}
price = 0.1 + 0.2
print(price)
print(price in discount_by_price)
paise_table = {30: "10% off"}
print(round(price * 100) in paise_table)
dec_table = {Decimal("0.30"): "10% off"}
print(Decimal("0.1") + Decimal("0.2") in dec_table)
Output:
0.30000000000000004
False
True
True
Follow-ups:
- "Why
round(price * 100)and notint(price * 100)?"0.29 * 100is28.999999999999996;inttruncates it to 28,roundgives 29. - "Would a pandas merge on a float price column have the same issue?" Yes, it matches on exact equality too. Merge on integer paise or a rounded column.
Scoring: Strong: Predicts False, explains binary floating point and exact-equality lookup, and fixes it with paise or Decimal(str). Acceptable: Knows floats are imprecise but needs a nudge to connect it to dict lookup. Red flag: Suggests Decimal(0.1) from a float, or says the lookup will 'probably' work.
JR-TH-HSH-05 · Your own class as a dict key
Question: You want OrderKey(customer_id, day) objects as dict keys. What do you need to implement, and what breaks if someone changes a field after the key has been inserted?
Answer:
Implement __eq__ and __hash__ consistently: objects that compare equal must have equal hashes. In practice, hash a tuple of the same fields that __eq__ compares: hash((self.customer_id, self.day)). Without them you get identity semantics, so two OrderKey(101, "2025-03-01") objects would be different keys. Defining only __eq__ sets __hash__ to None and makes the class unhashable. Return NotImplemented for foreign types so comparisons with other objects behave sensibly.
Mutation breaks it. The dict placed the entry in a slot chosen by the old hash. After key.day changes:
- Looking up the old value goes to the right slot, but the stored key no longer compares equal.
- Looking up the new value computes a different hash and (almost always) looks in a different slot.
The entry still counts in len() and shows up in iteration, but no lookup can reach it.
So hash only fields that never change, and make key objects immutable: @dataclass(frozen=True) generates matching __eq__/__hash__ and blocks assignment, or use a NamedTuple or a plain tuple.
class OrderKey:
def __init__(self, customer_id, day):
self.customer_id = customer_id
self.day = day
def __eq__(self, other):
if not isinstance(other, OrderKey):
return NotImplemented
return (self.customer_id, self.day) == (other.customer_id, other.day)
def __hash__(self):
return hash((self.customer_id, self.day))
totals = {OrderKey(101, "2025-03-01"): 1200}
print(totals[OrderKey(101, "2025-03-01")])
key = OrderKey(102, "2025-03-01")
totals[key] = 800
key.day = "2025-03-02" # mutate a field used in the hash
print(OrderKey(102, "2025-03-01") in totals)
print(OrderKey(102, "2025-03-02") in totals)
print(len(totals))
Output:
1200
False
False
2
Follow-ups:
- "Write it as a dataclass."
@dataclass(frozen=True)on a class withcustomer_id: intandday: str. Equality and hash come from the fields, and assignment raisesFrozenInstanceError. - "Why return
NotImplementedinstead ofFalsefor other types?" It lets Python try the other operand's__eq__and then fall back to identity, instead of your class deciding for both sides. - "Can you hash only
customer_idwhile__eq__uses both fields?" Yes, that's legal (equal objects still have equal hashes). It's just slower, because keys with the same customer collide.
Scoring: Strong: Implements a consistent __eq__/__hash__, explains the stuck-entry failure via slot placement, and recommends frozen dataclasses or tuples. Acceptable: Writes both methods correctly but is vague on why mutation breaks lookups. Red flag: Implements __hash__ returning a random or id-based value with field-based __eq__, or doesn't know __hash__ exists.
JR-TH-HSH-06 · NaN in sets and dict keys
Question: A sensor feed stores missing readings as float("nan"). You de-duplicate with set(readings) and check membership with in. What surprises are waiting?
Answer:
Trap: "NaN is just a value, so a set keeps one copy of it." NaN isn't equal to anything, including itself (nan == nan is False, per IEEE 754).
Container lookups (in on lists, dict and set lookups) check identity first, then equality (x is y or x == y). So the same NaN object is found (nan in [nan] and nan in {nan} are True), but a different NaN object never matches.
Each float("nan") creates a new object, and since 3.10 a NaN's hash is based on its identity, so the set keeps both NaNs: len(set(readings)) is 3, not 2. Results then depend on whether NaNs happen to be the same object: a reused math.nan behaves differently from values parsed out of a CSV. You get inconsistent de-dup counts and dict keys that can't be looked up.
Fix: detect missing values explicitly (math.isnan, pd.isna, np.isnan) and handle them before de-duplicating or using values as keys. Pandas' drop_duplicates, unique and value_counts(dropna=False) treat all NaNs as one value.
import math
nan = float("nan")
print(nan == nan)
print(nan in [nan], nan in {nan})
readings = [float("nan"), 21.5, float("nan"), 21.5]
print(len(set(readings)))
cleaned = {x for x in readings if not math.isnan(x)}
print(cleaned, sum(math.isnan(x) for x in readings))
Output:
False
True True
3
{21.5} 2
Follow-ups:
- "What is
math.nan in {math.nan}?"True: it's the same object, so the identity check succeeds. - "What happens if you sort a list containing NaN?" Every comparison with NaN is
False, so the order around the NaNs is inconsistent and nothing raises. Drop or fill NaNs first. - "How do you count missing readings?"
sum(math.isnan(x) for x in readings), orseries.isna().sum()in pandas.
Scoring: Strong: Explains NaN ≠ NaN, the identity shortcut in containers, why the set has 3 elements, and fixes it with explicit missing-value handling. Acceptable: Knows NaN ≠ NaN but is surprised by nan in [nan], then reasons it out from the identity hint. Red flag: Says the set has 2 elements and can't explain why when shown 3.
JR-TH-MEM-01 · When is a local object freed?
Question: Inside a function you build a large object (say a 500 MB DataFrame) and return only a row count. When is that memory released? What if you had also appended it to a module-level list?
Answer:
CPython uses reference counting: every object tracks how many references point to it (names, list slots, attributes, closures). When the function returns, its local names disappear. If a local name was the only reference, the count drops to zero and the object is freed immediately: "freed temp" prints before "count: 3".
If something longer-lived also refers to it (a global list, a cache, an attribute of a long-lived object), it stays alive until that reference goes away: "freed cached" only appears after cache.clear(). Module-level lists and dicts that only ever grow are the usual reason a long-running job's memory keeps climbing.
A separate cyclic garbage collector cleans up objects that only reference each other.
__del__ is used here only to show the moment of freeing. Immediate freeing is a CPython detail (PyPy differs), so release files and connections with with blocks, not by relying on this.
class Report:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"freed {self.name}")
def row_count():
report = Report("temp") # only a local name refers to it
return 3
cache = []
def keep():
report = Report("cached")
cache.append(report) # a second reference outlives the call
return 5
print("count:", row_count())
print("kept:", keep())
cache.clear()
print("end")
Output:
freed temp
count: 3
kept: 5
freed cached
end
Follow-ups:
- "How can you see an object's reference count?"
sys.getrefcount(obj). It's one higher than you expect, because the argument itself is a temporary reference. - "Does
del xfree the object?" It removes the namex. The object is freed only if that was the last reference.
Scoring: Strong: Explains reference counting, immediate freeing on return, and that extra references (globals, caches) keep objects alive. Acceptable: Knows objects are freed 'when no longer used' and gets the global-list case with a hint. Red flag: Thinks memory is freed only when the program exits, or that return copies the object.
JR-TH-MEM-02 · sys.getsizeof is shallow
Question: You check sys.getsizeof(rows) on a list of 1 lakh long strings, get about 800 KB, and conclude the data is small. Is that right?
Answer:
Trap: "getsizeof reports the memory of the object and everything inside it." It's shallow. For a container it counts only the container itself: a header plus one 8-byte pointer per element on 64-bit builds. It doesn't count the objects those pointers refer to.
A list of three 1 MB strings reports under 1 KB, exactly the same as a list of three one-character strings. 1 lakh pointers is about 800 KB, whatever the strings contain.
To estimate the real size, add the container and its elements, recursing into nested containers and counting shared objects only once.
Practical tools:
df.memory_usage(deep=True)for pandas (deep=Trueis required for string columns).arr.nbytesfor NumPy.tracemallocto measure what a block of code actually allocated.pympler.asizeof(third-party) for deep sizes of Python objects.
import sys
short = ["a", "b", "c"]
long = ["x" * 1_000_000, "y" * 1_000_000, "z" * 1_000_000]
print(sys.getsizeof(short) == sys.getsizeof(long))
print(sys.getsizeof(long) < 1_000)
deep = sys.getsizeof(long) + sum(sys.getsizeof(s) for s in long)
print(deep > 3_000_000)
Output:
False
True
True
Follow-ups:
- "Why can summing element sizes overcount?" The same object can appear many times (e.g. a repeated string, or interned strings); count each object once, e.g. by tracking
id(). - "What does
df.memory_usage()withoutdeep=Truereport for a string column?" Only the 8-byte pointers per row, not the string objects, so it badly underestimates.
Scoring: Strong: Says getsizeof is shallow, explains pointer slots, and names memory_usage(deep=True), nbytes or tracemalloc. Acceptable: Suspects the number is too low and reasons to 'it doesn't count the strings' with a hint. Red flag: Trusts the 800 KB figure for capacity planning.
JR-TH-MEM-03 · Shrinking a pandas DataFrame
Question: A pandas DataFrame of orders uses several times more RAM than the CSV on disk. Why, and how do you reduce it?
Answer:
Default dtypes are wasteful:
- String columns are
objectdtype: each cell is a pointer to a separate Pythonstrobject, with roughly 50 bytes of overhead each. - Integers default to
int64and floats tofloat64.
Measure first with df.memory_usage(deep=True) or df.info(memory_usage="deep").
Ways to reduce it:
- Low-cardinality text columns (city, status, state) →
category. Pandas then stores small integer codes plus one copy of each label; here the city column shrinks more than 50×. - Downcast numbers with
pd.to_numeric(..., downcast="integer"). Here it givesint32, because the maximum, 99,999, doesn't fit inint16. Check the value range first. - Set dtypes and columns at load time (
usecols=,dtype={"city": "category"}) so peak memory never reaches the bloated version. - Parse dates into
datetime64. - If it still doesn't fit, process in chunks with
chunksize=.
Caveats: category is bad for high-cardinality columns (IDs, emails), downcast floats lose precision, and small integer types can overflow in later arithmetic.
import pandas as pd
cities = ["Mumbai", "Bengaluru", "Hyderabad", "Chennai"] * 25_000
df = pd.DataFrame({"city": cities, "amount": range(100_000)})
before = df.memory_usage(deep=True)
df["city"] = df["city"].astype("category")
df["amount"] = pd.to_numeric(df["amount"], downcast="integer")
after = df.memory_usage(deep=True)
print(df["city"].dtype, df["amount"].dtype)
print(before["city"] // after["city"] > 50)
print(before["amount"], after["amount"])
Output:
category int32
True
800000 400000
Follow-ups:
- "When does
categorymake things worse?" When almost every value is unique: you pay for codes and all the labels. Also, assigning a value that isn't an existing category raises an error until you add it. - "How do you find which columns to fix?"
df.memory_usage(deep=True).sort_values()together withdf.nunique()to spot low-cardinality text columns. - "The CSV is 20 GB. Is dtype tuning enough?" Probably not. Read in chunks and aggregate, or use a tool that processes data lazily or out of core (DuckDB, Polars, a database).
Scoring: Strong: Explains object-dtype overhead, measures with deep=True, applies category, downcasting and load-time dtypes, and knows the caveats. Acceptable: Knows category and usecols but can't explain why object columns are big. Red flag: Suggests del on unused variables or gc.collect() as the main fix.
JR-TH-MEM-04 · A tiny NumPy slice holding 80 MB
Question: You slice 5 values out of a 10-million-element NumPy array, del the big array, and keep the slice. How much memory is still held?
Answer:
Trap: "Only the 5 values (40 bytes), since the big array was deleted." Basic slicing (big[:5], big[::2], big[:, 0]) returns a view: a new small array object that points into the original buffer and keeps a reference to it in .base. del big only removes the name; the 80 MB buffer stays alive as long as sample exists.
The real bug: an ML or data pipeline keeps "just a few rows" or one column of a huge array in a list, a cache or a model attribute, and memory never drops.
Fix: call .copy() when you keep a small part of a big array beyond its lifetime. Then sample.base is None and it owns only its 40 bytes. Check with arr.base or np.shares_memory(a, b).
Views also mean writing to the slice changes the original. Fancy and boolean indexing (a[[1, 2]], a[mask]) return copies. In pandas, a small slice of a large DataFrame can likewise keep the parent's data alive; call .copy() on subsets you keep.
import numpy as np
big = np.zeros(10_000_000) # ~80 MB of float64
sample = big[:5] # a view, not a copy
del big
print(sample.base is not None, sample.base.nbytes)
big = np.zeros(10_000_000)
sample = big[:5].copy() # owns its own 40 bytes
del big
print(sample.base is None, sample.nbytes)
Output:
True 80000000
True 40
Follow-ups:
- "What else can keep a big array alive after
del?" Another name or container, a closure, a cache, an exception traceback that references the frame, or Jupyter's output history (_,Out[n]). - "Is
big[big > 0]a view?" No. Boolean indexing always returns a new array (a copy).
Scoring: Strong: Explains views and .base, predicts that 80 MB stays alive, fixes with .copy(), and knows which indexing makes copies. Acceptable: Knows slices are views once reminded, and connects that to memory with a hint. Red flag: Believes del big frees the buffer regardless of other references.
JR-TH-MEM-05 · Reference cycles and the garbage collector
Question: Two objects refer to each other, and you delete all your names for them. Are they freed? Explain what Python's garbage collector does, where cycles come from in real code, and how to avoid them.
Answer:
Reference counting alone can't free a cycle. a.peer → b and b.peer → a, so each count stays at 1 even after del a, b, and nothing is printed. CPython's cyclic garbage collector handles this: it periodically scans container objects, finds groups reachable only from each other, and frees them. gc.collect() forces a run, after which both are freed.
The collector is generational: new objects are checked often, and objects that survive are checked less often. Runs are triggered by allocation counts, so in a normal program (gc is enabled by default; the snippet disables it to make the timing visible) cycles are freed later, not immediately. Since 3.4, cycles containing objects with __del__ are collected too.
Where cycles come from:
- Parent ↔ child links (tree nodes, doubly linked lists, ORM relationships).
- An object storing one of its own bound methods as a callback.
- Exceptions:
e.__traceback__→ frame → locale. That's why Python deletes the name inexcept ... as e:at the end of the block.
To avoid them, use weakref.ref for back-references (child → parent), break links explicitly when done, and don't keep exceptions or tracebacks around longer than needed.
import gc
gc.disable() # so nothing is collected before we ask
freed = []
class Node:
def __init__(self, name):
self.name = name
self.peer = None
def __del__(self):
freed.append(self.name)
a, b = Node("a"), Node("b")
a.peer, b.peer = b, a # a reference cycle
del a, b
print("after del:", sorted(freed))
gc.collect()
print("after gc.collect():", sorted(freed))
Output:
after del: []
after gc.collect(): ['a', 'b']
Follow-ups:
- "Does
gc.collect()give memory back to the operating system?" Not necessarily. Python's allocator keeps freed memory for reuse, so the process size may not shrink. - "How do you see what the collector is doing?"
gc.collect()returns the number of unreachable objects found;gc.get_count()andgc.get_stats()show generation counts and collection history. - "Is calling
gc.disable()a good optimisation?" Rarely. Cycles then accumulate until the next explicit collect; only do it with measurements and a plan to collect manually.
Scoring: Strong: Explains why refcounting misses cycles, how the generational collector finds them, gives real sources of cycles, and suggests weakrefs. Acceptable: Knows a GC exists for cycles but can't say when it runs or where cycles come from. Red flag: Thinks reference counting frees cycles, or that Python has no garbage collector.
JR-TH-MEM-06 · WeakValueDictionary is not a cache
Question: To stop a session cache from growing forever, a colleague changes it from a dict to weakref.WeakValueDictionary. When does that help, and what are the surprises?
Answer:
Trap: "Weak references give you a cache that drops old entries when memory gets tight." A weak reference doesn't keep its object alive, and that's all it does. An entry in a WeakValueDictionary disappears as soon as no strong reference exists anywhere else: not under memory pressure, not least-recently-used, but immediately (in CPython).
It works well as a registry of objects that are alive anyway. For example, one Session per active user, held by in-flight requests: lookups return that same instance, and the entry vanishes once nothing else uses it.
It's useless as a cache for values only the cache holds: "rohan" vanished right after insertion because nothing else referenced it.
More surprises:
- Instances of many built-in types can't be weakly referenced:
int,str,tuple,listanddictall raiseTypeError. You can't store plain dicts or strings as values (subclasses oflistanddictwork). - Entries can disappear between
if key in cacheandcache[key], so usecache.get(key).
For a real bounded cache, use functools.lru_cache(maxsize=...), a TTL cache (e.g. cachetools.TTLCache), or Redis, all of which evict by size or time.
import weakref
class Session:
def __init__(self, user):
self.user = user
cache = weakref.WeakValueDictionary()
active = Session("kavya")
cache["kavya"] = active
cache["rohan"] = Session("rohan") # nothing else holds this one
print(sorted(cache.keys()))
del active
print(sorted(cache.keys()))
try:
weakref.ref({"user": "kavya"})
except TypeError as exc:
print(exc)
Output:
['kavya']
[]
cannot create weak reference to 'dict' object
Follow-ups:
- "What is
WeakKeyDictionaryfor?" Attaching extra data to objects you don't own (e.g. per-request metadata) without keeping those objects alive; the entry goes when the key object dies. - "Why might the same code behave differently on PyPy?" PyPy doesn't use reference counting, so dead objects (and their weak entries) disappear only when its GC runs, not immediately.
Scoring: Strong: Explains weak vs strong references, the registry-not-cache use case, the built-in type restriction, and suggests a size/TTL cache instead. Acceptable: Knows weak references don't keep objects alive, but expects entries to stay until memory is low. Red flag: Believes WeakValueDictionary is an LRU cache.
JR-TH-MLE-01 · Batch vs online inference
Question: What's the difference between batch (offline) inference and online (real-time) inference? Give an example of when you'd use each.
Answer:
Batch inference: a scheduled job scores many records at once and writes the predictions to a table or file that applications read later.
- Examples: nightly churn scores for every customer for the retention team; weekly demand forecasts per store and SKU; pre-computed "recommended for you" lists.
- Pros: simple (a script or pipeline job), efficient (vectorised over large batches, can run on cheap spare capacity), easy to rerun and inspect, no latency pressure.
- Cons: predictions are as stale as the last run, and you score records that may never be used.
Online inference: a service (e.g. a FastAPI endpoint) scores a request when it arrives and returns the prediction within milliseconds.
- Examples: fraud check on a UPI payment, a credit decision during a loan application, ranking search results for this query.
- Pros: uses the freshest inputs (this transaction, this session), and only scores what's needed.
- Cons: has to meet a latency target and stay available; features must be available at request time; it needs monitoring, scaling and on-call.
How to choose: if the inputs are known in advance and a prediction a few hours old is fine, use batch. If the prediction depends on something that happens at request time, or the answer is needed immediately, use online. A common hybrid: heavy features computed in batch and stored, plus a light online model that combines them with request data.
Follow-ups:
- "Your batch job writes churn scores at 2 a.m. IST. What should consumers check before using them?" That the run succeeded and is recent: a
scored_attimestamp or run ID per row, plus the model version, so a failed run isn't silently replaced by yesterday's scores. - "Why must online features be computed the same way as training features?" If serving code computes a feature differently from the training pipeline (different window, fill value or encoding), the model sees inputs it was never trained on. This training/serving skew degrades predictions without any error.
- "What's the danger of loading the model inside the request handler?" Every request pays the loading time (often seconds) and memory. Load once at startup and reuse it.
Scoring: Strong: Defines both with realistic examples, compares freshness, latency, cost and operational burden, chooses by when inputs are known and how fresh predictions must be, and mentions hybrids. Acceptable: Explains scheduled vs per-request scoring with an example of each, without the trade-offs. Red flag: Thinks online is always better, or confuses batch inference with mini-batch training.
JR-TH-MLE-02 · Can't get attribute on module __main__ when loading a model
Question: A pipeline saved with joblib.dump in your notebook loads fine there. In the inference service, joblib.load fails with AttributeError: Can't get attribute 'clean_text' on <module '__main__' ...>. The file was copied correctly. What happened?
Answer:
Trap: "The file got corrupted" or "pickle saves the whole function, so it should just work."
Pickle (and joblib, which uses pickle) stores functions and classes by reference: the module name and the attribute name, not their code. A function defined in a notebook or a script you run directly lives in the module __main__, so the file records "__main__.clean_text". The snippet shows the name is in the bytes. On load, pickle imports that module and looks up the name. In the service, __main__ is the server's entry script, which has no clean_text, so loading fails. (Unlike this demo, the real message also shows the module's file path.)
The same applies to custom transformer classes and any helper the pipeline references. Lambdas and nested functions can't be pickled at all (PicklingError).
Fix:
- Put custom preprocessing code in an importable module or package (e.g.
features/text.py) and import it in the notebook before fitting, so the pickle recordsfeatures.text.clean_text. - Install that same package in the serving environment.
- Pin library versions (scikit-learn, numpy) between training and serving; pickles of library objects may not load, or may warn and behave differently, across versions.
- Save metadata with the artefact: library versions, git commit, feature list.
Pickle files run code when loaded, so only load files your own pipeline produced.
import pickle
def clean_text(s):
return s.strip().lower()
model = {"weights": [0.4, 0.6], "preprocess": clean_text}
blob = pickle.dumps(model)
# The function is stored by reference (module + name), not as code
print(b"__main__" in blob, b"clean_text" in blob)
del clean_text # simulate loading in a process that doesn't define it
try:
pickle.loads(blob)
except AttributeError as e:
print("AttributeError:", str(e).split(" on ")[0])
Output:
True True
AttributeError: Can't get attribute 'clean_text'
Follow-ups:
- "Could you fix it by copying
clean_textinto the server'smain.py?" Only if the server is started aspython main.py(so that file is__main__). Under uvicorn or gunicorn,__main__is the server's own module, so it breaks again. It's fragile either way; use a shared package. - "If you later change the body of
clean_textin the package, what does the old model file use?" The new code, because only the name was saved. Changing preprocessing silently changes old models' behaviour, so version preprocessing code together with the model. - "Name a format that doesn't depend on Python code paths." ONNX for many model types (and skops for scikit-learn with safer loading). Tree models in XGBoost or LightGBM have their own native save formats (JSON or text).
Scoring: Strong: Explains storage by reference (module + name), why __main__ differs between notebook and server, fixes it with an importable package installed in both places, and adds version pinning and metadata. Acceptable: Knows the function must be importable when loading and suggests moving it to a module, without explaining __main__. Red flag: Re-uploads the file, or suggests retraining inside the service on every start.
JR-TH-MLE-03 · Train, validation and test sets
Question: Why do we split data into training, validation and test sets instead of just train and test? How do you decide how to split?
Answer:
Each set has one job:
- Training set: the model learns its parameters (weights, tree splits) from it.
- Validation set: you use it to make choices: hyperparameters, features, early stopping, the decision threshold, which model to ship.
- Test set: touched once, at the end, to estimate how the chosen model performs on unseen data.
With only train and test, every choice you make by looking at test scores slowly fits the test set too. After 50 experiments, the best test score is optimistic: you've selected the configuration that happened to suit those particular rows. A held-back test set keeps one honest number.
How to split:
- Size: commonly 70/15/15 or 80/10/10. With little data, use k-fold cross-validation on train+validation for choices, and still hold out a test set.
- Classification with rare classes: stratify (
train_test_split(..., stratify=y)) so every split has the same class balance. A test set with three fraud cases can't tell you much. - Time-dependent data (demand, churn, fraud): split by time (train on January–September, validate on October, test on November), never shuffle, so you evaluate on the future the way production will.
- Repeated entities (many rows per customer or patient): keep each entity on one side (
GroupKFold, or a hash of the ID), or the model is rewarded for memorising customers. - Fix
random_stateso splits are reproducible, and fit all preprocessing (scalers, imputers, encoders) on the training part only.
Follow-ups:
- "Your test score is much lower than your validation score. What could explain it?" Overfitting to the validation set through many choices; a distribution difference (test is from a later period, or a different region); a leaky feature that helped on validation; or a test set that's small and noisy. Investigate before 'fixing' by tuning on test.
- "When is k-fold cross-validation the wrong tool?" For time series (folds would train on the future; use
TimeSeriesSplitinstead), and when entities repeat across rows without group-aware folds. It's also expensive for large models. - "Can you retrain on train + validation + test before deploying?" Often yes, once the configuration is fixed, to use all the data. But you then have no untouched estimate for that exact model, so keep the earlier test result as the reported number and monitor in production.
Scoring: Strong: Explains each set's role and why tuning on test inflates scores, and adapts splitting to imbalance (stratify), time (temporal split) and repeated entities (group split), with preprocessing fit on train only. Acceptable: Explains train/validation/test roles and stratification, but treats every dataset with a random split. Red flag: Uses the test set for tuning, or shuffles time-series data before splitting.
JR-TH-MLE-04 · A model that degrades with no errors in the logs
Question: A loan-approval model has served for three months. The API returns 200 OK with p99 latency well under target, and there are no exceptions. The business says approval quality has got noticeably worse. How can the model be failing when the service is healthy, and what should you monitor?
Answer:
Trap: "If the endpoint returns 200s quickly and logs no errors, the model is fine."
Service health and model health are different things. A model fails silently: it always returns a number. Common causes:
- Data/input drift: the inputs change. A new partner app sends annual income in a field trained on monthly income, a new city appears, a field starts arriving empty and gets the default fill value.
- Concept drift: the relationship itself changes, e.g. repayment behaviour after an interest-rate change or a festival season.
- Upstream bugs: a renamed field, a changed unit, a feature pipeline that stopped updating, so features are stale.
What to monitor:
- Inputs: per feature, null rate, unknown-category rate, min/max/mean, and a distribution comparison with training data. One common score is the Population Stability Index (PSI) over binned values. A rule of thumb: below 0.1 is stable, 0.1–0.25 is worth a look, above 0.25 is a real shift. In the snippet, week 1 is stable; by week 12 the income distribution has moved hugely (PSI ≈ 1.1), pointing straight at the annual-vs-monthly bug.
- Outputs: the distribution of scores and the approval rate per day. A sudden jump is a cheap early alarm.
- Outcomes: accuracy or default rates once labels arrive. For loans that's weeks or months later, which is why input and output monitoring matter.
Log model version, inputs (minus personal data) and predictions for every request so you can investigate, alert on thresholds, and treat retraining as a response to a diagnosed cause, not a reflex.
import numpy as np
def psi(expected_share, actual_share, eps=1e-4):
"""Population Stability Index between two binned distributions."""
e = np.clip(np.asarray(expected_share, dtype=float), eps, None)
a = np.clip(np.asarray(actual_share, dtype=float), eps, None)
return float(np.sum((a - e) * np.log(a / e)))
# Share of applicants per monthly-income band: <25k, 25-50k, 50-100k, >100k
training = [0.20, 0.40, 0.30, 0.10]
week_1 = [0.21, 0.39, 0.30, 0.10]
week_12 = [0.05, 0.15, 0.30, 0.50] # a partner app now sends annual income
for name, live in [("week 1", week_1), ("week 12", week_12)]:
print(f"{name}: PSI = {psi(training, live):.3f}")
Output:
week 1: PSI = 0.001
week 12: PSI = 1.097
Follow-ups:
- "Would retraining on the last three months fix the annual-income problem?" No. It would teach the model to accept a unit bug. Fix the input (convert or reject at the API boundary with validation), then decide whether retraining is needed.
- "Why do the bins for live data have to be the ones from training?" PSI compares shares in the same buckets. Recomputing bin edges on live data (e.g. live quantiles) would make every bucket hold the same share and hide the drift.
- "What would input validation at the API have caught here?" A range check (monthly income above, say, ₹50 lakh is suspicious) would have flagged or rejected annual figures on day one, instead of three months of bad decisions.
Scoring: Strong: Separates service health from model health, names input drift, concept drift and upstream bugs, and proposes monitoring inputs (nulls, ranges, PSI), outputs and delayed outcomes, with logging and validation. Acceptable: Mentions data drift and comparing live distributions with training, without output monitoring or label delay. Red flag: Says a healthy API means a healthy model, or that the only remedy is to retrain on a schedule.
JR-TH-MLE-05 · Turning a notebook model into a prediction API
Question: You have a trained house-price model in a notebook. Walk me through turning it into a prediction API that other teams can call.
Answer:
1. Package the artefact. Save the model and its preprocessing as one object (a scikit-learn Pipeline), with a version string, the training data snapshot, metrics, the feature list and the library versions. Move any custom code from the notebook into an importable package.
2. Load once at startup. Use FastAPI's lifespan (or a module-level load) so the model is read once per worker, not on every request. Each worker process holds its own copy, which matters for memory with large models.
3. Define the contract with Pydantic. A request model with types and ranges (area_sqft: float = Field(gt=100, le=20_000)), and a response model. Bad input then gets a 422 with a clear detail before your code runs, instead of a nonsense prediction. Return the model version with each prediction.
4. Keep the endpoint simple and correct. Build the feature row in the exact training order, from validated fields. Model prediction is CPU work, so use a plain def endpoint; FastAPI runs it in a threadpool. An async def endpoint that does heavy CPU work blocks the event loop for every other request.
5. Operability. Add a /health endpoint, structured logs of inputs (no personal data), prediction and latency, pinned dependencies and a Docker image. If callers need many predictions, add a batch endpoint.
6. Test it. TestClient tests for a valid request, invalid input (422) and a known example whose prediction matches the notebook's.
The snippet is a minimal version: the model loads once, valid requests get a price and version, and a negative area is rejected with 422 pointing at area_sqft.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
def load_model():
print("loading model once")
return lambda area, rooms: round(4500 * area + 250_000 * rooms)
state = {}
@asynccontextmanager
async def lifespan(app):
state["model"] = load_model()
yield
state.clear()
app = FastAPI(lifespan=lifespan)
class HouseIn(BaseModel):
area_sqft: float = Field(gt=100, le=20_000)
bedrooms: int = Field(ge=1, le=10)
class PriceOut(BaseModel):
price_inr: int
model_version: str
@app.post("/predict", response_model=PriceOut)
def predict(house: HouseIn):
price = state["model"](house.area_sqft, house.bedrooms)
return PriceOut(price_inr=price, model_version="2026-03-v2")
with TestClient(app) as client:
print(client.post("/predict", json={"area_sqft": 1200, "bedrooms": 2}).json())
print(client.post("/predict", json={"area_sqft": 1100, "bedrooms": 3}).json())
bad = client.post("/predict", json={"area_sqft": -5, "bedrooms": 2})
print(bad.status_code, bad.json()["detail"][0]["loc"])
Output:
loading model once
{'price_inr': 5900000, 'model_version': '2026-03-v2'}
{'price_inr': 5700000, 'model_version': '2026-03-v2'}
422 ['body', 'area_sqft']
Follow-ups:
- "Why does the snippet use
with TestClient(app) as client:?" Thewithblock runs the app's lifespan startup and shutdown. Without it, the startup code never runs andstate["model"]would be missing. - "How do you roll out a new model version safely?" Deploy it alongside the old one, compare predictions on the same traffic (shadow mode) or send a small percentage to it, watch metrics, then switch. Keep the old artefact so you can roll back.
- "The model takes 2 GB of RAM and you run 4 uvicorn workers. What happens?" Each worker loads its own copy, so 8 GB. Use fewer workers with threads, a dedicated model server, or a smaller or quantised model.
Scoring: Strong: Covers packaging with preprocessing and version, loading once via lifespan, Pydantic request/response with ranges and 422s, sync vs async for CPU work, health checks, logging, pinned environment and tests. Acceptable: Loads the model at startup, defines a Pydantic input and a POST endpoint, and returns predictions, missing versioning or the async pitfall. Red flag: Loads the model per request, accepts unvalidated dicts, or calls heavy predict code inside async def without understanding the blocking.
JR-TH-MLE-06 · 98% accuracy on a fraud model
Question: Your fraud model scores 98% accuracy on a test set where 2% of transactions are fraud. Is it a good model?
Answer:
Trap: "98% is excellent."
You can't tell from accuracy. A "model" that predicts not fraud for every transaction is also 98% accurate on this data, and it catches zero frauds (the snippet). With heavy class imbalance, accuracy is dominated by the majority class.
What to look at instead:
- The baseline: always compare with the majority-class predictor (98% here) and with the current rule-based system. A model has to beat those to be worth deploying.
- The confusion matrix: how many frauds were caught (true positives), missed (false negatives), and how many genuine customers were flagged (false positives).
- Recall (share of frauds caught) and precision (share of flagged transactions that are really fraud), and PR-AUC as a threshold-free summary that focuses on the rare class.
- The threshold:
predict()uses 0.5 by default, which is rarely right for rare events. Choose it from business costs: a missed ₹50,000 fraud versus the cost of blocking a genuine payment and annoying the customer.
Also check the evaluation itself: a stratified or time-based split so the test set actually contains frauds, and enough of them; 20 frauds makes every metric noisy. During training, class weights or resampling (applied to the training set only) help the model pay attention to the rare class.
import numpy as np
y_true = np.array([1] * 20 + [0] * 980) # 2% fraud
y_pred = np.zeros_like(y_true) # "model" that always says not fraud
accuracy = (y_pred == y_true).mean()
tp = int(((y_pred == 1) & (y_true == 1)).sum())
recall = tp / int((y_true == 1).sum())
print(f"accuracy={accuracy:.1%} recall={recall:.0%} frauds caught={tp}/20")
Output:
accuracy=98.0% recall=0% frauds caught=0/20
Follow-ups:
- "Why might ROC-AUC still look good when the model is weak at catching fraud?" ROC uses the false-positive rate over the huge negative class, so many false alarms still look like a small rate. PR-AUC uses precision, which shows that flood of false alarms directly.
- "You undersampled genuine transactions to 50/50 for training. What happens to the predicted probabilities?" They're inflated: the model learned a 50% base rate instead of 2%. Rankings may still be fine, but treat the scores as uncalibrated: recalibrate, or choose thresholds on a validation set with the real class balance.
- "Precision is 0.30 and recall is 0.80. Explain that to a product manager." We catch 80% of fraud, and for every 10 transactions we block, about 3 are actually fraud and 7 are genuine customers who get inconvenienced. Raising the threshold trades some caught fraud for fewer false alarms.
Scoring: Strong: Shows the all-negative baseline gets the same accuracy, uses confusion matrix, precision, recall and PR-AUC, sets the threshold from business costs, and checks that the test set has enough positives. Acceptable: Knows accuracy misleads under imbalance and names precision and recall, without baselines or threshold choice. Red flag: Accepts 98% accuracy as proof of a good model, or oversamples before splitting.
JR-TH-MOD-01 · The different forms of import
Question: What's the difference between import json, from json import loads, import pandas as pd and from module import *? Which do you prefer and why?
Answer:
import jsonbinds the module object to the namejson; you writejson.loads(...), so it's always clear where a name comes from.from json import loadsbinds just that one attribute in your namespace. It's a copy of the reference taken at import time.import pandas as pdis the same asimport pandas, under an alias; use the community conventions (pd,np).from module import *imports every public name: those listed in__all__, or else every name not starting with_. It clutters the namespace, can silently overwrite your own names, and hides where things come from. Linters flag it, and it's best kept to the interactive shell.
In every case the module's code runs only on the first import; later imports reuse the cached module from sys.modules.
Prefer import module or explicit from module import name. Put imports at the top of the file, grouped as stdlib, third-party, then local (PEP 8); tools like isort or ruff do this automatically.
Follow-ups:
- "What does
__all__do?" It lists the namesfrom module import *exports, and documents the module's public API. - "Where does Python look for the module?" Along
sys.path: the script's directory (or current directory),PYTHONPATH, the standard library, then the active environment'ssite-packages.
Scoring: Strong: Explains what each form binds, why * is discouraged, and that modules are cached after first import. Acceptable: Knows the syntax differences but not that a module only runs once. Red flag: Thinks from x import y imports only part of the file's code, or uses import * by habit.
JR-TH-MOD-02 · A file named after a library
Question: A developer creates statistics.py (or random.py, email.py, pandas.py) in their project folder. Now import statistics followed by statistics.mean(...) fails with AttributeError. What's going on?
Answer:
Trap: "Python always picks the standard library or installed package first." The directory of the script you run (or the current directory for python -m and the REPL) comes first on sys.path, before the standard library and site-packages. So your statistics.py is imported instead of the stdlib module: its top-level code runs ("my statistics.py ran"), and mean doesn't exist, giving AttributeError: module 'statistics' has no attribute 'mean'.
Variants:
- Your
random.pyitself doesimport random: you get "partially initialized module 'random' has no attribute ... (most likely due to a circular import)". - A file named after a module some other library imports (
email.py,logging.py,typing.py) can break that library in confusing ways.
Diagnose with print(statistics.__file__). Fix by renaming the file, and avoid naming files after stdlib modules or your dependencies. Python 3.13 adds a hint to the error message when it detects this kind of shadowing.
from pathlib import Path
# Simulate a beginner's script folder that contains a file named statistics.py
Path("statistics.py").write_text('print("my statistics.py ran")\n')
import statistics
print(statistics.__file__.endswith("statistics.py"), hasattr(statistics, "mean"))
Output:
my statistics.py ran
True False
Follow-ups:
- "How do you see the search order?"
python -c "import sys; print(sys.path)". The first entry is the script directory or''(the current directory). - "How does a
src/layout help?" Your package isn't importable just because you're in the repo root, so tests run against the installed package and stray files at the root can't shadow anything.
Scoring: Strong: Explains sys.path[0] precedence, predicts the side-effect print and the AttributeError, and diagnoses with __file__. Acceptable: Suspects the filename once asked what's in the folder, and renames it. Red flag: Reinstalls Python or the package to fix it.
JR-TH-MOD-03 · Virtual environments and reproducible installs
Question: What problem does a virtual environment solve? Walk me through setting one up for a new project and making it reproducible for a teammate.
Answer:
A venv is a directory with its own python executable (a link to a base interpreter) and its own site-packages. Packages installed into it don't touch the system Python or other projects, so project A can use pandas 1.5 while project B uses pandas 2.2.
Workflow:
python -m venv .venvcreates it.source .venv/bin/activate(on Windows,.venv\Scripts\activate) activates it. Activation only puts.venv/binfirst onPATH; you can also call.venv/bin/pythondirectly.python -m pip install ...installs packages.python -m pipguarantees pip installs into the interpreter you'll actually run.- Record dependencies. Declare direct dependencies in
pyproject.toml(orrequirements.txt), and for an application pin exact versions with a lock file (pip freeze > requirements.txt, pip-tools, Poetry or uv). - Commit those files, never the
.venvfolder: add it to.gitignore, because venvs are large, platform-specific and contain absolute paths.
A teammate then runs python -m venv .venv and python -m pip install -r requirements.txt (or uv sync / poetry install). To check which environment you're in, look at sys.prefix or which python.
Follow-ups:
- "Pinning for a library vs an application?" A library declares compatible ranges (
pandas>=2.0,<3) so it can coexist with other packages. An application pins exact versions in a lock file for reproducible deploys. - "Can you copy a venv to another machine or folder?" No. Scripts inside it contain absolute paths and the binaries are platform-specific; recreate it from the requirements instead.
- "What does
pip freezeinclude that you might not want?" Every installed package, including transitive dependencies and tools you installed by hand, all mixed together. Lock-file tools keep direct and resolved dependencies separate.
Scoring: Strong: Explains isolation, the create → activate → python -m pip → lock → commit flow, and the library vs app pinning difference. Acceptable: Can create and use a venv and freeze requirements, but is vague on what activation does. Red flag: Installs everything globally with sudo pip, or commits the .venv folder.
JR-TH-MOD-04 · Installed, but ModuleNotFoundError
Question: You run pip install httpx, it says "Successfully installed", but python app.py (or your Jupyter notebook) still raises ModuleNotFoundError: No module named 'httpx'. What's going on?
Answer:
Trap: "The package is broken, so reinstall it" (or restart the machine). Almost always, the pip you ran belongs to a different Python from the one running your code:
- the venv isn't activated in this terminal, so
pipis the system one; - several Python versions are installed (
pipfor 3.11, code running on 3.12); - the IDE or Jupyter kernel is using another interpreter.
Diagnose by comparing python -c "import sys; print(sys.executable)" with pip --version, which prints which Python's site-packages it installs into.
Fix:
- Always use
python -m pip install ...with the samepythonyou run. - In Jupyter,
%pip install httpxinstalls into the kernel's environment. - In VS Code, select the venv's interpreter.
Other causes worth checking: the install name differs from the import name (scikit-learn → import sklearn, beautifulsoup4 → bs4, python-dotenv → dotenv), or a local file is shadowing the package.
Follow-ups:
- "Why does
python -m pipavoid this?" It runs the pip module of that exact interpreter, so packages land in thesite-packagesthat interpreter reads. - "Same symptom inside a Docker image?" Same cause: e.g. installing with one Python while the entry point runs another, or installing into a venv the container doesn't activate. Use the same interpreter path for both.
Scoring: Strong: Immediately suspects an interpreter mismatch, diagnoses with sys.executable and pip --version, and fixes with python -m pip / %pip. Acceptable: Reaches 'wrong environment' after a couple of guesses. Red flag: Keeps reinstalling, or copies the package folder into the project by hand.
JR-TH-MOD-05 · Laying out a project so imports just work
Question: How do you structure a small Python project (a package, its tests, and a CLI entry point) so that imports work the same way in tests, scripts and production, without sys.path hacks?
Answer:
Put the code in a real package and install it.
Layout:
src/billing/__init__.py,src/billing/invoices.py,src/billing/cli.pytests/test_invoices.pypyproject.tomlat the root, declaring the name, dependencies and build backend.
Install it into your venv in editable mode: python -m pip install -e .. Now import billing works from anywhere (tests, notebooks, scripts), and edits take effect without reinstalling.
Imports: inside the package, use absolute imports (from billing.invoices import total) or explicit relative ones (from .invoices import total).
Running: run entry points as modules (python -m billing.cli), or declare a console script under [project.scripts] so billing becomes a command. Don't run files inside the package by path (python src/billing/cli.py): that file becomes __main__ with no parent package, so relative imports fail, and its folder goes on sys.path.
Avoid sys.path.append(...): it depends on the current directory and breaks in CI, cron and Docker.
The src/ layout means tests exercise the installed package, which catches packaging mistakes (missing files, wrong package names) before release. A lighter option for tests only is pytest's pythonpath = ["src"] setting.
Follow-ups:
- "What does
__init__.pydo?" It marks a regular package and runs when the package is first imported. It's a good place to re-export the public API. Without it you get an implicit namespace package, which works but behaves differently (no single location, easy to create by accident). - "What does 'editable' install mean?" pip installs a pointer (a
.pthfile or import hook) to your source directory instead of copying it, so changes are visible immediately. - "Where do dependencies go?"
[project] dependenciesinpyproject.toml; dev-only tools (pytest, ruff) in an optional group such as[project.optional-dependencies] dev.
Scoring: Strong: Describes package + pyproject + editable install, python -m or console scripts, why running by path breaks imports, and why sys.path hacks are fragile. Acceptable: Knows __init__.py and python -m, but relies on running from the repo root instead of installing. Red flag: Adds sys.path.insert(0, '..') to every file, or copies modules into the tests folder.
JR-TH-MOD-06 · Config read at import time
Question: settings.py sets GST_RATE = float(os.environ.get("GST_RATE", "0.18")) at module level. A test sets os.environ["GST_RATE"] = "0.05" (or uses monkeypatch.setenv) and then calls the billing code, but it still uses 0.18. Why?
Answer:
Trap: "Environment variables are read live, so setting one in the test is enough." Module-level code runs once, at the first import, and the module is then cached in sys.modules. GST_RATE was computed at that moment and is now just a float; changing os.environ later doesn't recompute it. The function that reads the variable at call time sees the new value.
If any earlier test, conftest.py or imported module already imported settings, the test's change is too late. Whether the test passes then depends on test order, the classic flaky test.
Fixes:
- Read config at call time, or build a settings object at startup and pass it in. For example, FastAPI
Depends(get_settings)with@lru_cacheonget_settings; tests callget_settings.cache_clear()or override the dependency. - In tests, patch the value where it's used:
monkeypatch.setattr(settings, "GST_RATE", 0.05). If another module didfrom settings import GST_RATE, it has its own copy, so patch that module's name. importlib.reload(settings)is a last resort: modules that already copied names keep the old values.
General rule: keep import-time side effects small. No DB connections, network calls, or reading values that tests need to vary.
import os
from pathlib import Path
Path("settings.py").write_text(
"import os\n"
"GST_RATE = float(os.environ.get('GST_RATE', '0.18'))\n"
"def gst_rate():\n"
" return float(os.environ.get('GST_RATE', '0.18'))\n"
)
import settings
os.environ["GST_RATE"] = "0.05" # e.g. a test sets the env var after import
print(settings.GST_RATE) # read once, at import time
print(settings.gst_rate()) # read at call time
Output:
0.18
0.05
Follow-ups:
- "Why does
from settings import GST_RATEmake patching harder?" It copies the binding into the importing module at import time, so patchingsettings.GST_RATEdoesn't affect it. Patchbilling.GST_RATEinstead, or useimport settingsand readsettings.GST_RATEat call time. - "Pydantic's
BaseSettingsreads env vars. Same issue?" It reads them when an instance is created (it lives in the separatepydantic-settingspackage in v2). A module-level instance has the same import-time problem; create it via a cached function you can reset in tests.
Scoring: Strong: Explains run-once import and the sys.modules cache, sees the test-order flakiness, and fixes it with call-time config or patching where the name is used. Acceptable: Gets the import-time reason with a hint and suggests importlib.reload. Red flag: Concludes monkeypatch.setenv is broken, or sets env vars in every test file's top level as a workaround.
JR-TH-MUT-01 · `festival = base_prices` is not a copy
Question: You write festival = base_prices and then change festival["tea"] to 15 for a sale. What happens to base_prices, and how do you get an independent copy?
Answer:
Assignment never copies. It binds a second name to the same dict, so base_prices["tea"] is now 15 as well, and festival is base_prices is True. This is aliasing: one mutable object, two names.
To get a separate object, copy explicitly:
- dict:
base_prices.copy(),dict(base_prices)or{**base_prices} - list:
items.copy(),list(items)oritems[:] - nested data (dicts of lists, lists of dicts):
copy.deepcopy(obj), because the copies above are shallow and still share the inner objects.
Aliasing only bites with mutable objects. With a str or an int you can't change the object in place, so "changing" one name always rebinds it to a new object.
base_prices = {"tea": 20, "coffee": 35}
festival = base_prices # alias, not a copy
festival["tea"] = 15
print(base_prices["tea"], festival is base_prices)
menu = {"tea": 20, "coffee": 35}
promo = menu.copy() # new dict
promo["tea"] = 15
print(menu["tea"], promo is menu)
Output:
15 True
20 False
Follow-ups:
- "Is
promo = menu.copy()enough if each value is a list of sizes?" No. The outer dict is new, but each list is shared, sopromo["tea"].append(...)changesmenutoo. Usecopy.deepcopyor rebuild the inner lists. - "How would you check whether two names share an object while debugging?"
a is b(or compareid(a)andid(b)). For nested data, check the inner objects too, for examplea["tea"] is b["tea"].
Scoring: Strong: Says assignment binds a name, predicts the shared change, gives copy options, and mentions shallow versus deep. Acceptable: Knows .copy() fixes it but doesn't mention nested data. Red flag: Believes = copies the dict.
JR-TH-MUT-02 · `cities += [...]` versus `cities = cities + [...]` with an alias
Question: shared = cities, then cities += ["Kochi"]. Does shared see Kochi? Would cities = cities + ["Kochi"] behave the same?
Answer:
Trap: "a += b is just shorthand for a = a + b." For lists it isn't.
cities += [...]callslist.__iadd__, which extends the existing list in place (like.extend()) and rebindscitiesto that same list.sharedis the same object, so it sees Kochi.cities = cities + [...]builds a new list and rebinds onlycities.sharedstill points to the old two-element list.
For immutable types (str, int, tuple) there is no __iadd__, so += falls back to + and always creates a new object. That's why code += "-KA" never affects an alias. Same operator, different behaviour depending on whether the type is mutable.
cities = ["Pune", "Delhi"]
shared = cities
cities += ["Kochi"]
print(shared)
cities = ["Pune", "Delhi"]
shared = cities
cities = cities + ["Kochi"]
print(shared, cities)
code = "IN"
alias = code
code += "-KA"
print(alias, code)
Output:
['Pune', 'Delhi', 'Kochi']
['Pune', 'Delhi'] ['Pune', 'Delhi', 'Kochi']
IN IN-KA
Follow-ups:
- "Inside a function,
def add(tags): tags += ["new"]. Does the caller's list change?" Yes, because+=mutates the list the caller passed.tags = tags + ["new"]would leave the caller's list alone. - "What does
cities += "Goa"do?"extendaccepts any iterable, so it adds the characters'G','o','a'.cities = cities + "Goa"raisesTypeErrorinstead, sincelist + strisn't defined.
Scoring: Strong: Explains in-place __iadd__ versus building a new list, predicts both cases, and contrasts immutable types. Acceptable: Gets the list case right but can't explain why strings differ. Red flag: Treats the two forms as identical.
JR-TH-MUT-03 · Functions that modify the caller's data
Question: A helper add_gst(items) updates each item's price in place and returns the list. A teammate calls it on the cart before showing a preview, and the checkout total is now taxed twice. What's the design problem and how would you fix it?
Answer:
Python passes a reference to the same object, so a function that mutates its argument changes the caller's data. Returning the same list makes it worse: the caller can't tell from the call site (billed = add_gst(cart)) that cart was modified too, so applying it again during checkout adds GST twice.
Pick one clear contract:
- Return new data and leave the input alone (the default for most helpers): build new dicts, for example
[{**item, "price": ...} for item in items]. Safe to call twice and easy to test. - Mutate in place and return
None, likelist.sort(), and name it so it's obvious (apply_gst_inplace). Use this only when copying is too expensive.
Also watch for nested data: {**item} copies one level only, so any inner list is still shared.
def add_gst(items):
for item in items:
item["price"] = round(item["price"] * 1.18, 2)
return items
cart = [{"sku": "pen", "price": 10.0}]
billed = add_gst(cart)
print(cart[0]["price"], billed is cart)
def with_gst(items):
return [{**item, "price": round(item["price"] * 1.18, 2)} for item in items]
cart = [{"sku": "pen", "price": 10.0}]
billed = with_gst(cart)
print(cart[0]["price"], billed[0]["price"])
Output:
11.8 True
10.0 11.8
Follow-ups:
- "How would a test catch the original bug?" Keep a deep copy of the input, call the function, and
assert cart == original. Or assert that calling it twice gives the same result as calling it once. - "Where does the same question come up in pandas?" Functions that take a DataFrame and add columns modify the caller's frame. Work on
df.copy()or return a new frame fromdf.assign(...).inplace=Trueis discouraged for the same reason.
Scoring: Strong: Names the mutation-plus-return problem, gives both contracts with when to use each, and mentions shallow copies. Acceptable: Fixes it by copying but doesn't articulate a convention. Red flag: Thinks Python passes a copy, so the function can't affect the caller.
JR-TH-MUT-04 · `dict.fromkeys(cities, [])` shares one list
Question: You initialise orders = dict.fromkeys(["Chennai", "Indore"], []) and then append an order ID to the Chennai list. What does orders contain?
Answer:
Trap: "Only Chennai gets the order." Both cities show it: {'Chennai': ['ORD-1'], 'Indore': ['ORD-1']}.
dict.fromkeys(keys, value) evaluates value once and stores that same object under every key. With an immutable value like 0 or None that's harmless. With a list, every key aliases one list, so appending through any key shows up under all of them.
Fixes that create a new list per key:
- a dict comprehension:
{city: [] for city in cities}(the[]is evaluated on each iteration) collections.defaultdict(list), which builds a new list the first time each key is touchedorders.setdefault(city, []).append(order_id)while you fill it
cities = ["Chennai", "Indore"]
orders = dict.fromkeys(cities, [])
orders["Chennai"].append("ORD-1")
print(orders)
orders = {city: [] for city in cities}
orders["Chennai"].append("ORD-1")
print(orders)
Output:
{'Chennai': ['ORD-1'], 'Indore': ['ORD-1']}
{'Chennai': ['ORD-1'], 'Indore': []}
Follow-ups:
- "How would you confirm the aliasing quickly?"
orders["Chennai"] is orders["Indore"]returnsTrue. - "Is
dict.fromkeys(cities, 0)safe for counters?" Yes. Ints are immutable, soorders[city] += 1rebinds that key to a new int rather than changing a shared object.
Scoring: Strong: Predicts the shared list, explains the value is evaluated once, and gives the comprehension or defaultdict fix. Acceptable: Predicts it correctly but only offers one fix. Red flag: Expects Indore's list to stay empty and can't explain the result when shown.
JR-TH-MUT-05 · Returning a cached mutable object
Question: load_config(env) is decorated with @lru_cache and returns a dict. One request handler appends a feature flag to the returned config for its own use. What happens to later requests, and how do you make the cache safe?
Answer:
lru_cache stores the object it returned and hands every later caller that same object. The handler's "local" change is made to the cached dict, so every later call to load_config("prod") sees the extra flag, for the life of the process. This is a nasty production bug because it depends on which request ran first and disappears when you restart.
Ways to make it safe:
- Return a copy from a thin wrapper: cache a private
_load(env)and haveget_config(env)returncopy.deepcopy(_load(env)). Callers can then change their copy freely. Deep-copying costs a little time per call. - Cache immutable data: a frozen dataclass, tuples instead of lists,
types.MappingProxyTypefor a read-only dict view. Mutations then raise errors instead of corrupting shared state (proxies are read-only only one level deep). - Agree a convention that callers never mutate the result, and back it with a test.
The same aliasing applies to any module-level cache dict, class attributes and default arguments.
import copy
from functools import lru_cache
@lru_cache(maxsize=None)
def load_config(env):
print(f"loading {env}")
return {"env": env, "features": ["upi"]}
cfg = load_config("prod")
cfg["features"].append("beta-checkout") # one caller customises "its" config
print(load_config("prod")) # every later caller sees it
@lru_cache(maxsize=None)
def _load(env):
print(f"loading {env}")
return {"env": env, "features": ["upi"]}
def get_config_safe(env):
return copy.deepcopy(_load(env))
cfg = get_config_safe("dev")
cfg["features"].append("beta-checkout")
print(get_config_safe("dev"))
Output:
loading prod
{'env': 'prod', 'features': ['upi', 'beta-checkout']}
loading dev
{'env': 'dev', 'features': ['upi']}
Follow-ups:
- "Why is
cfg.copy()in the wrapper not enough here?" It's shallow. Thefeatureslist would still be the cached one, soappendwould still leak. You needdeepcopy, or copy the nested list too. - "How would you write a test that catches this?" Call the function, mutate the result, call it again, and assert the second result is unchanged. Call
load_config.cache_clear()in a fixture so tests don't share cached state.
Scoring: Strong: Explains that the cache returns the same object, describes the cross-request leak, and offers a copy or immutability fix, noting shallow versus deep. Acceptable: Spots the shared object but proposes only a shallow copy. Red flag: Thinks lru_cache returns a fresh copy each time.
JR-TH-MUT-06 · Class attributes with `append` versus `+=`
Question: class Tracker: tags = []; hits = 0. On instance a you call a.tags.append("mumbai") and a.hits += 1. What do b.tags and b.hits show for another instance b?
Answer:
Trap: "Both are shared" or "neither is shared". b.tags is ['mumbai'] but b.hits is still 0.
a.tags.append(...)readsa.tags. Lookup finds notagsin the instance, falls back to the class, and mutates the one list that every instance shares.a.hits += 1readsTracker.hits(0), computes 1, then assignsa.hits = 1. Assignment through an instance always creates an instance attribute, which now shadows the class attribute foraonly.
The bug to remember: a mutable class attribute is shared state across all instances (and across requests in a web app). Put per-instance containers in __init__ (self.tags = []), or in a dataclass use field(default_factory=list). A dataclass refuses a bare [] default with a ValueError for exactly this reason.
class Tracker:
tags = []
hits = 0
a, b = Tracker(), Tracker()
a.tags.append("mumbai")
a.hits += 1
print(b.tags, b.hits, a.hits)
print("hits" in vars(a), "tags" in vars(a))
Output:
['mumbai'] 0 1
True False
Follow-ups:
- "What would
a.tags += ["pune"]do?" Both things:list.__iadd__extends the shared class list in place, and thena.tags = <that list>creates an instance attribute pointing to the same shared list. So every instance still sees the change. - "When is a class-level constant fine?" When it's immutable and meant to be shared, such as
MAX_RETRIES = 3or a tuple of allowed states, and you never mutate it.
Scoring: Strong: Predicts both values and explains mutation through lookup versus assignment creating an instance attribute; gives the __init__ or default_factory fix. Acceptable: Gets the list right but guesses wrong on hits, then reasons it out when shown vars(a). Red flag: Can't explain the difference between class and instance attributes.
JR-TH-NUM-01 · int(), round(), floor() and ceil()
Question: What do int(x), round(x), math.floor(x) and math.ceil(x) return for 3.7 and for -3.7? And what does int("3.5") do?
Answer:
int(x)truncates toward zero:3and-3.round(x)rounds to the nearest integer:4and-4(exact halves are a special case, see the follow-up).math.floor(x)goes down (toward minus infinity):3and-4.math.ceil(x)goes up:4and-3.
All four return an int here. They only differ in direction, and the difference shows up with negative numbers, which is where bugs hide (refunds, temperature, time offsets). Pick the one that matches the business rule: "number of full boxes" is floor, "number of boxes needed" is ceil.
int("3.5") raises ValueError: int() on a string accepts only an integer literal. Convert with float() first if decimals are allowed, or validate the input.
import math
for x in [3.7, -3.7]:
print(x, int(x), round(x), math.floor(x), math.ceil(x))
print(int("42"), float("3.5"))
try:
int("3.5")
except ValueError as e:
print("ValueError:", e)
Output:
3.7 3 4 3 4
-3.7 -3 -4 -4 -3
42 3.5
ValueError: invalid literal for int() with base 10: '3.5'
Follow-ups:
- "How many delivery vans of capacity 12 do you need for 50 parcels, without importing
math?"-(-50 // 12)= 5, or(50 + 12 - 1) // 12.math.ceil(50 / 12)is clearer when float precision isn't a concern. - "What does
round(2.5)return?"2: Python rounds exact halves to the nearest even number (banker's rounding).
Scoring: Strong: All eight values right, explains truncation vs floor for negatives, and knows int("3.5") raises. Acceptable: Positive cases right; works out the negative ones with a moment's thought. Red flag: Thinks int() rounds, or that floor and int are the same for negative numbers.
JR-TH-NUM-02 · round(2.5) and round(2.675, 2)
Question: What do round(2.5) and round(2.675, 2) return? A product manager expects "round half up" on invoice amounts. How do you give them that?
Answer:
Trap: 3 and 2.68. They are 2 and 2.67, for two different reasons:
round()uses round half to even (banker's rounding): an exact.5goes to the nearest even number, soround(0.5)is0,round(1.5)andround(2.5)are both2,round(3.5)is4. This avoids a systematic upward bias when many values are rounded.2.675can't be stored exactly in binary; the float is actually2.67499999999999982236..., slightly below the half, so it rounds down. It's not a half at all.
For money with a business rounding rule, use Decimal built from strings and quantize with an explicit mode: Decimal("2.675").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) gives 2.68. Keep floats out of the calculation entirely, and round once at the defined step (per line item or per invoice, as finance specifies), not repeatedly.
print(round(0.5), round(1.5), round(2.5), round(3.5))
print(round(2.675, 2))
print(f"{2.675:.20f}")
from decimal import Decimal, ROUND_HALF_UP
print(Decimal("2.675").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
print(Decimal("2.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP))
Output:
0 2 2 4
2.67
2.67499999999999982236
2.68
3
Follow-ups:
- "Does f-string formatting like
f"{x:.2f}"follow the same rules?" Yes: it rounds the true binary value, also half to even on exact ties, sof"{2.675:.2f}"is'2.67'. And it only changes the displayed text, not the stored value. - "Does NumPy's
np.roundbehave the same way?" Yes, NumPy also rounds halves to even, sonp.round(2.5)is2.0. pandas'.round()uses NumPy, so it does too.
Scoring: Strong: Gives 2 and 2.67, separates banker's rounding from the binary representation issue, and uses Decimal from strings with ROUND_HALF_UP. Acceptable: Knows about banker's rounding; needs a hint for 2.675. Red flag: Answers 3 and 2.68 and suggests adding 0.0001 before rounding as the fix.
JR-TH-NUM-03 · Storing and adding up prices
Question: You're building the order total for a checkout service. Prices arrive from the catalogue API as strings like "0.10" and "499.00". How do you store and add them, and why not just use float?
Answer:
Floats are binary fractions, and most decimal amounts (0.10, 0.20) have no exact binary representation. Each value carries a tiny error, so 0.1 + 0.2 gives 0.30000000000000004. After many additions and comparisons, totals can be off by a paisa, equality checks fail, and reconciliation with the payment gateway breaks.
Two standard approaches:
- Integer paise (smallest currency unit): store
49900for ₹499.00. Integer arithmetic is exact and fast, databases handle it well, and you convert to rupees only for display. Most payment gateways' APIs already use the smallest unit. Decimalbuilt from the string (Decimal("0.10")), with an explicit rounding step usingquantize. Good when you need fractional paise during calculations (tax, discounts) before rounding.
Never build a Decimal from a float (Decimal(0.1) captures the binary error exactly), and never mix the two types: Decimal + float raises TypeError. In the database use NUMERIC/DECIMAL or an integer column, not FLOAT.
from decimal import Decimal
prices = ["0.10", "0.20"] # rupees, as strings from the API
print(float(prices[0]) + float(prices[1]))
print(sum(Decimal(p) for p in prices))
print(Decimal(0.1)) # built from a float: too late
paise = [int(Decimal(p) * 100) for p in prices]
print(paise, sum(paise), f"₹{sum(paise) / 100:.2f}")
Output:
0.30000000000000004
0.30
0.1000000000000000055511151231257827021181583404541015625
[10, 20] 30 ₹0.30
Follow-ups:
- "Where is it fine to use
float?" Measurements and statistics where tiny relative errors don't matter: model features, averages, percentages for a dashboard. Compare those withmath.iscloseorpytest.approx, never==. - "How do you split ₹100 three ways in paise so nothing is lost?"
divmod(10000, 3)gives(3333, 1): two people pay 3333 paise and one pays 3334, so the parts sum to exactly 10000.
Scoring: Strong: Explains binary representation error, offers integer paise and Decimal from strings, warns about Decimal(float) and mixing types, and mentions the database type. Acceptable: Knows float is wrong for money and suggests Decimal; misses the from-string detail. Red flag: Uses float and "fixes" it with round() everywhere, or doesn't see why 0.1 + 0.2 isn't 0.3.
JR-TH-NUM-04 · What int() and float() accept from strings
Question: A form field "quantity" is parsed with float(raw) and checked with if qty < 0: reject. Which inputs get through that you probably didn't intend? How do int() and float() differ on strings like " 42 ", "1,250", "10.0", "1e3" and "nan"?
Answer:
Trap: "float() only accepts normal numbers, so the < 0 check is enough." Both functions are more liberal than people expect:
- Both accept surrounding whitespace (
" 42 "), a sign, and underscores between digits ("4_2"is 42). - Neither accepts thousands separators:
"1,250"raisesValueError, which is a real issue for Indian-formatted input like"1,25,000". int()rejects"10.0"and"1e3";float()accepts both.float()accepts"nan","inf","-inf","infinity"in any case.nan < 0isFalse, so NaN passes the negative check and then poisons totals (sumbecomesnan);infpasses too.
For user input: decide the exact format you accept, strip and remove separators deliberately, parse with the narrowest function (int for counts), then check math.isfinite(x) and the range. Better still, declare the field in a Pydantic model (qty: int = Field(ge=1, le=100)), so parsing and range checks happen in one place. (By default Pydantic rejects NaN and infinity for an int field; a float field needs allow_inf_nan=False.)
for raw in ["42", " 42 ", "4_2", "1,250", "10.0", "1e3", "nan", "-inf"]:
results = []
for conv in (int, float):
try:
results.append(repr(conv(raw)))
except ValueError:
results.append("ValueError")
print(f"{raw!r:8} int={results[0]:12} float={results[1]}")
Output:
'42' int=42 float=42.0
' 42 ' int=42 float=42.0
'4_2' int=42 float=42.0
'1,250' int=ValueError float=ValueError
'10.0' int=ValueError float=10.0
'1e3' int=ValueError float=1000.0
'nan' int=ValueError float=nan
'-inf' int=ValueError float=-inf
Follow-ups:
- "Why is
if qty < 0 or qty > 100: rejectstill not enough for floats?" Every comparison with NaN isFalse, so NaN passes both checks. Testmath.isfinite(qty)first, or write the check positively:if not (0 <= qty <= 100): reject, which rejects NaN. - "How would you parse
"1,25,000"safely?" Remove commas explicitly (raw.replace(",", "")) only for the fields where that format is expected, thenint(); or validate the shape with a regex first so typos like"1,2,5"are rejected rather than silently accepted.
Scoring: Strong: Knows float("nan") and "inf" parse and slip through comparisons, lists the int/float differences, and proposes isfinite, range checks or a Pydantic field. Acceptable: Knows the int/float differences on "10.0" and commas; surprised by NaN but understands the comparison problem once told. Red flag: Thinks a try/except ValueError around float() is complete validation.
JR-TH-NUM-05 · Python ints never overflow; NumPy and pandas numbers can
Question: In plain Python, 2_000_000_000 * 2 is fine. Can the same calculation go wrong in NumPy or pandas? And why can a 16-digit order ID change by one after loading it into a DataFrame?
Answer:
Python's int is arbitrary precision: it grows as needed and never overflows. NumPy and pandas store numbers in fixed-size machine types (int32, int64, float64), so two things can happen:
- Integer overflow wraps around silently. In an
int32array,2_000_000_000 * 2doesn't fit in 32 bits and becomes-294967296: no exception, and array operations don't even warn. This bites withint32columns (common on Windows builds of older NumPy, in Parquet files, or afterastype("int32")to save memory), counts, sums of paise, and nanosecond timestamps. Useint64(or check value ranges) for anything that can grow. float64holds integers exactly only up to 2^53 (about 9 × 10^15). A column of large IDs containing a missing value is converted tofloat64(because classicint64can't holdNaN), and 9007199254740993 becomes 9007199254740992. Your join on order ID then quietly misses rows.
Remedies: read ID columns as strings (dtype={"order_id": "string"}); use pandas' nullable Int64 dtype when missing values are possible; keep counts and money in int64; and check df.dtypes after loading instead of trusting inference.
import numpy as np
import pandas as pd
big = 2_000_000_000
print(big * 2) # Python int: no overflow
arr = np.array([big], dtype=np.int32)
print(arr * 2, (arr * 2).dtype) # wraps silently
print(arr.astype(np.int64) * 2)
order_id = 9007199254740993 # 2**53 + 1
s = pd.Series([order_id, None]) # missing value forces float64
print(s.dtype, int(s.iloc[0]), int(s.iloc[0]) == order_id)
Output:
4000000000
[-294967296] int32
[4000000000]
float64 9007199254740992 False
Follow-ups:
- "Why didn't NumPy raise an error on the overflow?" Array arithmetic uses the CPU's fixed-width integer operations, which wrap around; checking every element would be slow. NumPy warns only for some scalar operations, so don't rely on it.
- "How do you check the safe range of a dtype?"
np.iinfo(np.int32).maxis 2147483647;np.iinfo(np.int64).maxis about 9.2 × 10^18.
Scoring: Strong: Explains fixed-width wraparound vs Python's big ints, the 2^53 float limit and why NaN forces float64, and gives concrete fixes (string IDs, Int64, check dtypes). Acceptable: Knows NumPy uses fixed-size types and can overflow; needs a hint for the float precision loss of IDs. Red flag: Assumes NumPy behaves like Python ints, or believes floats store any integer exactly.
JR-TH-NUM-06 · Converting rupees to paise with int()
Question: A payment integration needs the amount in paise, and the code does int(amount * 100) where amount is a float like 4.35. Tests with 499.0 and 10.5 pass. What does it send for 4.35?
Answer:
Trap: "435, because 4.35 × 100 is 435." 4.35 is stored as the nearest binary fraction, slightly below 4.35, so 4.35 * 100 evaluates to 434.99999999999994. int() truncates, it doesn't round, so the result is 434: the customer is charged one paisa less, and the amount no longer matches the order, so reconciliation fails. It's intermittent: 1.15, 19.99 and 0.29 are hit too, but 499.0 and 10.5 are exactly representable, which is why the tests passed.
Two separate fixes:
- Round instead of truncate if you must start from a float:
round(amount * 100)gives 435, because the error is tiny compared with 0.5. - Better, don't use floats for money at all: parse the amount as
Decimalfrom its original string, or keep paise as an integer from the start, so there's nothing to round.int(Decimal("4.35") * 100)is exactly 435.
The general lesson: int(float_value) is only safe when the float is known to be a whole number; after any arithmetic, round explicitly. Add test cases with amounts like 4.35 and 19.99, not just round numbers.
from decimal import Decimal
for rupees in [4.35, 1.15, 19.99, 0.29]:
paise = rupees * 100
print(rupees, repr(paise), int(paise), round(paise))
print(int(Decimal("4.35") * 100))
Output:
4.35 434.99999999999994 434 435
1.15 114.99999999999999 114 115
19.99 1998.9999999999998 1998 1999
0.29 28.999999999999996 28 29
435
Follow-ups:
- "Is
round(amount * 100)always safe?" For normal rupee amounts, yes in practice, since the float error is far smaller than half a paisa. But if the value can have fractional paise (after tax or discount), you need an explicit rounding rule, which meansDecimalwithquantize. - "Where else does
int()truncation cause bugs?" Converting durations (int(elapsed_seconds / 60)for minutes), percentages to integers, and pixel or index calculations after division. Useround,math.floor, or//deliberately.
Scoring: Strong: Predicts 434, explains representation error plus truncation, knows why round-number tests miss it, and recommends Decimal from strings or integer paise, with round() as a stop-gap. Acceptable: Suspects float error once told the answer is not 435; fixes it with round(). Red flag: Insists the result is 435, or proposes int(amount * 100 + 0.0001)-style patches.
JR-TH-OOP-01 · What super().__init__() is for
Question: A subclass defines its own __init__. What does calling super().__init__(...) do, and what happens if you forget it?
Answer:
Defining __init__ in the subclass replaces the parent's __init__; Python does not call the parent's automatically. super().__init__(...) runs the parent's initialiser on the same object, so the attributes the parent sets up (owner, balance) exist. Then the subclass adds its own.
If you forget it, the object is created without the parent's attributes, and the error appears later, the first time any code touches them: AttributeError: 'SavingsBroken' object has no attribute 'balance'. Usually you call super().__init__ first, then set the subclass's extra attributes. If the subclass adds nothing to initialisation, don't define __init__ at all and the parent's is used.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
class SavingsBroken(Account):
def __init__(self, owner, rate):
self.rate = rate # forgot super().__init__
class Savings(Account):
def __init__(self, owner, rate, balance=0):
super().__init__(owner, balance) # parent sets owner and balance
self.rate = rate
s = Savings("Anil", 0.035, 10_000)
print(s.owner, s.balance, s.rate)
b = SavingsBroken("Meena", 0.035)
print(b.rate)
print(b.balance)
Output:
Anil 10000 0.035
0.035
AttributeError: 'SavingsBroken' object has no attribute 'balance'
Follow-ups:
- "Why
super().__init__(...)rather thanAccount.__init__(self, ...)?" Both work here, butsuper()doesn't hard-code the parent's name and follows the method resolution order correctly when there is multiple inheritance or mixins. - "Can a subclass override a regular method and still use the parent's version?" Yes: call
super().method(...)inside the override, e.g. to add logging around the parent behaviour.
Scoring: Strong: Explains that the subclass __init__ replaces the parent's, predicts the later AttributeError, and knows when not to define __init__ at all. Acceptable: Knows super().__init__ must be called; unsure exactly what error appears without it. Red flag: Believes the parent's __init__ runs automatically, or cannot explain what super() refers to.
JR-TH-OOP-02 · Two __init__ methods in one class
Question: A developer coming from Java writes two __init__ methods in Invoice: one taking amount, one taking amount, currency. Does Python pick the right one based on the arguments?
Answer:
Trap: "Yes, like constructor overloading." Python has no overloading by signature. A class body runs top to bottom like ordinary code, and the second def __init__ simply rebinds the name, replacing the first. Only the last definition exists, so Invoice(500) fails with TypeError: ... missing 1 required positional argument: 'currency'. Linters flag it (flake8/ruff: redefinition of unused name), but Python itself raises nothing at definition time.
The Python ways to get the same flexibility:
- Default arguments:
def __init__(self, amount, currency="INR"). - Keyword-only parameters for optional settings, so call sites stay readable.
- Alternative constructors with
@classmethod:Invoice.from_paise(49_900),Invoice.from_dict(row). Each has a clear name, andcls(...)builds subclasses correctly.
(typing.overload exists, but it only describes several signatures to type checkers; there is still one real implementation.)
class Invoice:
def __init__(self, amount):
self.amount = amount
self.currency = "INR"
def __init__(self, amount, currency): # replaces the first __init__
self.amount = amount
self.currency = currency
print(Invoice(500, "USD").currency)
try:
Invoice(500)
except TypeError as e:
print("TypeError:", e)
class Invoice2:
def __init__(self, amount, currency="INR"):
self.amount = amount
self.currency = currency
@classmethod
def from_paise(cls, paise, currency="INR"):
return cls(paise / 100, currency)
print(Invoice2(500).currency, Invoice2.from_paise(49_900).amount)
Output:
USD
TypeError: Invoice.__init__() missing 1 required positional argument: 'currency'
INR 499.0
Follow-ups:
- "Does the same "last definition wins" rule apply to two methods with the same name anywhere in a class, or two functions in a module?" Yes. A
defis an assignment to a name; a second one with the same name replaces the first wherever it appears. - "Why
@classmethodrather than@staticmethodforfrom_paise?" It receivescls, soSubInvoice.from_paise(...)builds aSubInvoice. A static method would have to hard-codeInvoice2(...).
Scoring: Strong: Says the second definition replaces the first, predicts the TypeError, and offers defaults and @classmethod alternative constructors. Acceptable: Unsure at first but reasons that a def rebinds the name; suggests default arguments. Red flag: Expects Python to dispatch on argument count, or "fixes" it by adding a third __init__.
JR-TH-OOP-03 · What @dataclass generates, and default_factory
Question: What does @dataclass write for you? Why does tags: list = [] inside a dataclass raise an error, and what do you write instead?
Answer:
From the annotated class attributes, @dataclass generates __init__ (parameters in field order, with defaults), a readable __repr__, and __eq__ that compares the fields in order. Options add more: order=True for </>, frozen=True for read-only instances, slots=True for lower memory.
A default like [] is evaluated once, when the class is defined, so every instance would share one list: the same bug as a mutable default argument in a function. The dataclass machinery refuses list, dict and set defaults with a ValueError (it rejects any unhashable default) and tells you to use field(default_factory=list), which calls list() for each new instance.
Also: fields without defaults must come before fields with defaults, and type annotations are not validated: Order("1", 2) is accepted. Use Pydantic when data comes from outside and needs checking.
from dataclasses import dataclass, field
@dataclass
class Order:
order_id: int
customer: str
amount_paise: int = 0
tags: list[str] = field(default_factory=list)
o1 = Order(1, "Kavya", 49_900)
o2 = Order(1, "Kavya", 49_900)
o1.tags.append("express")
print(o1)
print(o2.tags, o1 == o2)
print(Order(1, "Kavya", 49_900, ["express"]) == o1)
try:
@dataclass
class Bad:
tags: list = []
except ValueError as e:
print("ValueError:", e)
Output:
Order(order_id=1, customer='Kavya', amount_paise=49900, tags=['express'])
[] False
True
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
Follow-ups:
- "How do you add a computed attribute, say
amount_rupees, or validation, to a dataclass?" A@propertyfor derived values;__post_init__for validation or fields computed from others (declared withfield(init=False)). - "Is a class attribute without an annotation, like
currency = "INR", a field?" No. Only annotated names become fields; an unannotated one stays a plain class attribute and is not in__init__,__repr__or__eq__.
Scoring: Strong: Lists __init__, __repr__, __eq__, explains the evaluated-once default and default_factory, and notes that annotations aren't validated. Acceptable: Knows the generated methods and default_factory; vague on why the shared default is a problem. Red flag: Thinks dataclasses validate types, or would "fix" the error by making the default None and never initialising it.
JR-TH-OOP-04 · A property setter that assigns to itself
Question: You turn price into a @property with a setter that rejects negative values. Inside the setter you write self.price = value, and in the getter return self.price. What happens when you create an object?
Answer:
Trap: "It stores the value in the instance like a normal attribute." A property lives on the class and takes over every obj.price read and every obj.price = ... assignment, including the ones inside its own getter and setter. So self.price = value in the setter calls the setter again, which assigns again, and so on until RecursionError: maximum recursion depth exceeded. The getter has the same problem.
The fix is to store the real value under a different name, by convention with a leading underscore: the setter writes self._price, the getter returns self._price. Assigning self.price = price in __init__ is then a good thing: it goes through the setter, so validation also runs when the object is created.
class Product:
def __init__(self, price):
self.price = price # goes through the setter
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("price cannot be negative")
self._price = value # store under a different name
class BrokenProduct:
def __init__(self, price):
self.price = price
@property
def price(self):
return self.price # calls the getter again
@price.setter
def price(self, value):
self.price = value # calls the setter again
p = Product(250)
print(p.price, vars(p))
try:
Product(-1)
except ValueError as e:
print("ValueError:", e)
try:
BrokenProduct(250)
except RecursionError as e:
print("RecursionError:", e)
Output:
250 {'_price': 250}
ValueError: price cannot be negative
RecursionError: maximum recursion depth exceeded
Follow-ups:
- "What happens if you define the getter but no setter, and then assign
p.price = 10?"AttributeError: the property is read-only (the message says it has no setter). That's how you make computed or read-only attributes. - "Why don't plain attributes need this underscore trick?" Without a property on the class,
self.price = valuejust stores into the instance__dict__; nothing intercepts it.
Scoring: Strong: Explains that the property intercepts its own assignments, predicts the RecursionError, fixes it with _price, and notes that __init__ should go through the setter. Acceptable: Sees the recursion once asked "what does self.price = ... call?"; fixes it correctly. Red flag: Thinks the property and the instance attribute are separate and both exist, or bypasses validation by writing self._price in __init__ without knowing why.
JR-TH-OOP-05 · Subclassing dict vs UserDict vs composition
Question: You need a dict that lowercases its keys (for HTTP headers). A teammate subclasses dict and overrides __setitem__. Why can that miss keys, and what are the better options? When do you choose composition over inheritance?
Answer:
Built-in types like dict and list are implemented in C, and their methods don't call each other through your overrides. dict.__init__, update() and setdefault() insert directly, without going through your __setitem__. So d["X-Request-ID"] = ... is lowercased but LowerKeyDict({"Content-Type": ...}) and d.update({...}) are not: the dict ends up with mixed-case keys and lookups fail randomly depending on how the key was added.
Options:
collections.UserDict(orUserList): a pure-Python wrapper whose methods (update,__init__,setdefault) all go through__setitem__, so one override covers everything.- Composition: a small class that holds a dict and exposes only the operations you need (
get(name),set(name, value)). Less surface area, nothing to bypass. - Inherit from
collections.abc.MutableMappingand implement the five abstract methods; you get the rest for free.
Rule of thumb: inherit when the new class really is a specialised version and every inherited method is still correct ("is-a"); use composition when you want to reuse behaviour but control the interface ("has-a"). Subclassing a built-in to change one method is usually the wrong choice. (For headers specifically, httpx.Headers already does this.)
from collections import UserDict
class LowerKeyDict(dict):
def __setitem__(self, key, value):
super().__setitem__(key.lower(), value)
class LowerKeyUserDict(UserDict):
def __setitem__(self, key, value):
super().__setitem__(key.lower(), value)
d = LowerKeyDict({"Content-Type": "json"})
d["X-Request-ID"] = "abc"
d.update({"Accept": "text"})
print(d)
u = LowerKeyUserDict({"Content-Type": "json"})
u["X-Request-ID"] = "abc"
u.update({"Accept": "text"})
print(u)
Output:
{'Content-Type': 'json', 'x-request-id': 'abc', 'Accept': 'text'}
{'content-type': 'json', 'x-request-id': 'abc', 'accept': 'text'}
Follow-ups:
- "With
LowerKeyUserDict, doesu["CONTENT-TYPE"]find the value?" Not yet: only__setitem__lowercases. Override__getitem__,__contains__and__delitem__too, so reads and deletes normalise the key the same way. - "What's the cost of
UserDictcompared withdict?" It's slower because every operation goes through Python-level methods, andisinstance(u, dict)is False, which some libraries check.
Scoring: Strong: Explains that C-level dict methods bypass the override, offers UserDict/MutableMapping/composition, and gives a clear is-a vs has-a rule. Acceptable: Recognises from the output that update and the constructor skip __setitem__; knows UserDict after a hint. Red flag: Fixes it by overriding update alone, assuming that covers every path, or thinks inheritance is always the right reuse tool.
JR-TH-OOP-06 · Double-underscore attributes are not private
Question: Wallet stores its balance as self.__balance to make it "private". A subclass PromoWallet adds cashback with self.__balance = self.balance() + amt. Does the balance go up? Is __balance actually private?
Answer:
Trap: "Double underscore makes it private, and the subclass updates the same attribute." Python has no private attributes. Inside a class body, a name like __balance is name-mangled, rewritten to _ClassName__balance using the class where the code is written. So Wallet's code reads and writes _Wallet__balance, while PromoWallet's code writes a different attribute, _PromoWallet__balance. The cashback lands in a new attribute, balance() still returns 100, and nothing raises.
It isn't privacy either: w._Wallet__balance reads it from anywhere. Mangling exists to avoid accidental name clashes between a class and its subclasses, which is exactly why it breaks code that expects to share the attribute.
Convention: use a single underscore (self._balance) for "internal, please don't touch"; subclasses can use it normally. Use double underscores only when you specifically want to avoid clashes with subclasses, e.g. in a base class meant for others to extend.
class Wallet:
def __init__(self):
self.__balance = 100 # stored as _Wallet__balance
def balance(self):
return self.__balance
class PromoWallet(Wallet):
def add_cashback(self, amt):
self.__balance = self.balance() + amt # stored as _PromoWallet__balance
w = PromoWallet()
w.add_cashback(50)
print(w.balance())
print(sorted(vars(w)))
print(w._Wallet__balance)
try:
print(w.__balance)
except AttributeError as e:
print("AttributeError:", e)
Output:
100
['_PromoWallet__balance', '_Wallet__balance']
100
AttributeError: 'PromoWallet' object has no attribute '__balance'
Follow-ups:
- "Why does
w.__balancefail outside the class, whileself.__balanceworks inside it?" Mangling only happens to names written inside a class body. Outside,w.__balancelooks for an attribute literally called__balance, which doesn't exist. - "Are names like
__init__mangled?" No. Names that both start and end with two underscores (dunders) are never mangled.
Scoring: Strong: Explains mangling to _ClassName__x per defining class, predicts the two separate attributes and the unchanged balance, and recommends a single underscore. Acceptable: Knows about mangling but needs to trace the subclass case with the interviewer. Red flag: Believes __x is enforced privacy, or expects an error at the subclass assignment.
JR-TH-SCO-01 · Local variables and module-level constants
Question: A module defines GST_RATE = 0.18, and invoice_total(amount) computes tax = amount * GST_RATE. Can the function read GST_RATE? Can code outside the function use tax?
Answer:
Yes and no.
- Names assigned inside a function are local: they exist only while that call runs and disappear when it returns. Reading
taxat module level raisesNameError. - A function can read a module-level (global) name without any declaration. When
GST_RATEisn't found locally, Python looks in the enclosing functions, then the module, then the built-ins.
Reading module-level constants this way is normal and fine. What you should avoid is functions that change module-level state. To get a value out of a function, return it rather than setting a global.
GST_RATE = 0.18 # module (global) scope
def invoice_total(amount):
tax = amount * GST_RATE # reads the global; `tax` is local
return amount + tax
print(invoice_total(1000))
try:
print(tax)
except NameError as e:
print("NameError:", e)
Output:
1180.0
NameError: name 'tax' is not defined
Follow-ups:
- "If the function also had a parameter named
GST_RATE, which value would it use?" The parameter. It's a local name, and local names are found first, so the global is shadowed inside that function. - "Why write constants in UPPER_CASE?" It's the PEP 8 convention that says 'don't reassign this'. Python doesn't enforce it, but readers and linters rely on it.
Scoring: Strong: Explains local lifetime, read access to globals, and the lookup order, and recommends returning values. Acceptable: Gets both answers right but can't describe the lookup order. Red flag: Thinks function-local variables are visible after the call, or that reading a global needs the global keyword.
JR-TH-SCO-02 · A loop variable left over from the previous batch
Question: A job loops over batches of orders and, after the inner for order in batch: loop, logs last order: {order}. One batch is empty. What does the log say for that batch?
Answer:
Trap: "It logs None or crashes." It logs the previous batch's last order.
A for loop doesn't create a scope. The loop variable is an ordinary name in the surrounding function or module and keeps whatever it was last bound to. If the iterable is empty, the loop body never runs and the name is never rebound, so it still holds the stale value. If the name was never bound at all, reading it raises NameError (UnboundLocalError inside a function).
Don't rely on a loop variable after the loop. Track what you need explicitly: initialise last = None before the loop, or use batch[-1] if batch else None.
batches = [["ORD-1", "ORD-2"], [], ["ORD-9"]]
for batch in batches:
for order in batch:
pass # process order
print(f"batch of {len(batch)} done, last order: {order}")
def last_item(items):
for item in items:
pass
return item
try:
last_item([])
except UnboundLocalError as e:
print("UnboundLocalError:", e)
Output:
batch of 2 done, last order: ORD-2
batch of 0 done, last order: ORD-2
batch of 1 done, last order: ORD-9
UnboundLocalError: cannot access local variable 'item' where it is not associated with a value
Follow-ups:
- "Do list comprehensions behave the same way?" No. In Python 3 a comprehension has its own scope, so its loop variable doesn't leak and can't go stale like this.
- "How could a linter help?" pylint warns when a loop variable is used after the loop (
undefined-loop-variable), and type checkers such as Pyright report a name that is possibly unbound.
Scoring: Strong: Predicts the stale value, explains that loops don't create a scope, and fixes it with an explicit variable. Acceptable: Knows loop variables persist after the loop but misses the empty-batch case. Red flag: Expects each loop to start with a fresh, empty variable.
JR-TH-SCO-03 · Replacing `global` state in a small script
Question: A data script keeps total_rows and failed_rows as module globals and updates them from several functions with global. A reviewer asks you to remove the globals. Why, and what would you replace them with?
Answer:
global works, but it makes the code harder to trust:
- Hidden inputs and outputs. You can't tell from a function's signature that it reads or changes counters, so the code is harder to read and reuse.
- Tests leak into each other. State survives between calls, so a test passes alone and fails after another test, unless every test resets the globals.
- Concurrency and re-runs. Two runs in one process (a scheduler, a notebook, a web worker) share and corrupt the counts.
Replacements, simplest first:
- Return values:
process(rows) -> (ok, failed)and let the caller add them up. - Pass a state object in: a small dataclass
RunStatspassed as an argument and updated withstats.failed += 1. Noglobalis needed, because you're changing an attribute, not rebinding a name. - A class that owns the state when several functions share it.
Module-level constants and objects that are set once (a logger, a compiled regex) are fine; the problem is mutable state that changes during the run.
Follow-ups:
- "Why doesn't
stats.failed += 1need aglobalornonlocaldeclaration?" The namestatsis only read, never rebound. The+=assigns to an attribute of the object, which doesn't change any scope. - "Is
logger = logging.getLogger(__name__)at module level a bad global?" No. It's set once and never rebound, and it's the standard pattern.
Scoring: Strong: Gives concrete costs (hidden dependencies, test leakage, re-runs) and at least two replacements, separating constants from changing state. Acceptable: Says "globals are bad practice" and suggests return values, without the testing angle. Red flag: Sees nothing wrong with mutable global state, or thinks removing global means the variables can't be read at all.
JR-TH-SCO-04 · An assignment in a branch that never runs
Question: With TAX = 0.18 at module level, price(amount, export=False) contains if export: TAX = 0.0 and then return amount * (1 + TAX). What does price(100) do?
Answer:
Trap: "export is False, so the assignment never runs and the function reads the global 0.18." It raises UnboundLocalError.
Whether a name is local is decided when the function is compiled, not when a line runs. Any assignment to TAX anywhere in the body (including +=, a for TAX in ... target, or an import) makes TAX local for the whole function. At runtime the branch is skipped, the local TAX is never set, and reading it fails.
Fixes: use a different local name (rate = 0.0 if export else TAX), or, if you really mean to change the module setting, declare global TAX (rarely the right design).
TAX = 0.18
def price(amount, export=False):
if export:
TAX = 0.0 # makes TAX local for the WHOLE function
return amount * (1 + TAX)
try:
print(price(100))
except UnboundLocalError as e:
print("UnboundLocalError:", e)
def price_fixed(amount, export=False):
rate = 0.0 if export else TAX
return amount * (1 + rate)
print(price_fixed(100), price_fixed(100, export=True))
Output:
UnboundLocalError: cannot access local variable 'TAX' where it is not associated with a value
118.0 100.0
Follow-ups:
- "Why does Python decide this at compile time?" So every name in a function has one fixed meaning. It makes local variable access fast (array slots instead of dict lookups) and avoids a name switching between global and local halfway through a call.
- "Would
print(TAX)as the first line of the function work?" No. It fails the same way, becauseTAXis local throughout the function, even before the assignment line.
Scoring: Strong: Predicts the error and explains that locality is decided per function at compile time; gives a clean fix. Acceptable: Predicts the error but explains it as "the global was overwritten". Red flag: Expects 118.0 and can't reason about why it fails.
JR-TH-SCO-05 · `from settings import DEBUG` keeps a stale value
Question: billing.py does from settings import DEBUG. A test sets settings.DEBUG = True, but billing still behaves as if debugging is off. Why, and how do you fix it?
Answer:
from settings import DEBUG runs once, at import time, and creates a new global name DEBUG in billing's namespace, bound to whatever object settings.DEBUG referred to at that moment. The two modules now have separate names. Rebinding settings.DEBUG later changes only the name in settings; billing.DEBUG still points to the old False.
Fixes:
- Import the module and look the attribute up when needed:
import settingsand thensettings.DEBUGinside the function. Every access then sees the current value. - In tests, patch the name where it's used:
monkeypatch.setattr(billing, "DEBUG", True)ormock.patch("billing.DEBUG", True). - Better still, pass configuration in as an argument or read it from a settings object, so code doesn't depend on import-time copies.
If the imported object is mutable (for example a dict FEATURES), changes to its contents are visible through both names. Only rebinding is not shared.
import sys, types
# Stand-in for a real settings.py module
settings = types.ModuleType("settings")
settings.DEBUG = False
sys.modules["settings"] = settings
from settings import DEBUG # binds a new name to the current object
import settings as s
s.DEBUG = True # e.g. a test flips the flag
print(DEBUG, s.DEBUG)
Output:
False True
Follow-ups:
- "Why does
mock.patch("settings.DEBUG", True)not help here?" It rebinds the name insettings, butbillinglooks up its ownDEBUGname. You must patch the namespace where the code looks the name up. - "Does
importlib.reload(settings)updatebilling.DEBUG?" No. Reload re-runssettingsand rebinds names inside it; names other modules already copied withfrom ... importstill point to the old objects.
Scoring: Strong: Explains that from import binds a separate name at import time, distinguishes rebinding from mutation, and patches where the name is used. Acceptable: Fixes it by switching to import settings without explaining the mechanism. Red flag: Believes from x import y creates a live link to x.y.
JR-TH-SCO-06 · Creating variables dynamically with `locals()`
Question: A junior wants variables col_0, col_1, col_2 created in a loop inside a function and writes locals()[f"col_{i}"] = i * 10. They tested the same trick at module level and it worked. Does it work inside the function?
Answer:
Trap: "It worked at module level, so it works everywhere." It doesn't work in a function.
- At module level,
locals()isglobals(), the module's real namespace dict, so writing to it really creates names. - In a function, local variables live in fixed slots chosen at compile time.
locals()returns a dictionary snapshot of them. Writing to it doesn't create or change real local variables (in 3.13+, PEP 667 makes it explicitly an independent snapshot). Also, since the compiler never saw an assignment tocol_0, a latercol_0in the function is compiled as a global lookup and raisesNameError.
The real fix is simple: when names are data, use a dict (columns[f"col_{i}"]) or a list (columns[i]). Dynamic variable names make code impossible to lint or type-check, and writing to globals() from a function is worse still.
def build_columns():
for i in range(3):
locals()[f"col_{i}"] = i * 10
try:
return col_0
except NameError as e:
return f"NameError: {e}"
print(build_columns())
columns = {f"col_{i}": i * 10 for i in range(3)}
print(columns["col_1"])
Output:
NameError: name 'col_0' is not defined
10
Follow-ups:
- "What are
locals()andglobals()legitimately used for?" Debugging and introspection (printing what's in scope), and occasionally passing a namespace tostr.format_maporevalin tooling. Not for creating variables. - "How would you do the same thing with pandas columns?" Just add the columns to the frame:
df[f"col_{i}"] = i * 10in the loop, or build a dict and calldf.assign(**cols).
Scoring: Strong: Explains why it works at module level but not in a function, and redirects to a dict or list. Acceptable: Knows it fails and suggests a dict, without the module-versus-function explanation. Red flag: Recommends exec or writing to globals() to make it work.
JR-TH-SRT-01 · Sorting records by a field
Question: You have a list of employee dicts. How do you sort them by salary, highest first? And by department, then name?
Answer:
sorted(employees, key=..., reverse=True) returns a new list; employees.sort(key=..., reverse=True) sorts in place and returns None.
key is a function called once per element; the sort compares the values it returns. Options are lambda e: e["salary"] or operator.itemgetter("salary"), which is slightly faster. itemgetter("dept", "name") returns a tuple, and tuples compare element by element, so that gives a department-then-name order.
Ravi comes before Meera although both earn ₹18 lakh: Python's sort is stable, so equal keys keep their original order, even with reverse=True.
To mix directions (salary descending, name ascending), negate the numeric part: key=lambda e: (-e["salary"], e["name"]).
from operator import itemgetter
employees = [
{"name": "Ananya", "dept": "Data", "salary": 1_450_000},
{"name": "Ravi", "dept": "Backend", "salary": 1_800_000},
{"name": "Meera", "dept": "Data", "salary": 1_800_000},
{"name": "Karthik", "dept": "QA", "salary": 900_000},
]
by_salary = sorted(employees, key=itemgetter("salary"), reverse=True)
print([e["name"] for e in by_salary])
by_dept_then_name = sorted(employees, key=itemgetter("dept", "name"))
print([(e["dept"], e["name"]) for e in by_dept_then_name])
Output:
['Ravi', 'Meera', 'Ananya', 'Karthik']
[('Backend', 'Ravi'), ('Data', 'Ananya'), ('Data', 'Meera'), ('QA', 'Karthik')]
Follow-ups:
- "What if the records are objects, not dicts?"
operator.attrgetter("salary")orkey=lambda e: e.salary. - "Some employees have no
salarykey. What happens?"KeyErrorfrom the key function. Decide a policy, e.g.key=lambda e: e.get("salary", 0), or filter those rows out first. - "Case-insensitive sort by name?"
key=lambda e: e["name"].casefold(), otherwise uppercase letters sort before lowercase.
Scoring: Strong: Uses key and reverse fluently, knows sorted vs .sort(), and mentions stability or tuple keys. Acceptable: Gets the single-field sort right and needs a nudge for the two-field version. Red flag: Writes x = employees.sort(...) and uses x, or hand-writes a bubble sort.
JR-TH-SRT-02 · What sorted() does to a dict
Question: marks = {"Priya": 82, "Arjun": 95, "Divya": 78}. A candidate writes sorted(marks) to rank students by marks. What do they get, and how do you produce the ranking?
Answer:
Trap: "A dict sorted by marks." Iterating a dict yields its keys, so sorted(marks) returns a list of the names in alphabetical order, ['Arjun', 'Divya', 'Priya']. The marks aren't used, and the result isn't a dict.
To rank by value, sort the items() with a key on the value (kv[1]), or use sorted(marks, key=marks.get, reverse=True) when you only need the names.
If you need a dict, wrap it in dict(...). Dicts keep insertion order (guaranteed since 3.7), so the new dict iterates in ranked order. That's only true at the moment you build it: a student added later goes at the end. There is no auto-sorted dict in the standard library, so for a ranking you'll reuse, keep the sorted list of pairs.
marks = {"Priya": 82, "Arjun": 95, "Divya": 78}
print(sorted(marks))
print(sorted(marks.items(), key=lambda kv: kv[1], reverse=True))
ranked = dict(sorted(marks.items(), key=lambda kv: kv[1], reverse=True))
print(ranked)
print(list(ranked)[0])
Output:
['Arjun', 'Divya', 'Priya']
[('Arjun', 95), ('Priya', 82), ('Divya', 78)]
{'Arjun': 95, 'Priya': 82, 'Divya': 78}
Arjun
Follow-ups:
- "Two students tie on marks. Which comes first?" The one inserted into the dict first (the sort is stable). For a deterministic tiebreak, sort by
(-marks, name):key=lambda kv: (-kv[1], kv[0]). - "Only the topper is needed. Still sort?" No:
max(marks, key=marks.get)is O(n) and returns the first maximum.
Scoring: Strong: Says 'a sorted list of keys' at once and shows the items() + key version, noting the ordered-dict caveat. Acceptable: Realises keys are returned after a moment and fixes it. Red flag: Believes sorted returns a dict, or that dicts are unordered in modern Python.
JR-TH-SRT-03 · Top-k without sorting everything
Question: From a dict of 10 lakh product revenues you need the top 3 products. Do you sort everything? What are the options and their costs?
Answer:
Sorting works and is readable: sorted(revenue.items(), key=..., reverse=True)[:3]. It costs O(n log n) time and builds a new list of all n items.
For a small k, heapq.nlargest(k, iterable, key=...) keeps only a heap of k items. That's O(n log k) time and O(k) extra memory, and it works on a generator streaming from a file. For k = 1, use max(..., key=...), which is O(n). When k is close to n, plain sorting is faster.
Ties: nlargest is documented as equivalent to sorted(..., reverse=True)[:k], so equal values keep their input order ("coffee" before "vada pav"). max returns the first maximum it meets.
In practice, if the data is in a database, let it do the work (ORDER BY revenue DESC LIMIT 3). In pandas, use df.nlargest(3, "revenue").
import heapq
revenue = {"tea": 5_400, "coffee": 9_100, "samosa": 7_250,
"lassi": 3_900, "vada pav": 9_100, "idli": 6_800}
print(heapq.nlargest(3, revenue.items(), key=lambda kv: kv[1]))
print(max(revenue, key=revenue.get))
print(sorted(revenue.items(), key=lambda kv: kv[1], reverse=True)[:3])
Output:
[('coffee', 9100), ('vada pav', 9100), ('samosa', 7250)]
coffee
[('coffee', 9100), ('vada pav', 9100), ('samosa', 7250)]
Follow-ups:
- "Revenue is computed per line from a 5 GB CSV. Does
nlargeststill work?" Yes. Pass it a generator of(product, revenue)tuples; memory stays O(k). - "Bottom 3?"
heapq.nsmallest(3, ...), ornlargestwith a negated key. - "Why is n log k better than n log n here?" With k = 3, log k is a small constant, so it's effectively linear, and you never hold a full sorted copy.
Scoring: Strong: Offers nlargest/max/sort with correct complexities and picks by k; mentions pushing the query to the DB or pandas. Acceptable: Sorts and slices correctly and knows max for k = 1, but not heapq. Red flag: Sorts the whole dataset to find a single maximum and can't say what sorting costs.
JR-TH-SRT-04 · Sorting dates stored as DD-MM-YYYY strings
Question: Invoice dates arrive from a CSV as DD-MM-YYYY strings. A report sorts them with sorted() and takes max() as the latest invoice. What goes wrong?
Answer:
Trap: "Python knows these are dates and sorts them correctly." They're just strings, compared character by character from the left. So the comparison is effectively by day, then month, then year. "28-12-2024" comes out as the max because "2" beats "1" and "0", even though 11 Feb 2025 is later, and the "sorted" list jumps between years. There's no error, just a wrong "latest invoice" in the report.
Fixes:
- Parse at load time and sort real dates:
datetime.strptime(s, "%d-%m-%Y"), orpd.to_datetime(col, format="%d-%m-%Y"). - Or store ISO 8601 (
YYYY-MM-DD). It sorts correctly even as text, because it's fixed-width, zero-padded and puts the most significant part first.
The same bug appears with numbers stored as text ("100" < "99") and version strings ("1.10" < "1.9").
from datetime import datetime
invoices = ["05-01-2025", "28-12-2024", "11-02-2025", "02-03-2024"]
print(sorted(invoices))
print(max(invoices))
parsed = sorted(invoices, key=lambda s: datetime.strptime(s, "%d-%m-%Y"))
print(parsed)
Output:
['02-03-2024', '05-01-2025', '11-02-2025', '28-12-2024']
28-12-2024
['02-03-2024', '28-12-2024', '05-01-2025', '11-02-2025']
Follow-ups:
- "What does pandas do with
pd.to_datetime("05-01-2025")if you don't give a format?" It assumes month first and gives 1 May 2025. Indian day-first data needsformat="%d-%m-%Y"(ordayfirst=True). For a list, pandas infers the format from the first element, so a later"28-12-2024"raises an error instead of silently mixing formats. - "Why does ISO format sort correctly as strings?" Fixed width, zero padding, and year → month → day order, so string order matches time order.
- "And timestamps from different time zones?" Convert to timezone-aware datetimes (e.g. normalise to UTC, display in IST). Comparing naive with aware datetimes raises
TypeError.
Scoring: Strong: Predicts the wrong max, explains character-by-character comparison, and fixes it by parsing (with an explicit format) or ISO storage. Acceptable: Spots the problem once shown the output and fixes it with strptime. Red flag: Thinks sorted detects dates, or 'fixes' it by sorting on the year substring only.
JR-TH-SRT-05 · Key functions vs comparison functions
Question: Python 3's sort only takes a key function, not a comparison function. Why? And what do you do when the ordering is naturally pairwise, for example arranging numbers to form the largest possible number?
Answer:
A key maps each element to a value once (n calls), and the sort then compares those values with <. That's fast, and keys compose (a tuple key gives a multi-level sort). A comparison function cmp(a, b), returning negative/zero/positive, is called O(n log n) times in Python code, which is slower and easier to get wrong. Python 3 removed the cmp= argument.
Some orderings depend on both elements and don't reduce to a simple per-element key. "Put a before b if a + b makes a bigger number than b + a" is one: plain descending string order puts "30" before "3" and gives 9534303, but the right answer is 9534330.
functools.cmp_to_key(cmp) adapts a comparator: it wraps each element in an object whose __lt__ calls your function, so sorted can use it as a key.
The comparator must be consistent (if a < b and b < c, then a < c). Otherwise the sort gives an arbitrary order without raising any error.
from functools import cmp_to_key
def compare(a: str, b: str) -> int:
# put a before b if a+b makes the bigger number
if a + b > b + a:
return -1
if a + b < b + a:
return 1
return 0
nums = [3, 30, 34, 5, 9]
parts = sorted(map(str, nums), key=cmp_to_key(compare))
print(parts)
print("".join(parts))
print("".join(sorted(map(str, nums), reverse=True)))
Output:
['9', '5', '34', '3', '30']
9534330
9534303
Follow-ups:
- "What does your function return for
[0, 0]?""00". Handle it explicitly, e.g. return"0"if the first part is"0". - "Where else have you seen
cmp_to_key?" Porting Python 2 code that usedcmp=, and comparison-based orderings from libraries (e.g.locale.strcoll, thoughlocale.strxfrmis its key version). - "Could you avoid
cmp_to_keyfor a normal multi-field order?" Yes. Almost always use a tuple key, negating numeric fields that should be descending.
Scoring: Strong: Explains n key calls vs n log n comparisons, why some orders are pairwise, uses cmp_to_key correctly, and mentions consistency. Acceptable: Knows cmp_to_key exists and writes a working comparator with help on the sign convention. Red flag: Tries to pass cmp= to sorted, or believes plain descending string order already solves the puzzle.
JR-TH-SRT-06 · reverse=True is not the same as reversing afterwards
Question: Is sorted(x, key=k, reverse=True) the same as sorted(x, key=k)[::-1]?
Answer:
Trap: "Yes, reverse=True just reverses the result." Both put the distinct keys in the same order, but they treat ties differently.
reverse=True keeps the sort stable: items with equal keys stay in their original relative order (T1 before T3, T2 before T4). Sorting ascending and then reversing the list also reverses the tied items (T3 before T1). reversed(sorted(...)) behaves like [::-1].
Where it matters:
- Multi-pass sorting (sort by the secondary key, then by the primary key with
reverse=True) relies on the second pass being stable. - Queues and rankings where ties should keep arrival order (first come, first served).
- Pagination: an unstable order among ties can show a row on two pages, or skip it.
Takeaway: use reverse=True or negate the key, and add an explicit tie-breaker whenever tie order matters.
tickets = [("T1", 2), ("T2", 1), ("T3", 2), ("T4", 1)]
a = sorted(tickets, key=lambda t: t[1], reverse=True)
b = sorted(tickets, key=lambda t: t[1])[::-1]
print(a)
print(b)
print(a == b)
Output:
[('T1', 2), ('T3', 2), ('T2', 1), ('T4', 1)]
[('T3', 2), ('T1', 2), ('T4', 1), ('T2', 1)]
False
Follow-ups:
- "Priority descending, ticket ID ascending: how?" One pass with
key=lambda t: (-t[1], t[0]), or two stable passes: sort by ID, then by priority withreverse=True. - "Is pandas
sort_valuesstable?" Not guaranteed for a single-column sort with the defaultkind="quicksort". Passkind="stable"(or"mergesort") when tie order matters. - "And SQL
ORDER BY?" No stability guarantee at all. Add a unique tie-breaker column such as the primary key.
Scoring: Strong: Says 'not the same for ties', explains stability under reverse=True, and gives a real case (pagination, FIFO ties). Acceptable: Thinks they're identical, then works out the tie difference when asked to trace equal keys. Red flag: Doesn't know what a stable sort is.
JR-TH-STR-01 · String methods return new strings
Question: A cleaning step calls city.strip() and city.title() on each value, but the output still has spaces and lowercase names. Why?
Answer:
Strings are immutable: no method changes a string in place. strip(), title(), replace(), lower() all return a new string and leave the original untouched. Calling them without using the result does nothing useful. You have to rebind the name: city = city.strip().title() (methods can be chained because each returns a string). Trying to change a character directly, city[0] = "B", raises TypeError.
This differs from lists, where items.sort() and items.append() change the list in place and return None. A quick rule: if a method returns a new value, assign it; if it mutates, don't.
city = " bengaluru "
city.strip() # result discarded: city is unchanged
city.title()
print(repr(city))
city = city.strip().title() # rebind the name to the new string
print(repr(city))
try:
city[0] = "B"
except TypeError as e:
print("TypeError:", e)
Output:
' bengaluru '
'Bengaluru'
TypeError: 'str' object does not support item assignment
Follow-ups:
- "How do you do the same cleaning on a pandas column?"
df["city"] = df["city"].str.strip().str.title(). The.strmethods also return a new Series, so the result must be assigned back. - "Why print with
repr()while debugging whitespace problems?"reprshows the quotes and escapes like\tand\n, so leading and trailing spaces become visible.
Scoring: Strong: Explains immutability, that methods return new strings, rebinds correctly, and contrasts with in-place list methods. Acceptable: Spots the missing assignment; explanation of immutability is loose. Red flag: Thinks strip() modifies the string, or "fixes" it by calling the method twice.
JR-TH-STR-02 · split() versus split(" ")
Question: Is " Asha Rao ".split() the same as " Asha Rao ".split(" ")? And what does "".split(",") return?
Answer:
Trap: "Yes, the default separator is a space." They're different:
split()with no argument splits on any run of whitespace (spaces, tabs, newlines) and drops empty strings, including leading and trailing whitespace:['Asha', 'Rao'].split(" ")splits on every single space, so consecutive, leading and trailing spaces produce empty strings:['', '', 'Asha', '', 'Rao', '', ''].
With an explicit separator, empty fields are kept, which is what you want for delimited data ("101,Ravi,,Chennai," gives five fields, two empty). Edge case: "".split() is [], but "".split(",") is [''], a list with one empty string, so "number of fields" on an empty line is 1, not 0. For names typed by users, use split(); for real CSV, use the csv module.
row = " Asha Rao "
print(row.split())
print(row.split(" "))
line = "101,Ravi,,Chennai,"
print(line.split(","))
print("".split(), "".split(","))
Output:
['Asha', 'Rao']
['', '', 'Asha', '', 'Rao', '', '']
['101', 'Ravi', '', 'Chennai', '']
[] ['']
Follow-ups:
- "How do you split only on the first comma, e.g.
"key,value,with,commas"?"s.split(",", 1)(maxsplit), ors.partition(","), which always returns three parts. - "How do you normalise internal spaces in a name, "Asha Rao" to "Asha Rao"?"
" ".join(name.split()).
Scoring: Strong: Explains whitespace-run splitting vs single-separator splitting, gets both outputs right including the empty strings, and knows the empty-string edge case. Acceptable: Knows they differ and roughly how; misses the "".split(",") case. Red flag: Thinks they're identical, or post-filters empty strings without knowing why they appear.
JR-TH-STR-03 · Case-insensitive matching with casefold
Question: You need to de-duplicate customer emails and match city names typed by users, ignoring case. Is .lower() enough? What else should you normalise?
Answer:
For English text .lower() usually works, but .casefold() is the method designed for caseless comparison. It applies extra Unicode rules: the German ß casefolds to ss, so "Straße" and "STRASSE" match with casefold() but not with lower(). Use casefold() for comparisons and keys, and keep the original string for display.
Also normalise before comparing:
strip()leading and trailing whitespace (copy-pasted emails often have it);- collapse internal spaces if relevant (
" ".join(s.split())); - for text from different sources, Unicode normalisation (
unicodedata.normalize("NFC", s)), so that an accented letter typed as one character and as letter-plus-accent compare equal.
Do it in one helper used everywhere (normalise_email(s)), and store the normalised key in its own column, so de-duplication and lookups agree. For emails specifically, only the domain part is case-insensitive by standard, but in practice most systems treat the whole address case-insensitively.
a, b = "STRASSE", "Straße"
print(a.lower(), b.lower(), a.lower() == b.lower())
print(a.casefold(), b.casefold(), a.casefold() == b.casefold())
emails = [" Priya.S@Example.com ", "priya.s@example.com"]
print(len({e.strip().casefold() for e in emails}))
Output:
strasse straße False
strasse strasse True
1
Follow-ups:
- "In pandas, how would you find duplicate emails ignoring case and spaces?"
key = df["email"].str.strip().str.casefold(), thendf[key.duplicated(keep=False)]. - "Should you store the casefolded email instead of what the user typed?" Store both: the original for display and sending, the normalised key (with a unique index) for lookups and uniqueness.
Scoring: Strong: Names casefold() and why it exists, adds strip and Unicode normalisation, and proposes a single normalising helper or stored key. Acceptable: Uses lower() plus strip() correctly; learns about casefold() from the example. Red flag: Compares raw strings, or normalises differently in different places so lookups and de-duplication disagree.
JR-TH-STR-04 · rstrip(".csv") is not "remove the suffix"
Question: To get a report name without its extension, a script does name.rstrip(".csv"). It works for report.csv. What does it do for sales_vs.csv and cvs.csv, and what should it use?
Answer:
Trap: "rstrip(".csv") removes the text .csv from the end." The argument to strip, lstrip and rstrip is a set of characters, not a substring. rstrip(".csv") keeps removing characters from the right while they are any of ., c, s, v. For report.csv it stops at t and happens to look right; sales_vs.csv loses the vs as well and becomes sales_, and cvs.csv becomes an empty string. The same bug appears with url.lstrip("https://"), which also eats the sh of shop.example.in.
Use str.removesuffix(".csv") / removeprefix("https://") (Python 3.9+), which remove the exact text once and only if present. For file names specifically, pathlib.Path(name).stem and .suffix are clearer and handle directories too.
for name in ["sales_vs.csv", "report.csv", "cvs.csv"]:
print(repr(name.rstrip(".csv")), repr(name.removesuffix(".csv")))
url = "https://shop.example.in"
print(url.lstrip("https://"))
print(url.removeprefix("https://"))
Output:
'sales_' 'sales_vs'
'report' 'report'
'' 'cvs'
op.example.in
shop.example.in
Follow-ups:
- "What does
Path("data/2026/sales.tar.gz").stemreturn?"sales.tar:stemdrops only the last suffix..suffixesgives['.tar', '.gz']. - "Why did tests with
report.csvnot catch this?" The test input didn't end in any of the stripped characters before the extension. Test with names ending inc,sorvbefore.csv, and an empty-result case.
Scoring: Strong: Explains the character-set semantics, predicts sales_ and the empty string, and gives removesuffix or pathlib. Acceptable: Knows rstrip takes characters once reminded; works out the outputs. Red flag: Believes rstrip removes a substring, or "fixes" it with name[:-4] without checking the suffix is there.
JR-TH-STR-05 · A vendor file that won't decode
Question: A daily vendor CSV load fails with UnicodeDecodeError. A colleague suggests opening it with encoding="latin-1" because "that never fails". What are the right options, and why is that suggestion risky?
Answer:
latin-1 maps every byte to some character, so decoding never raises, which is exactly the problem: if the file is really UTF-8, every non-ASCII character turns into garbage silently (₹ becomes â\x82¹), and the corruption flows into the warehouse and reports. A loud failure is better than quiet mojibake.
Steps instead:
- Find out the actual encoding. Ask the vendor or check the spec; inspect the bytes around the failing position (the error message gives it). Usual suspects: UTF-8, UTF-8 with a BOM (Excel exports),
cp1252(Windows), UTF-16 (some Excel "Unicode text" exports). - Decode with that encoding explicitly, never the platform default. For Excel's UTF-8 files use
utf-8-sig: plainutf-8keeps the BOM as\ufeffglued to the first column name, sodf["city"]raisesKeyErroreven though the header looks right. - If a few bytes are genuinely bad, decide deliberately:
errors="replace"(marks them with�) or quarantine the bad rows, and log how many. Don't silently drop data. - A file truncated mid-character (e.g. a cut-off download) also raises, with "unexpected end of data": check the size or checksum before blaming the encoding.
pd.read_csv(path, encoding="utf-8-sig") takes the same options, and encoding_errors= controls the error behaviour.
data = "city,amount\nPune,₹500\n".encode("utf-8")
print(repr(data.decode("latin-1"))) # never fails, silently wrong
print(repr(data.decode("ascii", errors="replace")))
with_bom = "\ufeffcity,amount\n".encode("utf-8")
print(repr(with_bom.decode("utf-8").split(",")[0]))
print(repr(with_bom.decode("utf-8-sig").split(",")[0]))
try:
"₹".encode("utf-8")[:2].decode("utf-8")
except UnicodeDecodeError as e:
print("UnicodeDecodeError:", e.reason)
Output:
'city,amount\nPune,â\x82¹500\n'
'city,amount\nPune,���500\n'
'\ufeffcity'
'city'
UnicodeDecodeError: unexpected end of data
Follow-ups:
- "How many bytes is
₹in UTF-8, and why did the ASCII decode show three replacement characters?" Three bytes (e2 82 b9). None is valid ASCII, so each byte became one�. - "Why does
json.dumps({"symbol": "₹"})show"₹"? Is it a bug?" No.ensure_ascii=Trueis the default and escapes non-ASCII characters; it's still valid JSON and decodes back to₹. Passensure_ascii=Falsefor readable output, and write the file withencoding="utf-8".
Scoring: Strong: Explains why latin-1 silently corrupts, insists on identifying the real encoding, knows utf-8-sig and the BOM-in-header bug, and handles bad bytes deliberately. Acceptable: Knows to specify the encoding and that latin-1 "might be wrong"; needs a hint for the BOM case. Red flag: Uses errors="ignore" or latin-1 to make the error go away without checking the data.
JR-TH-STR-06 · Parsing CSV lines with split(",")
Question: A loader reads a CSV export line by line and does line.split(","). It has worked for months. A new customer record makes the row have the wrong number of columns. What happened, and what else can break this approach?
Answer:
Trap: "CSV just means split on commas." CSV has quoting rules. A field containing a comma, a quote or a newline is wrapped in double quotes, and a quote inside a field is written as two quotes. So 101,"Sharma, Ravi",Pune has three fields, but split(",") produces four and leaves stray quote characters. Other things that break it:
- Embedded newlines: an address like
"Flat 4B\nMG Road"is one field but spans two physical lines, so reading line by line splits one record into two broken ones. - Escaped quotes:
"said ""ok"""should becomesaid "ok". - Line endings:
\r\nfrom Windows leaves\ron the last field.
Use the csv module (csv.reader / csv.DictReader), opening the file with newline="" so the module handles line endings and quoted newlines itself, or pd.read_csv. The same applies to writing: csv.writer adds the quoting for you, while ",".join(fields) produces files other tools can't read back. It "worked for months" because no value happened to contain a comma.
import csv
import io
line = '101,"Sharma, Ravi",Pune,"said ""ok"""'
print(line.split(","))
row = next(csv.reader(io.StringIO(line)))
print(row, len(row))
data = 'id,address\r\n1,"Flat 4B\nMG Road"\r\n2,Indiranagar\r\n'
print([r for r in csv.reader(io.StringIO(data, newline=""))])
print(data.splitlines())
Output:
['101', '"Sharma', ' Ravi"', 'Pune', '"said ""ok"""']
['101', 'Sharma, Ravi', 'Pune', 'said "ok"'] 4
[['id', 'address'], ['1', 'Flat 4B\nMG Road'], ['2', 'Indiranagar']]
['id,address', '1,"Flat 4B', 'MG Road"', '2,Indiranagar']
Follow-ups:
- "Why
newline=""when opening a CSV file?" It stops Python's text layer from translating line endings, so thecsvmodule sees the raw\r\nand can tell a record separator from a newline inside a quoted field. Without it, quoted newlines can be mangled and writing on Windows produces blank lines between rows. - "How would you detect this kind of problem in a pipeline before it corrupts data?" Validate every row's field count and types against the expected schema, and send failing rows to a quarantine file with their line number instead of loading them.
Scoring: Strong: Explains quoting, escaped quotes and embedded newlines, uses csv with newline="" (or pandas), and mentions writing with csv.writer too. Acceptable: Knows quoted commas break split and that the csv module fixes it; misses embedded newlines. Red flag: Proposes a regex or a smarter split to handle quotes by hand, or blames the customer data.
JR-TH-TST-01 · How pytest finds tests and why plain assert works
Question: How does pytest find your tests, and why can you write a plain assert instead of self.assertEqual(...)?
Answer:
Discovery. Starting from the paths you give it (or testpaths in the config), pytest collects:
- files named
test_*.pyor*_test.py; - functions in them whose names start with
test; - methods starting with
testinside classes namedTest*that have no__init__.
Plain assert. When pytest imports a test module (and conftest.py), it rewrites the assert statements so that a failure reports the values involved. assert total == 1200 fails with assert 1180 == 1200, and for lists and dicts it shows a diff of what differs. You get readable failures without learning assertEqual, assertIn, assertDictEqual and the rest.
Two caveats a junior should know:
- Rewriting only applies to test modules,
conftest.pyand plugins. Asserts inside a helper module you import give a bareAssertionErrorunless you register it withpytest.register_assert_rewrite. assertis for tests and internal sanity checks, not for validating input in application code. Running Python with-Oremoves everyassertstatement.
Follow-ups:
- "Your file is called
pricing_tests.pyand pytest reports "no tests ran". Why?" It matches neithertest_*.pynor*_test.py(note the extra s). Rename it totest_pricing.pyor changepython_filesin the config. - "A test class has an
__init__method to set up shared data. What happens?" pytest skips collecting the class and emits a collection warning. Use a fixture (orsetup_method) for per-test setup instead. - "Why is
if amount < 0: raise ValueError(...)better thanassert amount >= 0in a payment function?" Withpython -Othe assert disappears, so invalid input would pass silently. A raisedValueErroris always checked and gives callers a specific exception to handle.
Scoring: Strong: States the file, function and class naming rules, explains assertion rewriting (failure shows the compared values or a diff), and knows asserts are stripped under -O so they don't belong in input validation. Acceptable: Knows the test_ prefix convention and that pytest shows better failure messages, without explaining rewriting or the -O caveat. Red flag: Thinks pytest runs every function in every file, or uses assert to validate user input in production code.
JR-TH-TST-02 · pytest.raises with match is a regex search
Question:
parse_amount("₹-500") raises ValueError("Invalid amount (₹-500)"). Your test is:
with pytest.raises(ValueError, match="Invalid amount (₹-500)"):
parse_amount("₹-500")
The message is exactly the same text, yet the test fails with "Regex pattern did not match". Why?
Answer:
Trap: "The text matches exactly, so a different exception must have been raised."
match is not a string comparison. pytest runs re.search(pattern, str(exc)), so the pattern is a regular expression. Here ( and ) form a group instead of matching literal parentheses, so the pattern looks for Invalid amount ₹-500 (no brackets), which doesn't appear in the message. The snippet shows the same check pytest does.
Fixes:
match=re.escape("Invalid amount (₹-500)")when you want the literal text;- or match a short, distinctive part:
match="Invalid amount"; - or capture it and assert on it directly:
with pytest.raises(ValueError) as exc_info: ...thenassert str(exc_info.value) == "Invalid amount (₹-500)".
A related mistake: wrapping too much. If the with block also contains setup code that can raise ValueError, the test can pass because the wrong line failed. Keep only the one call that should raise inside the block, and do any further checks after it.
import re
message = "Invalid amount (₹-500)"
# pytest.raises(..., match=pattern) checks re.search(pattern, str(exc))
print(bool(re.search("Invalid amount (₹-500)", message)))
print(bool(re.search(re.escape("Invalid amount (₹-500)"), message)))
print(bool(re.search("Invalid amount", message)))
Output:
False
True
True
Follow-ups:
- "Would
match="Invalid amount"also pass if the message were "Invalid amount: too large"?" Yes.re.searchfinds the pattern anywhere in the message. Anchor it (^...$) or comparestr(exc_info.value)exactly if the full message matters. - "How do you assert on an attribute of the exception, such as
err.code == "NEG_AMOUNT"?" Usewith pytest.raises(PaymentError) as exc_info:, then after the blockassert exc_info.value.code == "NEG_AMOUNT". - "What happens if the code raises no exception at all?" The test fails with
Failed: DID NOT RAISE <class 'ValueError'>.
Scoring: Strong: Explains that match is a regex applied with re.search, names re.escape or a simpler pattern as the fix, and mentions keeping the with block to the single call that should raise. Acceptable: Spots that the parentheses are special characters and fixes the pattern, but doesn't know it's a search (substring) rather than a full match. Red flag: Concludes the code raises a different exception and changes the code under test, or removes match entirely so the test passes.
JR-TH-TST-03 · Parametrising tests instead of looping
Question: What does @pytest.mark.parametrize do, and why is it better than looping over test cases inside one test function?
Answer:
It generates a separate test for each set of arguments:
@pytest.mark.parametrize(
"amount, expected_gst",
[
(1000, 180),
(0, 0),
pytest.param(999.99, 180, id="rounds-up"),
],
)
def test_gst(amount, expected_gst):
assert gst_18(amount) == expected_gst
Why it beats a for loop in one test:
- Every case runs. In a loop, the first failing assert stops the test, so you never learn whether later cases also fail.
- Failures name the case. The report shows
test_gst[rounds-up]ortest_gst[0-0], not "test_gst failed somewhere". - You can select and mark single cases:
pytest -k rounds-up, orpytest.param(..., marks=pytest.mark.xfail)for a known bug. - The test body stays simple, with no loop and no bookkeeping, and adding a case means adding a row of data.
Useful details:
- Stacking two
parametrizedecorators gives every combination (the cartesian product): 3 amounts × 2 states = 6 tests. - Fixtures can be parametrised too (
@pytest.fixture(params=[...]), readrequest.param), for example to run the same tests against two storage backends. - Don't force everything into one table. If cases need very different setup or assertions, separate tests are clearer.
Follow-ups:
- "How do you give readable names to cases whose arguments are long dicts?" Pass
ids=["empty-cart", "gst-exempt", ...]toparametrize, or wrap each case inpytest.param(..., id="..."). - "Two stacked parametrize decorators have 4 and 5 values. How many tests run?" 20: one for each combination.
- "A parametrize value is a list and the test appends to it. With a second
parametrizestacked on top, what can go wrong?" pytest doesn't copy parameter values. The same list object is passed to every combination, so appends from one test show up in the next and results depend on order. Use immutable values (tuples) or build a fresh list inside the test or a fixture.
Scoring: Strong: Explains one test per case, independent pass/fail with readable IDs, selecting and marking single cases, and knows about stacking or parametrised fixtures. Acceptable: Knows it runs the test once per argument set and that failures are reported per case. Red flag: Prefers a loop with prints, or thinks parametrize runs cases in parallel.
JR-TH-TST-04 · Argument order with stacked @patch decorators
Question:
A test patches two functions and names the mocks in the order the decorators appear:
@patch("notify.send_email")
@patch("notify.send_sms")
def test_alert(mock_email, mock_sms):
send_low_balance_alert("98450 12345")
mock_sms.assert_called_once()
The test passes, but production never sends the SMS. Which mock is which?
Answer:
Trap: "The arguments follow the decorators from top to bottom, so mock_email is the email mock."
Decorators are applied bottom-up: the one closest to the function wraps it first, so its mock is passed first. Here @patch("notify.send_sms") is nearest the function, so the first parameter (named mock_email) is really the SMS mock and mock_sms is the email mock. The snippet checks this with is.
So mock_sms.assert_called_once() checks the email function. If the code sends an email but the SMS call is broken or missing, the test still passes. Nothing errors, because each name holds a perfectly valid MagicMock; it's just the wrong one.
Ways to avoid it:
- Name the parameters in reverse order of the decorators (the bottom decorator matches the first argument).
- Use context managers, where the names sit right next to their targets:
with patch("notify.send_sms") as mock_sms, patch("notify.send_email") as mock_email:. - In pytest, use the
monkeypatchfixture, ormocker.patch(...)from pytest-mock, which returns the mock directly. - Make assertions that would fail if the wiring were wrong, e.g. check the arguments:
mock_sms.assert_called_once_with("98450 12345", ANY).
import sys
import types
from unittest.mock import patch
notify = types.ModuleType("notify")
notify.send_email = lambda to: f"email to {to}"
notify.send_sms = lambda to: f"sms to {to}"
sys.modules["notify"] = notify
@patch("notify.send_email")
@patch("notify.send_sms")
def test_alert(mock_email, mock_sms): # names in top-to-bottom order: wrong
print(mock_email is notify.send_email)
print(mock_email is notify.send_sms)
test_alert()
Output:
False
True
Follow-ups:
- "Why does stacking work bottom-up?"
@aover@boverdef fmeansf = a(b(f)).b's patch wraps the function first, so its mock is added to the arguments first;a's mock comes after it. - "With
@patch("notify.send_sms", return_value=True)stacked as well, is a mock still passed as an argument?" Yes. Keyword arguments such asreturn_valueconfigure the mock, and it's still passed in. Only when you givenew=(a replacement object) doespatchnot inject an argument. - "How would you assert the SMS was sent to the right number?"
mock_sms.assert_called_once_with("98450 12345", ANY), or inspectmock_sms.call_args. Checking the arguments also catches mixed-up mocks, because the email mock would have been called with an email address.
Scoring: Strong: Knows decorators apply bottom-up so the bottom patch is the first argument, explains why the test passes silently, and offers context managers, monkeypatch/mocker, or argument-checking asserts as safeguards. Acceptable: Gets the order right when prompted to think about how decorators stack, but doesn't connect it to the silent false pass. Red flag: Insists the order matches top to bottom, or "fixes" it by deleting the assertion.
JR-TH-TST-05 · Unit-testing code that calls an external HTTP API
Question: Your service calls a third-party exchange-rate API. How do you unit-test that code, including its error paths, without hitting the real API?
Answer:
Why not call the real API in unit tests: it's slow, can be down, has rate limits or costs money, its data changes (so assertions break), and some APIs have side effects (sending a real SMS, creating a real payment).
Design so the HTTP client can be swapped. Pass the client in, or build it in one place, instead of calling httpx.get(...) deep inside the logic. Wrap the API in a small class of your own (RateClient.get_inr_rate) so the rest of the code depends on your interface, not on raw HTTP.
Replace the network with a fake transport. With httpx, httpx.MockTransport(handler) routes every request to a Python function that returns a canned httpx.Response or raises an exception. (For requests, the responses library does the same; respx is a convenient alternative for httpx.) In the snippet one handler covers three cases:
- the happy path (200 with JSON);
- a timeout, by raising
httpx.ConnectTimeoutfrom the handler; - a server error (503).
It also records what was sent, so the test can assert the request itself: the right query parameters, the API key header, the right method and path.
What to cover: success, 4xx (bad key, unknown currency), 5xx, timeouts, malformed or unexpected JSON (a missing "inr" key), and your retry and fallback behaviour. Store a real response body as a fixture file so the fake returns realistic data.
Keep a few integration tests against the provider's sandbox (many Indian payment gateways have a test mode), marked so they run separately (@pytest.mark.integration) rather than on every commit.
import httpx
def get_inr_rate(client: httpx.Client, currency: str) -> float | None:
try:
resp = client.get("/rates", params={"base": currency}, timeout=2.0)
resp.raise_for_status()
except (httpx.TimeoutException, httpx.HTTPStatusError):
return None # caller falls back to yesterday's rate
return resp.json()["inr"]
seen = []
def fake_api(request: httpx.Request) -> httpx.Response:
seen.append((request.url.params["base"], request.headers["x-api-key"]))
if request.url.params["base"] == "USD":
return httpx.Response(200, json={"inr": 83.25})
if request.url.params["base"] == "JPY":
raise httpx.ConnectTimeout("simulated timeout", request=request)
return httpx.Response(503)
client = httpx.Client(transport=httpx.MockTransport(fake_api),
base_url="https://rates.example.com",
headers={"x-api-key": "test-key"})
print(get_inr_rate(client, "USD"), get_inr_rate(client, "JPY"), get_inr_rate(client, "EUR"))
print(seen)
Output:
83.25 None None
[('USD', 'test-key'), ('JPY', 'test-key'), ('EUR', 'test-key')]
Follow-ups:
- "Why not just
mock.patch("httpx.get")everywhere?" It ties tests to how the code makes the call (function vs client,getvsrequest), so harmless refactors break tests, and you have to fake response objects by hand. A fake transport keeps the real httpx request and response handling and replaces only the network. - "The provider changes its JSON from
{"inr": 83.25}to{"rates": {"INR": 83.25}}. Do your unit tests catch it?" No, because they use your canned responses. That's what the occasional sandbox or contract test is for, along with validating responses (for example with a Pydantic model) so the change fails loudly in production instead of producingNoneeverywhere. - "How do you test that the code retries twice on 503 and then gives up?" Have the handler count calls and return 503 each time; assert the call count is 3 and the final result is the fallback. Make the backoff sleep injectable (or zero in tests) so the test doesn't actually wait.
Scoring: Strong: Explains why real calls don't belong in unit tests, injects or wraps the client, uses a fake transport or responses/respx, covers timeouts, error statuses and bad JSON, asserts on the outgoing request, and keeps separate sandbox tests. Acceptable: Mocks the HTTP call and tests the success and one error case, but patches at a low level and doesn't think about timeouts or asserting the request. Red flag: Calls the real API in unit tests, or only tests the happy path.
JR-TH-TST-06 · What 100% line coverage does not tell you
Question: Your module has 100% line coverage in CI. A teammate says it's "fully tested". What does coverage actually tell you, and what can still be wrong?
Answer:
Trap: "100% coverage means every behaviour is tested."
Coverage (coverage.py, usually via pytest --cov) records which lines ran during the tests. It says nothing about whether the results were checked, or whether every path was taken.
In the snippet, one test with order_value=199 executes every line of delivery_fee: line coverage is 100%. But the if has no else, and the path where the condition is false was never taken. The first real order above ₹500 crashes with UnboundLocalError.
What can still be wrong at 100% line coverage:
- Untaken branches. Turn on branch coverage (
pytest --cov --cov-branch, orbranch = truein the config) and the report shows theifas only partly covered. - No assertions. A test that calls a function and checks nothing still counts every line it runs as covered.
- Missing edge cases. Exactly ₹500, ₹0, negative values, empty lists,
None. Every line can run without any boundary being checked. - Missing code. Coverage can't flag a validation you forgot to write.
Use coverage to find untested code, not as proof of quality. A high target pursued for its own sake leads to tests written to touch lines rather than to check behaviour. Mutation testing (e.g. mutmut) checks whether the tests actually catch deliberately changed code.
def delivery_fee(order_value: float) -> int:
if order_value < 500:
fee = 40
return fee
# The only test: executes every line, so line coverage is 100%
assert delivery_fee(199) == 40
print("test passed")
print(delivery_fee(800))
Output:
test passed
UnboundLocalError: cannot access local variable 'fee' where it is not associated with a value
Follow-ups:
- "What would branch coverage report for
delivery_feewith only the ₹199 test?" A partial branch on theifline: the jump from the condition straight toreturn fee(the false case) was never taken. - "Which test values would you pick for this function?" Below, at and above the boundary: 499, 500 and 501 (or 499.99, 500, 500.01), plus 0. The 500 case pins down whether the rule is
<or<=. - "Is a coverage gate in CI (say, fail below 80%) a good idea?" As a floor against untested new code, yes. Don't treat a higher number as better tests, and review the tests' assertions, not just the percentage.
Scoring: Strong: Distinguishes executed from verified, explains line vs branch coverage using the example, names missing asserts and boundary values, and treats coverage as a tool for finding gaps. Acceptable: Says coverage doesn't guarantee correctness and mentions edge cases, without the line-vs-branch distinction. Red flag: Believes 100% coverage means the code is bug-free, or would add assertion-free tests to raise the number.
JR-TH-TYP-01 · Annotating everyday function signatures
Question: How would you annotate a function that takes a list of order IDs and a ledger mapping order ID to amount, and returns a dict of ID to amount, where the amount is None if the order isn't in the ledger?
Answer:
def amounts_for(order_ids: list[int], ledger: dict[int, float]) -> dict[int, float | None]:
Points a Junior should know:
- Since Python 3.9 the built-in types are generic:
list[int],dict[str, float],tuple[int, str],set[str]. The oldertyping.List,Dictand so on still work but are deprecated aliases. X | None(3.10+) means "an X orNone";Optional[X]is the older spelling of the same thing.-> Nonefor functions that return nothing.- For parameters, accepting something more general is friendlier:
Iterable[int]orSequence[int](fromcollections.abc) lets callers pass a tuple or a generator, not just a list.
Hints are not checked when the code runs; a type checker like mypy or pyright uses them, and editors use them for autocomplete.
Follow-ups:
- "How do you annotate
(order_id, city)pairs in a list?"list[tuple[int, str]]. For a tuple of any length of ints,tuple[int, ...]. - "How do you annotate a parameter that takes a callback receiving an int and returning a str?"
Callable[[int], str], imported fromcollections.abc(ortyping).
Scoring: Strong: Writes the signature correctly with built-in generics and | None, and mentions that hints aren't enforced at runtime. Acceptable: Uses typing.List/Optional correctly; unsure about the newer syntax. Red flag: Cannot annotate a dict's key and value types, or believes annotations make Python reject wrong types.
JR-TH-TYP-02 · A default of None on a non-optional type
Question: Is def fetch(order_id: int, timeout: float = None) -> dict: correctly annotated? Does Python or mypy complain?
Answer:
Trap: "It's fine, the default None makes it optional." Python itself doesn't care: annotations aren't checked at runtime, so it runs. But the annotation is wrong: it says timeout is always a float, yet the default is None. Old mypy versions silently treated this as Optional[float] ("implicit Optional"); since mypy 0.990 that is off by default, and mypy reports an incompatible default (pyright does too).
Write timeout: float | None = None. And remember the two ideas are separate: having a default makes the parameter optional to pass; | None says the value may be None. Inside the function you then must handle None before using it as a number (if timeout is None: timeout = 10.0), and mypy will remind you if you forget.
Follow-ups:
- "Is
def f(x: str | None):an optional parameter?" No. Callers must still pass it (possiblyNone). Only a default value makes it optional to pass. - "Why prefer
if timeout is Noneoverif not timeout?"not timeoutis also true for0.0, so a deliberate zero timeout would be silently replaced by the default.
Scoring: Strong: Says Python runs it, that type checkers reject it (implicit Optional is off by default), gives float | None = None, and separates "has a default" from "may be None". Acceptable: Knows it should be Optional[float]; unsure whether mypy complains by default. Red flag: Thinks the code fails at runtime, or that any parameter with a default is automatically optional-typed.
JR-TH-TYP-03 · TypedDict, dataclass or Pydantic for an API payload
Question: An endpoint receives an order as JSON. You could describe it with a TypedDict, a @dataclass, or a Pydantic BaseModel. Which of them actually checks the data at runtime, and when would you use each?
Answer:
All three use the same annotation syntax, but only Pydantic validates at runtime:
TypedDict: describes the shape of a plaindictfor the type checker. At runtime the object is adict; nothing is checked. Good for typing dicts you already trust (results ofjson.loadsfrom your own service, kwargs).@dataclass: a real class with generated__init__,__repr__,__eq__. It stores whatever you pass;amount="five hundred"is accepted. Good for internal data objects.- Pydantic
BaseModel: parses and validates on construction, converts compatible input ("42"to42), and raisesValidationErrorlisting each bad field with its location. Use it at boundaries where data comes from outside: request bodies, config files, third-party APIs. FastAPI uses it for exactly this.
Typical design: validate once at the edge with Pydantic, then pass trusted, typed objects (models or dataclasses) through the rest of the code.
from dataclasses import dataclass
from typing import TypedDict
from pydantic import BaseModel, ValidationError
class OrderTD(TypedDict):
id: int
amount: int
@dataclass
class OrderDC:
id: int
amount: int
class OrderPM(BaseModel):
id: int
amount: int
payload = {"id": 7, "amount": "five hundred"}
td = OrderTD(**payload)
print(type(td).__name__, td["amount"])
dc = OrderDC(**payload)
print(type(dc).__name__, dc.amount)
try:
OrderPM(**payload)
except ValidationError as e:
err = e.errors()[0]
print(type(e).__name__, err["loc"], err["type"])
Output:
dict five hundred
OrderDC five hundred
ValidationError ('amount',) int_parsing
Follow-ups:
- "In the real endpoint,
payload = json.loads(body). Would mypy catch a bad amount there?" No.json.loadsreturnsAny, so mypy acceptsOrderDC(**payload)whatever the JSON contains; the bad value exists only at runtime. That's why boundaries need runtime validation. - "In FastAPI, what does the client get when the body fails validation?" A 422 response with a JSON list of errors (location, message, type), generated from the Pydantic
ValidationError.
Scoring: Strong: States that only Pydantic validates, describes what each is for, and recommends validating at the boundary then passing typed objects inward. Acceptable: Knows Pydantic validates and dataclasses don't; unsure what a TypedDict is at runtime. Red flag: Believes dataclasses or TypedDict enforce types, or validates every internal function call with Pydantic "to be safe".
JR-TH-TYP-04 · What Pydantic accepts for an int field
Question: A Pydantic model has qty: int and in_stock: bool. The client sends {"qty": "3", "in_stock": "yes"}. Is that rejected? What about qty: 3.0 and qty: 3.5?
Answer:
Trap: "Pydantic enforces the annotations exactly, so a string is rejected." By default Pydantic v2 runs in lax mode: it converts input that unambiguously represents the type. "3" becomes 3; 3.0 becomes 3 because it has no fractional part; "yes", "true", "1" and 1 become True. But 3.5 is rejected (int_from_float), because converting it would lose data.
This is usually what you want for JSON and form data, but it can hide client bugs (a client sending numbers as strings) or accept values you didn't intend. If an API contract requires exact types, turn on strict mode (model_config = ConfigDict(strict=True), or Field(strict=True) per field, or StrictInt), and then "3" fails with int_type. Either way, decide deliberately and test the edge cases you care about.
from pydantic import BaseModel, ValidationError, ConfigDict
class Item(BaseModel):
qty: int
in_stock: bool
class StrictItem(BaseModel):
model_config = ConfigDict(strict=True)
qty: int
in_stock: bool
for data in [{"qty": "3", "in_stock": "yes"},
{"qty": 3.0, "in_stock": 1},
{"qty": 3.5, "in_stock": True}]:
try:
print(Item(**data))
except ValidationError as e:
print("error:", e.errors()[0]["type"])
try:
StrictItem(qty="3", in_stock=True)
except ValidationError as e:
print("strict error:", e.errors()[0]["type"])
Output:
qty=3 in_stock=True
qty=3 in_stock=True
error: int_from_float
strict error: int_type
Follow-ups:
- "How would you also reject a negative quantity?"
qty: int = Field(gt=0)(orge=1), orPositiveInt. Type conversion and value constraints are separate checks. - "Does Pydantic validate when you assign
item.qty = "abc"after creation?" Not by default. Setvalidate_assignment=Trueinmodel_configif assignments must be validated too.
Scoring: Strong: Predicts the conversions and the 3.5 rejection, explains lax vs strict mode, and knows how to switch to strict for a field or model. Acceptable: Knows Pydantic converts strings to ints; surprised by "yes" and 3.5 but explains them once shown. Red flag: Believes Pydantic is a pure type check with no conversion, or thinks 3.5 is silently truncated to 3.
JR-TH-TYP-05 · Working with mypy day to day
Question: Your team adds mypy to CI. You get errors like Item "None" of "str | None" has no attribute "strip", missing library stubs for a third-party package, and a place where you know a value's type better than mypy does. How do you deal with each without just silencing everything?
Answer:
Noneerrors: narrow the type. mypy follows control flow: afterif name is None: return ...(orraise),nameisstrbelow.isinstance(x, str)narrows the same way. This usually reveals a real bug: theNonecase was never handled. Avoidassert x is not Nonefor input validation, since asserts disappear withpython -O; use it only for things that truly can't happen.- Missing stubs: install the stub package if one exists (e.g.
types-requests,pandas-stubs), otherwise ignore missing imports for that module only in the config ([[tool.mypy.overrides]]withignore_missing_imports = true), not globally. - You know better:
typing.cast(Order, obj)tells the checker the type; it does nothing at runtime, so it's a promise you must keep. Prefer a real check (isinstance) when the value comes from outside. - Last resort:
# type: ignore[attr-defined]with the specific error code and, ideally, a comment why. A bare# type: ignorehides future errors on that line too. - Debugging:
reveal_type(expr)makes mypy print the type it inferred, which explains most confusing errors.
Roll out gradually: start with the defaults on new modules, then turn on stricter flags (disallow_untyped_defs, eventually --strict) module by module, so CI stays green while coverage grows.
Follow-ups:
- "Does
cast(int, "42")convert the string?" No. It returns the same string unchanged; only the type checker's view changes. Useint("42")to convert. - "Why might mypy report nothing for a function that is obviously wrong?" By default mypy doesn't check the bodies of functions that have no annotations at all. Annotate the function (or enable
check_untyped_defs) and the errors appear.
Scoring: Strong: Narrows with is None/isinstance, handles stubs per module, knows cast has no runtime effect, uses specific ignore codes, and describes a gradual rollout. Acceptable: Handles the None case correctly and knows # type: ignore; vague on stubs and cast. Red flag: Adds # type: ignore everywhere or sets ignore_errors = true to get CI green, or thinks cast validates.
JR-TH-TYP-06 · Any versus object
Question: A helper accepts "anything", and two teammates annotate it differently: x: Any and x: object. Are these the same to a type checker? Which would you pick?
Answer:
Trap: "Both mean any type, so they're the same." Both accept any argument, but they behave opposite ways inside the function:
Anyswitches type checking off in both directions. You can call any method on it (x.upper()), pass it anywhere, and assign it to any typed variable, and mypy accepts everything. Errors appear at runtime, as in the example:describe_any(42)passes the checker and then raisesAttributeError.Anyalso spreads: values derived from it areAnytoo.objectis the most general real type. Anything can be passed in, but inside you may only do what every object supports (repr,==,isinstance). To call.upper()you must first narrow withisinstance(x, str), so mypy forces you to handle the other cases.
Prefer object (or a precise union or Protocol) when you mean "I accept anything and will check it"; use Any only as a temporary escape hatch, for example while adding hints to old code or for genuinely dynamic data like raw json.loads output, and narrow it as soon as possible.
from typing import Any
def describe_any(x: Any) -> str:
return x.upper() # mypy: fine (Any switches checking off)
def describe_obj(x: object) -> str:
if isinstance(x, str): # must narrow before using str methods
return x.upper()
return repr(x)
print(describe_obj("pune"), describe_obj(42))
print(describe_any("pune"))
print(describe_any(42))
Output:
PUNE 42
PUNE
AttributeError: 'int' object has no attribute 'upper'
Follow-ups:
- "What would mypy say about
def f(x: object) -> str: return x.upper()?" An error:"object" has no attribute "upper". That's the point: it makes you handle non-strings. - "Where do
Anyvalues commonly sneak into a codebase without anyone writingAny?" Unannotated functions and parameters,json.loads, untyped third-party libraries, anddict[str, Any]configs. mypy flags like--disallow-any-genericsand--warn-return-anyhelp find them.
Scoring: Strong: Explains that Any disables checking while object requires narrowing, shows the runtime failure Any lets through, and gives sensible guidance on when each is acceptable. Acceptable: Knows Any is "unchecked" but needs prompting to explain what object forces you to do. Red flag: Treats them as identical, or uses Any everywhere to silence the type checker.