Debugging your own code is hard. Debugging someone else's code is a different skill entirely. You don't know why it was written this way, what the author assumed, or which parts are intentional versus accidental. Most developers learn to write and debug their own code — almost no one trains the other case deliberately.
When you debug your own code, you carry a mental model of the system. You know what you intended, which means a bug narrows the search: something deviated from intent. With unfamiliar code, you have no such model. Every assumption you make about intent could be wrong, which means bugs can hide in plain sight — behavior you assumed was wrong might be correct, and behavior you accepted might be the actual defect.
There is also a cognitive load problem. Building a mental model of unfamiliar code while simultaneously looking for a bug is doing two hard things at once. Most failed debugging sessions on unfamiliar code fail not because the developer lacked skill, but because they tried to debug before they understood — treating symptoms without ever forming a hypothesis about the system.
The fix is to separate these two phases deliberately: first understand enough to form a credible hypothesis, then debug to test it. Conflating them makes both worse.
The instinct when handed a bug in unfamiliar code is to open the relevant file and start reading. This is almost always the wrong move. Before reading any code, answer three questions: what is the observable symptom, under what exact conditions does it occur, and what would the correct behavior look like?
Observable symptom means the concrete, reproducible failure — not 'it crashes' but 'it throws a NullPointerException on line 47 when the input list contains more than 100 items.' Conditions mean the minimal set of inputs and state that triggers the bug consistently. Correct behavior means what should happen instead, stated precisely enough that you could write a test for it.
These three questions force you to understand the bug as a contract violation before you look for the cause. A bug is always a deviation between what the code does and what it should do. You cannot find the cause until you know both sides of that gap.
Before reading any specific function, spend five minutes building a map of the system. Where does this code live in the architecture? What calls it? What does it call? What data enters and what comes out? You are not trying to understand the logic yet — you are locating the code in a structure.
This matters because bugs in unfamiliar code are often not where they appear to be. The function that throws the exception is often innocent — it received bad data from somewhere upstream, or its output is being misused somewhere downstream. Reading only the function that failed means you are solving the wrong problem.
A useful five-minute map: find the entry point for the failing flow, list the functions it calls, identify where state is read and where it is written. Draw it on paper. This map will be incomplete and partially wrong, but it gives you a structure to interrogate rather than a pile of code to wade through.
If the codebase has tests — and most production codebases do — find the tests for the failing component before reading the implementation. Tests are the closest thing unfamiliar code has to a specification. They show you what the author expected the code to do, which inputs they considered, and which edge cases they thought about.
A function with good tests is nearly self-documenting: the tests tell you the contract, and the implementation tells you how it is fulfilled. A function with no tests sends a different signal: the author either did not know what the correct behavior was, or did not think it needed verification. Both are red flags when debugging.
Read failing tests with extra attention. A test that was passing and now fails tells you exactly what changed: some invariant the author encoded is now violated. That invariant, correctly understood, is often the entire bug report — far more precise than a stack trace.
Many developers open a debugger early in an unfamiliar codebase and step through execution hoping to spot the bug. This is exploration, not debugging — and it is mostly ineffective. Stepping through code you do not understand produces a stream of values with no frame to interpret them.
The right use of a debugger is hypothesis verification. First, form a specific hypothesis: 'I think the bug is that this list is empty when it reaches this function.' Then use the debugger to confirm or deny it: set a breakpoint before the function call, inspect the list, and check whether your hypothesis matches reality. If it does, you found your bug. If it does not, your model was wrong — update it and form a new hypothesis.
This approach — hypothesis first, then verification — is what separates efficient debugging from random exploration. Every debugger session should answer a specific question, not generate a random walk through execution.
Before concluding that a piece of unfamiliar code is simply wrong, read its git history. Code that looks strange almost always has a reason behind it — a bug fixed six months ago, a performance optimization added under deadline, a workaround for a third-party library's behavior. The commit messages and diffs tell this story.
The git history is especially important when you are tempted to simplify something. The 'obviously unnecessary' null check that looks like it can be removed was added after production incident. The 'redundant' validation that seems like copy-paste happened because the simplified version was exploited. Removing these without understanding their history is a reliable way to reintroduce fixed bugs.
A useful habit: before changing any code you do not understand, run `git log -p -- <filename>` and read the last five commits that touched it. Not to understand everything, but to check whether the code you are about to change has been changed before — and why.