Multi-file debugging under pressure
📖 Walk me through it — plain English
This lesson is not about an algorithm. It is about a job-interview format: someone hands you a real, multi-file codebase (a "repo," short for repository — the whole project folder under version control) that has a broken test or a reported bug, gives you 30–45 minutes, and watches how you hunt the bug down. They are not testing whether you can spot a typo. They are testing whether you can reason about cause and effect across several files. A "failing test" just means an automated check that should print PASS is printing FAIL.
Everyday analogy: it is like being a doctor with a sick patient. A bad doctor sees a cough, hands out cough syrup, and the patient leaves — symptom hidden, illness untouched. A good doctor asks "what is actually causing the cough?" and keeps asking until they can name the real disease. In debugging, the "cough" is the visible error and the "disease" is the root cause. The whole lesson is about being the second doctor, and saying your reasoning out loud so the interviewer can hear how you think.
The reason this round exists is that day-to-day engineering is mostly not writing fresh code on a blank page — it is walking into a system other people built, that you do not fully understand, and making one correct change without breaking the rest. The interviewer is simulating your first week on the job. They want to see that an unfamiliar 40-file repo does not paralyse you: that you have a method for getting oriented, narrowing the search, and proving you were right.
The core skill is a repeatable loop. You do not poke randomly; you run these six steps in order, narrating each one:
render() (the code that draws the screen) but the bad data came from fetch() (the code that loaded it). Fix fetch, not render — and name both options aloud.A few fast tools that save you from wasted hours: git log --oneline -- path/to/file shows recent edits to a file (Git is the version-control system that records every change; recent changes near a failure are suspect number one). Watch for a "silent error swallow" — code like try { } catch (e) {} that catches an error and then does nothing with it, hiding the real message; add a log line and the true error usually pops out.
Why this matters: the trap is finding a change that merely makes the symptom disappear and stopping there. The single highest-leverage thing they grade is whether you push past the symptom to a root cause you can actually name — and whether you scope-control instead of "fixing" unrelated tech debt. Say the root cause out loud; that sentence is often what wins the round.
Different from the snippet-debug round. You get a real repo with a failing test (or a user-reported bug) and 30–45 min. Tests root-cause reasoning across files, not just "find the typo." Common at Stripe, Datadog, Affirm, Anthropic FDE loops, plus most AI-startup trial days.
What this round actually is
Let's pin down the format precisely, because half of doing well is knowing what game you are playing. You are given a working project — often dozens of files, sometimes hundreds — and one concrete complaint: a unit test is red, an endpoint returns the wrong number, the app crashes on a particular input, a user filed a bug report. You did not write this code and you have never seen it before. You have well under an hour, you can usually run the code and run the tests, and you may have an editor, a terminal, and (increasingly) an AI assistant. The deliverable is a correct, minimal fix plus a sentence explaining the root cause — and, ideally, a test that locks it in.
A few terms worth defining up front, since they recur below:
- Repo / repository — the project folder tracked by version control. "Tracked" means every change to every file is recorded with an author, a timestamp, and a message.
- Stack trace — the printout an error produces showing the chain of function calls that led to the crash, innermost (where it blew up) usually at the bottom or top depending on the language, with file names and line numbers. It is a map straight to the scene.
- Reproduce / repro — to trigger the failure yourself, reliably, on demand. A "minimal repro" is the smallest input or command that still triggers it, with everything irrelevant stripped away.
- Root cause — the actual broken assumption, as opposed to the visible symptom. The symptom is where it hurts; the root cause is why.
- Regression test — a test you add that captures this specific bug, so that if anyone reintroduces it later, the test goes red and catches them.
git blame— a Git command that, for each line of a file, shows the commit and author that last changed it. It answers "who wrote this line, when, and in what change?" — invaluable for understanding why a suspicious line exists.
- Reproduce — get the failure on demand. Without a repro, every "fix" is a guess.
- Localize — binary-search the call stack. Add a print/breakpoint halfway, narrow to half.
- Form a hypothesis — "I think X is happening because Y." Say it out loud so the interviewer hears your model.
- Test the hypothesis — one targeted check. Print, assertion, manual call. Confirm or kill.
- Fix at the right layer — the bug appears at the leaf; the fix often belongs higher. State both options.
- Lock with a test — "this test fails before my change, passes after." Standard senior closure.
Navigating a large repo you've never seen
The first instinct of a nervous candidate is to open the biggest file and start reading top to bottom. Do not. You will burn fifteen minutes and learn almost nothing, because a repo is not a novel — you do not need to understand all of it, only the thin slice that touches your bug. The professional move is to navigate by the failure, working backward from the symptom toward the code that produces it. A few orientation moves, in rough order:
- Read the entry points, not everything. Skim the README, the test that's failing, and the directory names. Folder structure (
src/,api/,models/,tests/) tells you the shape of the system in thirty seconds. - Let the error name the file. The stack trace already contains file paths and line numbers. That is the single best starting point in the whole repo — it is the repo telling you where to look.
- Search by symbol, not by reading. Pick a distinctive string from the error, the failing test, or the bug report — a function name, an error message, a field name — and grep for it across the repo. This jumps you straight to the relevant code and to every place that touches it.
- Follow the data, not the control flow. Ask "where does this wrong value come from?" and trace it upstream: who set it, who passed it in, who computed it. You are reverse-engineering a pipeline, one hop at a time.
grep / search strategy
"Grep" is the classic command-line tool for searching text across files; today it usually means any fast project-wide search (grep -rn, ripgrep/rg, or your editor's "search in all files"). It is your fastest way to convert "I have no idea where this lives" into "here are the four files that mention it." The art is choosing a search term that is specific — common words match everywhere and drown you; rare, distinctive strings land you on target.
# Search the whole repo, with line numbers, for a distinctive string
rg -n "User not found" # the exact error text -> jumps to where it's raised
rg -n "def parse_amount" # a function definition -> its one source of truth
rg -n "parse_amount" # the function NAME -> every call site, too
rg -n "discount" src/billing # scope the search to one folder when you can
Good search targets, in order of usefulness: the literal error message text (it is almost always a unique string in the code), then the failing test's name and any function it calls, then the name of the misbehaving field or variable. Searching the name of a function finds both where it is defined and everywhere it is called — that call-site list is how you map the blast radius of a change before you make it.
Reading the stack trace
A stack trace looks intimidating but it is a gift: it is a recorded path from the outside of your program down to the exact line that failed. Read it like a sentence. The type of error tells you the category of problem; the message tells you the specifics; the frames (each "called from here" line) tell you the route the program took to get there.
Traceback (most recent call last):
File "app/api.py", line 42, in handle_request
total = compute_total(cart)
File "app/billing.py", line 17, in compute_total
return sum(parse_amount(i) for i in items)
File "app/billing.py", line 9, in parse_amount
return float(raw["price"])
KeyError: 'price' # <- the actual failure: a dict had no "price" key
In Python the bottom-most frame is where it actually blew up; the frames above it are the callers, oldest at the top. (Other languages print it inverted — newest on top — but the idea is identical.) Read three things: the last line (KeyError: 'price' — the what), the deepest frame (billing.py line 9 — the where), and the frame just above it (line 17 — who handed it the bad data). That last point matters: line 9 is the leaf, but the missing price key came from items, which came from cart up in handle_request. The trace is already pointing you up the data path toward the real cause.
Reproduce first — and minimize
This is the step beginners skip and seniors never do. Before you change a single character, get the failure to happen for you, reliably. If there is a failing test, run just that test (most frameworks let you run one by name) so you get a tight, fast feedback loop instead of re-running the whole suite. If it is a user-reported bug with no test, write the smallest script or input that triggers it. Why does this come first? Because a fix you cannot check is not a fix — it is a hope. The repro is the instrument that tells you, in seconds, whether you were right.
Then minimize the repro: strip away everything that is not needed to make it fail. A 200-line failing scenario with one bad value hidden inside is far harder to reason about than the same bug reduced to three lines. Minimizing is itself a debugging act — each thing you remove that doesn't stop the failure is a clue about what is and isn't involved.
# Run ONLY the failing test for a fast loop (pytest example)
pytest tests/test_billing.py::test_total_with_missing_price -x
# If there's no test yet, write the minimal repro by hand:
from app.billing import compute_total
print(compute_total([{"price": "5.00"}, {}])) # the {} (no "price") is what breaks it
A concrete walkthrough: a bug in a repo you've never seen
Let's run the whole loop end to end on the example above. You are dropped into an unfamiliar billing service. The bug report: "Checkout sometimes crashes with a 500 error." You have never opened this code.
Run the failing path; read the stack trace. It points at billing.py:9, KeyError: 'price'. Without reading 40 files, the repo just told you where to start.
Write the three-line minimal repro: a cart with one item missing price crashes every time. Now you have a reliable trigger and a fast loop.
Say it aloud: "I think some carts contain items with no price field, and parse_amount assumes the key always exists." Now follow the data up: who builds these items?
Grep for where items are created. git blame billing.py shows line 9 was added in a commit that also changed the upstream loader to allow free gifts (price = null). Hypothesis confirmed: gifts have no price.
Now the layer question. The crash is at the leaf (parse_amount), but the real story is upstream: the loader started emitting free-gift items with no price, and downstream code never expected that. You name both fixes out loud:
- Symptom layer: make
parse_amounttreat a missing price as0.00. Easy, but it silently hides whether a missing price is ever truly a bug. - Right layer: decide the invariant. If free gifts legitimately cost 0, the loader should set
price: "0.00"explicitly (orparse_amountshould default to 0 by design, documented). If a missing price is a data error, it should fail loudly with a clear message, not crash with a rawKeyError.
# 5. Fix at the right layer: free gifts are valid and cost 0 by design.
def parse_amount(raw):
# A missing price means a free item (e.g. a gift); treat as 0.00.
return float(raw.get("price", "0.00"))
# 6. Lock it with a regression test that was RED before, GREEN after.
def test_total_treats_missing_price_as_free():
assert compute_total([{"price": "5.00"}, {}]) == 5.00
Finally, verify: run the new test (it passes), re-run the original repro (no crash), and run the surrounding suite to confirm you broke nothing. Then deliver the one-sentence root cause: "A loader change introduced free-gift items with no price field, and parse_amount assumed the key always existed; I made the zero-price case explicit and added a regression test." That sentence is the round.
If you can't reproduce in 5 min, ask. "What inputs trigger it? What env?" Better than 30 min of speculation.
Recent changes near the failure are suspects #1. git log --oneline -- path/to/file takes 5 seconds and rules out hours of red herrings. git blame names the exact commit that touched a suspicious line.
Strategic prints at function entry/exit catch ordering and state-mutation bugs that debuggers miss. Especially in async code.
Fix the bug, not the surrounding tech debt. "I noticed three other things — should I file follow-ups?" beats silent scope creep.
Bug repros in render() but the cause is bad data in fetch(). Fix data, not render. State both options to the interviewer.
try { } catch (e) {} with no log can hide the actual exception. Add a log first; the real error often appears.
Grep the exact error text or the misbehaving field name. A distinctive string lands you on the right file in seconds; reading whole files top-to-bottom wastes the clock.
"Here's the failing test and what I see — what hypotheses am I missing?" Better than "fix this bug for me." Keeps you driving.
What interviewers look for
They are grading your process, not your speed. A candidate who narrates a clean loop and runs out of time mid-fix often scores higher than one who silently lands a fix by luck. Specifically, strong signal looks like:
- You reproduce before you touch anything. It is the clearest possible sign you debug by evidence, not by vibes.
- You narrate hypotheses and kill them with checks. "I think it's X — let me verify" followed by a targeted print is exactly the loop they want to hear.
- You name the root cause in one sentence. Pushing past the symptom to the broken assumption — and saying it plainly — is the top-graded moment.
- You fix at the right layer and explain the trade-off. Articulating both the symptom-layer patch and the root-layer fix shows judgment.
- You lock it with a test and scope-control. A red-before/green-after regression test plus "I'll file the unrelated issues as follow-ups" is textbook senior closure.
Pitfalls that sink the round
- Making random edits. Changing lines to "see if it helps" without a hypothesis is the single biggest red flag. Every change should be a test of a stated guess.
- Not reproducing first. Fixing blind means you can't tell whether your change did anything. You may "fix" the wrong thing and never know.
- Reading the whole repo. Trying to understand everything before acting burns the clock. Navigate by the failure; understand only the slice you need.
- Stopping at the symptom. A patch that makes the error disappear without explaining why it happened often just relocates the bug. Keep asking "but why?"
- Scope creep. Refactoring unrelated code or "cleaning up" while you're in there. Fix the bug; note the rest as follow-ups.
- Debugging in silence. The interviewer can't grade reasoning they can't hear. Think out loud, even when you're stuck — "I'm not sure yet, here's what I'd check next" is worth saying.
- Trusting an AI fix you can't explain. If you accept a suggestion you don't understand, you can't defend it or verify the layer. Use the assistant to generate hypotheses, then confirm each one yourself.
Takeaway: the round simulates your first week on an unfamiliar codebase. Navigate by the failure, not by reading everything: let the stack trace name the file, grep a distinctive string to find the slice, follow the data upstream, and lean on git log/git blame to spot the recent change. Run the loop out loud — reproduce, localize, hypothesize, test, fix at the right layer, lock with a test — and finish by naming the root cause in one sentence. Avoid random edits, fixing before reproducing, and scope creep.
Go deeper (optional): the canonical reference on this mindset is David Agans' Debugging (its nine rules — "make it fail," "quit thinking and look," "divide and conquer," "change one thing at a time" — are this lesson in book form). Julia Evans' debugging zines are a friendly visual companion.
Go deeper (optional, other guides): Modern Web Dev Guide — Debugging methodology — production triage, performance profiling, flaky tests, and the full AI-generated-code suspect table for day-to-day engineering.