Skip to main content

Debugging, Profiling, and Linting

What You'll Learn

Practical debugging techniques, how to profile slow Python code, and how to use linting tools to catch bugs before they reach production.

Debugging with print() — The First Tool

The simplest debugging: print what you don't understand:

def calculate_total(prices: list[float], discount: float) -> float:
print(f"DEBUG prices={prices} discount={discount}") # ← add this
subtotal = sum(prices)
print(f"DEBUG subtotal={subtotal}")
result = subtotal * (1 - discount)
print(f"DEBUG result={result}")
return result

Remove debug prints once fixed. Better: use log.debug() which you can silence without removing.

breakpoint() — Built-In Debugger (Python 3.7+)

breakpoint() drops you into an interactive debugger at that line:

def process_order(order: dict) -> float:
items = order["items"]
breakpoint() # execution stops here — inspect state interactively
total = sum(item["price"] * item["qty"] for item in items)
return total

pdb Commands

(Pdb) n # next line (step over)
(Pdb) s # step into function call
(Pdb) c # continue until next breakpoint
(Pdb) q # quit debugger
(Pdb) p variable # print variable value
(Pdb) pp variable # pretty print
(Pdb) l # list surrounding code
(Pdb) ll # list entire current function
(Pdb) where # show call stack
(Pdb) b 42 # set breakpoint at line 42
(Pdb) u # move up in call stack
(Pdb) d # move down in call stack

Conditional Breakpoints

for i, item in enumerate(items):
if i == 500: # only break on the 501st item
breakpoint()
process(item)

VS Code Debugger (GUI)

For a visual debugger, use VS Code with the Python extension:

  1. Click the gutter (left of line number) to set a breakpoint (red dot)
  2. Press F5 to start debugging
  3. Use the debug toolbar: Step Over, Step Into, Continue, Stop

This is often faster than command-line pdb for complex bugs.

Common Debugging Patterns

Inspect intermediate values

# Replace a complex one-liner with steps
result = [process(x) for x in items if x.is_valid() and x.score > 0.5]

# Debug version
valid = [x for x in items if x.is_valid()]
print(f"valid: {len(valid)}")
high_score = [x for x in valid if x.score > 0.5]
print(f"high_score: {len(high_score)}")
result = [process(x) for x in high_score]
print(f"result: {len(result)}")

Narrow down the failing case

# Which item causes the crash?
for i, item in enumerate(items):
print(f"Processing item {i}: {item}")
process(item) # crash somewhere in here

Check types when confused

print(type(value), repr(value))

Profiling — Finding Slow Code

Don't optimize without measuring first — you'll optimize the wrong thing.

cProfile — Built-In Profiler

# Profile a script
python3 -m cProfile -s cumulative slow_script.py

# Profile with output to file
python3 -m cProfile -o profile.out slow_script.py
# Profile a function inline
import cProfile

with cProfile.Profile() as pr:
result = slow_function(data)

pr.print_stats(sort="cumulative", limit=20)

Output:

ncalls tottime percall cumtime percall filename:lineno(function)
1000 0.234 0.000 1.230 0.001 processor.py:42(process_item)
500 0.987 0.002 0.987 0.002 database.py:18(query)

Look at cumtime (total time including called functions). The biggest numbers are your bottlenecks.

timeit — Measure Small Snippets

import timeit

# Compare two approaches
t1 = timeit.timeit('"+".join(str(n) for n in range(100))', number=10000)
t2 = timeit.timeit('"+".join([str(n) for n in range(100)])', number=10000)
print(f"Generator: {t1:.3f}s, List: {t2:.3f}s")
python3 -m timeit -n 10000 '"+".join(str(n) for n in range(100))'

line_profiler — Profile Line by Line

pip install line_profiler
from line_profiler import profile

@profile
def slow_function(data):
result = []
for item in data:
processed = transform(item) # ← which line is slow?
result.append(processed)
return result
kernprof -l -v script.py

Linting — Catch Bugs Before Runtime

ruff — Fast Linter + Formatter

pip install ruff

ruff check src/ # lint
ruff check src/ --fix # auto-fix what it can
ruff format src/ # format (like black)

Common issues ruff catches:

  • Unused imports
  • Undefined variables
  • Shadowed built-ins
  • Style violations
  • Dangerous patterns

mypy — Type Checker

pip install mypy
mypy src/
def greet(name: str) -> str:
return "Hello, " + name

greet(42) # mypy: error: Argument 1 to "greet" has incompatible type "int"

Setting Up in pyproject.toml

[tool.ruff]
line-length = 99
target-version = "py311"
select = ["E", "F", "I", "N", "W"]

[tool.mypy]
python_version = "3.11"
strict = false
ignore_missing_imports = true

Quick Debugging Workflow

1. Reproduce the bug consistently

2. Add print() or breakpoint() near the failure

3. Narrow down: which line, which variable, which value

4. Fix the root cause (not the symptom)

5. Write a test that would have caught it

6. Remove debug prints

Quick Reference

# Print debug
print(f"DEBUG: {var=}") # Python 3.8+ — prints "var=value"

# Breakpoint
breakpoint() # drops into pdb

# pdb commands
# n=next s=step c=continue q=quit p=print l=list

# Profile
import cProfile
cProfile.run("function()", sort="cumulative")

# Timeit
import timeit
timeit.timeit("expression", number=10000)

# Type check on the fly
type(x)
isinstance(x, str)
# CLI tools
ruff check src/
ruff format src/
mypy src/
python3 -m cProfile -s cumulative script.py
python3 -m timeit "expression"

What's Next

Lesson 4: Test Fixtures and Temp Files