How To Debug Python Code Like a Pro
Skill Level: Intermediate
Debugging is an essential skill for every Python developer. Whether you’re tracking down a subtle logic error, identifying memory leaks, or understanding why your code behaves unexpectedly in production, having a solid debugging toolkit can save you hours of frustration. This comprehensive guide walks you through professional-grade debugging techniques, from simple print statements to advanced IDE features and logging strategies.
The journey from casual debugging involves understanding the right tool for each situation. Some developers rely entirely on print statements, while others prefer strategic logging. Effective debugging requires a multi-faceted approach: knowing when to use print statements for quick checks, when to deploy the interactive debugger for deep inspection, and when logging is your best friend for production issues.
Throughout this article, we’ll explore practical examples using Python’s standard library tools, popular IDEs, and battle-tested patterns that professional development teams use daily.
Print Debugging vs. Professional Debuggers
The Case for Print Statements
Print debugging is often dismissed by purists, but it’s actually a legitimate technique for certain scenarios. When you need quick answers about variable values at specific points in your code, a strategically placed print statement can give you instant feedback.
def calculate_discount(price, discount_rate):
print(f\"Input price: {price}, discount_rate: {discount_rate}\")
discounted = price * (1 - discount_rate)
return discounted
result = calculate_discount(100, 0.2)Why Professional Debuggers Matter
Professional debuggers offer capabilities that print statements simply cannot provide. They allow you to pause execution, inspect the entire program state, step through code line by line, and modify variables on the fly.
Python’s Built-in pdb Debugger
Python includes the pdb (Python Debugger) module, a powerful interactive debugging tool. Use pdb.set_trace() to insert breakpoints. Once paused, inspect variables, step through code with ‘n’ (next), ‘s’ (step), ‘c’ (continue), or ‘p variable’ (print).
IDE Debugging: VS Code and PyCharm
Visual Studio Code
VS Code’s Python extension provides full-featured graphical debugging. Create a launch configuration in .vscode/launch.json and set breakpoints by clicking line margins. VS Code displays variables in the sidebar and allows inline inspection via hover.
PyCharm’s Advanced Features
PyCharm offers Data Inspector for viewing complex structures, Evaluate Expression for running code immediately, Conditional Breakpoints, Logpoint for non-stopping messages, and Remote Debugging support.
The Logging Module for Production
Python’s logging module is far superior to print statements for production code. Use appropriate log levels: DEBUG for diagnostics, INFO for confirmation, WARNING for unexpected events, ERROR for serious problems, CRITICAL for system failures.
Structured logging (JSON format) enables easier analysis in log aggregation systems, turning a million log lines into actionable insights.
Debugging Patterns
Rubber Duck Debugging
Explain your code line-by-line to an inanimate object. This forces you to articulate assumptions and often reveals logical errors you missed.
Assertion-Based Debugging
Assertions validate preconditions and catch violations early. Remember they’re disabled in production with the -O flag, so use for development only.
Context Managers
Create reusable debugging utilities using context managers to log execution time without cluttering main code.
Reading Tracebacks
Always read tracebacks from bottom to top. The innermost frame usually indicates the actual error. Tracebacks show error type, message, and execution chain with line numbers pointing to the problem.
Different Environments
Local Development
Use full debugging capabilities, verbose logging, and interactive debuggers for maximum visibility.
Production Safety
Can’t attach debuggers to running servers. Rely on comprehensive logging to files and external services. Avoid logging sensitive data.
FAQ
Q1: Debug multi-threaded code?
Use thread-safe logging with thread identifiers. Avoid print statements. Consider using threading.Lock() to control execution order during debugging.
Q2: Debug remote code?
Most IDEs support remote debugging. VS Code and PyCharm allow connecting to Python processes on other machines.
Q3: Find memory leaks?
Use tracemalloc module to track memory allocation and show top allocators in your code.
Q4: Debugging vs profiling?
Debugging finds why code is broken. Profiling measures performance. Use debugging when code fails; profiling when code is correct but slow.
Q5: Debug in Docker?
Run Python with unbuffered output (-u flag), map debugger ports for IDE debugging, or rely on logging. VS Code Remote Containers extension helps locally.
Q6: Leave debug code in production?
Never leave breakpoint() calls. Remove pdb.set_trace(). Logging is appropriate but use right levels and never log sensitive data.
Conclusion
Professional debugging separates junior from experienced engineers. Master the spectrum: print statements for iteration, pdb for exploration, logging for production visibility, and assertions for early issue catching. Develop intuition through practice on real projects. Debugging isn’t failure—it’s inevitable in development. Efficient debugging means more time building features.
Official Python Resources
Related Articles
- How to Dockerize a Python Application
- How to Deploy a Python App on AWS Lambda
- Python Exception Handling Best Practices
pdb: The Built-In Debugger
Python ships with an interactive debugger you don’t have to install. Drop breakpoint() into any line and the script pauses there, dropping you into a REPL with full access to local variables:
# example.py
def transform(items):
result = []
for i in items:
breakpoint() # script stops here, pdb prompt appears
result.append(i * 2)
return result
transform([1, 2, 3])
The pdb prompt accepts: n (next line), s (step into function), c (continue to next breakpoint), p var (print value), l (list source around current line), w (show stack trace), q (quit). Just typing a variable name evaluates it.
For Python 3.7+, breakpoint() is preferred over the older import pdb; pdb.set_trace() — it respects the PYTHONBREAKPOINT env var, so you can swap debuggers without editing source. Set PYTHONBREAKPOINT=ipdb.set_trace to use ipdb (better tab completion, colors); set PYTHONBREAKPOINT=0 to disable all breakpoints in production.
Post-Mortem Debugging
When a script crashes, you can drop into pdb at the moment of failure to inspect what went wrong — without modifying the source:
# Run the script normally
python script.py
# (it crashes)
# Now run it under post-mortem pdb
python -m pdb script.py
# at the prompt: c (continue) — runs until exception
# then automatically lands you at the crash point with the stack intact
# Or trigger post-mortem from inside code:
import pdb, sys, traceback
try:
risky_operation()
except Exception:
traceback.print_exc()
pdb.post_mortem()
Post-mortem is the single most underused debugging technique. Beats adding print statements after the fact and re-running.
logging vs print()
For anything more complex than a script, replace print() with logging. The difference matters: logging has levels (DEBUG, INFO, WARNING, ERROR, CRITICAL), can be routed to files / syslog / cloud, includes timestamps and call sites, and can be tuned per module without code changes:
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s",
)
logger = logging.getLogger(__name__)
def fetch_users(account_id: int):
logger.debug("fetch_users called with account_id=%s", account_id)
try:
users = db.query(...)
logger.info("fetched %s users", len(users))
return users
except Exception:
logger.exception("fetch_users failed") # auto-includes traceback
raise
logger.exception() inside an except block is the killer feature — it records the message AND the full stack trace, ready to ship to your log aggregator.
IDE Debuggers: VS Code and PyCharm
Graphical debuggers beat pdb for complex sessions. VS Code’s Python extension and PyCharm both give you:
- Click-to-set breakpoints in the gutter
- Conditional breakpoints (“break only if x > 100”)
- Step over / into / out buttons
- Variable inspector showing all locals (and globals)
- Watch expressions that auto-evaluate as you step
- Call stack with click-to-jump-to-frame
For a one-off bug, pdb at the terminal is faster. For a meaty multi-hour debugging session, an IDE debugger pays for itself.
Tracing and Profiling: Beyond Debugging
Sometimes “where is this slow?” or “why is this called 1000 times?” matters more than “what’s the bug?”. For those questions, profiling tools are the right answer:
# Built-in cProfile — function call counts and timings
import cProfile
cProfile.run("expensive_function()", sort="cumulative")
# tracemalloc — memory allocation tracking
import tracemalloc
tracemalloc.start()
result = process_data()
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat)
# py-spy — sampling profiler that runs against a live process
# pip install py-spy
# Then: py-spy top --pid 12345
# Or: py-spy record -o profile.svg -- python myscript.py
For production performance debugging, py-spy is unmatched — it attaches to a running process without restarting it, so you can profile a real workload as it happens.
Common Pitfalls
- Leaving breakpoints in production. A forgotten
breakpoint()in a server will block the request thread until somebody typescat the (invisible) prompt. SetPYTHONBREAKPOINT=0in production env to neutralize them. - print() debugging on large data.
print(huge_list)prints thousands of lines, scrolling away the useful info. Usepprintwith a depth limit, or print justlen(huge_list). - Catching exceptions too broadly.
try: ... except: passhides bugs forever. Catch specific exceptions and re-raise unexpected ones. - Ignoring warnings. Python’s warning system flags deprecated APIs and likely bugs. Run with
python -W errorin dev to surface them. - Debugging multi-threaded code without thread names. Logs from 10 threads interleave into chaos unless you include the thread name in the format:
%(threadName)s.
FAQ
Q: pdb, ipdb, pudb, or PyCharm?
A: pdb when you can’t install anything. ipdb for tab completion and colors. pudb for a TUI experience. PyCharm/VS Code when you want a real GUI and have the tooling installed.
Q: How do I debug a script that runs in production?
A: Don’t drop into pdb. Use structured logging + log aggregation + py-spy attach. Production debugging is reading logs and traces, not stepping through code.
Q: How do I debug async code?
A: breakpoint() works inside async functions. The pdb prompt suspends the coroutine. For more complex async debugging, the IDE debuggers handle await stepping much better than pdb.
Q: How do I find which test is slow?
A: pytest --durations=10 shows the 10 slowest tests. Combine with pytest -k "test_name" + cProfile for line-by-line breakdown.
Q: How do I debug memory leaks?
A: tracemalloc with snapshots before/after the suspect code, compare allocation sites. For production, install memray for a heap profile across a real workload.
Wrapping Up
The single biggest debugging improvement most Python developers can make: stop using print() and start using logging + breakpoint(). The second-biggest: learn post-mortem pdb — it turns every crash into a diagnostic session. The IDE debugger is the heavyweight option when those aren’t enough. And for “why is this slow?”, profilers (cProfile, py-spy) answer questions that no amount of stepping through code will reveal.