A bug is any gap between what the program does and what you intended. Finding and fixing them is a craft built on three habits: reading error messages carefully, forming a hypothesis before changing anything, and making one change at a time. The tools Python provides are a support for those habits, not a substitute for them.
The starting point is always the traceback. Read it bottom-up: the exception class and message tell you what went wrong; the frames above tell you how the program got there. Resist the urge to “just change something” before you've understood which specific value or condition broke the assumption.
For runtime investigation, Python has print, logging, breakpoint(), and pdb. Start with print when you have a quick hunch. Switch to logging when the investigation grows; switch to pdb when you want to step through the program live. Never ship debug prints; they tend to survive longer than the bug.
Static tools catch a different class of issue before the program runs. mypy or pyright flag type mismatches; ruff, flake8, pylint flag style and likely bugs (shadowed variables, unused imports, mutable default arguments). Running them in CI is a low-cost safety net that catches several classes of bug without any unit tests.
Reading tracebacks methodically
The last line is always ExceptionClass: message. Everything above it is the call chain; the deepest frame is at the bottom, right before the exception line. If the message mentions a value, add that value to a mental note and trace backwards to find where it came in.
For chained exceptions (raise X from err), read the “The above exception was the direct cause” banner. Both exceptions matter: the cause usually tells you what really went wrong; the wrapping exception tells you where your code couldn't continue.
Hypothesis-driven debugging
Phrase a specific hypothesis: “I think user.age is a string, not an int, at line 42.” Write one check (a print, a log, a breakpoint) that would confirm or reject it. Confirm before changing. Changes made without evidence either miss the real bug or introduce a new one.
When stuck, explain the bug out loud (or in a chat). The act of phrasing the problem for someone else often reveals the mistake. This is the famous “rubber-duck debugging”.
Debugging aids.
| Tool | Purpose |
|---|---|
tracebackmodule | Programmatic access to tracebacks. |
pdbmodule | Interactive debugger. |
breakpoint()built-in | Drop into the debugger at a chosen line. |
loggingmodule | Levelled, configurable logging. |
mypytool | Static type checker. |
rufftool | Fast linter and style checker. |
assert conditionstatement | Validate an invariant during development. |
warningsmodule | Emit non-fatal issues at runtime. |
Identifying and Fixing Code Issues code example
The script contains a subtle bug (an off-by-one error) that we track down with prints, a logger, and a breakpoint.
# Lesson: Identifying and Fixing Code Issues
import logging
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s")
log = logging.getLogger("demo")
def running_total(nums: list[int]) -> list[int]:
"""Return the cumulative sum (intended: total[i] = sum(nums[:i+1]))."""
totals: list[int] = []
running = 0
for i, n in enumerate(nums):
running += n
log.debug("i=%d n=%d running=%d", i, n, running)
totals.append(running)
return totals
print(running_total([1, 2, 3, 4]))
# Bug-hunting session (simulated): imagine the expected was [1, 3, 6, 10]
# and we got [1, 3, 6, 10]. Add an assertion to lock the behavior.
def average(nums: list[float]) -> float:
if not nums:
raise ValueError("cannot average an empty list")
return sum(nums) / len(nums)
try:
average([])
except ValueError as err:
print("handled:", err)
# Guard invariants with assert during development
def normalize(values: list[float]) -> list[float]:
total = sum(values)
assert total != 0, "sum must be non-zero for normalization"
return [v / total for v in values]
print(normalize([1, 2, 3]))
# To drop into pdb at a specific line, uncomment the next statement and run:
# breakpoint() # >> n (next), s (step), p var (print), c (continue)
Three debugging habits in one script:
1) `logging.debug` replaces `print` for repeated investigation; easy to silence.
2) `assert` documents and enforces invariants during development.
3) Raising specific exceptions on invalid input fails early with a clear message.
4) `breakpoint()` is the modern way to open pdb at the right line.
Reproduce a bug with a targeted print.
def unique_names(rows):
seen = set()
out = []
for r in rows:
name = r["name"].strip().lower()
if name not in seen:
seen.add(name)
out.append(name)
return out
rows = [{"name": "Ana"}, {"name": " Ana "}, {"name": "BEN"}]
print(unique_names(rows)) # should print ['ana', 'ben']
Invariants with assert.
def chunk(seq, n):
assert n > 0, "n must be positive"
return [seq[i:i+n] for i in range(0, len(seq), n)]
assert chunk([1, 2, 3, 4, 5], 2) == [[1, 2], [3, 4], [5]]
try:
chunk([1], 0)
except AssertionError:
pass
else:
raise AssertionError("should have raised")
Running the script prints something like:
DEBUG i=0 n=1 running=1
DEBUG i=1 n=2 running=3
DEBUG i=2 n=3 running=6
DEBUG i=3 n=4 running=10
[1, 3, 6, 10]
handled: cannot average an empty list
[0.16666666666666666, 0.3333333333333333, 0.5]