Debugging Techniques Every Developer Should Know
Debugging is the art of figuring out why code does not work as expected. Even the best developers spend a significant portion of their time debugging — studies suggest 30-50% of development time is spent finding and fixing bugs. Having a systematic approach and the right tools turns debugging from a frustrating guessing game into a methodical investigation. This article covers logging, interactive debugging, stack trace analysis, profiling, and git bisect, with practical examples you can apply immediately.
Structured Logging
Logging is the most basic and most important debugging tool. Print statements work for tiny scripts, but production systems need structured, level-based logging that can be searched and filtered. Python’s logging module supports severity levels (DEBUG, INFO, WARNING, ERROR, CRITICAL), log formatting, and output to multiple destinations (console, file, external service). Always use structured logging with JSON output so that log aggregation tools like the ELK stack, Splunk, or Datadog can parse and index your logs automatically. Include contextual data like request IDs, user IDs, and transaction IDs in every log message to trace a request across multiple services.
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)
# Always pass extra context for traceability
logger.info("Payment processed",
extra={"txn_id": "txn_abc123", "amount": 49.99})
Log at the right level — DEBUG for detailed diagnostic information, INFO for normal operations (request started, payment completed), WARNING for unexpected but non-critical issues (slow query, retry attempt), ERROR for failures that need investigation (database connection lost, API returned 500), and CRITICAL for catastrophic failures that require immediate human intervention. Too much logging (especially at INFO or DEBUG in production) generates noise and costs; too little leaves you blind when something goes wrong.
Interactive Debugging with Breakpoints
When logs are not enough, you need to pause execution and inspect the program state. Python’s built-in breakpoint function (available since Python 3.7) drops you into a debugger at the line where it is called. It respects the PYTHONBREAKPOINT environment variable, so you can use different debuggers in different environments — pdb locally, web-pdb in containers, or skip all breakpoints in production by setting PYTHONBREAKPOINT=0.
def calculate_discount(price, customer_tier, items_count):
# Set a breakpoint here to inspect variables
breakpoint()
base_discount = 0.05
if customer_tier == "gold":
base_discount += 0.10
elif customer_tier == "platinum":
base_discount += 0.15
if items_count >= 10:
base_discount += 0.05
return price * (1 - base_discount)
# In the debugger you can type:
# (Pdb) price -> 100.0
# (Pdb) customer_tier -> 'gold'
# (Pdb) c -> continue execution
In the debugger, you can type any Python expression to inspect variables, call functions, or modify state. The most useful commands are n (next line), s (step into function), c (continue until next breakpoint), l (show surrounding source code), p variable (print variable), and pp variable (pretty-print for complex objects). For web development, tools like ipdb (IPython-enhanced pdb), pudb (visual console debugger), and web-pdb (debug over HTTP in a browser) provide richer debugging experiences.
Reading Stack Traces
A stack trace shows the chain of function calls that led to an exception. Read it bottom to top — the last line in the traceback is usually where the error occurred (the deepest call in the stack). Your application code is typically in the middle of the traceback; the top lines are framework or library internals. When reading a traceback, identify the exception type (e.g., KeyError, AttributeError, ValueError), the error message, and the exact line number where it was raised. Then work backwards through the call chain to understand how your code reached that state.
Profiling for Performance Bugs
Not all bugs are logic errors — performance bugs (slow functions, memory leaks) are just as damaging. Profiling measures where your program spends its time and memory. cProfile is Python’s built-in deterministic profiler — it records every function call with timing information. Memory profiling with the memory-profiler package shows memory usage line by line, helping you identify objects that are unexpectedly retained.
# CPU profiling
import cProfile, pstats
def process_data():
data = [i ** 2 for i in range(100000)]
filtered = [x for x in data if x % 2 == 0]
return sum(filtered)
cProfile.run('process_data()', 'profile_stats')
p = pstats.Stats('profile_stats')
p.sort_stats('cumtime').print_stats(10)
Git Bisect — Finding the Regression Commit
When a bug appears that was not there before, git bisect performs a binary search through your commit history to find the exact commit that introduced the regression. Start by marking the current commit as bad and a known-good commit (from before the bug appeared) as good. Git then checks out a commit halfway between them, and you test whether the bug is present — you mark it good or bad. Each step halves the remaining search space, so finding the culprit among 1000 commits takes only about 10 steps.
# Start bisect
git bisect start
git bisect bad # current commit is broken
git bisect good v1.0 # tag v1.0 was working
# Git checks out a commit — test it
git bisect bad # or: git bisect good
# Repeat until git identifies the first bad commit
# Or automate with a test script:
git bisect run pytest tests/test_feature.py
# End bisect session
git bisect reset
Automated git bisect run is incredibly powerful — give it a script that exits with code 0 (good) or non-zero (bad), and it will run through the entire binary search without any manual intervention. Set this up as part of your CI pipeline to automatically identify which commit introduced a performance regression or test failure.
