Debugging techniques every developer should know (and the one I keep forgetting)
The bug report said "sometimes the total is wrong." No steps to reproduce, no screenshot, no timestamp. I spent two days chasing a rounding error that turned out to be a timezone issue in a cache key. Two days. For a line of code that looked perfectly innocent.
That's debugging. Not the heroic kind you see in conference talks, where someone stares at a stack trace and instantly spots the null pointer. The real kind is slower, messier, and mostly about knowing which techniques to reach for when your brain is telling you the answer is obvious.
What follows is the toolkit I've built over years of shipping software, including the parts that took me embarrassingly long to adopt. Some of these debugging techniques are old and boring. The boring ones tend to work.
Key takeaways
- Reproducing the bug reliably is 80% of the job. Everything else is easier once you can trigger it on demand.
- Print statements and logging are not amateur moves. Conditional breakpoints and structured logs solve more bugs than any fancy profiler.
- Binary search on your code (or your commit history) beats reading the whole file line by line.
- Rubber duck debugging works because explaining forces you to notice what you assumed.
- My worst debugging sessions all shared one trait: I changed code before I understood the bug.
- Production bugs need observability, not a local repro. If you can't see it, you can't fix it.
Why reproducing the bug first saves you hours
Here's the thing almost every experienced developer will tell you, and almost every junior developer ignores: reproduce the bug before you touch anything. I ignored this for years. I'd read the code, spot something that looked wrong, fix it, and declare victory. Sometimes it worked. Sometimes I'd "fixed" a bug that was still there, then spent another week confused about why the report kept coming back.
The reproduction step does something subtle. It converts a vague complaint ("sometimes it breaks") into a precise trigger ("it breaks when the user's session expires mid-request"). Once you have that trigger, the bug becomes a puzzle with a defined shape.
How to build a reliable reproduction
Write the smallest possible script, test, or reproduction case that fails. Not the full application. The smallest thing. If your bug lives in a 400-line function, pull out the 12 lines that matter and run them in isolation.
- Capture the exact input that triggers it, including edge cases like empty strings, null values, and the one weird character a user pasted from Excel
- Note the environment: OS, runtime version, environment variables, database state
- Run it five times. If it fails four out of five, you don't have a reliable repro yet. Keep digging
If you genuinely cannot reproduce it, that's your real bug. Intermittent failures usually point to concurrency, timing, uninitialized state, or something external like a flaky network call. Those are the ones that eat weekends.
The classic techniques that still work in 2026
Every few years someone declares print debugging dead. Then a senior engineer at a company you've heard of ships a fix using exactly that. The classics survive because they're fast and require nothing but a terminal.
Print and log debugging
Drop a print statement and see what the value actually is. I know. It sounds trivial. But the number of times I've been certain a variable held one value and it held another is genuinely embarrassing.
Structured logging takes this further. Instead of console.log("here"), log {"event": "payment_retry", "attempt": 3, "status": "timeout"}. When you're searching logs at 11pm, having fields you can filter on matters more than you'd think.
Rubber duck debugging
You explain the problem out loud to a rubber duck, a colleague, or your cat. Halfway through the explanation, you hear yourself say something like "and then the function returns the cached value"—and you stop. Because you never invalidate that cache.
I've solved more bugs this way than with any debugger. The duck doesn't know anything. You do. You just haven't heard yourself say it yet.
Binary search your code and your history
Two flavors, both underrated:
- Bisect the code — comment out half the function, see if the bug persists. Repeat. You'll narrow it down in a handful of steps instead of reading 300 lines.
- Bisect the commits — if the bug appeared recently, use
git bisect. Git will do the binary search for you across your commit history and hand you the exact commit that broke things. I once found a regression in a 900-commit range in about eight test runs.
Debugging techniques in Python you'll actually use
Python has a reputation for being easy to debug, and mostly that's deserved. The ecosystem gives you tools that are one import away, which is a luxury developers in compiled languages don't always have.
The built-in debugger
Drop breakpoint() anywhere in your code and you get a full interactive debugger. No imports, no setup. Once you're in, n steps to the next line, s steps into a function, c continues, and p some_variable prints whatever you want. If you've been debugging Python with print statements only, this is worth the twenty minutes it takes to learn.
Exception tracebacks and post-mortem debugging
When a script crashes, you don't have to lose the crime scene. Run it under python -m pdb your_script.py and when the exception fires, you land in the debugger at the exact line, with the full stack intact. You can inspect every local variable that led to the failure.
For quick investigations, traceback.print_exc() and the logging.exception() call inside an except block give you the full trace without swallowing the error. Swallowing errors is the single most common self-inflicted debugging wound I see in code review.
Assertions as checkpoints
An assert statement that documents your assumptions is a gift to your future self. If the assumption breaks, you find out immediately, at the exact line, instead of three functions later with a confusing type error.
A comparison: which technique for which situation
| Technique | Best for | Weak spot |
|---|---|---|
| Print / logging | Fast sanity checks, tracking values through a flow | Pollutes output if left in; noisy logs |
| Interactive debugger | Stepping through complex logic, inspecting state | Awkward in distributed or async systems |
| Git bisect | Regressions with a known-good starting point | Useless if the bug has always existed |
| Rubber duck | When you're stuck in a loop and can't see the obvious | Requires you to slow down and articulate |
| Profiler | Performance bugs where the code works but is slow | Doesn't help with logic errors |
Advanced techniques for stubborn bugs
Some bugs don't care about your print statements. They show up in production, once a week, only for one customer. For those, you need different tools.
Conditional breakpoints
A breakpoint that only fires when user_id == 8421 or when retry_count > 3. Instead of pausing on every request, you pause on the one that matters. In production-like environments, this is often the difference between finding the bug and giving up.
Profiling and tracing
If the code works but the response takes four seconds, a profiler tells you where the time actually goes. I've watched a team spend a week optimizing a database query that turned out to be 4% of the total time. The actual culprit was a logging call formatting a huge object on every request.
Distributed tracing does the same thing across service boundaries. When a request touches eight microservices, a trace shows you which hop is slow. Without it you're guessing.
Observability in production
You can't attach a debugger to a container that's already been replaced. What you can do is make sure your application emits enough structured logs, metrics, and traces that you can reconstruct what happened after the fact. This isn't debugging in the traditional sense. It's building the ability to debug things you'll never reproduce locally.
What is debugging in programming?
Debugging is the process of finding, understanding, and fixing the gap between what you expected your code to do and what it actually does. That gap is almost always in your head, not the machine. The computer is doing exactly what you told it to. You just told it something you didn't mean.
Which is why the most important debugging skill isn't knowing a tool. It's the willingness to question your own assumptions, especially the ones that feel too obvious to check.
The mistakes I keep making
Changing code before understanding the bug. I tweak something, the symptom disappears, I move on. Then it comes back two weeks later wearing a different hat. Every time.
Trusting the log message over the log data. A log said "cache miss" so I went looking at the cache layer. The cache was fine. The key was wrong because a timestamp was in a different timezone. The message was accurate and completely misleading at the same time.
Skipping the reproduction step because "I know what it is." I don't. Not always. And when I'm wrong, I've burned an hour I'll never get back.
The techniques above are worth learning. The discipline to use them in order, especially when you're frustrated and certain, is what actually separates developers who fix bugs from developers who chase them.
Next time something breaks at 4pm on a Friday, resist the urge to start editing. Reproduce it first. Then decide which tool fits. Your weekend self will thank you.