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.

Debug Dee in home office
Your debugger is worth a thousand print statements. Learn to use it.

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)
Input price: 100, discount_rate: 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).

Sudo Sam at vintage computer terminal
pdb turns debugging from guesswork into systematic exploration.

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.

Loop Larry confused with errors
Get systematic with your debugging approach.

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.

Pyro Pete with logging dashboard
Structured logging turns chaos into searchable truth.

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.

Cache Katie with magnifying glass
Right patterns turn logs into insights.

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

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 types c at the (invisible) prompt. Set PYTHONBREAKPOINT=0 in production env to neutralize them.
  • print() debugging on large data. print(huge_list) prints thousands of lines, scrolling away the useful info. Use pprint with a depth limit, or print just len(huge_list).
  • Catching exceptions too broadly. try: ... except: pass hides 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 error in 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.