- Distinguish between syntax errors and exceptions and explain why exceptions are the preferred error-signalling mechanism in Python
- Write try/except/else/finally blocks that catch specific exceptions and handle them appropriately
- Navigate the built-in exception hierarchy and select the correct exception type to catch or raise
- Create custom exception classes and raise them to signal domain-specific errors
- Apply the EAFP principle and use context managers to manage resources safely
Programs fail. Files go missing, networks drop out, users type nonsense where you expected a number. The question is never whether errors will happen — it is how your program will respond when they do. A program that crashes with an incomprehensible traceback is barely better than one that silently produces wrong results. Python's exception system gives you the tools to detect errors, respond to them gracefully, and keep your program in a consistent state even when things go wrong.
Exceptions vs Syntax Errors
Python distinguishes between two kinds of problems. A syntax error means your code is not valid Python — you forgot a colon, mismatched parentheses, or misspelled a keyword. Python catches these before your program runs:
# SyntaxError: expected ':'
if True
print("hello")
An exception is an error that occurs during execution. The code is valid Python, but something goes wrong at runtime:
print(10 / 0) # ZeroDivisionError
int("hello") # ValueError
open("no_such_file") # FileNotFoundError
Syntax errors must be fixed in the source code. Python will not run a file with syntax errors at all — the parser rejects it before execution begins. Exceptions, on the other hand, occur in running code that is syntactically correct. They can be caught, handled, and recovered from — which is the subject of this chapter.
try, except, else, finally
The try statement is Python's exception-handling mechanism. At its simplest, you wrap code that might fail in a try block and specify what to do if it does in an except block:
try:
number = int(input("Enter a number: "))
print(f"You entered {number}")
except ValueError:
print("That is not a valid number")
If int() raises a ValueError, execution jumps to the except block. If no exception occurs, the except block is skipped entirely.
The full form includes else (runs only if no exception occurred) and finally (runs no matter what):
try:
f = open("data.txt")
content = f.read()
except FileNotFoundError:
print("File not found")
else:
print(f"Read {len(content)} characters")
finally:
print("This always runs")
The else clause is useful for code that should run only on success but should not be inside the try block — because putting too much code in try risks catching exceptions you did not intend to handle. Keep the try block as narrow as possible: wrap only the line that might fail, not the twenty lines around it.
The finally clause is essential for cleanup: closing files, releasing locks, restoring state. It runs whether the code succeeded, raised an exception, or even executed a return statement. If you need to guarantee that something happens — a temporary file is deleted, a database connection is closed, a lock is released — finally is where it belongs.
Catching Specific Exceptions
A critical rule: never write a bare except that catches everything:
# DO NOT DO THIS
try:
result = dangerous_operation()
except:
pass # silently swallows ALL exceptions, including KeyboardInterrupt
This hides bugs, prevents Ctrl+C from working, and makes debugging a nightmare. Always catch specific exception types:
try:
value = my_dict["key"]
except KeyError:
value = "default"
You can catch multiple exception types in one except clause:
try:
result = int(user_input) / divisor
except (ValueError, ZeroDivisionError) as e:
print(f"Error: {e}")
The as e clause captures the exception object, which you can inspect for details. The exception object typically has a human-readable message accessible via str(e), and exception-specific attributes that provide additional context.
You can also stack multiple except clauses to handle different exceptions differently:
try:
data = fetch_from_network()
except ConnectionError:
data = load_from_cache()
except TimeoutError:
print("Request timed out, try again later")
The Exception Hierarchy
Python's exceptions form a hierarchy rooted at BaseException. The most important branch is Exception, which is the parent of nearly all exceptions you will encounter:
BaseException
├── KeyboardInterrupt
├── SystemExit
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── KeyError
├── IndexError
├── FileNotFoundError
├── AttributeError
├── ZeroDivisionError
├── RuntimeError
└── ... (dozens more)
KeyboardInterrupt, SystemExit, and GeneratorExit inherit from BaseException but not from Exception. This is deliberate: when you write except Exception, you catch programming errors and data problems, but you do not accidentally swallow a user pressing Ctrl+C or the system trying to exit. This is another reason never to write a bare except — it catches BaseException, which includes things you almost certainly should not catch.
Raising Exceptions
You are not limited to catching exceptions — you can raise them to signal errors in your own code:
def withdraw(balance, amount):
if amount < 0:
raise ValueError("Amount must be positive")
if amount > balance:
raise ValueError(f"Insufficient funds: balance is {balance}")
return balance - amount
raise creates an exception object and immediately exits the current function, propagating up the call stack until something catches it. If nothing does, the program terminates with a traceback.
You can also re-raise the current exception inside an except block with a bare raise:
try:
process_data(data)
except ValueError:
log_error("Data processing failed")
raise # re-raise the original exception
This pattern is useful for logging or cleanup before letting the exception propagate. You can also chain exceptions using raise NewException() from original_exception, which preserves the original traceback and makes it clear that one error caused another. Exception chaining is particularly valuable in library code, where you want to raise a domain-specific exception without losing the underlying cause.
Custom Exception Classes
For domain-specific errors, define your own exception classes by inheriting from Exception:
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw {amount}: balance is {balance}"
)
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
account = BankAccount(100)
try:
account.withdraw(200)
except InsufficientFundsError as e:
print(e) # Output: Cannot withdraw 200: balance is 100
print(e.balance) # Output: 100
print(e.amount) # Output: 200
Custom exceptions carry context about what went wrong, making error handling more precise and more informative than using generic exceptions with string messages.
EAFP vs LBYL
Python has a strong cultural preference for EAFP — Easier to Ask Forgiveness than Permission. Instead of checking whether an operation will succeed before attempting it (LBYL — Look Before You Leap), you try the operation and handle the exception if it fails:
# LBYL style — check first
if "key" in my_dict:
value = my_dict["key"]
else:
value = "default"
# EAFP style — try first
try:
value = my_dict["key"]
except KeyError:
value = "default"
EAFP is preferred in Python for several reasons. It avoids race conditions (the thing you checked might change between the check and the use). It is often faster when the success case is common, because exceptions in the rare failure case are cheaper than a check on every call. And it reads more naturally: "do this, and if it fails, do that instead."
That said, LBYL has its place. When the failure case is common — checking whether a string is numeric before converting it, for instance — a simple if is clearer and more efficient than a try/except. The choice between EAFP and LBYL is pragmatic, not dogmatic. Use the one that makes your code clearest in each situation.
Context Managers
A context manager is an object that defines setup and teardown actions for a block of code. You use it with the with statement:
with open("data.txt") as f:
content = f.read()
# f is automatically closed here, even if an exception occurred
The with statement calls the object's __enter__ method at the start and its __exit__ method at the end — guaranteed, regardless of exceptions. This is far safer than manual try/finally:
class Timer:
def __enter__(self):
import time
self.start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
elapsed = time.time() - self.start
print(f"Elapsed: {elapsed:.4f} seconds")
return False # do not suppress exceptions
with Timer():
total = sum(range(1_000_000))
The three parameters of __exit__ describe any exception that occurred. Returning False (or None) lets the exception propagate; returning True suppresses it. In practice, you almost always return False — suppressing exceptions silently is rarely the right thing to do.
Warnings and Logging
Not every problem deserves an exception. Warnings signal issues that are worth noting but not fatal:
import warnings
def connect(host, use_ssl=False):
if not use_ssl:
warnings.warn("Connection is not encrypted", UserWarning)
return f"Connected to {host}"
connect("example.com")
The logging module provides structured, configurable output for tracking what your program is doing:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Application started")
logger.warning("Disk space is low")
logger.error("Failed to save file")
Logging is not error handling — it is observability. But it complements error handling beautifully: catch the exception, log it, and then decide whether to retry, fall back, or re-raise.
Error handling is where the optimism of writing new code meets the realism of deploying it. Every try block is an acknowledgement that something might go wrong, and every except block is a decision about what to do about it. The discipline is not in writing error-free code — that is impossible — but in thinking clearly about failure modes and handling them honestly.