Why this matters
Every programmer writes bugs; what separates beginners from experienced engineers is how quickly and calmly they find them. Debugging is not staring at code hoping the mistake jumps out. It is a method, much like a scientific experiment: observe, form a hypothesis, test it, repeat. This lesson gives you that method and three everyday tools: reading tracebacks properly, printing the right things, and the logging module, which is what real programs use instead of scattered prints.
The method
When a program misbehaves:
- Reproduce the problem reliably. Find the exact input that triggers it and a single command that shows it. A bug you cannot reproduce, you cannot confirm fixed.
- Read the evidence. If there is a traceback, read all of it (below). If there is wrong output, write down exactly how it differs from what you expected.
- Isolate. Shrink the input and the code until the bug is in a small region. Cut the 10,000-line input in half: does it still fail? Keep the failing half. Comment out steps. Call the suspicious function directly with one value.
- Hypothesize, then test the hypothesis. "I think
scoresholds strings, somaxcompares text." Check it with one targeted print or one experiment in the REPL. If wrong, discard the idea and form another; do not change code at random. - Fix the cause, not the symptom, and add a test that would have caught it (a regression test, from the first lesson of this module).
The most common debugging failure is skipping steps 2-4: changing things until the output looks right, without understanding why. That hides bugs instead of fixing them.
Reading a traceback
A traceback is a snapshot of the call stack at the moment an exception escaped: which function called which, down to the line that failed.
Traceback (most recent call last):
File "report.py", line 22, in <module>
main()
File "report.py", line 18, in main
print(format_row(row))
File "report.py", line 11, in format_row
return f"{row['name']:<10} {unit_price(row):>8.2f}"
File "report.py", line 6, in unit_price
return row["total"] / row["qty"]
~~~~~~~~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zero
- Read the last line first: the exception type and message say what went wrong.
- The frame just above it is where: file, line number, function, and the source line, with
~~~^~~~markers pointing at the exact sub-expression (here the division). - The frames above that say how the program got there, outermost first: module-level code called
main, which calledformat_row, which calledunit_price. Each frame is a function call that was in progress, with its own local variables. - Frames from the standard library or other packages often appear in the middle. Look for the last frame in your own code: that is usually where your mistake is, even if the exception was raised deeper inside a library you called with bad arguments.
Here the arithmetic is fine; the data is not. The useful question is "which row has qty 0, and should it have been filtered out earlier?", which leads you back up the stack to where rows are read.
If an exception happens while handling another one, you see two tracebacks joined by During handling of the above exception, another exception occurred:. The last one is what finally stopped the program; the first one is often the real cause.
Print debugging, done well
Adding print calls is a perfectly respectable technique. Make them informative:
print("DEBUG scores:", repr(scores), type(scores[0]))
DEBUG scores: ['9', '10', '8'] <class 'str'>
- Use
repr(or the f-string=specifier).print(x)hides the difference between10and"10", and between"ada"and"ada\n";reprshows quotes, escapes and trailing whitespace. - The
=specifier prints the expression and its value:print(f"{total=} {count=}")givestotal=27 count=3. - Label each print so you know which line produced it, and print at the boundaries: what a function receives and what it returns.
- Print to stderr when stdout is the program's real output:
print("DEBUG", x, file=sys.stderr). Then debugging output does not corrupt the results (or break an exercise's expected output). - Remove them afterwards. Debug prints left in code are noise at best and leak data at worst.
In the exercise with three bugs, print(repr(scores)) immediately shows the scores are strings, and string comparison is alphabetical: "9" > "10" is True, because "9" sorts after "1".
logging: prints that grew up
For anything bigger than a throwaway script, use the standard logging module. Every log message has a level:
| Level | Use for |
|---|---|
DEBUG | detailed internals, only useful while diagnosing |
INFO | normal milestones: "loaded 1,200 records" |
WARNING | something unexpected but handled: "skipping malformed line 7" |
ERROR | an operation failed |
CRITICAL | the program cannot continue |
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logging.debug("raw input: %r", "x") # below INFO: not shown
logging.info("loaded %d records", 1200)
logging.warning("skipping line %d: %r", 7, "n/a")
INFO: loaded 1200 records
WARNING: skipping line 7: 'n/a'
What is happening:
basicConfigconfigures the root logger once, at program start: the minimum level to show, the format of each line, and the destination. By default the destination is stderr, keeping stdout clean for real output. Passfilename="app.log"to log to a file instead.- Messages below the level are discarded cheaply. Turning on detail is a one-word change (
level=logging.DEBUG), with no need to add or remove prints throughout the code. Without anybasicConfig, the threshold is WARNING and the format isWARNING:root:message. - Pass arguments separately (
"loaded %d records", n) rather than pre-formatting with an f-string. The string is only formatted if the message will actually be shown, and log-collection tools can group messages by their template.%dis an integer,%susesstr,%rusesrepr. - Format fields include
%(asctime)s(timestamp),%(name)s(logger name),%(levelname)s,%(funcName)sand%(lineno)d.
In larger programs, each module creates its own named logger with log = logging.getLogger(__name__) and calls log.info(...). The names form a hierarchy (shop, shop.cart), so you can turn on DEBUG for one part of the program only. Library code should never call basicConfig; configuring output is the application's job.
Inside an except block, logging.exception("could not parse %s", path) logs at ERROR level and includes the full traceback, which is ideal for long-running programs that must report a failure and carry on.
Common mistakes
- Reading only the last line of a traceback, or only the first. The last line says what, the last frame of your code says where, the rest says how.
- Debug prints corrupting output. They go to stdout by default; use stderr or logging.
print(x)hiding types.5and'5'print identically; userepr.- Calling
basicConfigafter logging has started. The firstlogging.info(...)call (with no configuration) configures the root logger with defaults, and laterbasicConfigcalls are then ignored. Configure once, at the very start ofmain. - f-strings in log calls:
logging.debug(f"{huge_object}")formats the string even when DEBUG is off. - Changing several things at once while debugging. If the bug disappears, you do not know which change fixed it, or whether one of them introduced a new bug.
Recap
- Debug methodically: reproduce, read the evidence, isolate, test one hypothesis at a time, fix the cause, add a regression test.
- A traceback lists the call stack outermost first; the last line says what failed and the frame above it says where.
- Print debugging works: use
reprorf"{x=}", label your prints, send them to stderr, remove them afterwards. logginghas levels (DEBUG to CRITICAL), is configured once withbasicConfig, writes to stderr by default, and takes arguments separately from the format string.
Going further: read the "Logging HOWTO" in the Python documentation (the basic tutorial section), and try python3 -X faulthandler plus python3 -X importtime -c "import json" to see two more built-in diagnostic switches.