Error Handling and Exceptions
try/except/else/finally, raising and re-raising, custom exception classes, and exception chaining with raise...from.
Why exceptions instead of error codes
Python signals a runtime error by raising an exception — an object that propagates up the call stack, unwinding one function call after another, until something explicitly handles it or the program terminates with a traceback. This is a fundamentally different style from a C-like language returning a special error code that the caller has to remember to check: an exception can't be silently ignored, and the code path for "everything went fine" stays clean and uncluttered by error-checking on every single line.
def divide(a, b):
return a / b
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # ZeroDivisionError: division by zero
Left unhandled, that ZeroDivisionError propagates all the way up and crashes the program with a traceback showing exactly where it happened. Handling it — deciding what "recovering" even means for that specific error — is what try/except is for.
try / except
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero")
return None
return result
print(divide(10, 2)) # 5.0
print(divide(10, 0)) # Cannot divide by zero -> None
Catch the most specific exception type that applies, not a bare except: — a bare except (or except Exception:) swallows errors you never intended to catch, including genuine bugs, making them far harder to diagnose later:
def parse_config_value(raw):
try:
return int(raw)
except ValueError:
print(f"'{raw}' is not a valid integer, defaulting to 0")
return 0
A single try can catch several different exception types, either with a tuple in one except or with multiple except clauses handled differently:
def load_user(user_id):
try:
return database.fetch(user_id)
except (ConnectionError, TimeoutError):
print("Network issue — retrying later")
return None
except KeyError:
print(f"No user with id {user_id}")
return None
Python checks except clauses top to bottom and runs the first one that matches, so put more specific exception types before more general ones (a ConnectionError clause after a catch-all except Exception clause would never run, since the general one already caught it).
else and finally
A try statement supports two more optional clauses beyond except:
else— runs only if thetryblock completed with no exception raised. It's a place to put code that should run on success but that you don't want accidentally caught by theexceptclauses above it (which are only watching thetryblock itself).finally— always runs, whether an exception was raised, handled, or not raised at all — even if thetry/exceptblock returns or re-raises. It's the right place for cleanup that absolutely must happen (closing a file handle, releasing a lock, logging that an operation finished).
def read_config(path):
try:
f = open(path)
except FileNotFoundError:
print(f"{path} not found, using defaults")
return {}
else:
# only reached if open() succeeded — no exception to accidentally catch here
contents = f.read()
return parse(contents)
finally:
# always runs — whether open() failed, parse() failed, or everything succeeded
print("read_config finished")
Raising exceptions yourself
raise triggers an exception on purpose — validating input and refusing to continue with bad data is the most common reason:
def set_age(age):
if age < 0:
raise ValueError(f"Age cannot be negative, got {age}")
return age
set_age(-5) # ValueError: Age cannot be negative, got -5
A bare raise with no expression, used only inside an except block, re-raises the exception currently being handled — useful for logging or partial cleanup before letting the original error continue propagating unchanged:
def process_payment(amount):
try:
charge_card(amount)
except PaymentError:
logger.error(f"Payment of {amount} failed")
raise # re-raise the same PaymentError, unchanged, after logging it
Custom exception classes
Python's built-in exceptions (ValueError, KeyError, TypeError, and dozens more) cover generic situations, but real applications benefit from their own exception types that describe a specific business-level failure. A custom exception is just a class inheriting from Exception (or a more specific built-in exception, when that relationship is genuinely accurate):
class InsufficientFundsError(Exception):
"""Raised when a withdrawal would overdraw an account."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw {amount}: balance is only {balance}"
)
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
account = BankAccount(balance=100)
try:
account.withdraw(250)
except InsufficientFundsError as e:
print(e) # Cannot withdraw 250: balance is only 100
print(e.balance, e.amount) # 100 250 — extra structured data on the exception itself
Attaching structured data (balance, amount above) directly onto the exception, rather than only a formatted message string, lets calling code make decisions based on the failure — showing a specific UI message, retrying with a smaller amount, logging structured fields — without parsing text back out of an error message.
Building a small hierarchy of related custom exceptions under one common base class lets calling code catch broadly or narrowly, its choice:
class ApplicationError(Exception):
"""Base class for all of this application's own exceptions."""
class InsufficientFundsError(ApplicationError):
pass
class AccountFrozenError(ApplicationError):
pass
try:
account.withdraw(250)
except ApplicationError as e:
# catches InsufficientFundsError, AccountFrozenError, or any future subclass —
# without needing to list every one of them individually
print(f"Transaction failed: {e}")
Exception chaining with raise ... from
It's common to catch a low-level exception and raise a different, more meaningful one in its place — translating a generic KeyError into a domain-specific ConfigurationError, for instance. Doing this with a plain raise still shows the original exception in the traceback (as "the above exception was the direct cause"), but raise NewError(...) from original_error makes that causal relationship explicit and intentional rather than incidental:
class ConfigurationError(Exception):
pass
def load_setting(config, key):
try:
return config[key]
except KeyError as e:
raise ConfigurationError(f"Missing required setting: {key}") from e
Traceback (most recent call last):
File "app.py", line 5, in load_setting
return config[key]
KeyError: 'database_url'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "app.py", line 12, in <module>
load_setting(config, "database_url")
File "app.py", line 7, in load_setting
raise ConfigurationError(f"Missing required setting: {key}") from e
ConfigurationError: Missing required setting: database_url
Both tracebacks are printed, which is exactly the point — someone debugging this later sees both "what the application-level error was" (ConfigurationError) and "what actually caused it underneath" (KeyError), instead of the original root cause being silently discarded. raise NewError(...) from None is the deliberate opposite — it explicitly suppresses the original exception's traceback entirely, for the rare case where the underlying cause is genuinely irrelevant noise.
Common mistakes
- Using a bare
except:(orexcept Exception:) that catches everything, including bugs like aTypeErrorfrom a typo — masking real problems instead of handling the specific failure you actually anticipated. - Swallowing an exception silently (an empty
except SomeError: pass) with no logging at all — the failure vanishes with no trace, making a production issue nearly impossible to diagnose later. - Raising a new exception from inside an
exceptblock with a plainraise NewError(...)instead ofraise NewError(...) from e— it still works, but discards the clear, deliberate link to the original underlying cause in the traceback. - Putting cleanup code that must always run inside the
tryblock after the risky operation, instead of infinally— if the risky operation raises, that cleanup code is skipped entirely.
Interview questions
Q: What's the difference between except's else clause and just putting that code after the try/except block?
Code in else only runs if the try block raised no exception at all, and — crucially — it isn't covered by the except clauses above it, so an exception raised inside else itself won't be accidentally caught as if it came from the original try block. Code placed after the entire try/except statement runs regardless of whether an exception was raised and handled, which is a meaningfully different guarantee.
Q: When would you write a custom exception class instead of raising a built-in one like ValueError?
When the failure represents a specific, meaningful condition in your application's domain (InsufficientFundsError, AccountFrozenError) rather than a generic "bad value" — a custom exception lets calling code catch that specific condition precisely, attach structured data relevant to it (like the account's balance), and build a hierarchy of related exceptions under one common base class that calling code can catch at whatever level of specificity it needs.