Skip to main content
  1. Languages/
  2. Python Guides/

Advanced Python Debugging: Beyond Print Statements in 2025

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the early days of a developer’s career, print("here") is the universal hammer. But as we move into 2025, with Python applications becoming increasingly distributed, asynchronous, and complex, relying solely on print statements is like trying to perform surgery with a spoon.

Whether you are debugging a race condition in a FastAPI microservice, a memory leak in a data processing pipeline, or a silent failure in an asyncio task, you need a systematic approach. The difference between a junior developer and a senior engineer often lies in how they handle bugs when the code doesn’t crash but simply behaves wrongly.

In this guide, we will move beyond basic troubleshooting. We will implement structural logging, master the interactive debugger (PDB/IPDB), and explore low-overhead production profiling tools.

Prerequisites and Environment Setup
#

To follow this guide effectively, you should have a modern Python environment set up. By 2025, Python 3.14+ is the standard, bringing significant improvements to error messages and debugging capabilities.

1. Environment Configuration
#

We will use uv (the spiritual successor to pip and poetry for speed) to manage our dependencies.

Directory Structure:

debugging_mastery/
├── pyproject.toml
├── src/
│   └── app.py
└── logs/

pyproject.toml Create this file to define our environment:

[project]
name = "debugging-mastery"
version = "0.1.0"
description: "Advanced debugging examples for Python DevPro"
requires-python = ">=3.13"
dependencies = [
    "structlog>=24.1.0",
    "ipdb>=0.13.13",
    "colorama>=0.4.6",
    "py-spy>=0.3.14"
]

[tool.uv]
dev-dependencies = [
    "pytest>=8.0.0"
]

To install dependencies:

uv sync
source .venv/bin/activate

Part 1: Structural Logging – The First Line of Defense
#

Before you ever reach for a debugger, your logs should tell you where to look. In 2025, flat text logs are insufficient for modern observability stacks (like ELK or Datadog). You need Structured Logging (JSON).

Standard logging tells you “User failed to login.” Structured logging tells you {"event": "login_failed", "user_id": 105, "ip": "192.168.1.1", "reason": "timeout"}.

Implementing Structlog
#

The structlog library is the industry standard for Python. It bridges the gap between human readability during development and machine parsability in production.

src/logger_setup.py

import sys
import structlog
import logging

def configure_logging(development: bool = True):
    shared_processors = [
        structlog.contextvars.merge_contextvars,
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
    ]

    if development:
        # Pretty printing for local dev
        processors = shared_processors + [
            structlog.dev.ConsoleRenderer()
        ]
    else:
        # JSON for production
        processors = shared_processors + [
            structlog.processors.dict_tracebacks,
            structlog.processors.JSONRenderer()
        ]

    structlog.configure(
        processors=processors,
        logger_factory=structlog.stdlib.LoggerFactory(),
        wrapper_class=structlog.stdlib.BoundLogger,
        cache_logger_on_first_use=True,
    )
    
    # Redirect standard logging to structlog
    logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.INFO)

# Usage
if __name__ == "__main__":
    configure_logging(development=True)
    log = structlog.get_logger()
    
    user_id = 42
    # Bind context to all subsequent logs in this scope could be done via contextvars
    log.info("user_login", user_id=user_id, status="success")
    
    try:
        1 / 0
    except ZeroDivisionError:
        log.error("calculation_failed", user_id=user_id, exc_info=True)

Why this matters:

  1. Context: You don’t string formatting. You pass key=value.
  2. Filtering: In production, you can query event="calculation_failed" instantly.

Part 2: Interactive Debugging with PDB and IPDB
#

Logs show you history; debuggers show you the present. Python’s built-in debugger, pdb, is powerful, but ipdb (Interactive PDB) adds syntax highlighting, tab completion, and better traceback introspection, utilizing the power of IPython.

The Modern Breakpoint
#

Since Python 3.7, we have the built-in breakpoint() function. You no longer need to type import pdb; pdb.set_trace().

By default, breakpoint() calls pdb. To make it use ipdb automatically, set this environment variable:

