Debugging Techniques for Developers: Practical Tips That Actually Work

Debugging techniques decide how much of your week goes to building and how much goes to staring at code that refuses to behave. I have worked on websites, e-commerce stacks and my own CRM since 2012. In that time, finding out why code misbehaves has cost me far more hours than writing it. This guide walks through breakpoints, logging, git bisect, rubber duck debugging, profilers and production debugging in the order I actually use them.
My goal is not to hand you a tool list. Instead, I want to give you a repeatable way of thinking that keeps you calm when something breaks. The code snippets stay short, because these methods work the same way in Python, JavaScript or PHP.
What are debugging techniques, and why do developers need a system?
Debugging techniques are repeatable methods for locating and removing the cause of a gap between how a program should behave and how it actually behaves. You need a system because random trial and error works on tiny bugs but burns hours on complex ones, and it often hides the bug instead of fixing it.
In practice, debugging has four steps. First, reproduce the bug. Second, narrow down where it happens. Third, understand why it happens. Finally, verify the fix. Many developers skip the second and third steps and jump straight to a patch. The result is usually a change that silences the symptom and leaves the root cause in place.
I follow one simple rule. If I cannot explain the bug in a single sentence, I am not ready to fix it. For example, "the cart total is wrong" is a symptom. "The discount applies before VAT, so the total is wrong" is an explanation. Once you can write the second sentence, the fix is often only a few lines.
Why is reproducing the bug always the first step?
You cannot confirm a fix for a bug you cannot reproduce. So before anything else, find the shortest sequence of steps that triggers the bug every time. That sequence becomes both your test case and your proof after the fix.
While reproducing, I ask these questions in order:
- Which browser, device, operating system or server shows the bug?
- Does it affect one user, one role or one specific record?
- Does it happen every time, or only now and then?
- Did it start after a deploy, a dependency update or a config change?
- If I shrink the input, does the bug still appear?
The last question is the most valuable one. A bug in a thousand-row CSV file can shrink to a single row if you keep splitting the file in half. As a result, you end up with a tiny, shareable example that you can turn into a test. The Stack Overflow guide to a minimal reproducible example describes the same principle: minimal, complete and reproducible.
How do breakpoints work in debugging?
A breakpoint is a marker that pauses your program on a specific line. When execution stops, you can inspect variable values, read the call stack and step through code line by line. In other words, you compare your assumption about a value with its real value.
Every modern debugger offers three core step commands:
- Step over runs the current line and moves to the next one.
- Step into enters the function called on the current line.
- Step out runs to the end of the current function and returns to the caller.
In Python, the quickest entry point is the built-in breakpoint() function. It arrived with PEP 553 in Python 3.7 and opens pdb by default. The official pdb documentation lists every command. In JavaScript, the debugger; statement pauses execution when DevTools is open. That said, remove these lines before you commit. I use a pre-commit hook for exactly that.
When do conditional breakpoints and logpoints save the day?
A conditional breakpoint pauses only when an expression you define is true. Imagine a loop that runs ten thousand times, and you only care about order_id == 4821. A normal breakpoint stops you ten thousand times. By contrast, a conditional one takes you straight to the problem iteration.
Meanwhile, a logpoint prints a message to the console without pausing. Chrome DevTools and VS Code also support it. You can watch a value without adding console.log, saving the file or rebuilding. The Chrome DevTools breakpoint guide also covers DOM change, XHR and fetch, and event listener breakpoints.
My personal favourite is "pause on caught exceptions". When a try block quietly swallows an error, the normal flow leaves no trace. With this option on, the debugger stops on the exact line that threw. Therefore, many "no error, but the result is wrong" bugs become visible in seconds.
How does logging fit into debugging techniques?
Logging is your program telling its own story while it runs. A breakpoint only helps on your machine, at the moment you trigger it. A log, on the other hand, tells you what happened in production at 3 a.m. in a session you never saw. For that reason, logging is the long-term backbone of all debugging techniques.
A good log line contains:
- A timestamp, ideally with a time zone.
- A level: DEBUG, INFO, WARNING, ERROR or CRITICAL.
- An ID for the request or job, such as a request ID or order number.
- A short, stable message that says what happened.
- Context fields, such as user ID, amount or target service.
That said, too much logging is also a problem. When everything goes to the log, the important line gets lost, and storage costs grow. Also, passwords, card numbers and personal data should never reach your logs. In my own projects, the logger itself masks phone and email fields. That way, one careless developer cannot turn into a data leak.
Why is structured logging stronger than plain text logs?
Structured logging means you write each log entry as an object of key and value pairs, usually JSON. A plain text line such as "User 42 paid 150" reads well for humans. For machines, however, it is hard to parse. A JSON entry, by contrast, lets you filter, count and chart right away.
For instance, take an entry like {"event":"payment_failed","user_id":42,"amount":150,"provider":"pos"}. With it, you can answer "how many payments failed per provider in the last hour" with one query. With plain text, you need fragile regular expressions. Worse, your query breaks silently the day someone rewords the message.
In systems with several services, a correlation ID becomes critical. If a request travels from the front end to the API, then to a payment service and a queue, every service should log the same ID. The OpenTelemetry concept of traces standardises this idea. In short, it groups the whole journey of a request under one trace. As a result, "which service slowed down" stops being a guess.
How do you find the commit that introduced a bug with git bisect?
Git bisect finds the commit that introduced a bug through binary search. You mark one "good" commit and one "bad" commit. Git then checks out the commit in the middle, you test it, and you report the result. Each step cuts the range in half.
The basic flow looks like this:
- Start the session with git bisect start.
- Mark the current, broken version with git bisect bad.
- Mark a version you know works with git bisect good v2.3.0.
- Test each commit Git checks out and answer good or bad.
- When Git names the first bad commit, leave with git bisect reset.
The power, then, comes from simple maths. In a range of 1,000 commits, you need at most about 10 steps, because 2 to the power of 10 is roughly 1,000. Moreover, git bisect run accepts a test script and drives the whole search for you. An exit code of 0 means good, and most other codes mean bad. The official git-bisect documentation covers the details.
When can git bisect mislead you?
Bisect assumes that every commit builds and can run your test. If a middle commit does not compile, or another bug breaks the test there, the answer will be wrong. In that case, use git bisect skip, and Git picks a nearby commit instead.
Next, the second trap is a flaky bug. If the bug appears once in ten runs, a single passing run tells you little. So for flaky bugs, I wrap the test in a loop that runs twenty or thirty times and fails on the first failure. The search takes longer, but the result holds up.
The third trap is the environment. If the real cause lives in a dependency version, a database schema or a server setting, bisect can point at an innocent commit. In short, treat the bisect result as a hypothesis. Read the change in that commit and explain how it connects to the bug. If you cannot explain it, keep looking at the environment.
Does rubber duck debugging actually work?
Yes, it works. Rubber duck debugging means explaining your code line by line, out loud, as if to someone who knows nothing about it. The name comes from The Pragmatic Programmer by Andrew Hunt and David Thomas. Specifically, the book tells the story of a developer who explained his code to a rubber duck on his desk.
The reason it works is simple. When you read code in your head, you skip lines you "know" are fine. When you explain them, you cannot skip them. Putting each line into words exposes your assumptions. I often catch the bug halfway through a sentence like "this list is always full, because..."
Also, you do not need a real duck. Writing the problem, the expected behaviour and the actual behaviour into an empty note works just as well. In fact, many developers find the answer while typing a question to a colleague, before they even hit send. Put simply, the method slows your thinking down and puts it in order.
How do you debug performance problems with a profiler?
A profiler measures where your program spends its time and memory. Performance bugs do not produce wrong results. Instead, they produce correct results far too slowly. That is why breakpoints fall short here, and you need measurement.
I split profilers into three rough groups:
- CPU profilers show which function runs for how long. Examples include cProfile in Python, the --prof flag in Node.js and the Performance panel in Chrome DevTools.
- Memory profilers show which objects hold memory and where leaks grow. Examples include heap snapshots in the browser and tracemalloc in Python.
- Database profilers show slow queries and missing indexes. The MySQL slow query log and EXPLAIN are the basics.
In practice, a flame graph is the fastest way to read the output. The widest boxes are the functions that eat most of the time. On the web side, I recommend pairing this with the front end checks from my Google Lighthouse performance test guide. For the search side of speed, see how site speed affects SEO.
How do you catch hidden performance bugs like N+1 queries?
An N+1 query problem happens when code runs one query to fetch a list, then one extra query for every item in it. A category page with a hundred products fires a hundred and one queries. In development, with ten records, you never notice. In production, with thousands, the page hangs for seconds.
The most reliable way to catch it is to count queries per request. Laravel Debugbar, Django Debug Toolbar and similar tools list every query on a page. When you see dozens of near identical queries, you are almost certainly looking at N+1. The fix is usually eager loading or a single JOIN.
Likewise, similar hidden bugs include file reads inside loops, uncached external API calls and needless serialisation. They all share one trait: the logic is correct, but it collapses as scale grows. So when someone reports slowness, my first move is not to guess. Instead, I open the profiler and look for the widest box.
How do you debug production issues safely?
Production debugging means finding a bug in a live system that real users depend on, usually without breakpoints. The rules change here. First you stop the impact, then you look for the root cause. Trial and error on a live system can turn a small bug into a full outage.
My production order looks like this:
- Measure the impact: how many users, which flow and since when.
- Check the latest change: a deploy, a config edit, a DNS change or a certificate renewal.
- Roll back if needed, since reverting the last deploy is usually safer than a hot fix.
- Filter logs and your error tracker by correlation ID.
- Reproduce the bug in a staging environment and fix it there.
Small tools also help with infrastructure issues. If a domain points to the wrong server, a DNS lookup tool shows the records in seconds. If you suspect a redirect loop, a redirect checker shows the whole chain. That way, you rule out infrastructure before you blame the code.
How should you read error trackers and stack traces?
Error trackers such as Sentry, Rollbar or Bugsnag catch production exceptions automatically. They group similar errors and attach the stack trace, browser details and user steps to each one. Consequently, you learn about a bug before a customer complains.
Still, many developers underrate stack traces. Yet a trace is a map of the failure. For example, the top line shows where the error surfaced. However, the real cause often sits lower down, on the line where your code passed a bad value into a library. So I do not read traces from the top. I start at the first frame that belongs to my own files.
Front end errors also add another obstacle: minified JavaScript. The trace says "main.a3f.js line 1, column 48213" and tells you nothing. Once you upload source maps to your error tracker, the trace points to real files and line numbers again. Also, upload source maps only to the tracker instead of serving them publicly, so your source code stays private.
How do you apply hypothesis driven debugging?
In hypothesis driven debugging, you write down what you expect before every experiment. A sentence like "if the cache causes this, disabling the cache should make the bug disappear" gives the experiment meaning. An experiment without a hypothesis teaches you nothing, whatever the outcome.
I keep a simple three column note: hypothesis, experiment and result. As a result, every rejected hypothesis shrinks the search area. For example, once you rule out the database connection, then the cache, then the third party API, the remaining space gets small, and the bug has fewer places to hide.
There is also one more benefit. When you brief a teammate, you can say "I tried these, and it was not these". So nobody repeats the same experiments. Moreover, once you find the root cause, the note becomes a ready draft for your post incident review.
How do you tell dependency bugs from environment bugs?
Some bugs do not live in your code at all. They live around it: a library version, the operating system, the PHP or Node version, an environment variable or a time zone. "It works on my machine" usually points to this category. For these bugs, comparing two environments beats reading code line by line.
First, I check the lock file: package-lock.json, composer.lock or a requirements file. If the two environments install different versions, the bug most likely lives there. Next, I compare environment variables and config files. If you use containers, running the same image locally removes the environment gap in one move.
Time zones and character encoding are the two sneakiest differences. Broken special characters or dates that shift by one day often come from a mismatch between the server and the database. Therefore, I verify both settings on day one whenever I move a project to a new server.
Which debugging techniques fit which situation?
Above all, each technique answers a different question. The table below sums up which tool I reach for first, based on the symptom. Treat it as a starting point, not a rulebook. On most bugs you will combine two or three debugging techniques.
| Symptom | First technique | Supporting technique | Typical environment |
|---|---|---|---|
| Wrong result, reproducible locally | Breakpoint | Unit test | Development |
| Worked yesterday, broken today | Git bisect | Diff review | Development |
| Only in production, only sometimes | Structured logs | Error tracker | Production |
| Correct but far too slow | Profiler | Query counter | Staging and production |
| No idea where to start | Rubber duck | Minimal example | Anywhere |
| Error swallowed silently | Pause on caught exceptions | ERROR level log | Development |
Notice one thing: the production rows contain no breakpoints. Pausing a live system means pausing your users. So in production, your eyes are logs and traces, and your safest tool is the rollback button.
What are the most common debugging mistakes?
The mistake I see most often is changing several things at once. If you change three lines together and the bug disappears, you do not know which change fixed it. Worse, the other two might have introduced a new bug. Change one variable per experiment. That rule holds for science, and it holds for code.
Other common mistakes include:
- Editing code before reading the error message.
- Testing stale code because a cache, a build output or the browser cache still serves the old version.
- Crossing a module off the suspect list too early because "that part is definitely fine".
- Skipping the reproduction steps after the fix.
- Silencing the error with try and catch before finding the root cause.
I want to stress the cache point. PHP OPcache, a CDN, a service worker or the browser cache can make you debug code that never runs. When I get suspicious, I add a temporary, obvious log line to confirm the new code actually executes. Then I open a private window and clear the server cache on purpose. That two minute check has saved me many hours of searching in the wrong place.
Which habits make debugging faster?
Developers who master debugging techniques are rarely smarter. Usually they are more organised. The first habit is a bug journal with symptom, hypothesis, experiment and result. That short note saves hours when the same bug returns six months later. It also makes knowledge sharing inside a team much easier.
The second habit is turning every fixed bug into a test. After that, once your reproduction steps become an automated test, the same bug cannot quietly return. Developers call this a regression test. Combined with bisect, it forms a strong safety net.
The third habit is taking breaks. It sounds like a cliché, I know. Still, after two hours on the same function, your brain stops reading the code and starts seeing what it remembers. After a ten minute walk, I often spot the bug at first glance. Finally, set up your tools in advance. Configure the debugger, log levels and error tracking on a calm day, not in the middle of an incident.
How can a team debug more efficiently together?
In a team, debugging is largely a problem of sharing information. If the person reporting a bug gives too little context, the person fixing it loses hours on reproduction. That is why a good bug report template directly affects how fast a team debugs.
In practice, my template has five fields: expected behaviour, actual behaviour, reproduction steps, environment details and a screenshot or log excerpt if available. Nobody should be able to open a ticket without them. As a result, vague reports like "the site is broken" turn into actionable tasks.
Pair debugging also works well, and it is one of the most social debugging techniques. One person drives the keyboard while the other observes and challenges assumptions. Essentially, it is rubber duck debugging with a duck that talks back. In larger setups, such as a micro frontend architecture with several teams, these shared sessions help you find which team boundary the bug sits on.
How does debugging affect SEO and conversions on websites?
On web projects, debugging is not only a technical matter. It directly shapes what visitors and search engines see. If a JavaScript error breaks the checkout button, the code shows one exception, but the business loses a sale. A redirect loop, meanwhile, can push a page out of the index.
So on client projects I set up error tracking not only on the server but also on the front end and on key conversion steps. I track browser console errors, failed form submissions and error codes from the payment provider separately. For crawling and indexing errors on Google's side, I rely on Google Search Console reports.
For a broader health check, my technical SEO tips make a good checklist. If you are building a new site, planning logging and error tracking from day one is a standard part of my web design process. On online stores, checkout errors are one of the first things I review in e-commerce consulting.
Where should you start to improve your debugging skills?
The most effective first step is to properly learn the debugger for your language. Many developers rely on print statements for years, so they never learn other debugging techniques. For one week, make a rule: every bug starts with a breakpoint. Step commands, conditional breakpoints and the call stack will soon become reflexes.
Next, keep your Git history clean. Small, single purpose commits that always build make bisect reliable. With huge "various fixes" commits, bisect may find the right commit, but finding the guilty line inside it stays hard.
Lastly, add logging and error tracking to your own side projects. Even on a small project, writing structured logs and connecting an error tracker changes how you think about production bugs. In short, debugging is not a talent. It is a craft that grows with practice. For more posts like this one, browse the software category.




