Chapter 12: Errors, Exceptions, and Debugging¶
1. Overview¶
Every program encounters unexpected situations: a file that does not exist, a user who types letters where a number was expected, a network request that times out. Python's exception system gives you a structured way to detect these situations, respond to them, and keep your program from crashing unexpectedly.
This chapter also covers debugging — the practical skill of figuring out why
your code does not behave the way you expect. Reading tracebacks, using
print() strategically, and working with the built-in debugger are all tools
you will use throughout your Python career.
2. What You Will Learn¶
- The difference between syntax errors and runtime exceptions
- How to read a Python traceback
- Common built-in exception types and when they occur
- Handling exceptions with
try,except,else, andfinally - Catching multiple exception types
- Accessing exception details with
as - Raising exceptions with
raise - Creating custom exception classes
- The
assertstatement for internal checks - Practical debugging techniques:
print(),breakpoint(), and thepdbdebugger
3. Core Concepts¶
3.1 Syntax Errors vs. Exceptions¶
Python reports two broad categories of problems.
Syntax errors are detected before the program runs. Python cannot even parse the code.
Fix syntax errors first — nothing else will run until they are gone.
Exceptions (also called runtime errors) occur while the program is running. The code is syntactically valid, but something goes wrong during execution.
3.2 Reading a Traceback¶
When an unhandled exception occurs, Python prints a traceback — a record of the call stack at the moment of the error. Learning to read tracebacks quickly is one of the most valuable debugging skills.
Traceback (most recent call last):
File "example.py", line 7, in <module>
calculate(10)
File "example.py", line 5, in calculate
return divide(x, 0)
File "example.py", line 2, in divide
return a / b
ZeroDivisionError: division by zero
Read a traceback from bottom to top:
- The last line names the exception type and gives a short message.
ZeroDivisionError: division by zero - The line just above it shows the exact line of code that raised the error.
- Working upward, each frame shows the call that led to the next one.
- The top frame is where execution started (usually the module level or the entry point).
The most useful information is almost always at the bottom. Start there.
3.3 Common Built-in Exceptions¶
| Exception | When it occurs |
|---|---|
ValueError |
Right type, wrong value — e.g., int("abc") |
TypeError |
Wrong type — e.g., "hello" + 5 |
IndexError |
Sequence index out of range — e.g., lst[99] on a short list |
KeyError |
Dict key not found — e.g., d["missing"] |
AttributeError |
Object has no such attribute — e.g., 42.upper() |
NameError |
Name not defined — e.g., using a variable before assigning it |
ZeroDivisionError |
Division or modulo by zero |
FileNotFoundError |
File or directory does not exist |
PermissionError |
Not allowed to read/write a file |
ImportError |
Module cannot be imported |
StopIteration |
Iterator has no more items |
RecursionError |
Maximum recursion depth exceeded |
OverflowError |
Arithmetic result too large for a float |
MemoryError |
Not enough memory |
OSError |
General OS-level failure (parent of many file errors) |
You do not need to memorize all of these. When you see an unfamiliar exception, read the message — it usually tells you exactly what went wrong.
3.4 Handling Exceptions with try and except¶
Wrap code that might raise an exception in a try block. If an exception
occurs, Python jumps to the matching except block.
try:
number = int(input("Enter a number: "))
print(f"You entered: {number}")
except ValueError:
print("That is not a valid number.")
If the user types "hello", int("hello") raises a ValueError, and the
except block runs. If the user types "42", the except block is skipped.
Catching multiple exception types¶
List multiple exception types in a tuple to handle them the same way:
try:
value = int(input("Enter a number: "))
result = 100 / value
print(f"100 / {value} = {result}")
except (ValueError, ZeroDivisionError):
print("Please enter a non-zero number.")
Or use separate except clauses to handle them differently:
try:
value = int(input("Enter a number: "))
result = 100 / value
except ValueError:
print("That is not a valid integer.")
except ZeroDivisionError:
print("Cannot divide by zero.")
Python checks except clauses in order and runs the first one that matches.
Catching any exception¶
Use except Exception to catch any non-system-exiting exception. This is
useful at the top level of a program, but avoid it deep inside your code —
it hides bugs.
Never use a bare except: without a type. It catches everything, including
KeyboardInterrupt and SystemExit, which you almost never want to suppress.
3.5 The else Clause¶
The else block runs only if the try block completed without raising an
exception. Use it for code that should run on success but that you do not want
inside the try block.
try:
value = int(input("Enter a number: "))
except ValueError:
print("Invalid input.")
else:
# Only runs if no exception was raised
print(f"You entered {value}. Its square is {value ** 2}.")
Keeping the success path in else makes it clear which code is "protected"
by the try and which code runs only on success.
3.6 The finally Clause¶
The finally block always runs — whether an exception occurred or not, and
whether it was handled or not. Use it for cleanup that must happen regardless
of outcome: closing files, releasing locks, disconnecting from a database.
def read_file(path: str) -> str:
f = open(path, encoding="utf-8")
try:
return f.read()
finally:
f.close() # always runs, even if read() raises an exception
In practice, the with statement (covered in Chapter 13) handles this pattern
more cleanly. But finally is still useful when you need explicit cleanup
logic.
try:
result = risky_operation()
except SomeError:
handle_error()
else:
use_result(result)
finally:
cleanup() # always runs
3.7 Accessing Exception Details with as¶
Use as to bind the exception object to a name. The exception object has a
args attribute and can be converted to a string for a human-readable message.
try:
with open("missing.txt") as f:
content = f.read()
except FileNotFoundError as e:
print(f"Could not open file: {e}")
# Could not open file: [Errno 2] No such file or directory: 'missing.txt'
You can also inspect the exception type:
try:
result = int("abc")
except ValueError as e:
print(type(e).__name__) # ValueError
print(e.args) # ("invalid literal for int() with base 10: 'abc'",)
3.8 Raising Exceptions¶
Use raise to signal that something has gone wrong. Raise the most specific
exception type that fits the situation.
def set_age(age: int) -> None:
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}.")
if age < 0 or age > 150:
raise ValueError(f"Age must be between 0 and 150, got {age}.")
print(f"Age set to {age}.")
set_age(25) # Age set to 25.
set_age(-1) # ValueError: Age must be between 0 and 150, got -1.
set_age("old") # TypeError: Age must be an integer, got str.
Re-raising an exception¶
Inside an except block, a bare raise re-raises the current exception
without modifying it. This is useful when you want to log the error but still
let it propagate.
import logging
try:
result = risky_operation()
except Exception as e:
logging.error(f"Operation failed: {e}")
raise # re-raise the original exception
Exception chaining¶
Use raise NewException(...) from original to chain exceptions. This
preserves the original cause in the traceback.
def load_config(path: str) -> dict:
try:
with open(path) as f:
import json
return json.load(f)
except FileNotFoundError as e:
raise RuntimeError(f"Config file not found: {path}") from e
3.9 Custom Exceptions¶
Define your own exception classes by inheriting from Exception (or a more
specific built-in exception). Custom exceptions make your code's error
conditions explicit and allow callers to catch them specifically.
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the available balance."""
def __init__(self, amount: float, balance: float) -> None:
self.amount = amount
self.balance = balance
super().__init__(
f"Cannot withdraw {amount:.2f}: balance is only {balance:.2f}."
)
class BankAccount:
def __init__(self, balance: float = 0.0) -> None:
self.balance = balance
def withdraw(self, amount: float) -> None:
if amount > self.balance:
raise InsufficientFundsError(amount, self.balance)
self.balance -= amount
print(f"Withdrew {amount:.2f}. New balance: {self.balance:.2f}.")
account = BankAccount(100.0)
account.withdraw(30.0) # Withdrew 30.00. New balance: 70.00.
try:
account.withdraw(200.0)
except InsufficientFundsError as e:
print(e)
print(f"You tried to withdraw: {e.amount}")
print(f"Available balance: {e.balance}")
For a hierarchy of related errors, create a base exception class and inherit from it:
class AppError(Exception):
"""Base class for all application errors."""
class DatabaseError(AppError):
"""Raised for database-related failures."""
class NetworkError(AppError):
"""Raised for network-related failures."""
Callers can then catch AppError to handle all application errors, or catch
DatabaseError specifically.
3.10 The assert Statement¶
assert checks that a condition is true. If it is false, it raises an
AssertionError. Use it to catch programming mistakes — incorrect assumptions
in your own code — not to validate user input.
def average(numbers: list[float]) -> float:
assert len(numbers) > 0, "Cannot average an empty list."
return sum(numbers) / len(numbers)
Assertions can be disabled globally with the -O (optimize) flag when running
Python. Never use assert for security checks or input validation that must
always run.
# Wrong — assertions can be disabled
assert user.is_admin(), "Access denied."
# Correct — use a proper check
if not user.is_admin():
raise PermissionError("Access denied.")
3.11 Debugging Techniques¶
Print debugging¶
The simplest technique: add print() calls to inspect values at key points.
def process(data: list[int]) -> list[int]:
print(f"Input: {data}") # debug
result = [x * 2 for x in data if x > 0]
print(f"After filter: {result}") # debug
return result
Remove or comment out debug prints before committing code.
breakpoint()¶
Python 3.7+ includes breakpoint(), which drops you into the interactive
debugger (pdb) at that line.
def calculate(items: list[int]) -> int:
total = 0
for item in items:
breakpoint() # execution pauses here
total += item
return total
At the (Pdb) prompt, useful commands are:
| Command | What it does |
|---|---|
n |
Execute the next line (step over) |
s |
Step into a function call |
c |
Continue until the next breakpoint |
p expr |
Print the value of an expression |
pp expr |
Pretty-print the value |
l |
List the surrounding source code |
q |
Quit the debugger |
h |
Show help |
Inspecting objects¶
x = [1, 2, 3]
print(type(x)) # <class 'list'>
print(dir(x)) # list of attributes and methods
print(vars(x)) # __dict__ of an object (not for built-ins)
help(x.append) # documentation for a method
Checking types at runtime¶
def process(value):
if not isinstance(value, (int, float)):
raise TypeError(f"Expected a number, got {type(value).__name__}.")
return value * 2
Logging instead of printing¶
For larger programs, use the logging module instead of print(). It lets
you control verbosity without removing statements from the code.
import logging
logging.basicConfig(level=logging.DEBUG)
def divide(a: float, b: float) -> float:
logging.debug(f"divide called with a={a}, b={b}")
if b == 0:
logging.error("Division by zero attempted.")
raise ZeroDivisionError("Cannot divide by zero.")
result = a / b
logging.debug(f"Result: {result}")
return result
Log levels in order of severity: DEBUG, INFO, WARNING, ERROR,
CRITICAL. Set the level to WARNING in production to suppress debug output.
4. Practical Examples¶
4.1 Robust Input Loop¶
Keep asking for input until the user provides a valid value.
def get_positive_int(prompt: str) -> int:
"""Ask for a positive integer, retrying on invalid input."""
while True:
raw = input(prompt)
try:
value = int(raw)
except ValueError:
print(f" '{raw}' is not a valid integer. Try again.")
continue
if value <= 0:
print(f" Please enter a positive number. Got {value}.")
continue
return value
# Usage (interactive — uncomment to run):
# count = get_positive_int("How many items? ")
# print(f"You want {count} items.")
4.2 Safe Dictionary Lookup¶
def get_config_value(config: dict, key: str, default=None):
"""Return a config value, or default if the key is missing."""
try:
return config[key]
except KeyError:
return default
# Equivalent and more idiomatic:
def get_config_value(config: dict, key: str, default=None):
return config.get(key, default)
The dict.get() method is usually cleaner than try/except for this pattern.
Use try/except when the operation is more complex than a single lookup.
4.3 File Reading with Error Handling¶
from pathlib import Path
def read_text_file(path: str | Path) -> str | None:
"""Read a text file and return its contents, or None on failure."""
try:
return Path(path).read_text(encoding="utf-8")
except FileNotFoundError:
print(f"File not found: {path}")
return None
except PermissionError:
print(f"Permission denied: {path}")
return None
except OSError as e:
print(f"Could not read {path}: {e}")
return None
content = read_text_file("notes.txt")
if content is not None:
print(content)
4.4 Validating Function Arguments¶
def create_user(name: str, age: int, email: str) -> dict:
"""Create a user record after validating inputs."""
if not isinstance(name, str) or not name.strip():
raise ValueError("Name must be a non-empty string.")
if not isinstance(age, int) or not (0 <= age <= 150):
raise ValueError(f"Age must be an integer between 0 and 150, got {age!r}.")
if not isinstance(email, str) or "@" not in email:
raise ValueError(f"Invalid email address: {email!r}.")
return {
"name": name.strip(),
"age": age,
"email": email.lower(),
}
try:
user = create_user("Alice", 30, "alice@example.com")
print(user)
except ValueError as e:
print(f"Validation error: {e}")
4.5 Context Manager for Timing¶
import time
from contextlib import contextmanager
@contextmanager
def timer(label: str = ""):
"""Context manager that prints elapsed time."""
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
tag = f"[{label}] " if label else ""
print(f"{tag}Elapsed: {elapsed:.4f}s")
with timer("sorting"):
data = sorted(range(1_000_000), reverse=True)
# [sorting] Elapsed: 0.0821s (time will vary)
4.6 Exception Hierarchy in a Library¶
class StorageError(Exception):
"""Base class for storage-related errors."""
class ReadError(StorageError):
"""Raised when a read operation fails."""
class WriteError(StorageError):
"""Raised when a write operation fails."""
class StorageFullError(WriteError):
"""Raised when there is no space left."""
def __init__(self, needed: int, available: int) -> None:
self.needed = needed
self.available = available
super().__init__(
f"Need {needed} bytes but only {available} bytes available."
)
def save_data(data: bytes, available_space: int) -> None:
needed = len(data)
if needed > available_space:
raise StorageFullError(needed, available_space)
print(f"Saved {needed} bytes.")
try:
save_data(b"hello world", available_space=5)
except StorageFullError as e:
print(f"Storage full: {e}")
print(f" Needed: {e.needed} bytes")
print(f" Available: {e.available} bytes")
except WriteError as e:
print(f"Write failed: {e}")
except StorageError as e:
print(f"Storage error: {e}")
4.7 Retry Logic¶
import time
def retry(func, attempts: int = 3, delay: float = 1.0, exceptions=(Exception,)):
"""Call func up to `attempts` times, waiting `delay` seconds between tries."""
last_error = None
for attempt in range(1, attempts + 1):
try:
return func()
except exceptions as e:
last_error = e
print(f"Attempt {attempt}/{attempts} failed: {e}")
if attempt < attempts:
time.sleep(delay)
raise RuntimeError(f"All {attempts} attempts failed.") from last_error
# Example usage with a simulated flaky operation:
import random
call_count = 0
def flaky_operation():
global call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Simulated network failure.")
return "success"
result = retry(flaky_operation, attempts=5, delay=0.0, exceptions=(ConnectionError,))
print(f"Result: {result}")
# Attempt 1/5 failed: Simulated network failure.
# Attempt 2/5 failed: Simulated network failure.
# Result: success
5. Common Mistakes¶
5.1 Catching Too Broadly¶
Catching Exception (or worse, bare except) hides bugs and makes debugging
harder.
# Bad — catches everything, including bugs in your own code
try:
result = complex_operation(data)
except Exception:
print("Something went wrong.")
# Better — catch only what you expect
try:
result = complex_operation(data)
except ValueError as e:
print(f"Invalid data: {e}")
except IOError as e:
print(f"I/O error: {e}")
5.2 Silently Swallowing Exceptions¶
An empty except block hides errors completely. At minimum, log the error.
# Bad — error disappears silently
try:
process(item)
except Exception:
pass
# Better — at least log it
try:
process(item)
except Exception as e:
print(f"Warning: could not process item: {e}")
5.3 Using Exceptions for Normal Control Flow¶
Exceptions are for exceptional situations. Using them for expected conditions makes code harder to read and slower.
# Awkward — using exceptions for a normal check
try:
index = items.index(target)
except ValueError:
index = -1
# Clearer
index = items.index(target) if target in items else -1
That said, Python's "easier to ask forgiveness than permission" (EAFP) style
does use try/except for things like dict lookups and attribute access, where
it is idiomatic and efficient.
5.4 Forgetting finally for Cleanup¶
If you open a resource manually, always close it — even if an exception occurs.
# Risky — f.close() is skipped if an exception occurs
f = open("data.txt")
data = f.read() # what if this raises?
f.close()
# Safe — finally always runs
f = open("data.txt")
try:
data = f.read()
finally:
f.close()
# Best — use a context manager (with statement)
with open("data.txt") as f:
data = f.read()
5.5 Raising the Wrong Exception Type¶
Raise the exception type that best describes the problem. Callers use the type to decide how to respond.
# Wrong — ValueError is for bad values, not missing files
def load(path: str):
if not Path(path).exists():
raise ValueError(f"File not found: {path}")
# Correct
def load(path: str):
if not Path(path).exists():
raise FileNotFoundError(f"File not found: {path}")
5.6 Modifying the Exception Message Accidentally¶
When re-raising, use bare raise to preserve the original traceback. Using
raise e creates a new traceback that starts at the except line.
try:
risky()
except SomeError as e:
raise e # traceback starts here — loses original context
try:
risky()
except SomeError:
raise # re-raises with original traceback intact
6. Practice Tasks¶
-
Write a function
safe_divide(a: float, b: float) -> float | Nonethat returnsa / b, orNoneifbis zero, without raising an exception to the caller. -
Write a function
parse_int(s: str) -> intthat converts a string to an integer and raises aValueErrorwith a helpful message if the string is not a valid integer. -
Write a function
read_lines(path: str) -> list[str]that reads a file and returns its lines as a list. HandleFileNotFoundErrorandPermissionErrorseparately, printing a descriptive message for each. -
Create a custom exception
NegativeValueError(ValueError)that stores the offending value. Write a functionsqrt(x: float) -> floatthat raisesNegativeValueErrorifxis negative. -
Write a function
load_json(path: str) -> dictthat reads a JSON file and returns its contents as a dict. Handle missing files and invalid JSON separately. -
Write a function
get_element(lst: list, index: int, default=None)that returnslst[index]ordefaultif the index is out of range. -
Add
try/except/else/finallyto a function that opens a file, reads it, and prints a success or failure message. Thefinallyblock should always print "Done." -
Use
breakpoint()to step through a short function and inspect its local variables at each step.
7. Key Takeaways¶
- Syntax errors are caught before the program runs. Exceptions occur at runtime.
- Read tracebacks from the bottom up: the last line names the exception; the frames above show the call chain.
- Use
try/exceptto handle exceptions you expect and can recover from. - Catch specific exception types — avoid bare
exceptand overly broadexcept Exception. - The
elseclause runs only when no exception occurred. Use it for success logic that should not be inside thetryblock. - The
finallyclause always runs. Use it for cleanup. - Use
asto bind the exception object and access its message and attributes. raisesignals an error. Use the most specific exception type that fits.- Bare
raiseinside anexceptblock re-raises the original exception with its original traceback. - Custom exceptions inherit from
Exceptionand make your error conditions explicit and catchable. assertis for catching programming mistakes, not for validating user input.breakpoint()drops you intopdbfor interactive debugging.print()andloggingare useful for tracing values.
Further Reading¶
What's Next¶
Ready to continue? Head to the next chapter: Files, Paths, JSON, and CSV.
→ Chapter 13 — Files, Paths, JSON, and CSV
See also: - Exercise - Solution - Cheatsheet