export PYTHONBREAKPOINT=ipdb.set_trace

The Debugging Workflow
#

Here is a visual representation of how a senior developer approaches a bug using these tools:

%%{init: {'theme':'base', 'themeVariables': { 'primaryColor': '#e3f2fd', 'primaryTextColor': '#0d47a1', 'primaryBorderColor': '#1976d2', 'lineColor': '#42a5f5', 'secondaryColor': '#bbdefb', 'tertiaryColor': '#90caf9', 'background': '#ffffff', 'mainBkg': '#f5fbff', 'secondBkg': '#e3f2fd', 'darkPrimaryColor': '#1e3a5f', 'darkPrimaryTextColor': '#bbdefb', 'darkPrimaryBorderColor': '#42a5f5', 'darkLineColor': '#90caf9', 'darkSecondaryColor': '#263850', 'darkTertiaryColor': '#37474f', 'darkBackground': '#0d1117', 'darkMainBkg': '#161b22', 'darkSecondBkg': '#1e2a3a' }}}%% flowchart TD A["Operating System"]:::os B["System Python<br/>(Do Not Touch)"]:::warning C["Version Management"]:::version D["pyenv"]:::tool E["Python 3.12"]:::version F["Python 3.14"]:::version G["Project Directory"]:::project H["pyproject.toml"]:::config I["uv.lock / poetry.lock"]:::lock J[".venv<br/>(Virtual Environment)"]:::venv K["Site Packages"]:::libs L["Binaries / Scripts"]:::bin A --> B B --> C C --> D D --> E D --> F F --> J G --> H G --> I G --> J H --> K I --> K J --> K J --> L classDef os fill:#e0e0e0,stroke:#666,stroke-width:1px,color:#000,rx:8px,ry:8px classDef warning fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#b71c1c,rx:12px,ry:12px classDef version fill:#e8f5e8,stroke:#43a047,stroke-width:2px,color:#1b5e20 classDef tool fill:#e1f5fe,stroke:#0277bd,stroke-width:3px,color:#01579b classDef project fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#4a148c classDef config fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:#e65100 classDef lock fill:#fce4ec,stroke:#880e4f,stroke-width:2px,color:#880e4f classDef venv fill:#e3f2fd,stroke:#1976d2,stroke-width:4px,color:#0d47a1 classDef libs fill:#e8f5e8,stroke:#689f38,stroke-width:1px,color:#33691e classDef bin fill:#e1f5fe,stroke:#0288d1,stroke-width:1px,color:#01579b class A os class B warning class C version class D tool class E,F version class G project class H config class I lock class J venv class K libs class L bin

Practical Example: Debugging a Logic Error
#

Let’s write a script with a subtle logic bug and debug it.

src/payment_processor.py

import time
from dataclasses import dataclass

@dataclass
class Order:
    id: str
    amount: float
    tax_rate: float

def calculate_total(order: Order, discount: float = 0.0) -> float:
    # BUG: We are applying discount to tax only, not the subtotal
    subtotal = order.amount
    tax = subtotal * order.tax_rate
    
    # Let's pause here to inspect
    breakpoint() 
    
    total = subtotal + tax - discount
    return total

def process_payment(order_id: str):
    # Simulate database fetch
    order = Order(id=order_id, amount=100.0, tax_rate=0.2) # Expect 120 total
    final_amount = calculate_total(order, discount=10.0)   # Expect 110 total
    
    print(f"Processing payment for Order {order.id}: ${final_amount}")

if __name__ == "__main__":
    process_payment("ORD-2025-X")

Essential PDB/IPDB Commands
#

When execution stops at breakpoint(), your terminal becomes an interactive shell. Here are the commands you must memorize:

Command Full Name Description
n Next Execute the current line and move to the next line in the current function.
s Step Execute the current line. If it’s a function call, step into that function.
c Continue Resume execution until the next breakpoint is hit.
l List Show the source code around the current line.
u / d Up / Down Move up or down the stack frame (e.g., to see who called this function).
pp Pretty Print pp variable_name prints complex dictionaries/objects legibly.
w Where Print a stack trace, showing exactly where you are in the execution chain.

