Every program encounters unexpected situations — a file that doesn’t exist, a network that’s down, user input that fails validation, a database that’s unreachable. Python’s exception handling system lets you respond to these situations gracefully rather than crashing. In FastAPI, exception handling is doubly important: unhandled exceptions return unhelpful 500 Internal Server Error responses instead of clear error messages, and a good exception hierarchy maps Python errors directly to appropriate HTTP status codes. Understanding how to raise, catch, and propagate exceptions correctly is one of the most important skills for building reliable APIs.
try / except Basics
# Syntax: try the code, except if a specific error occurs
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero")
# Catch multiple exception types
try:
value = int("not a number")
except (ValueError, TypeError) as e:
print(f"Conversion error: {e}")
# Catch and inspect the exception
try:
with open("missing.txt") as f:
content = f.read()
except FileNotFoundError as e:
print(f"File not found: {e.filename}")
print(f"Error details: {e}")
# Multiple except clauses — most specific first
try:
data = process_input(user_data)
except ValueError as e:
print(f"Invalid value: {e}") # catches ValueError
except TypeError as e:
print(f"Wrong type: {e}") # catches TypeError
except Exception as e:
print(f"Unexpected error: {e}") # catches everything else
# AVOID: bare except catches everything including KeyboardInterrupt
# except: ← never do this
# pass
Exception is the base class for all non-system-exiting exceptions. BaseException is the root (includes KeyboardInterrupt, SystemExit). Catching Exception catches almost all runtime errors — but not keyboard interrupts or system exits. Catching BaseException catches everything including attempts to Ctrl+C the process — almost never what you want. Always catch the most specific exception type you expect.as e clause to capture the exception object and log it — even if you handle the error gracefully, always log the original exception details for debugging. In FastAPI, route handlers that silently swallow exceptions are a nightmare to debug in production. At minimum, logger.exception("Unexpected error") (which logs the full traceback) inside a broad except Exception as e: block.try/except to check whether a key exists in a dict, whether a list is empty, or whether a value is the right type is both slower and less readable than explicit checks. FastAPI’s dependency injection and Pydantic validation catch most input problems before they reach your business logic — only use try/except for genuinely unexpected failures.else and finally
# else: runs if NO exception was raised in the try block
# finally: ALWAYS runs — even if exception was raised or return was called
def read_config(path: str) -> dict:
import json
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
print(f"Config file not found: {path}")
return {}
except json.JSONDecodeError as e:
print(f"Invalid JSON in config: {e}")
return {}
else:
# Only runs if no exception — clean success path
print(f"Config loaded successfully: {len(data)} keys")
return data
finally:
# Always runs — cleanup code goes here
print("Config read attempt complete")
# finally guarantees cleanup even with early return or exception
def process_with_lock(resource):
lock = acquire_lock(resource)
try:
return do_work(resource)
except Exception:
log_failure()
raise # re-raise after logging
finally:
release_lock(lock) # ALWAYS released ✓
Raising Exceptions
# raise — intentionally trigger an exception
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Divisor cannot be zero")
return a / b
# raise with from — chain exceptions (preserve original cause)
def load_settings(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise RuntimeError(f"Settings file missing: {path}") from e
# The original FileNotFoundError is preserved as __cause__
# Re-raise the same exception (after logging)
def process():
try:
risky_operation()
except Exception as e:
logger.error(f"Operation failed: {e}", exc_info=True)
raise # re-raises the same exception with original traceback
# raise without argument — re-raise current exception in except block
try:
something()
except ValueError:
cleanup()
raise # same as above
Python’s Built-in Exception Hierarchy
BaseException
├── SystemExit ← sys.exit()
├── KeyboardInterrupt ← Ctrl+C
└── Exception ← base for all "normal" exceptions
├── TypeError ← wrong type
├── ValueError ← right type, wrong value
├── AttributeError ← attribute doesn't exist
├── KeyError ← dict key missing
├── IndexError ← list index out of range
├── FileNotFoundError (subclass of OSError)
├── PermissionError (subclass of OSError)
├── ZeroDivisionError
├── ImportError
├── RuntimeError
├── NotImplementedError ← subclass of RuntimeError
└── StopIteration
Exception Groups (Python 3.11+)
# Python 3.11+ — handle multiple exceptions simultaneously
# Useful for async code where multiple tasks may fail
import asyncio
async def fetch_all(urls: list) -> list:
try:
results = await asyncio.gather(*[fetch(url) for url in urls],
return_exceptions=False)
return results
except* ConnectionError as eg:
# except* handles ExceptionGroup — catches all ConnectionError instances
print(f"Connection failures: {len(eg.exceptions)}")
return []
except* ValueError as eg:
print(f"Value errors: {eg.exceptions}")
return []
Common Mistakes
Mistake 1 — Catching too broadly (hiding bugs)
❌ Wrong — swallowing all exceptions:
try:
result = complex_operation()
except Exception:
pass # silent failure — bugs vanish, nothing logged!
✅ Correct — catch specific, log everything:
try:
result = complex_operation()
except ValueError as e:
logger.warning(f"Invalid input: {e}")
result = default_value
except Exception as e:
logger.exception(f"Unexpected error") # logs full traceback
raise # re-raise — don't hide it
Mistake 2 — Exception in finally masking the original
❌ Wrong — finally raises, original exception lost:
try:
risky()
except ValueError:
handle()
finally:
cleanup() # if cleanup() raises, ValueError is lost!
✅ Correct — guard the finally block:
finally:
try:
cleanup()
except Exception as e:
logger.error(f"Cleanup failed: {e}") # log but don't raise ✓
Mistake 3 — Using assert for input validation in production
❌ Wrong — asserts are disabled with python -O:
assert user_id > 0, "user_id must be positive" # silently skipped with -O flag!
✅ Correct — use explicit raises:
if user_id <= 0:
raise ValueError(f"user_id must be positive, got {user_id}") # ✓ always runs
Quick Reference
| Pattern | Code |
|---|---|
| Catch specific | except ValueError as e: |
| Catch multiple | except (ValueError, TypeError) as e: |
| Clean success | else: block after except |
| Always run | finally: block |
| Raise new | raise ValueError("msg") |
| Chain exceptions | raise NewError("msg") from original |
| Re-raise same | raise (bare, inside except) |
| Log + re-raise | logger.exception("msg"); raise |