Python: Zero to Hero
Home/Advanced Python
Share

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:

  1. Reproduce — get a minimal, consistent way to trigger the bug.
  2. Isolate — narrow down which part of the code is wrong.
  3. Understand — figure out exactly why it's wrong.
  4. Fix — change the code to match your intent.
  5. 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.

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:

  1. Start at the bottom: ZeroDivisionError: division by zero — the actual error.
  2. The line above it: return 1 / 0 in function c at line 8 — where it happened.
  3. Work upward to understand the call chain: a() called b() called c().

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:

  1. Click the gutter to set a breakpoint (red dot appears).
  2. Press F5 to start debugging.
  3. The program pauses at your breakpoint.
  4. Use the debug toolbar: Step Over (F10), Step Into (F11), Step Out (Shift+F11), Continue (F5).
  5. Inspect variables in the Variables pane on the left.
  6. Hover over any variable to see its value.
  7. 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:

  1. Find the last known good state (a commit, an input, a code path that worked).
  2. Find the current broken state.
  3. Test the midpoint. Is it broken?
    • Yes: the bug is in the first half.
    • No: the bug is in the second half.
  4. 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 diff is 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 into pdb. Learn six commands: n, s, c, q, p, w.
  • logging is print() 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, is vs ==, variable shadowing.
  • traceback.format_exc() captures the exception traceback as a string for logging.
  • assert documents and enforces invariants in development code.
  • inspect module: getsource, signature, getfile for runtime introspection.
  • git bisect finds the exact commit that introduced a regression.
  • The icecream library 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.

© 2026 Abhilash Sahoo. Python: Zero to Hero.