Debugging Python
Objectives
By the end of this chapter, you should be able to:
- Identify and understand common Python errors
- Catch errors with
tryandexcept - Set breakpoints in your code
- Use the
loggingmodule instead of scatteredprint()calls
๐ก Why this matters: Every program you write will eventually break. Knowing what each error actually means turns a wall of red text into information you can act on immediately.
Common Built-In Errors
| Error | Happens when | Example |
|---|---|---|
NameError |
A variable isn’t defined | test โ NameError: name 'test' is not defined |
KeyError |
A dictionary doesn’t have the key you asked for | {}["foo"] โ KeyError: 'foo' |
AttributeError |
A value doesn’t have the attribute or method you called | "awesome".foo โ AttributeError: 'str' object has no attribute 'foo' |
IndexError |
A list index is out of range | ["hello"][2] โ IndexError: list index out of range |
ValueError |
The type is right, but the value isn’t valid | int("foo") โ ValueError: invalid literal for int() with base 10: 'foo' |
TypeError |
Python can’t combine two incompatible types | "awesome" + [] โ TypeError: can only concatenate str (not "list") to str |
ZeroDivisionError |
You divide a number by zero | 5 / 0 โ ZeroDivisionError: division by zero |
Raising Your Own Errors
The raise keyword lets you throw an error deliberately. This is useful when you’re writing your own validation, so bad input fails loudly instead of causing confusing problems later:
def set_age(age):
if age < 0:
raise ValueError("age cannot be negative")
return age
set_age(-5)
Traceback (most recent call last):
...
ValueError: age cannot be negative
Catching Errors with try/except
Wrap code that might fail in a try block, and handle the specific error in except:
try:
foobar
except NameError as err:
print(err)
You could catch every possible error with a bare except:, but avoid this:
try:
nice + []
except:
print("Something went wrong!")
The problem: catching everything means you can never tell what actually went wrong. Always except a specific error type, or a tuple of a few, if more than one is possible:
try:
# do some stuff
except (NameError, ValueError) as e:
# do some other stuff
Running Cleanup Code with finally
Sometimes you need a block of code to run no matter what, whether the try block succeeds or raises an error. finally guarantees that:
try:
file = open("data.txt")
risky_operation(file)
except FileNotFoundError:
print("Couldn't find that file.")
finally:
print("This always runs, error or not.")
finally is especially useful for cleanup, like closing a file or a network connection, that has to happen either way. In practice, a with block (from the file I/O module) handles this automatically for files, but finally is the general-purpose tool for any cleanup that isn’t file-specific.
Setting Breakpoints
To pause execution and inspect your code interactively, drop this line in:
import pdb; pdb.set_trace()
Since Python 3.7, the built-in breakpoint() function does the same thing without an import:
breakpoint()
Inside the debugger prompt ((Pdb)), a few commands cover most of what you need: n runs the next line, c continues execution until the next breakpoint (or the end), and q quits the debugger entirely.
Beyond print(): the logging Module
print() is fine for quick checks, but it doesn’t scale: you can’t easily turn it off, filter by severity, or tell where in your program a message came from. The built-in logging module solves all three:
import logging
logging.basicConfig(level=logging.INFO)
logging.debug("Detailed info, useful while diagnosing a problem")
logging.info("Confirmation that things are working as expected")
logging.warning("Something unexpected happened, but the program is still working")
logging.error("A more serious problem: some functionality has failed")
logging.critical("A serious error: the program itself may not be able to continue")
Each function logs at a different level, in increasing severity: DEBUG, INFO, WARNING, ERROR, CRITICAL. basicConfig(level=logging.INFO) sets the minimum level that actually gets shown. Here, debug() calls are silently skipped, while info() and everything more severe are printed:
INFO:root:Confirmation that things are working as expected
WARNING:root:Something unexpected happened, but the program is still working
ERROR:root:A more serious problem: some functionality has failed
CRITICAL:root:A serious error: the program itself may not be able to continue
Change the level once (say, to WARNING) and every debug()/info() call across your whole program goes quiet without deleting a single line of code. A scattered pile of print() statements can’t do that.
Try It
- Trigger three different errors on purpose (an undefined variable, a missing dictionary key, an out-of-range list index) and read each traceback.
- Wrap one of them in a
try/exceptthat catches only that specific error and prints a friendlier message instead. - Add a
finallyblock to that sametry/exceptand confirm it runs whether or not the error actually happens. - Drop a
breakpoint()into a short script and step through it, usingcto continue. - Replace a few
print()calls in a short script withloggingcalls at different levels, then changebasicConfig’s level and see which messages disappear.
Recap
- Different errors point at different problems:
NameError(undefined name),KeyError(missing key),AttributeError(missing attribute),IndexError(bad index),ValueError(right type, wrong value),TypeError(incompatible types),ZeroDivisionError(division by zero). try/exceptcatches specific errors so you can respond to them; a bareexcept:hides what actually went wrong, so avoid it.finallyruns no matter what, success or error, which makes it the right place for cleanup code.breakpoint()(orimport pdb; pdb.set_trace()) pauses execution so you can inspect your code as it runs.logginggives you severity levels (DEBUGthroughCRITICAL) and a single place to control what gets shown: a real upgrade over scatteredprint()calls.
Next lesson: organizing code into modules, and importing them.