Chapter 29: Debugging
Every programmer spends a significant portion of their time debugging. The difference between a junior and a senior developer isn't that the senior writes fewer bugs — it's that the senior finds and fixes them faster.
Debugging is a skill. It has techniques, tools, and a mindset. This chapter teaches all three.
The Debugging Mindset
Before touching any tool, adopt this mental model:
A bug is a gap between what you think the code does and what it actually does.
Your job is to find where your mental model is wrong. The code is always right — it does exactly what you told it to do. The bug is in your assumptions.
The process:
- Reproduce — get a minimal, consistent way to trigger the bug.
- Isolate — narrow down which part of the code is wrong.
- Understand — figure out exactly why it's wrong.
- Fix — change the code to match your intent.
- Verify — confirm the fix works and didn't break anything else.
Skipping steps is how you waste hours. Especially step 1 — if you can't reliably reproduce a bug, you can't reliably fix it.
Print Debugging — Simple but Effective
The oldest debugging technique: print values to see what's happening.
def process_order(items, discount_code):
subtotal = sum(item["price"] * item["qty"] for item in items)
discount = apply_discount(subtotal, discount_code)
tax = subtotal * 0.08
total = subtotal - discount + tax
return total
# Something is wrong. Add prints to see the values.
def process_order(items, discount_code):
subtotal = sum(item["price"] * item["qty"] for item in items)
print(f"DEBUG subtotal={subtotal}")
discount = apply_discount(subtotal, discount_code)
print(f"DEBUG discount={discount}")
tax = subtotal * 0.08
print(f"DEBUG tax={tax}")
total = subtotal - discount + tax
print(f"DEBUG total={total}")
return total
Print debugging is fast for simple bugs. Its weakness: you have to remember to remove the prints, and it doesn't help with complex control flow.
Use repr() when the value might be ambiguous:
# Is it an empty string or None?
print(repr(value)) # '' vs None vs ' '
A slightly smarter approach — a debug print that includes the variable name:
def dbg(label, value):
print(f" [DEBUG] {label} = {value!r}")
return value # returns the value so you can wrap expressions
# Use it inline without restructuring code
total = dbg("total", subtotal - dbg("discount", apply_discount(subtotal, code)) + tax)
Python 3.8+ f-strings have a built-in = specifier for this:
x = 42
y = "hello"
items = [1, 2, 3]
print(f"{x=}") # x=42
print(f"{y=}") # y='hello'
print(f"{items=}") # items=[1, 2, 3]
print(f"{x + y=}") # x + y=... (expression + result)
# Use in debugging:
def calculate(a, b):
result = a ** 2 + b ** 2
print(f"{a=} {b=} {result=}")
return result
breakpoint() and pdb
breakpoint() drops you into Python's interactive debugger at that exact line. From there you can inspect variables, step through code line by line, and evaluate expressions.
def find_max(numbers):
max_val = numbers[0]
for i, n in enumerate(numbers):
breakpoint() # execution pauses here
if n > max_val:
max_val = n
return max_val
find_max([3, 1, 4, 1, 5, 9, 2, 6])
When execution hits breakpoint(), you see the (Pdb) prompt. Essential commands:
| Command | What it does |
|---|---|
h |
Help — list all commands |
n |
Next — run the current line, stop at the next one |
s |
Step — step into a function call |
c |
Continue — run until the next breakpoint |
q |
Quit — exit the debugger |
l |
List — show source code around the current line |
p expr |
Print — evaluate and print an expression |
pp expr |
Pretty-print — like p but formatted |
w |
Where — show the call stack |
u / d |
Up/down — move up/down the call stack |
b line |
Breakpoint — set a new breakpoint at a line number |
r |
Return — run until the current function returns |
a |
Args — print arguments of the current function |
> find_max([3, 1, 4, 1, 5, 9, 2, 6])
> /path/to/script.py(5)find_max()
-> if n > max_val:
(Pdb) p n
3
(Pdb) p max_val
3
(Pdb) p numbers
[3, 1, 4, 1, 5, 9, 2, 6]
(Pdb) n
> /path/to/script.py(6)find_max()
-> max_val = n
(Pdb) c
> /path/to/script.py(5)find_max()
-> if n > max_val:
(Pdb) p i, n, max_val
(1, 1, 3)
Post-mortem debugging
If your program crashes with an uncaught exception, you can inspect the crash state after the fact:
import pdb
try:
buggy_function()
except Exception:
pdb.post_mortem() # drops into debugger at the crash point
Or from the command line — run with pdb and it automatically opens the debugger at any crash:
python -m pdb my_script.py
Conditional breakpoints
Break only when a specific condition is true — avoid stepping through 999 iterations to get to the interesting one:
for i, item in enumerate(large_list):
if i == 500:
breakpoint() # only break at index 500
process(item)
Or in pdb directly:
(Pdb) b 15, n > 100 # break at line 15 only when n > 100
logging — Structured Debug Output
print() is for users. logging is for developers. The key difference: logging has levels, and you can turn it on and off without changing code.
import logging
# Basic setup — goes to stderr
logging.basicConfig(
level = logging.DEBUG,
format = "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt= "%H:%M:%S",
)
log = logging.getLogger(__name__)
log.debug("This is debug info — very verbose")
log.info("Normal operation message")
log.warning("Something unexpected but not fatal")
log.error("Something failed")
log.critical("System cannot continue")
Output:
10:23:45 DEBUG __main__: This is debug info — very verbose
10:23:45 INFO __main__: Normal operation message
10:23:45 WARNING __main__: Something unexpected but not fatal
10:23:45 ERROR __main__: Something failed
10:23:45 CRITICAL __main__: System cannot continue
Log levels
| Level | Value | When to use |
|---|---|---|
DEBUG |
10 | Detailed diagnostic info, only during development |
INFO |
20 | Confirmation that things are working as expected |
WARNING |
30 | Something unexpected, but the program continues |
ERROR |
40 | A serious problem, the function couldn't complete |
CRITICAL |
50 | A fatal error, the program may not be able to continue |
Setting the level to WARNING in production means DEBUG and INFO messages are silent — no code changes needed.
Named loggers
Use logging.getLogger(__name__) in every module. It creates a logger named after the module, which lets you control logging per module:
# calculator.py
import logging
log = logging.getLogger(__name__) # logger name: "calculator"
def divide(a, b):
log.debug(f"divide({a}, {b}) called")
if b == 0:
log.error("Division by zero attempted")
raise ZeroDivisionError("Cannot divide by zero.")
result = a / b
log.debug(f"divide result: {result}")
return result
# main.py
import logging
import calculator
logging.basicConfig(level=logging.WARNING) # show only warnings+
logging.getLogger("calculator").setLevel(logging.DEBUG) # but debug for calculator
calculator.divide(10, 2) # DEBUG messages print for calculator only
calculator.divide(5, 0) # ERROR message prints
Logging to a file
import logging
logging.basicConfig(
level = logging.DEBUG,
format = "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
handlers = [
logging.FileHandler("app.log"), # write to file
logging.StreamHandler(), # also write to console
],
)
Structured logging with extra
log = logging.getLogger(__name__)
def process_request(request_id, user_id, endpoint):
extra = {"request_id": request_id, "user_id": user_id}
log.info("Request received: %s", endpoint, extra=extra)
# ... process ...
log.info("Request completed", extra=extra)
Reading Tracebacks
Python tracebacks are read bottom up — the last line is the actual error; the lines above show how you got there.
def a():
return b()
def b():
return c()
def c():
return 1 / 0
a()
Traceback (most recent call last):
File "script.py", line 9, in <module>
a()
File "script.py", line 2, in a
return b()
File "script.py", line 5, in b
return c()
File "script.py", line 8, in c
return 1 / 0
ZeroDivisionError: division by zero
Reading it:
- Start at the bottom:
ZeroDivisionError: division by zero— the actual error. - The line above it:
return 1 / 0in functioncat line 8 — where it happened. - Work upward to understand the call chain:
a()calledb()calledc().
The line that caused the error is almost always at the bottom. Go there first.
Common exceptions and their usual causes
| Exception | Common cause |
|---|---|
NameError |
Typo in a variable name, or used before defined |
TypeError |
Wrong type passed to a function, or unsupported operation |
ValueError |
Right type, wrong value (e.g. int("hello")) |
IndexError |
List index out of range |
KeyError |
Dictionary key doesn't exist |
AttributeError |
Object doesn't have that attribute/method (typo, or wrong type) |
ZeroDivisionError |
Divided by zero |
FileNotFoundError |
File path is wrong or file doesn't exist |
ImportError |
Module not found or not installed |
RecursionError |
Infinite recursion — missing base case |
StopIteration |
Calling next() on exhausted iterator |
MemoryError |
Out of memory — often from accidentally creating huge data |
Common Bug Patterns and How to Find Them
Off-by-one errors
# Bug: loop runs one too many times
for i in range(1, len(items) + 1): # should be range(len(items))
print(items[i]) # IndexError on last iteration
# Debug: print the index and length
for i in range(1, len(items) + 1):
print(f"i={i}, len={len(items)}, accessing items[{i}]")
Mutating a list while iterating it
# Bug: removing items from a list you're looping over
numbers = [1, 2, 3, 4, 5, 6]
for n in numbers:
if n % 2 == 0:
numbers.remove(n) # modifies the list mid-iteration — skips items
print(numbers) # [1, 3, 5] — looks right but worked by accident
# The real danger:
items = [2, 4, 6, 8]
for item in items:
if item % 2 == 0:
items.remove(item)
print(items) # [4, 8] — wrong! skipped every other even number
# Fix: iterate a copy, or use a comprehension
items = [item for item in items if item % 2 != 0]
Mutable default arguments
# Bug: the list is created once, shared across all calls
def add_item(item, container=[]): # default created at function definition time
container.append(item)
return container
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] — expected ['b']!
print(add_item("c")) # ['a', 'b', 'c'] — state persists between calls!
# Fix: use None as default, create fresh each call
def add_item(item, container=None):
if container is None:
container = []
container.append(item)
return container
is vs ==
# Bug: comparing with 'is' when you mean '=='
a = 1000
b = 1000
print(a is b) # False! (different objects)
print(a == b) # True (same value)
# 'is' checks identity (same object in memory)
# '==' checks equality (same value)
# Common mistakes:
x = None
if x is None: # correct
pass
if x == None: # works but not idiomatic — never do this
pass
name = "Alice"
if name is "Alice": # WRONG — may or may not work (string interning)
pass
if name == "Alice": # correct
pass
Variable shadowing
# Bug: accidentally reusing a name
list = [1, 2, 3] # 'list' now shadows the built-in!
result = list([4, 5]) # TypeError: 'list' object is not callable
# Safer names: items, numbers, my_list
items = [1, 2, 3]
# Other commonly shadowed names:
# str, int, float, dict, set, input, print, type, id, min, max, sum
Integer division returning float, or vice versa
total = 10
count = 3
avg = total / count # 3.3333... (float division)
avg = total // count # 3 (integer division — truncates)
# Bug: expected integer but got float
page = 7
items_per_page = 3
pages = page / items_per_page # 2.333... — should be 3 (ceiling)
import math
pages = math.ceil(page / items_per_page) # 3 — correct
The traceback Module
For capturing and logging exception information programmatically:
import traceback
import logging
log = logging.getLogger(__name__)
def safe_process(data):
try:
return risky_operation(data)
except Exception:
log.error("Failed to process data:\n%s", traceback.format_exc())
return None
# Get the traceback as a string
try:
1 / 0
except ZeroDivisionError:
tb_str = traceback.format_exc()
print(tb_str)
# Traceback (most recent call last):
# File "<stdin>", line 2, in <module>
# ZeroDivisionError: division by zero
assert for Invariants
assert is not just for tests — it's a debugging tool. Use it to document and verify assumptions that must always be true:
def calculate_average(numbers):
assert len(numbers) > 0, "Cannot average an empty list"
assert all(isinstance(n, (int, float)) for n in numbers), \
f"All items must be numeric, got: {numbers}"
return sum(numbers) / len(numbers)
def binary_search(items, target):
assert items == sorted(items), "binary_search requires a sorted list"
# ... search ...
Run with python -O script.py (optimize flag) to disable all asserts in production — they have zero cost. Keep them in development where they catch bugs early.
Introspection Tools
Python lets you examine objects at runtime:
# What type is this?
print(type(obj))
print(isinstance(obj, list))
# What attributes/methods does it have?
print(dir(obj))
# What does this object look like?
print(repr(obj))
# Where is this function/class defined?
import inspect
print(inspect.getfile(MyClass))
print(inspect.getsource(my_function))
# What arguments does this function accept?
print(inspect.signature(my_function))
# What is the call stack right now?
import traceback
traceback.print_stack()
# What variables are in scope?
print(locals())
print(globals())
# A diagnostic helper — print everything about an object
def inspect_object(obj, label="obj"):
print(f"\n{'─'*40}")
print(f" {label}: {obj!r}")
print(f" type: {type(obj).__name__}")
print(f" id: {id(obj)}")
if hasattr(obj, "__dict__"):
print(f" attrs: {obj.__dict__}")
print(f"{'─'*40}\n")
IDE Debugging
All modern Python editors have a visual debugger. In VS Code:
- Click the gutter to set a breakpoint (red dot appears).
- Press
F5to start debugging. - The program pauses at your breakpoint.
- Use the debug toolbar: Step Over (F10), Step Into (F11), Step Out (Shift+F11), Continue (F5).
- Inspect variables in the Variables pane on the left.
- Hover over any variable to see its value.
- Use the Debug Console to evaluate expressions.
This is usually faster than pdb for complex bugs — you can see the full call stack, all local variables, and watch expressions simultaneously.
icecream — A Better Debug Print
pip install icecream
from icecream import ic
def process(items):
total = ic(sum(items)) # prints: ic| sum(items): 15
avg = ic(total / len(items)) # prints: ic| total / len(items): 3.0
return avg
process([1, 2, 3, 4, 5])
ic() prints the expression and its value, then returns the value so you can wrap it around any expression without restructuring code. It also includes the file, line number, and parent function by default.
Disable all ic() calls without removing them:
from icecream import ic
ic.disable() # all ic() calls become no-ops
Systematic Debugging: The Bisect Method
For a hard bug — especially a regression (code that used to work and now doesn't) — use binary search on the code itself:
- Find the last known good state (a commit, an input, a code path that worked).
- Find the current broken state.
- Test the midpoint. Is it broken?
- Yes: the bug is in the first half.
- No: the bug is in the second half.
- Repeat until you've isolated the exact change that introduced the bug.
With git:
git bisect start
git bisect bad # current state is broken
git bisect good v1.0.0 # v1.0.0 was working
# git checks out the midpoint
# test it, then:
git bisect good # or: git bisect bad
# repeat until git identifies the offending commit
git bisect reset
Project: A Debug-Friendly Logger Class
"""
debug_utils.py — Debugging utilities for development.
"""
import logging
import time
import functools
import traceback
import sys
from typing import Any, Callable, TypeVar
from contextlib import contextmanager
F = TypeVar("F", bound=Callable[..., Any])
def setup_logging(
level: str = "DEBUG",
log_file: str | None = None,
fmt: str = "%(asctime)s %(levelname)-8s %(name)-20s %(message)s",
) -> None:
"""Configure root logger with console (and optional file) handler."""
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
if log_file:
handlers.append(logging.FileHandler(log_file))
logging.basicConfig(
level = getattr(logging, level.upper()),
format = fmt,
datefmt = "%H:%M:%S",
handlers= handlers,
force = True, # override any existing configuration
)
def trace(func: F) -> F:
"""Decorator: log every call with arguments and return value."""
log = logging.getLogger(func.__module__)
@functools.wraps(func)
def wrapper(*args, **kwargs):
args_repr = [repr(a) for a in args]
kw_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
signature = ", ".join(args_repr + kw_repr)
log.debug(f"-> {func.__qualname__}({signature})")
try:
result = func(*args, **kwargs)
log.debug(f"<- {func.__qualname__} = {result!r}")
return result
except Exception as e:
log.error(f"[-] {func.__qualname__} raised {type(e).__name__}: {e}")
raise
return wrapper # type: ignore
@contextmanager
def timer(label: str = "", level: int = logging.DEBUG):
"""Context manager: log how long a block takes."""
log = logging.getLogger(__name__)
start = time.perf_counter()
try:
yield
finally:
ms = (time.perf_counter() - start) * 1000
tag = f"[{label}] " if label else ""
log.log(level, f"{tag}Elapsed: {ms:.2f}ms")
def catch_and_log(
*exceptions: type[Exception],
default: Any = None,
reraise: bool = False,
) -> Callable:
"""
Decorator: catch exceptions, log them, optionally return a default value.
"""
def decorator(func: F) -> F:
log = logging.getLogger(func.__module__)
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except exceptions as e:
log.error(
f"{func.__qualname__} failed: {type(e).__name__}: {e}\n"
f"{traceback.format_exc()}"
)
if reraise:
raise
return default
return wrapper # type: ignore
return decorator
class DebugProxy:
"""
Wrap any object and log every attribute access and method call.
Useful for understanding how a third-party object is being used.
"""
def __init__(self, obj: Any, name: str = ""):
object.__setattr__(self, "_obj", obj)
object.__setattr__(self, "_name", name or type(obj).__name__)
object.__setattr__(self, "_log", logging.getLogger(__name__))
def __getattr__(self, name: str) -> Any:
obj = object.__getattribute__(self, "_obj")
log = object.__getattribute__(self, "_log")
label= object.__getattribute__(self, "_name")
attr = getattr(obj, name)
log.debug(f"{label}.{name} accessed -> {attr!r}")
if callable(attr):
def traced(*args, **kwargs):
log.debug(f"{label}.{name}({args}, {kwargs})")
result = attr(*args, **kwargs)
log.debug(f"{label}.{name} returned {result!r}")
return result
return traced
return attr
# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
setup_logging("DEBUG")
log = logging.getLogger(__name__)
@trace
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
@catch_and_log(ValueError, ZeroDivisionError, default=0.0)
def safe_divide(a: float, b: float) -> float:
return a / b
with timer("Fibonacci(10)"):
result = fibonacci(10)
log.info(f"fibonacci(10) = {result}")
log.info(f"safe_divide(10, 2) = {safe_divide(10, 2)}")
log.info(f"safe_divide(10, 0) = {safe_divide(10, 0)}") # logs error, returns 0.0
# DebugProxy wraps a list and logs every operation
proxy = DebugProxy([], "my_list")
proxy.append(1)
proxy.append(2)
proxy.extend([3, 4, 5])
log.info(f"List contents: {proxy._obj}")
Debugging Checklist
When you're stuck on a bug, go through this list:
- Read the error message. All of it. The last line first.
- Read the traceback bottom-up. Find the line that actually crashed.
- Check your assumptions. Print the value you think is there. Is it actually that?
- Reproduce with minimal input. Strip away everything not needed to trigger the bug.
- Check recent changes. What changed since it last worked?
git diffis your friend. - Search for the error message. Someone has hit this before.
- Explain the code out loud. Describe what each line does. The bug often reveals itself.
- Take a break. A fresh mind sees things a tired mind misses. Not a joke — it works.
- Check the obvious things last. Typos, wrong variable names, copy-paste errors.
What You Learned in This Chapter
- Debugging is finding the gap between what you think the code does and what it actually does.
- The five-step process: reproduce, isolate, understand, fix, verify.
print(f"{var=}")(Python 3.8+) prints both name and value — fast and clean.breakpoint()drops intopdb. Learn six commands:n,s,c,q,p,w.loggingisprint()for professionals — has levels, can be silenced without code changes, writes to files, per-module control.- Read tracebacks bottom-up. The last line is the error; the lines above are the call chain.
- Common bugs: off-by-one, mutating during iteration, mutable default arguments,
isvs==, variable shadowing. traceback.format_exc()captures the exception traceback as a string for logging.assertdocuments and enforces invariants in development code.inspectmodule:getsource,signature,getfilefor runtime introspection.git bisectfinds the exact commit that introduced a regression.- The
icecreamlibrary gives you richer print-style debugging.
What's Next?
Chapter 30 covers Performance and Profiling — how to measure where your code is slow, and how to speed it up. cProfile, line_profiler, timeit, algorithmic complexity, and practical optimization patterns. Because fast code starts with measuring, not guessing.