Look, I used to be the kind of developer who would stare at broken code for 45 minutes straight, convinced I could just think my way to the answer. Spoiler: I couldn't. And neither can you.
This year I finally got serious about debugging. Not "add a print statement and pray" serious. Actually serious. And it genuinely saved me around 100 hours. I tracked it.
Here's everything I learned.
The Real Cost of Bad Debugging
Before we get into techniques, let me hit you with some math that changed how I think about this.
The average developer spends 35-50% of their time debugging. That's not my number, that's from a Cambridge University study. If you work 40 hours a week, you're spending 14-20 hours just finding and fixing bugs.
Now imagine you could cut that by even 30%. That's 4-6 hours back every single week. Over a year? That's 200-300 hours. Enough time to build a side project, learn a new language, or just... not burn out.
The techniques below helped me cut my debugging time by roughly half. Some of them are obvious. Some of them felt stupid until they worked.
Technique 1: Rubber Duck Debugging (But Actually Do It)
Yeah, you've heard of this one. Explain your code to a rubber duck. Cool concept, nobody actually does it.
I started doing it. For real. Not with a duck though. I use a notes app and literally type out what my code is supposed to do, line by line. Something like:
"OK so this function takes a user ID, queries the database for their orders, filters out cancelled ones, and returns the total."
Nine times out of ten, I catch the bug while typing that explanation. Because when you have to explain something step by step, you can't skip over the part where your logic breaks.
Here's the thing that makes this work: you have to be specific. Don't say "this function processes the data." Say exactly what it does with the data. The bug lives in the gap between what you think the code does and what it actually does.
Pro tip: If you're working remotely and feel weird talking to yourself, open a DM with yourself on Slack. Type your explanation there. Same effect, and it creates a searchable log of your debugging process.
Technique 2: The Binary Search (Bisect) Method
This one genuinely changed my life. I'm not being dramatic.
When you have a bug and you don't know where it is, most people start from the beginning and trace through the code linearly. That's O(n) debugging. We can do better.
The bisect method works like this:
- Find a point in your code where you know things are correct (usually the input)
- Find a point where things are broken (usually the output)
- Check the midpoint
- If the midpoint is correct, the bug is in the second half
- If the midpoint is broken, the bug is in the first half
- Repeat
This turns an O(n) search into O(log n). For a 1000-line function (yes, they exist, no, they shouldn't), that's the difference between checking 1000 spots and checking 10.
Git bisect uses this exact same principle. If you know your code worked in commit A and is broken in commit Z, git bisect will binary search through your commit history to find exactly which commit introduced the bug.
git bisect start
git bisect bad # current commit is broken
git bisect good abc123 # this old commit worked fine
# git checks out the middle commit
# you test it
git bisect good # or git bisect bad
# repeat until found
I used git bisect three times this year on production bugs that nobody could figure out. Found the culprit in under 5 minutes each time. My team thought I was some kind of wizard. Nope, just binary search.
Technique 3: Print Debugging Done Right
"Just use a debugger" is advice that sounds smart but ignores reality. Sometimes you're debugging a production system. Sometimes the bug disappears when you attach a debugger (hello, race conditions). Sometimes you're working in an environment where breakpoints aren't practical.
Print debugging isn't bad. Bad print debugging is bad.
Here's how to do it right:
Label everything. Not print(x). Instead: print(f"[DEBUG] user_id={user_id}, order_count={len(orders)}, total={total}"). When you have 15 print statements and they all just say random numbers, you've created a new problem.
Add timestamps. If you're debugging anything async or performance-related, timestamps are essential.
import time
print(f"[{time.time():.3f}] Starting order processing for user {user_id}")
Use conditional prints. Don't print every iteration of a loop that runs 10,000 times. Print when something unexpected happens.
for order in orders:
if order.total < 0: # this shouldn't happen
print(f"[WARN] Negative total found: order_id={order.id}, total={order.total}")
Clean up after yourself. I use a specific tag like #DEBUG so I can search and delete all my debug prints when I'm done. Or use logging levels so debug prints don't leak into production.
Technique 4: Breakpoints Over Print (When Possible)
OK so I just defended print debugging, but let me be real: when you can use breakpoints, they're almost always better.
The main advantage isn't just seeing variable values. It's being able to explore. When you hit a breakpoint, you can:
- Check any variable in the current scope
- Evaluate expressions on the fly
- Step through code one line at a time
- Move up and down the call stack
- Modify variables and continue execution
That last one is huge. You can test a fix without restarting your program. "What if this value was 5 instead of 0?" Just change it in the debugger and see what happens.
Most modern IDEs make this dead simple. VS Code, PyCharm, Xcode, they all have excellent debugging tools. Spend 30 minutes learning your IDE's debugger. It'll pay for itself in a day.
Conditional breakpoints are the secret weapon. Instead of breaking every time a line is hit, only break when a condition is true. Like only pause when user_id == 42 or when response.status >= 400. This saves so much time when debugging issues that only happen with specific data.
Technique 5: Minimize the Reproduction
This is probably the most important technique and the one most people skip.
When you find a bug, your first instinct is to start fixing it. Fight that instinct. Instead, spend time making the smallest possible reproduction case.
If the bug happens in a 500-line function, can you reproduce it in a 20-line script? If it happens with a specific API request, what's the minimal JSON payload that triggers it?
Why? Because a minimal reproduction:
- Often reveals the root cause just by creating it
- Makes it obvious when the bug is fixed
- Can become a test case
- Is way easier to share with teammates
I spent 3 hours once trying to fix a complex data processing bug. Then I extracted the core logic into a simple test script, and the bug became obvious in 5 minutes. The function was comparing strings to integers. That's it. But it was hidden under layers of abstraction in the main codebase.
Technique 6: Read the Error Message. No, Actually Read It.
I know this sounds condescending. Bear with me.
When you see a stack trace, what do you do? If you're like most developers, you look at the top or bottom line and start guessing. Stop doing that.
Read the entire error message. Read the stack trace from bottom to top. Understand the chain of calls that led to the error. The actual cause is often several frames deep, not at the top.
Also, google the exact error message. Not a paraphrased version. The exact text. Copy paste it into your search engine. Chances are someone hit this exact same error and posted about it.
Technique 7: Explain It to Someone Else
Different from rubber duck debugging. I mean actually grab a colleague and explain the bug to them.
This works for two reasons. First, the same effect as rubber duck debugging. Explaining forces clarity. Second, fresh eyes. They might immediately spot something you've been staring past for an hour.
I can't count how many times I've walked over to a coworker, started explaining a bug, and solved it before they even said anything. They just sat there nodding while I essentially talked myself into the answer.
Some teams formalize this as "debugging pairs." One person drives, one person asks questions. It works really well.
My Debugging Checklist
When I'm stuck, I literally go through this list:
- Read the error message properly
- Create a minimal reproduction
- Check the obvious things (typos, wrong variable, off-by-one)
- Explain the expected vs actual behavior out loud
- Binary search for the problem location
- Check what changed recently (git diff, git log)
- Take a 10-minute break, then look again
- Ask someone else to look at it
Step 7 is not a joke. I've solved more bugs on coffee breaks than at my desk. Your brain keeps working on problems in the background.
The Meta-Skill: Knowing When to Stop
The hardest debugging skill isn't technical. It's knowing when you've been staring at something too long and need to step away.
After about 45 minutes of not making progress, your effectiveness drops dramatically. You start making changes without thinking. You start doubting things that are correct. You enter a spiral.
Set a timer. If you haven't made meaningful progress in 30-45 minutes, get up. Walk around. Work on something else. Come back in an hour.
Some of my worst debugging experiences happened because I was too stubborn to take a break. Some of my best debugging happened the morning after I gave up and went home.
Wrapping Up
Debugging isn't a talent. It's a skill, and it can be improved with practice and the right techniques. The bisect method alone probably saved me 50+ hours this year. Proper print debugging saved another 30. And learning to step away and come back fresh saved my sanity.
Start with one technique. Try the bisect method on your next bug. See how it feels. Then gradually add others to your toolkit.
Your future self, the one who's not staying late because of a mystery bug, will thank you.
If you found this useful, I share more stuff like this on Telegram and sell developer toolkits on Boosty.
Top comments (0)