The “Step” vs “Next” Trap: A common mistake is using s (step) when you encounter a print() or library function. You will find yourself debugging Python’s internal libraries. Use n (next) to stay in your code, and s only when you want to enter a function you wrote.


Part 3: Debugging Asyncio Applications
#

Asynchronous Python is standard in 2025 (FastAPI, Django Async). Debugging async code is harder because the stack trace often points to the event loop rather than your logical error.

Enabling Async Debug Mode
#

Python provides a “debug mode” for asyncio that detects non-awaited coroutines and slow callbacks.

src/async_debug.py

import asyncio
import logging

# Configure basic logging
logging.basicConfig(level=logging.DEBUG)

async def slow_operation():
    # Simulate a blocking I/O operation improperly called in async code
    import time
    time.sleep(1) # This BLOCKS the event loop!
    return "done"

async def main():
    print("Starting task...")
    await slow_operation()
    print("Finished task.")

if __name__ == "__main__":
    # Enable asyncio debug mode explicitly
    asyncio.run(main(), debug=True)

When you run this, Python will output warnings about the blocking call. If you see Executing <Task...> took 1.002 seconds, you know you have a blocking operation starving your event loop.

Inspecting Hanging Tasks
#

If your application “hangs” without crashing, you can inspect the current tasks.

async def monitor_tasks():
    while True:
        await asyncio.sleep(5)
        tasks = asyncio.all_tasks()
        print(f"\n--- Active Tasks: {len(tasks)} ---")
        for t in tasks:
            print(t)
            # You can even print the stack for a specific task
            # t.print_stack()

Injecting a background monitor task like this during development is a lifesaver for identifying deadlocks.


Part 4: Production Debugging with Py-Spy
#

You cannot use breakpoint() in production. Pausing a web request in a live environment causes timeouts and service degradation.

Enter py-spy. It is a sampling profiler for Python programs. It lets you visualize what your Python program is spending time on without restarting the program or modifying the code. It is written in Rust and has extremely low overhead.

Installation
#

uv pip install py-spy
# Note: On Linux, you may need sudo privileges to spy on other processes

Top 3 Use Cases
#

  1. Top (Real-time view): Works like the unix top command but for Python functions.

    py-spy top --pid 12345

    Output: Shows which functions are consuming the most CPU in real-time.

  2. Dump (Stack Trace): If a process is hung, dump the stack trace of all threads.

    py-spy dump --pid 12345

    This is magical. It shows you exactly where every thread is paused, even if the interpreter is stuck in a C-extension loop.

  3. Record (Flame Graphs): Generate a flame graph to analyze performance over time.

    py-spy record -o profile.svg --pid 12345

Best Practices for 2025
#

To wrap up, here are the “Golden Rules” for maintaining debuggable codebases.

1. The “Six Months Later” Rule
#

Write logs for the developer (you) who has to debug this six months from now at 3 AM.

  • Bad: log.error("Error occurred")
  • Good: log.error("payment_gateway_timeout", transaction_id=tx_id, gateway="stripe", retries=3)

2. Never Commit Breakpoints
#

In 2025, CI/CD pipelines should include a linter check to ensure breakpoint() or import pdb does not reach the main branch.

Add to your pre-commit config:

-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
    -   id: debug-statements

3. Use correlation IDs
#

In distributed systems (microservices), generate a unique request_id at the entry point (e.g., Load Balancer or Nginx) and pass it through to every service. Include this ID in every log message using structlog context variables. This allows you to trace a single user action across 10 different services.

Conclusion
#

Debugging is not a dark art; it is a learned skill that relies on visibility. By moving from unstructured print statements to structured logging, leveraging the power of IPDB for local investigation, and utilizing py-spy for production introspection, you reduce the time from “incident reported” to “root cause found.”

The tools for 2025 are powerful. They allow us to see inside the interpreter with minimal impact. Your next step? Take the structlog configuration provided in Part 1 and integrate it into your current project. Your future self will thank you.

Further Reading
#