Transform from squinting at error messages to systematically hunting down bugs. Learn how Python’s built-in debugger (PDB) can reduce your debugging time by 60-80% and help you write more reliable code.
Why PDB Matters in 2025
The Python Debugger (PDB) is a built-in tool included with every Python installation—no additional dependencies required. Yet many developers still rely on print() statements to diagnose issues, a practice that wastes hours and often misses the root cause.
The Cost of Poor Debugging
- Blind investigation: Print debugging gives you only snapshots, not a complete picture of program state
- Incomplete understanding: You never see variable states between operations
- False conclusions: One print statement might miss the actual problem location
- Production risks: Rushed debugging leads to incomplete fixes that fail in production
Why PDB Is Your Secret Weapon
PDB provides what print statements cannot: interactive, real-time inspection of your program’s execution state. You pause execution at any point, examine every variable, walk through the call stack, and even test hypotheses by executing arbitrary code—all without restarting your program.
| Aspect | Print Debugging | PDB Debugging |
|---|---|---|
| Setup time | 30 seconds | 5 seconds |
| Inspection granularity | Predefined points | Any point in code |
| Variable examination | Specific values only | Complete state + expressions |
| Testing hypotheses | Requires code changes | Direct execution |
| Debugging loops | Step through each iteration | Skip to specific conditions |
Getting Started with PDB: Three Simple Methods
Method 1: Inline Breakpoint in Your Code
Insert a breakpoint directly at the point where you suspect the issue:
def calculate_discount(price, discount_percent):
"""Calculate discounted price with tax."""
discounted = price * (1 - discount_percent / 100)
pdb.set_trace() # Execution pauses here
tax = discounted * 0.1
return discounted + tax
When your program reaches pdb.set_trace(), execution halts and the interactive PDB console opens.
Method 2: Python 3.7+ Breakpoint (Recommended)
Modern Python offers a cleaner syntax:
def calculate_discount(price, discount_percent):
discounted = price * (1 - discount_percent / 100)
breakpoint() # Cleaner, more readable
tax = discounted * 0.1
return discounted + tax
The breakpoint() function automatically imports and invokes PDB. You can even control the debugger via the PYTHONBREAKPOINT environment variable:
# Use ipdb instead of pdb
PYTHONBREAKPOINT=ipdb.set_trace python my_script.py
# Disable all breakpoints without removing them
PYTHONBREAKPOINT=0 python my_script.py
Method 3: Command-Line Debugging
Debug your entire script from the start without modifying code:
python -m pdb my_script.py
This launches PDB at the first line of your script. Use next or step to begin execution.
Method 4: Post-Mortem Debugging After a Crash
When your script crashes with an exception, inspect the state immediately:
import pdb
import traceback
try:
result = risky_operation()
except Exception:
traceback.print_exc()
pdb.post_mortem() # Drops into debugger at exception point
10 Essential PDB Commands (Quick Reference)
These commands form the foundation of effective debugging. Master them to work at professional speed.
| Command | Short | Purpose | Example |
|---|---|---|---|
| list | l |
Show current code context (11 lines) | (Pdb) l |
| next | n |
Execute next line (skip function internals) | (Pdb) n |
| step | s |
Step into functions at current line | (Pdb) s |
| continue | c |
Resume execution until next breakpoint | (Pdb) c |
p |
Print variable or expression value | (Pdb) p total_price |
|
| pp | pp |
Pretty-print complex objects | (Pdb) pp user_dict |
| where | w |
Display call stack (how you got here) | (Pdb) w |
| return | r |
Continue until current function returns | (Pdb) r |
| until | unt |
Continue until line > current (exit loops) | (Pdb) unt |
| quit | q |
Exit debugger and terminate program | (Pdb) q |
Practical Command Flow Example
# Script with intentional bug
def process_order(items, discount=0):
total = 0
for item in items:
price = item['price']
breakpoint() # Pause here
total += price * (1 - discount)
return total
process_order([{'price': 100}, {'price': 50}], discount=0.2)
Debugging session:
> process_order(items, discount)
(Pdb) l # View context
(Pdb) p item # Inspect current item
(Pdb) p total # Check running total
(Pdb) n # Execute next line
(Pdb) p total # Verify update
(Pdb) c # Continue to next iteration
Advanced Breakpoint Strategies
Setting Breakpoints at Specific Lines
Use the break command (while already in the debugger) to set breakpoints without restarting:
(Pdb) break my_script.py:25 # Set breakpoint at line 25
(Pdb) break # List all breakpoints
(Pdb) break 3 # Set at line 3 of current file
(Pdb) break my_function # Break at function start
Conditional Breakpoints (Advanced Technique)
Stop execution only when a specific condition is true—essential for debugging loops:
# Only pause when x exceeds 100
(Pdb) break my_script.py:30, x > 100
Real-world example:
# Find anomaly in large dataset
def analyze_transactions(transactions):
for idx, tx in enumerate(transactions):
amount = tx['amount']
breakpoint() # This will be conditional
process(amount)
# In PDB console:
# (Pdb) break, amount > 10000 # Only pause for large transactions
Ignoring Breakpoints Temporarily
(Pdb) ignore 1 5 # Skip first breakpoint 5 times
(Pdb) disable 1 # Disable breakpoint 1 (don't delete)
(Pdb) enable 1 # Re-enable breakpoint 1
(Pdb) clear 1 # Delete breakpoint 1 permanently
Watchpoints: Monitor Variable Changes
PDB doesn’t have native watchpoints, but you can emulate them with conditional breakpoints:
def suspicious_function():
state = {'balance': 1000}
for i in range(10):
state['balance'] -= 50
# Pause if balance drops too low
if state['balance'] < 100:
breakpoint()
process(state)
Deep Variable Inspection Techniques
Inspecting Different Data Types
# Simple values
(Pdb) p price # 99.99
(Pdb) p type(price) # <class 'float'>
# Lists and tuples
(Pdb) p items[0] # Access first element
(Pdb) p len(items) # Get length
(Pdb) p items[0:3] # Slice notation works
# Dictionaries (use pp for pretty-printing)
(Pdb) pp user_data # Pretty-print entire dict
(Pdb) p user_data.keys() # Get all keys
(Pdb) p user_data.get('email') # Safe access
# Objects and custom classes
(Pdb) p obj.__dict__ # See all attributes
(Pdb) p vars(obj) # Same as above, shorter
(Pdb) p obj.method_name # Call methods
Evaluating Complex Expressions
Use the ! prefix to evaluate arbitrary Python code:
# Execute Python expressions
(Pdb) !result = sum([1, 2, 3, 4, 5])
(Pdb) p result # 15
# Test data transformations
(Pdb) !processed = [x * 2 for x in numbers]
(Pdb) p processed
# Call functions
(Pdb) !expensive_result = calculate_hash(data)
(Pdb) p expensive_result
Modifying Variables During Debugging
Change variable values on-the-fly to test different scenarios:
(Pdb) p user_discount
5
(Pdb) user_discount = 50 # Test with different value
(Pdb) n # Continue with modified value
# Test edge cases
(Pdb) items = [] # Test empty list
(Pdb) n
(Pdb) items = [1, 2, 3, 4, 5] # Test normal list
(Pdb) n
The dir() Command: Explore Object Members
# See all attributes and methods of an object
(Pdb) !dir(user_object)
# Filter to non-private attributes
(Pdb) ![attr for attr in dir(user) if not attr.startswith('_')]
# Check if object has attribute
(Pdb) !hasattr(user, 'email') # True/False
Post-Mortem Debugging: Investigating Crashes
When your code raises an exception, don’t just read the traceback—debug it interactively:
Basic Post-Mortem Debugging
import pdb
import sys
def risky_division(a, b):
"""This will crash with b=0."""
return a / b
try:
result = risky_division(10, 0)
except:
# Enter debugger at the point of exception
pdb.post_mortem(sys.exc_info()[2])
More Practical Example: Database Queries
import pdb
import traceback
def fetch_user_orders(user_id):
"""Fetch orders, but might fail if user_id is invalid."""
try:
# Simulate database query that might fail
if not isinstance(user_id, int):
raise TypeError(f"Expected int, got {type(user_id)}")
orders = db.query(f"SELECT * FROM orders WHERE user_id = {user_id}")
return orders
except Exception as e:
print(f"\n❌ Error: {e}\n")
traceback.print_exc()
# Debug the exception interactively
pdb.post_mortem()
# This call will trigger post-mortem debugging
fetch_user_orders("not_an_integer")
Useful Commands in Post-Mortem Mode
(Pdb) where # See exact location of error
(Pdb) up # Move up one frame in call stack
(Pdb) down # Move down one frame
(Pdb) p user_id # Inspect variable at crash point
(Pdb) p sys.exc_info() # See exception details
Automatic Post-Mortem in Tests
# Drop into debugger on test failure
pytest --pdb your_test.py
# Drop into debugger on first failure
pytest -x --pdb your_test.py
Real-World Debugging Scenarios
Scenario 1: Debugging an Off-by-One Error
def slice_data(data, start, end):
"""Extract elements from start to end. Bug: fence post error."""
result = []
for i in range(start, end): # Bug: should be range(start, end + 1)?
result.append(data[i])
return result
# User reports: "Expected 5 items, got 4"
items = [1, 2, 3, 4, 5]
breakpoint()
output = slice_data(items, 0, 5)
Debugging session:
(Pdb) n # Execute next
(Pdb) s # Step into slice_data
(Pdb) p start, end # 0, 5
(Pdb) p list(range(start, end)) # [0, 1, 2, 3, 4]
(Pdb) !len(list(range(start, end))) # Only 5, not 6
# Found the bug! range(0, 5) gives [0,1,2,3,4] not [0,1,2,3,4,5]
Scenario 2: Tracking State Changes in a Loop
def calculate_fibonacci(n):
"""Calculate nth Fibonacci number. Something's wrong with large n."""
a, b = 0, 1
for _ in range(n):
a, b = b, a + b # Are a and b changing correctly?
return a
# Something's wrong. Let's debug:
def debug_fibonacci(n):
a, b = 0, 1
for i in range(n):
breakpoint() # Pause each iteration
a, b = b, a + b
return a
Debugging:
(Pdb) l # See the swap line
(Pdb) p a, b # Check before swap
(Pdb) n # Execute swap
(Pdb) p a, b # Verify values changed correctly
(Pdb) c # Continue to next iteration
Scenario 3: Null Reference/None Value Debugging
def process_user(user_data):
"""Process user, but user_data might have missing fields."""
name = user_data['name']
email = user_data.get('email') # May be None
# This will crash if email is None
domains = email.split('@')[1]
return f"{name} at {domains}"
# Crash: 'NoneType' object has no attribute 'split'
user = {'name': 'Alice'} # Missing 'email' field
breakpoint()
process_user(user)
Debugging:
(Pdb) p user_data
(Pdb) p email # None - found the problem!
(Pdb) p email is None # Confirm it's None
# Fix: Add email validation before calling split()
Scenario 4: Debugging Complex Data Structures
users_by_id = {
1: {'name': 'Alice', 'orders': [100, 200, 300]},
2: {'name': 'Bob', 'orders': [50, 75]}
}
def get_user_total(user_id):
user = users_by_id.get(user_id)
if not user:
return 0
total = sum(user['orders'])
breakpoint() # Inspect the data
return total
Debugging with pp (pretty-print):
(Pdb) pp users_by_id # See full structure
(Pdb) p user_id # Current user
(Pdb) p user # User's data
(Pdb) p user['orders'] # Just the orders
(Pdb) p sum(user['orders']) # Calculated total
Professional Debugging Workflows
Workflow 1: Bug Report → Root Cause in Minutes
When you receive “Feature X is broken”:
- Identify entry point: Add
breakpoint()at the function entry - Check inputs: Inspect all function parameters
- Trace execution: Step through code
ncommand - Verify outputs: Check intermediate results with
p - Pinpoint failure: Identify exact line where output becomes wrong
- Test fix: Modify variable and continue to verify the fix would work
Workflow 2: Integration Testing with PDB
# Run your test with debugger active
pytest --pdb tests/test_payment.py
# Drops into debugger on failure
# Now you can inspect actual vs expected values
(Pdb) p actual_amount # What did the function return?
(Pdb) p expected_amount # What should it be?
(Pdb) p actual_amount == expected_amount # Why don't they match?
Workflow 3: Debugging External API Calls
def fetch_from_api(endpoint):
response = requests.get(f"https://api.example.com/{endpoint}")
breakpoint() # Pause to inspect response
data = response.json()
return process_data(data)
# In debugger:
# (Pdb) p response.status_code # 200? 404? 500?
# (Pdb) p response.text # What's the actual response?
# (Pdb) pp response.json() # Pretty-print JSON
Workflow 4: Debugging Asynchronous Code
import asyncio
async def fetch_data():
breakpoint() # PDB works with async too!
result = await expensive_operation()
return result
# Run with asyncio support
asyncio.run(fetch_data())
PDB Alternatives & When to Use Them
While PDB is powerful and built-in, the Python debugging ecosystem offers specialized tools:
| Tool | Best For | Pros | Cons |
|---|---|---|---|
| PDB | General debugging, server environments | Built-in, no dependencies, works everywhere | Terminal-only, minimal UI |
| ipdb | Interactive development, data exploration | Enhanced syntax highlighting, tab completion | Requires installation: pip install ipdb |
| PyCharm Debugger | Professional IDE debugging, remote debugging | Full UI, variable watches, conditional breakpoints | IDE-only, resource-intensive |
| VS Code Debugger | Lightweight IDE debugging, quick iterations | Free, lightweight, excellent UI | Less powerful than PyCharm |
| pdb++ | Enhanced PDB with modern features | Better syntax highlighting, sticky breakpoints | Requires installation: pip install pdbpp |
Using ipdb for Enhanced Experience
pip install ipdb
import ipdb
ipdb.set_trace() # Better syntax highlighting + tab completion
Or automatically use ipdb when available:
import sys
def breakpoint_hook():
try:
import ipdb
return ipdb.set_trace
except ImportError:
return __builtins__.breakpoint()
breakpoint = breakpoint_hook()
Professional Best Practices & Pro Tips
Best Practice 1: Strategic Breakpoint Placement
- Place at function entries: Verify inputs are correct
- Place before complex logic: Check state before critical operations
- Place at data transformation points: Verify data structure changes
- Remove before committing: Don’t push breakpoints to version control
Use a pre-commit hook to catch stray breakpoints:
#!/bin/bash
# .git/hooks/pre-commit
if git diff --cached | grep -q "breakpoint()\|pdb.set_trace()"; then
echo "❌ Error: Debugging code detected in staged changes"
exit 1
fi
Best Practice 2: Combine print() and PDB Strategically
- Use print() for: Expected logs, status messages, progress tracking
- Use PDB for: Unexpected behavior, complex state inspection, root cause analysis
def process_batch(items):
print(f"Processing {len(items)} items...") # Status log
for item in items:
breakpoint() # Debug only when something seems wrong
result = process_item(item)
print(f"✓ Processed {item['id']}") # Progress
Best Practice 3: Use whatis() and help()
# See what an object is
(Pdb) whatis my_variable # Display type information
# Get help on an object
(Pdb) help(my_list.append) # Documentation for method
(Pdb) !help(json.dumps) # Help for any function
Best Practice 4: Create Debugging Aliases
Create a .pdbrc file in your home directory for custom commands:
# ~/.pdbrc
alias ll list -
alias pp pp
alias locals pp locals()
alias globals pp globals()
alias tb traceback
Now in PDB:
(Pdb) locals # Shows all local variables nicely
(Pdb) globals # Shows all global variables
(Pdb) ll # List with line numbers (recent lines)
Best Practice 5: Document Your Debugging Process
def calculate_tax(amount, rate):
"""
Calculate tax amount.
Note: If this function gives wrong results:
1. Check that amount is numeric (p type(amount))
2. Verify rate is 0-1 scale, not 0-100 (p rate)
3. Inspect multiplication result (p amount * rate)
"""
tax = amount * rate
return round(tax, 2)
Best Practice 6: Use Context Managers for Debugging
from contextlib import contextmanager
@contextmanager
def debug_section(name):
"""Context manager to mark debugging sections."""
print(f"\n📍 Entering debug section: {name}")
yield
print(f"✅ Leaving debug section: {name}\n")
# Usage
with debug_section("User authentication"):
breakpoint() # Debug code here
user = authenticate(credentials)
Troubleshooting Common Debugging Problems
Problem: “Cannot access variable in PDB”
Cause: Variable doesn’t exist in current scope
Solution: Check the call stack to understand variable scope
(Pdb) where # See where you are in the code
(Pdb) up # Move up one frame to outer scope
(Pdb) p outer_var # Now accessible
Problem: “Breakpoint not being hit”
Causes:
- Exception is raised before breakpoint
- Code path doesn’t execute that line
- Breakpoint condition is never true
Solution: Add print statement before breakpoint to confirm code is reached
print("About to hit breakpoint") # Verify this prints
breakpoint()
Problem: “Mysterious behavior after variable modification”
Cause: Changes in debugger don’t persist if you continue execution
Solution: Modify and test within the same session using expressions
(Pdb) my_var = 100 # Modify variable
(Pdb) n # Execute next line with new value
(Pdb) p result # Verify the change affected outcome
Your Debugging Mastery Roadmap
You now have professional-grade debugging skills. Here’s how to deepen your expertise:
Immediate: This Week
- Replace one print-debugging session with PDB
- Master the 5 commands: n, s, p, w, c
- Try one conditional breakpoint
Short-term: This Month
- Use PDB on three different types of bugs (logic, data, integration)
- Install and try ipdb
- Create your .pdbrc alias file
Long-term: This Quarter
- Master post-mortem debugging for production issues
- Learn remote debugging for distributed systems
- Integrate PDB into your team’s debugging standards
The developers who can quickly identify and fix bugs are exponentially more valuable than those who just write code. PDB mastery is the fastest path to that level.
