Python Exceptions
An exception is Python’s way of telling you that something went wrong while your program was running — a file wasn’t found, a list index didn’t exist, or a user typed text where a number was expected. Instead of crashing silently or continuing with garbage data, Python raises an exception object that carries information about the failure, and your code gets a structured way to detect it, react to it, and keep running. Mastering exceptions is what separates scripts that fall over on the first bad input from programs that behave predictably under real-world conditions.
Overview: How Exceptions Work
Every error in Python — from division by zero to a missing dictionary key — is represented by an exception object, an instance of a class that ultimately inherits from BaseException. When something goes wrong, Python constructs one of these objects and raises it. This immediately stops normal execution of the current statement and begins unwinding the call stack: Python walks back up through each function call that is currently active, looking for a surrounding try block that declares it can handle this kind of exception. If it finds a matching except clause, execution jumps into that block. If it walks all the way back to the top level of the program without finding a handler, the interpreter prints a traceback — the chain of calls that led to the error — and the program terminates.
Almost every exception you write and catch derives from Exception, itself a subclass of BaseException. Three direct subclasses of BaseException sit outside the Exception branch on purpose: SystemExit, KeyboardInterrupt, and GeneratorExit. Keeping them separate means a well-written except Exception: clause won’t accidentally swallow a user pressing Ctrl+C or the interpreter shutting down. A bare except:, however, catches literally everything — including those three — which is one reason experienced Python developers avoid it.
Every exception instance carries an args tuple (usually just the message string you see printed), plus two attributes Python sets automatically to help you debug: __context__, which records another exception that was already being handled when this one was raised (implicit chaining), and __cause__, which records an exception you explicitly linked with raise ... from ... (explicit chaining). Exceptions also carry a __traceback__ attribute — the frame-by-frame record of where the error occurred, which is exactly what an unhandled crash prints to your terminal.
Because raising and catching exceptions is relatively cheap in Python, and the language itself leans on them heavily (a StopIteration ends every for loop internally, KeyError powers dictionary lookups, IndexError bounds-checks sequences), Python culture favors EAFP — “Easier to Ask Forgiveness than Permission” — over LBYL, “Look Before You Leap.” Instead of checking whether a key exists before reading it, idiomatic Python just attempts the read and catches KeyError if it’s missing. This avoids a redundant check-then-use pattern and is generally considered more Pythonic.
Syntax
The general shape of exception handling in Python looks like this:
try:
...
except SomeException as exc:
...
except (AnotherException, YetAnotherException):
...
else:
...
finally:
...
| Clause | Purpose | Runs when |
|---|---|---|
try: |
Wraps code that might fail | Always, first |
except ExcType: |
Handles one specific exception type (or its subclasses) | Only if that type was raised in try |
except ExcType as e: |
Same, but binds the exception object to the name e |
Same as above |
except (TypeError, ValueError): |
Handles several types with one block | If any listed type was raised |
else: |
Code that should run only when no exception occurred | After try completes cleanly |
finally: |
Cleanup code | Always — exception or not, even across a return |
raise ExcType(msg) |
Creates and raises a new exception | Whenever executed |
raise NewErr from cause |
Raises a new exception while recording another as its explicit cause | Whenever executed |
except clauses are checked top to bottom, and Python uses the first one that matches (using an isinstance-style check against the exception’s type). This means more specific exception types must be listed before more general ones — an except Exception: placed first will intercept everything below it, and any later, more specific clause becomes unreachable dead code.
Examples
Example 1: try / except / else / finally in Action
def parse_age(value: str) -> int:
try:
age = int(value)
except ValueError:
print(f"'{value}' is not a valid integer.")
return -1
else:
print(f"Parsed age: {age}")
return age
finally:
print("Finished attempting to parse.")
parse_age("25")
parse_age("twenty-five")
Output:
Parsed age: 25
Finished attempting to parse.
'twenty-five' is not a valid integer.
Finished attempting to parse.
When the input converts cleanly, the try block succeeds, so the else block runs and prints the parsed age. Notice that finally still runs afterward, even though else already executed a return — Python holds the return value, runs finally, and only then actually returns. When the input fails to convert, the except ValueError block runs instead of else, but finally still fires exactly the same way. This demonstrates that finally is unconditional cleanup code, while else is “only if nothing went wrong” code.
Example 2: Multiple Except Clauses and a Custom Exception
class InsufficientFundsError(Exception):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw {amount:.2f}: balance is only {balance:.2f}"
)
def withdraw(balance: float, amount: float) -> float:
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
accounts = [100.0, 100.0, 100.0]
amounts = [30.0, -5.0, 250.0]
for amount in amounts:
try:
accounts[0] = withdraw(accounts[0], amount)
except ValueError as exc:
print(f"Invalid input: {exc}")
except InsufficientFundsError as exc:
print(f"Transaction failed: {exc}")
else:
print(f"Success! New balance: {accounts[0]:.2f}")
print(f"Final balance: {accounts[0]:.2f}")
Output:
Success! New balance: 70.00
Invalid input: Withdrawal amount must be positive
Transaction failed: Cannot withdraw 250.00: balance is only 70.00
Final balance: 70.00
This example defines a custom exception, InsufficientFundsError, by subclassing Exception and building a descriptive message in __init__. The loop shows three outcomes for three different withdrawal amounts: a normal success (handled by else), an invalid negative amount (a built-in ValueError), and an amount that exceeds the balance (the custom exception). Because each except clause names a distinct type, Python routes each failure to the correct handler without any manual type-checking.
Example 3: Raising and Chaining Exceptions
def convert_temperatures(readings: list[str]) -> list[float]:
results = []
for raw in readings:
try:
celsius = float(raw)
except ValueError as exc:
raise ValueError(f"Bad reading {raw!r} in sensor data") from exc
results.append(celsius * 9 / 5 + 32)
return results
try:
fahrenheit = convert_temperatures(["21.5", "19.0", "N/A"])
except ValueError as exc:
print(f"Conversion failed: {exc}")
print(f"Original cause: {exc.__cause__!r}")
else:
print(fahrenheit)
Output:
Conversion failed: Bad reading 'N/A' in sensor data
Original cause: ValueError("could not convert string to float: 'N/A'")
Here, a low-level ValueError raised by float("N/A") is caught, then re-raised as a new, more descriptive ValueError using raise ... from exc. This is exception chaining: the new exception’s __cause__ attribute stores the original one, so nothing is lost. If this had propagated all the way to an unhandled crash, Python’s traceback would show both exceptions, connected by the line “The above exception was the direct cause of the following exception.” Chaining like this is far more useful for debugging than losing the original error entirely.
Under the Hood: Step by Step
When Python executes a try statement, here is what actually happens internally:
- Python begins executing the statements inside
tryone at a time, as normal. - If one of them raises an exception, Python immediately stops executing that statement (and the rest of the
tryblock) and creates the exception object, attaching a fresh__traceback__frame. - Python compares the exception’s type against each
exceptclause, top to bottom, using anisinstancecheck (so a clause for a parent class also matches subclass instances). - The first matching clause runs. Inside it, the exception is considered “handled” — unless you call
raiseagain (bare, to re-raise the same exception, or with a new exception) to keep it propagating. - If no clause matches, the exception keeps propagating up to the caller’s stack frame, repeating this whole search there — and so on, until it’s handled or reaches the top of the program.
elseonly executes if thetryblock finished with no exception at all, and it runs after thetryblock but beforefinally.finallyalways executes last, no matter what: whether thetrysucceeded, whether anexcepthandled something, or even if the exception was never caught and is still propagating. This makes it the right place for cleanup like closing files or releasing locks.
Common Mistakes
Mistake 1: Using a Bare except:
Wrong:
try:
value = int(user_input)
except:
print("Something went wrong")
A bare except: catches every exception, including KeyboardInterrupt and SystemExit, and it also hides unrelated bugs — a typo in a variable name three lines later would raise NameError, and this code would silently print the same generic message instead of revealing the real problem. Always name the exception type you actually expect.
Corrected:
def parse_input(user_input: str) -> int | None:
try:
return int(user_input)
except ValueError:
print(f"'{user_input}' is not a valid integer")
return None
print(parse_input("42"))
print(parse_input("abc"))
Output:
42
'abc' is not a valid integer
None
Mistake 2: Catching Exception Too Broadly Around Too Much Code
Wrong:
def process_orders(orders):
try:
for order in orders:
total = order.qty * order.pric
print(total)
except Exception:
print("Failed to process orders")
Here, order.pric is a typo (it should be order.price), which would raise a real AttributeError — a bug worth knowing about. But because the entire loop is wrapped in one broad try with except Exception:, the typo is silently converted into a generic “Failed to process orders” message every time, and the actual cause never surfaces, even during testing.
Corrected:
class Order:
def __init__(self, qty: int, price: float):
self.qty = qty
self.price = price
def process_orders(orders: list[Order]) -> list[float]:
totals = []
for order in orders:
try:
totals.append(order.qty * order.price)
except AttributeError as exc:
print(f"Skipping malformed order: {exc}")
return totals
orders = [Order(3, 9.5), Order(2, 4.0)]
print(process_orders(orders))
Output:
[28.5, 8.0]
The fix narrows the try block to just the risky expression and catches only the specific exception type expected from malformed data, so any other, unrelated bug still surfaces normally.
Best Practices
- Catch specific exception types (
ValueError,KeyError, your own custom classes) instead of bareexcept:or blanketexcept Exception:, unless you immediately log and re-raise. - Keep the
tryblock as small as possible — wrap only the line or two that can actually fail, not an entire function body. - Use
elsefor code that should run only after success; keeping it outside thetryavoids accidentally catching exceptions that code itself might raise. - Prefer context managers (
with open(...) as f:) over manualtry/finallycleanup whenever a resource supports one — the cleanup happens automatically even on error. - Define custom exception classes for domain-specific errors (e.g.
class ConfigError(Exception): ...) so callers can catch precisely what they expect instead of guessing at built-in types. - Preserve context with
raise NewError(...) from original_excso both tracebacks remain visible; useraise ... from Noneto deliberately suppress noisy, expected chaining. - Never leave an
exceptblock empty (except Exception: pass) — at minimum log the exception’s message so failures aren’t invisible. - Reserve exceptions for genuinely exceptional situations; for routine checks a plain
ifis often clearer, but embrace EAFP (try it, catch the failure) for things like optional dictionary keys or duck-typed objects.
Practice Exercises
- Write a function
safe_divide(a, b)that returnsa / b, but catchesZeroDivisionError, prints a warning message, and returnsNoneinstead of crashing. Test it withsafe_divide(10, 2)andsafe_divide(10, 0). - Define a custom exception
NegativeAmountError. Write a functiondeposit(balance, amount)that raises it wheneveramountis negative, otherwise returnsbalance + amount. Loop over a list of deposit amounts including at least one negative value, catchingNegativeAmountErrorand printing which deposits failed. - Write a function that takes a list of strings expected to represent integers (for example
["10", "xx", "20"]) and returns two lists: one of successfully converted integers, and one of the original strings that failed, usingtry/exceptinside a loop rather than stopping at the first failure. For the example input, the successful list should be[10, 20]and the failed list should be['xx'].
Summary
- An exception is an object — an instance of a class derived from
BaseException— that Python raises when something goes wrong, unwinding the call stack until a matchingexcepthandles it. trywraps risky code,excepthandles specific failures,elseruns only on success, andfinallyalways runs, making it ideal for cleanup.exceptclauses are matched top to bottom by type, so specific exceptions must be listed before general ones likeException.- Custom exceptions, created by subclassing
Exception, let you model domain-specific errors that callers can catch precisely. raise NewError(...) from originalcreates explicit exception chaining, preserving the original cause via__cause__for easier debugging.- Avoid bare
except:and emptyexcept: passblocks — they hide real bugs and can even swallowKeyboardInterrupt. - Python’s EAFP style — try the operation and catch the failure — is usually preferred over pre-checking conditions with
if.
