Proper Exception Handling in Python
1. Overview
Python encourages the EAFP style: just do it, and handle errors if they occur. Thus exceptions appear everywhere in Python code. Misuse causes exceptions to be silently swallowed, unreadable tracebacks, and duplicate logs.
This article covers the core techniques: try/except/else/finally, catching the right exception type, defining a custom exception system, chaining exceptions with raise ... from, logging, ExceptionGroup, and how to write tests. After reading, you’ll have a clear set of rules to apply directly to your projects. The examples use Python 3.11 and above.
2. Structure of try/except/else/finally
A complete exception handler has four parts:
def parse_quantity(raw: str) -> int:
try:
quantity = int(raw)
except ValueError:
print(f"Invalid quantity: {raw!r}")
return 0
else:
print("Parse successful")
return quantity
finally:
print("Parse complete")
print(parse_quantity("3"))
print(parse_quantity("ba"))
Role of each part:
try: contains only code that might throw an exception, hereint(raw).except ValueError: runs whenint()fails to parse the string.else: runs whentrydoes not throw an exception. Happy-path code goes here instead of insidetry.finally: always runs, even if there’s areturnor exception. Use this to clean up resources.
Output:
Parse successful
Parse complete
3
Invalid quantity: 'ba'
Parse complete
0
Note that finally runs before the return value is printed. Python executes finally right before exiting the function.
2.1. Why use else
Compare these two approaches:
# Not good: try is too broad
try:
quantity = int(raw)
total = quantity * unit_price # what if unit_price is the wrong type?
except ValueError:
total = 0
If unit_price is a string, the multiplication won’t throw ValueError. Yet many other operations might throw ValueError from places you didn’t expect, and that error gets mistaken for “bad quantity”. Keep try as narrow as possible. Put the rest in else.
3. Catch specific exceptions, don’t catch all
The most common mistake is using bare except: or except Exception: and ignoring it:
import time
def fetch_order_status(order_id: str) -> str:
try:
time.sleep(10) # simulate slow API call
return "PAID"
except: # very bad
return "UNKNOWN"
Bare except: catches even KeyboardInterrupt and SystemExit. When you press Ctrl+C, the program doesn’t stop but returns "UNKNOWN". Python’s exception hierarchy is structured as follows:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── KeyError
├── OSError
└── ...
Rules:
- Catch the narrowest type you can actually handle, for example
KeyError,TimeoutError. - Use
except Exceptiononly at the “edge” of your application: worker loops, top-level HTTP handlers. When you do, always log. - Never use
except BaseExceptionor bareexcept:unless you re-raise immediately.
You can catch multiple types in one branch using a tuple:
def read_price(prices: dict[str, str], sku: str) -> float:
try:
return float(prices[sku])
except (KeyError, ValueError) as exc:
print(f"Could not read price for {sku}: {type(exc).__name__}")
return 0.0
print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
Could not read price for B2: ValueError
0.0
Could not read price for Z9: KeyError
0.0
4. Define custom exceptions for your domain
For your business logic, you should create a base exception for the module, then inherit from it:
class PaymentError(Exception):
"""Base error for the payment module."""
class InsufficientFundsError(PaymentError):
def __init__(self, order_id: str, balance: float, amount: float) -> None:
super().__init__(
f"Order {order_id}: balance {balance} is not enough to pay {amount}"
)
self.order_id = order_id
self.balance = balance
self.amount = amount
class CardDeclinedError(PaymentError):
pass
class PaymentService:
def __init__(self, balance: float) -> None:
self.balance = balance
def charge(self, order_id: str, amount: float) -> None:
if amount > self.balance:
raise InsufficientFundsError(order_id, self.balance, amount)
self.balance -= amount
Usage:
service = PaymentService(balance=100)
try:
service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
print(exc)
print("Shortage:", exc.amount - exc.balance)
except PaymentError:
print("Other payment error")
Order ORD-42: balance 100 is not enough to pay 250
Shortage: 150
This organization has three benefits:
- Calling code can catch
PaymentErrorto handle all payment errors, or catchInsufficientFundsErrorseparately if needed. - Exceptions carry structured data (
order_id,balance,amount) instead of just a string. The handler doesn’t need to parse the message. - Your errors are separate from system errors like
ValueError, so they won’t be caught by mistake.
Note: Inherit from
Exception, notBaseException. Class names should end withError, per PEP 8 convention.
5. Chain exceptions with raise ... from
When converting a low-level error to a domain error, you should preserve the original cause:
import json
class ConfigError(Exception):
pass
def load_config(text: str) -> dict:
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise ConfigError("Config file is invalid") from exc
load_config("{port: 8080}")
The traceback will show both errors:
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
ConfigError: Config file is invalid
from exc assigns the original error to the __cause__ attribute. The debugger immediately sees which column the JSON error is on.
If you omit from exc, Python still chains via __context__, but the message will be “During handling of the above exception, another exception occurred”. This implies your handler failed, when it actually didn’t. Always write from explicitly.
When the original error is just an unhelpful internal detail, use from None to hide it:
def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
try:
return tiers[customer_id]
except KeyError:
raise LookupError(f"Customer {customer_id} not found") from None
6. Clean up resources with context managers
For files, connections, and locks, with is safer than hand-written try/finally:
from pathlib import Path
def count_orders(path: Path) -> int:
with path.open(encoding="utf-8") as f:
return sum(1 for line in f if line.strip())
The file is always closed, even if the loop throws an exception.
When you really want to ignore an error, contextlib.suppress expresses intent better than try/except: pass:
from contextlib import suppress
from pathlib import Path
with suppress(FileNotFoundError):
Path("cache/orders.tmp").unlink()
Reading this line, others immediately understand: “delete the file if it exists, otherwise move on”.
7. Logging and re-raising exceptions
In an except block, use logger.exception() to log with a full traceback:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")
def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
try:
service.charge(order_id, amount)
except InsufficientFundsError:
logger.warning("Order %s was rejected due to insufficient funds", order_id)
return False
except PaymentError:
logger.exception("Undefined payment error for order %s", order_id)
raise
return True
Key points:
logger.exception(...)is equivalent tologger.error(..., exc_info=True). This should only be called insideexcept.- Bare
raisere-raises the current exception and preserves the traceback. Don’t writeraise exc, because the traceback will get an extra line pointing to this spot. - Expected, anticipated errors (
InsufficientFundsError) only needwarning, not the full traceback.
Note: Avoid the “log then re-raise” pattern at every layer. A single error will appear five times in the log and create noise. Only log where you actually handle the error, or at the outermost edge of the application.
8. Handle multiple errors at once with ExceptionGroup
Starting with Python 3.11, you can raise multiple exceptions at once. This is useful when validating data or running multiple tasks in parallel:
def validate_order(order: dict) -> None:
errors: list[Exception] = []
if order.get("quantity", 0) <= 0:
errors.append(ValueError("quantity must be greater than 0"))
if not order.get("customer"):
errors.append(KeyError("customer"))
if errors:
raise ExceptionGroup("Invalid order", errors)
try:
validate_order({"quantity": 0})
except* ValueError as group:
for e in group.exceptions:
print("Invalid value:", e)
except* KeyError as group:
for e in group.exceptions:
print("Missing field:", e)
Invalid value: quantity must be greater than 0
Missing field: 'customer'
Unlike regular except, multiple except* branches can run together. Each branch receives an ExceptionGroup subgroup containing only errors of that type. If any errors aren’t handled by any branch, they’re automatically re-raised.
asyncio.TaskGroup uses this mechanism too. When multiple child tasks fail, you get an ExceptionGroup.
9. Test exception behavior with pytest
Install pytest:
pip install pytest
Write tests for PaymentService and load_config:
# test_payment.py
import pytest
from payment import (
ConfigError,
InsufficientFundsError,
PaymentService,
load_config,
)
def test_charge_raises_when_balance_is_not_enough():
service = PaymentService(balance=100)
with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
service.charge("ORD-42", 250)
assert exc_info.value.amount == 250
assert service.balance == 100 # balance should not be deducted
def test_load_config_keeps_original_cause():
with pytest.raises(ConfigError) as exc_info:
load_config("{port: 8080}")
assert exc_info.value.__cause__ is not None
assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"
Explanation:
pytest.raises(...)fails the test if there’s no exception, or the wrong exception type.match=takes a regex and matches againststr(exception).exc_info.valueis the actual exception object. This lets you inspect attributes likeamountand__cause__.- The first test also checks the state after the error: the balance should remain unchanged. This is often forgotten.
Run the tests:
pytest -q
.. [100%]
2 passed in 0.02s
10. Common mistakes
Silently swallowing exceptions.
try:
service.charge(order_id, amount)
except Exception:
pass # order "succeeded" even though no money was charged
This type of error is nearly impossible to debug. If you really need to ignore it, use suppress with a specific exception type and add a comment explaining why.
return in finally.
def risky() -> str:
try:
raise RuntimeError("data loss")
finally:
return "ok" # exception disappears!
return, break, or continue in finally will suppress an exception in flight. Starting with Python 3.14, the interpreter raises a SyntaxWarning for this. Never write code like this.
Raising generic Exception.
raise Exception("error") forces callers to catch Exception, meaning they catch everything else too. Use an appropriate built-in exception (ValueError, TypeError) or a domain exception.
Using exceptions for normal flow in hot loops.
EAFP is Pythonic style. But if the “error” case happens most of the time, dict.get() or an if check is both faster and clearer:
# Better when the key is often absent
discount = discounts.get(customer_id, 0)
Using assert to validate input.
assert is disabled when running python -O. Use raise ValueError(...) for data from outside.
11. Conclusion
Proper exception handling in Python revolves around a few simple principles:
- Keep
trynarrow, put subsequent code inelse, and clean up withfinallyorwith. - Catch the most specific exception type. Only catch
Exceptionat application boundaries, and always log. - Create a custom exception system for your domain, carrying structured data.
- Use
raise ... from excto preserve the original cause, and bareraiseto re-raise. - Use
ExceptionGroupandexcept*when multiple errors occur together. - Write tests with
pytest.raisesto check both the error type and the state after the error.
Following these rules, your tracebacks will be more readable, your logs will be cleaner, and errors won’t be silently swallowed anymore.