Debugging
A systematic method for finding bugs instead of guessing at them — from reading error messages to isolating causes to knowing what to do when you're completely stuck.
Who This Is For
Anyone who writes code and gets stuck. You don't need to be an experienced developer — in fact, this guide is especially useful if debugging still feels like guessing. No prior knowledge is assumed beyond basic familiarity with writing and running code.
This is a 40-minute read. By the end, you'll have a systematic method for finding bugs instead of guessing at them, know how to read error messages and logs like a native, understand the failure patterns that show up over and over, and have specific techniques for debugging code written by AI — which fails in distinctive ways that aren't well-covered elsewhere.
Why Debugging Feels Hard (and Why It Doesn't Have To)
Most people debug the same way: they stare at the code, change something that feels suspicious, run it again, and hope the error goes away. When it doesn't, they change something else.
This is what debugging by intuition looks like, and it works sometimes — when you're lucky, or when you've seen this exact bug a hundred times before. But it scales badly. When the bug is subtle, when the system is complex, when you've been staring at the same code for an hour — intuition fails.
The alternative is treating debugging as a scientific method: forming hypotheses about what's wrong, finding ways to test those hypotheses, and systematically eliminating possibilities until you've isolated the cause.
What a Bug Actually Is
A bug is a gap between what the code does and what you intended it to do. That framing matters, because it tells you there are always two things to examine: the code's actual behavior, and your mental model of what it should do.
Sometimes the code is wrong. Sometimes your mental model is wrong. Sometimes both are.
The first job of debugging isn't to fix the code — it's to understand the gap precisely.
The Scientific Method Applied to Code
Debugging has the same structure as scientific investigation:
- Observe — what is actually happening?
- Hypothesize — what would explain this?
- Predict — if my hypothesis is right, what else should I observe?
- Test — design an experiment to check
- Conclude — update your hypothesis based on results
The discipline is: don't touch the code until you understand the problem. Diagnosis first, then treatment.
Bugs Don't Lie, but They Can Mislead
An error message tells you what went wrong from the system's perspective. That's not always where the actual problem is. A null pointer error in function B might be caused by function A returning null when it shouldn't. The symptom (crash in B) points at the wrong place.
The bug is always real — but the symptom and the cause are often in different places. Your job is to trace from symptom back to cause.
The Three Types of Bugs
Syntax errors — the code can't be parsed. Your editor usually catches these before you run the code.
Runtime errors — the code runs but crashes. An error is thrown, and you get a stack trace. These are usually the easiest to debug because the error is explicit.
Logic errors — the code runs without crashing, but produces wrong results. No error is thrown; the program does something other than what you intended. These are the hardest, because the system isn't telling you something's wrong — you have to notice it yourself.
The Anatomy of an Error Message
Error messages are trying to tell you something. Most people scan them for a few words and then start guessing. That's backwards. The error message is often telling you almost exactly what's wrong.
Most error messages have the same structure, even across different languages and frameworks:
What happened: The error type and a brief description.
TypeError: Cannot read properties of undefined (reading 'name')
A TypeError, trying to read a property called 'name' on something that was undefined.
Where it happened: A file path and line number.
at getUserName (users.js:47:12)
In the function getUserName, in the file users.js, on line 47.
How we got here: The stack trace — a list of function calls that led to the error, from most recent to oldest. The top of the stack is where the error occurred; further down is where it was called from.
The Most Common Error Types
| Error Type | What It Means |
|---|---|
| TypeError | Using a value as if it's something it isn't. Calling a function on null. Accessing a property on undefined. |
| ReferenceError | Trying to use a variable that doesn't exist. Usually a typo or scope issue. |
| SyntaxError | Code can't be parsed. Missing bracket, parenthesis, or semicolon. |
| IndexError / KeyError | Asking for something at a position or key that doesn't exist. |
| Import / Module Not Found | Module isn't installed, or the path is wrong. |
When the Error Message Seems Useless
Sometimes you get an error that's technically accurate but unhelpful — a generic "something went wrong" from a library that swallows the real error. In these cases:
- Look for a "caused by" section lower in the error
- Check if the library has a debug mode or verbose logging
- Search for the error message exactly as written — someone else has seen it
Reading the Stack Trace
New programmers often read only the first line of a stack trace (the error) and the second line (where it crashed). But the stack trace tells a story.
Often the most important clue is several lines down, in your own code, before the execution entered a library or framework.
Why Reproduction Matters
Before you can fix a bug, you need to be able to reproduce it reliably. If you can't reproduce a bug, you can't test whether a fix works.
You might change three things, the bug goes away, and you don't know which change fixed it — or if it just stopped happening by coincidence. Reliable reproduction gives you the ability to run an experiment. It transforms debugging from guessing into testing.
Creating a Minimal Reproduction
The goal is to find the smallest, simplest version of the code that still triggers the bug. This is called a minimal reproducible example (MRE).
Start with your failing case and remove things. Can you remove this function call? Does the bug still happen? Remove this parameter? Still happens? Each thing you remove that doesn't affect the bug is something you don't have to think about.
A minimal reproduction is also what you share when asking for help. "My app crashes when I click this button" is not useful. A fifty-line file that demonstrates the crash is.
Flaky Bugs — Intermittent Failures
Some bugs only happen sometimes: under specific timing conditions, with specific network latency, when the database is under load. These are the hardest class of bug because you can't rely on reproducing them.
For flaky bugs: add logging to capture what's happening when it fails. Look for patterns — does it fail more under load? On specific inputs? After a certain amount of time? The pattern usually points at a race condition, a timeout, or an assumption that doesn't hold in all environments.
Binary Search Debugging
The single most powerful debugging technique is binary search. Instead of looking at every line of code, you eliminate half the search space at a time.
Add a check in the middle of your code. Does the bug exist before this point, or after? If before, the bug is in the first half. If after, it's in the second half. Now check the middle of the remaining half. Repeat.
In practice, this looks like adding print statements or breakpoints at strategic points to confirm what the state of the program is at each stage. Git's bisect command applies this same binary search approach to your commit history — see the Git field guide for details.
The Power of Print Statements
Print statements are unfashionable compared to debuggers, but they're fast, require no setup, and work everywhere. Don't be embarrassed to use them.
The goal isn't to print everything — it's to add checkpoints that confirm or deny your hypothesis. "At this point, is x what I think it is? Is this function being called at all? Is it being called more times than I expect?"
print(f"[checkpoint A] user_id={user_id}, type={type(user_id)}")Print the value, the type, and when relevant, where in the code you are. The explicit label matters when you have multiple print statements and need to know which is which. Clean them up before you commit.
Using a Real Debugger
A debugger lets you pause execution at any point, inspect all variables at that moment, and step through code one line at a time. It's slower to set up than print statements but more powerful for complex bugs.
Every major language has one built in or available. Python has pdb (built in) and better alternatives like ipdb. JavaScript has the browser DevTools debugger and Node's built-in one. Most IDEs include visual debuggers.
Key Debugger Techniques
| Technique | What It Does |
|---|---|
| Breakpoints | Pause when execution reaches a specific line. Inspect what's in memory. |
| Step over | Execute the current line and pause on the next one. |
| Step into | If the current line calls a function, follow execution into that function. |
| Step out | Run the rest of the current function and pause when it returns. |
| Watch expressions | Continuously display the value of a specific variable as you step. |
Logging
For bugs that happen in production, or intermittently, or in code you can't easily step through, logging is the equivalent of print statements that run in the wild.
Good logging:
- Records when important things happen (function called, external call made, decision taken)
- Captures relevant context (user ID, request ID, the values being operated on)
- Has meaningful levels (DEBUG for verbose detail, INFO for normal operation, WARN for things that might be problems, ERROR for things that are)
- Includes timestamps
The Usual Suspects
Most bugs aren't novel. They're variations of the same patterns that have appeared in code since the beginning of software. Knowing these patterns by name speeds up diagnosis.
Off-by-One Errors
You loop through indices 0 to n when you meant 0 to n-1. You check < length when you should check <= length, or vice versa. You skip the first element, or process one element too many.
When your results are "almost right but shifted by one," think off-by-one.
Null and Undefined
Accessing a property on something that turned out to be null or undefined. This is the most common runtime error in most languages. It almost always means either (a) something didn't initialize that was supposed to, or (b) you're using a value before it's been set.
Wrong Type
Treating a string as a number, or vice versa. In dynamically typed languages, these are very common. The variable is named count and you assume it's a number, but somewhere upstream it got serialized to a string. Now count + 1 is "51" instead of 6.
# Print the type, not just the value print(type(my_variable)) # Python console.log(typeof myVariable) # JavaScript
Mutation When You Meant a Copy
You passed an object into a function, the function modified it, and now the original is changed — which you didn't intend. In many languages, objects are passed by reference, meaning the function operates on the same object in memory, not a copy.
If you're seeing state changes that seem to happen from somewhere you didn't expect, check whether an object is being mutated when a copy was intended.
Race Conditions
Two operations happen in an order you didn't account for. A file is read before it's finished being written. A database query runs before a previous update has committed. An async function resolves in a different order than you assumed.
The signature is: "it works fine locally but breaks occasionally in production" or "it works fine when I run it slowly but breaks when I add load."
Silent Failures
A function returns an error, and you don't check the return value. An exception is caught somewhere and swallowed silently. An API call fails and returns an empty result that gets processed as if it were valid data.
When results seem wrong with no errors, look for places where errors might be caught and silenced.
Cached Stale Data
You're operating on data that was true ten minutes ago but isn't anymore. A cached value is returned instead of fresh data. A build artifact is stale. A test is using old fixtures.
If your code seems to be doing the right thing but the behavior matches what used to be correct, suspect caching. Try clearing the cache, forcing a rebuild, or restarting.
Scope Confusion
A variable exists in one scope but you're trying to access it from another. A variable name is reused in a nested scope, shadowing the outer one. A closure captures a variable by reference and the value has changed by the time the closure runs.
When a variable has a value you didn't expect, check where it was defined and whether the scope allows what you're trying to do.
The New Failure Modes
AI coding tools have introduced a category of bugs that didn't exist in the same way before. They're important to understand because they fail differently than bugs written by humans.
Confident Fabrication
AI models will write plausible-looking code that calls functions, methods, or APIs that don't exist. They'll invent a function name that sounds right and fits the context. It compiles. It might even run. Then it crashes with a "method not found" error.
Outdated Patterns
Models are trained on code from across time. They might use an API that was deprecated two years ago, a pattern that was replaced by a better one, or a library version that's no longer current. The code is syntactically correct and would have worked previously. It doesn't work now.
When you get errors that suggest an API or method exists but doesn't behave as expected, check the current version of the documentation.
Superficially Correct but Subtly Wrong
AI code often handles the happy path correctly and handles error cases incorrectly or not at all. The main flow works. But edge cases — empty inputs, large inputs, null values, network failures, concurrent access — produce wrong results or crashes.
Copy-Paste Context Errors
AI generates code based on the context you provided. If you asked it to write a function for one context and then adapted that code for another, there may be constants, variables, or assumptions baked in that still refer to the original request. Field names, endpoint paths, hardcoded values.
When adapting AI-generated code, read every line for context dependencies. Don't assume it's context-neutral.
Inconsistency Across a Codebase
If you've generated multiple pieces of a system across different prompts, they might not fit together cleanly. The function in module A returns a dict; the function in module B that was supposed to consume it expects a list. Variable names don't match. Error handling conventions are inconsistent.
AI generates coherent local code but doesn't automatically ensure global coherence. Bugs at integration boundaries are common and worth looking for explicitly.
Using AI as a Debugging Partner
AI is genuinely useful for debugging, but in specific ways. Knowing how to use it well — and where it leads you astray — is its own skill.
Where AI Helps
- Explaining errors you don't understand. Paste the error message and relevant code, ask what's happening and why.
- Generating hypotheses. Describe what you're observing and ask for a list of things that could cause it.
- Finding patterns you've been staring at too long. "Here's a function that should return X but is returning Y. Can you see what's wrong?"
- Writing test cases. Once you understand the bug, AI can help generate tests that would have caught it.
- Understanding unfamiliar code. AI can explain what someone else's code is doing and why it might be failing.
Where AI Misleads You
- It will confidently suggest wrong fixes. It generates plausible-looking fixes based on the pattern of the problem. Always review as critically as you'd review code from a junior developer.
- It can send you down rabbit holes. If your description is incomplete, AI will generate hypotheses that match your description but not the real cause.
- It doesn't have full context. Showing it one file will produce answers that assume the problem is in that file. You often need to provide more context than feels natural.
- It can lead you to symptoms instead of causes. It might correctly identify that a null value is causing the crash but not identify where the null is coming from.
How to Prompt for Debugging Help
Give the AI more context than you think is necessary: the exact error message, the full stack trace, the relevant code, and what you've already tried.
Be explicit about what you've already checked. "I've verified that X is not null at this point" prevents the AI from suggesting things you've already ruled out.
What to Do When You Can't Find It
Everyone gets stuck. The difference between an experienced debugger and a novice isn't that the experienced one never gets stuck — it's that they have a protocol for getting unstuck.
Step 1: Write Down What You Know
Stop staring at the code. Open a text file and write:
- What is the bug? What exactly is happening vs. what should happen?
- When does it happen? What triggers it? When does it not happen?
- What have you already checked and ruled out?
- What do you believe the cause is, and why?
This forces precision. Often, the act of writing reveals either a gap in your reasoning or a clue you'd overlooked.
Step 2: Check Your Assumptions
Pick one thing you're treating as known and verify it explicitly. Print the value you're assuming is X. Check that the function you think is being called is actually being called. Verify that the data coming in is what you expect.
Step 3: Explain It to Someone (or Something)
The rubber duck method: explain the bug out loud to an inanimate object. Or to a colleague, or to an AI. The act of explaining forces you to articulate things you've been holding implicitly, and those implicit things are often where the mistake is.
"So the user submits the form, and the data goes to the endpoint, and the endpoint validates it, and... wait. What does the validation actually return when it fails?"
Explaining reveals gaps. Follow the gaps.
Step 4: Take a Break
This is not laziness — it's neurologically real. Your brain continues working on problems when you step away, and the reduced focus often makes you notice things you were too close to see. A fifteen-minute walk has a non-trivial chance of solving a bug you've been stuck on for an hour.
Step 5: Change Your Approach Entirely
If you've been reading code, try writing tests. If you've been using print statements, try a debugger. If you've been debugging the complex system, try reproducing the bug in isolation. If you've been trying to understand why the bug happens, try reverting to a state where it didn't happen and working forward.
Sometimes the bug is invisible to your current approach. Changing approach changes what's visible.
Step 6: Search for the Error Exactly
Copy the exact error message and paste it into a search engine. Don't paraphrase. Include the exact text in quotes. Stack Overflow, GitHub issues, and language-specific forums contain a vast trove of people who've seen the same error.
Debugging Less
The best debugging is debugging you don't have to do. Tests don't eliminate bugs, but they catch them earlier, when they're cheaper to fix, and they prevent regressions — bugs that you fixed and then reintroduced later.
Write Tests for Bugs You Fix
Every time you fix a bug, write a test that would have caught it before the fix, and verifies it's fixed after. This serves two purposes: it proves the fix works, and it prevents the bug from silently coming back.
The Types of Tests
| Type | What It Tests | Tradeoff |
|---|---|---|
| Unit tests | Single function or module in isolation | Fast and easy, but misses boundary bugs |
| Integration tests | How multiple components work together | Catches more, but slower and more complex |
| End-to-end tests | Whole system from the user's perspective | Closest to reality, but slowest and most brittle |
A healthy test suite has more unit tests than integration tests, and more integration tests than end-to-end tests. Invert this and you get a slow, fragile suite that's expensive to maintain.
Assertions
Assertions are checks baked into your code that verify your assumptions. They catch the gap between what you assumed and what's actually true.
assert user_id is not None, "user_id should be set before calling this function"
assert len(items) > 0, f"Expected at least one item, got {len(items)}"They're cheap to write and make bugs fail loudly at the point where an assumption breaks — rather than silently producing wrong results several steps later.
Defensive Programming
The bug patterns in Part 5 suggest specific defensive habits:
Check for null/undefined before accessing properties
Verify array bounds before indexing
Never trust the type of external data — validate or coerce
Return copies of objects when you don't want mutation
Check error return values — don't assume success
Log at decision points for production traceability
You don't need to defend against everything everywhere. Focus on the boundaries: where data comes in from outside your code (user input, API responses, database results), and where different modules hand off to each other.
The Browser DevTools
Frontend code has its own debugging environment: the browser's built-in developer tools. Every modern browser has DevTools accessible via right-click → Inspect, or F12.
The Tabs You'll Use Most
| Tab | What It Shows |
|---|---|
| Console | JavaScript errors, console.log() outputs, interactive JS execution |
| Network | Every HTTP request: URL, method, status, headers, body. Essential for debugging API calls. |
| Sources | JavaScript debugger. Set breakpoints, step through code, inspect variables. |
| Elements | Actual HTML and CSS as rendered. Useful for layout problems. |
| Application | Cookies, local storage, session storage. Useful for auth and persistence bugs. |
The Most Common Frontend Debugging Scenario
Something isn't working and you don't know why. Start in the Console tab: are there any errors? Address those first.
Then open the Network tab and reproduce the problem. Look at what API calls are being made. Click on the relevant one. Check the request body — is the data being sent what you expect? Check the response — what did the server return? Is the status code what you expected? For a deeper understanding of status codes, request/response anatomy, and API error handling, see the APIs field guide.
Debugging CSS
CSS failures are usually one of: the rule isn't applying (check specificity — a more specific rule is overriding), the rule is applying but not having the effect you expected (check your mental model of the property), or the element isn't where you think it is (check the box model in Elements).
In the Elements tab, clicking on an element shows all CSS rules applying to it on the right side, including which rules are being overridden (shown with strikethrough). This is usually faster than guessing.
The Human Side
Debugging is cognitively demanding and emotionally frustrating. Both the cognitive and emotional sides matter.
Frustration Degrades Performance
When you're frustrated, you narrow focus, skip steps, and start guessing more. This is the opposite of what debugging needs. Frustration leads to "I'll just try this" rather than "I'll test this hypothesis."
Recognizing that you're frustrated and doing something about it — taking a break, changing approach, asking for help — is a debugging skill. Not a soft one. A real one.
The Sunk Cost Trap
"I've been trying this approach for two hours, I must be close." Maybe. Or maybe the approach is wrong and you should abandon it. The time you've spent doesn't change the probability that your current approach will work.
When to Ask for Help
There's no shame in asking for help, and there's no virtue in suffering alone for longer than necessary. But there's a skill to asking well.
Before asking: make sure you've reproduced the bug, can describe it precisely, and have already tried the obvious things. Then ask with the minimal reproducible example and what you've already tried. This respects the helper's time and forces you to articulate the problem clearly — which often resolves it before you even finish asking.
The Click
When you find the bug — when it all makes sense — there's a distinctive feeling. Satisfaction, and often a slightly sheepish recognition that the answer was there the whole time.
That feeling is the reward. It doesn't come from guessing — it comes from methodical investigation. You built toward it step by step. The method is what makes that click reliable rather than lucky.
Debugging Cheat Sheet
When You See an Error
- Read the full error message and stack trace
- Find the first line of the trace in your code
- Form a hypothesis: what would cause this?
- Test the hypothesis before changing anything
The Debugging Loop
- Reproduce reliably
- Isolate — binary search to the cause
- Understand — why is this happening?
- Fix — now change the code
- Verify — does the fix work?
- Write a test — so it doesn't come back
When You're Stuck
Write down what you know and what you've tried
Check one assumption you haven't verified
Explain it out loud (rubber duck)
Take a break
Change your approach entirely
Search the exact error message
AI-Generated Code: Specific Checks
Do all functions/methods called actually exist?
Are you using current API versions, not deprecated ones?
Does error handling cover the likely failure cases?
Are there context dependencies from the original prompt?
Do the pieces fit together at integration boundaries?
Common Bug Patterns to Check First
| Pattern | Quick Check |
|---|---|
| Off-by-one | Is the loop range right? |
| Null/undefined | Did this get set when you expected? |
| Wrong type | Is this actually the type you think? |
| Mutation | Is something being modified that shouldn't be? |
| Silent failure | Is an error being caught and swallowed? |
| Stale cache | Is old data being returned? |
Frontend Debugging
- Open DevTools Console — any errors?
- Open Network tab — what requests are being made, what did they return?
- Click into the relevant request — check request body and response
- For layout: Elements tab, look at applied CSS rules
What You Can Do Now
Next time you hit a bug, resist the urge to immediately start changing code. Instead:
- Reproduce it reliably. Make sure you can trigger it on demand.
- Read the error message completely, including the stack trace.
- Form a hypothesis about what's causing it before touching anything.
- Add one print statement or checkpoint to test whether the hypothesis is right.
- Follow the evidence.
That sequence — reproduce, read, hypothesize, test, follow — is the foundation. Once it becomes habit, debugging stops feeling like guessing and starts feeling like investigation.
The bugs don't get easier. Your ability to find them does.