Exceptions Explained - Python's Error Handling Model
Reading time: ~18 minutes | Level: Foundation → Engineering
Here is something most Python developers have never thought to try:
try:
raise ValueError("something went wrong")
except ValueError as e:
print(type(e)) # <class 'ValueError'>
print(e.args) # ('something went wrong',)
print(e.__traceback__) # <traceback object at 0x...>
print(isinstance(e, Exception)) # True
print(isinstance(e, BaseException)) # True
Output:
<class 'ValueError'>
('something went wrong',)
<traceback object at 0x10f3a2840>
True
True
An exception is not a signal. It is not a special language keyword that magically aborts execution.
It is an object - an instance of a class - sitting on the heap like every other Python value, with attributes you can inspect, methods you can call, and a type that participates in the normal class hierarchy.
Once you understand that, everything about Python's error handling model becomes logical.
What You Will Learn
- What an exception is: an object, an instance of a class derived from
BaseException - The exception lifecycle:
raise→ unwind call stack → find handler → execute handler - The attributes of exception objects:
args,__str__,__repr__,__traceback__ - Why
SystemExit,KeyboardInterrupt, andGeneratorExitinherit fromBaseExceptiondirectly - How Python unwinds the call stack frame by frame looking for a handler
- Python's "unchecked" exception model and why it differs from Java
- What the traceback object contains and how to navigate it programmatically
- Exception chaining: explicit (
raise X from Y) and implicit (__context__) - Why bare
except:is almost always a bug - How to read production tracebacks from Django and FastAPI
Prerequisites
- Python 3.8+ in a terminal or REPL
- Understanding of Python functions and the call stack (how function calls nest)
- Basic class and inheritance concepts (knowing
isinstance()is enough)
Mental Model: An Exception Is Just an Object
Before anything else, anchor this mental model:
The exception object travels up the call stack as a first-class Python value. It is not a C-level signal or a special runtime mechanism - it is an ordinary object that gets passed to whatever handler catches it.
Part 1 - The Exception Object
Attributes of Every Exception
Every exception inherits from BaseException, which gives it these attributes:
try:
raise RuntimeError("connection timed out after 30s")
except RuntimeError as e:
# The error message tuple
print(e.args) # ('connection timed out after 30s',)
# How str() and repr() render it
print(str(e)) # connection timed out after 30s
print(repr(e)) # RuntimeError('connection timed out after 30s')
# The traceback - a linked chain of frame records
print(e.__traceback__) # <traceback object at 0x...>
# Exception chaining attributes (covered later)
print(e.__cause__) # None - no explicit raise ... from
print(e.__context__) # None - not raised inside an except block
print(e.__suppress_context__) # False
Multiple Arguments in args
You can pass multiple arguments to an exception constructor. They all go into args:
try:
raise ValueError("expected positive int", "got", -5)
except ValueError as e:
print(e.args) # ('expected positive int', 'got', -5)
print(str(e)) # ('expected positive int', 'got', -5)
Single-argument exceptions display cleanly with str(e). Multi-argument exceptions display as a tuple. This is why most exceptions use a single, well-formatted message string rather than multiple arguments.
Exception Objects Are Mutable
Because exceptions are regular objects, you can attach extra data to them:
class APIError(Exception):
def __init__(self, message, status_code, endpoint):
super().__init__(message)
self.status_code = status_code
self.endpoint = endpoint
try:
raise APIError("User not found", 404, "/api/users/42")
except APIError as e:
print(e) # User not found
print(e.status_code) # 404
print(e.endpoint) # /api/users/42
This is the pattern behind every well-designed exception in production libraries - the exception carries structured data, not just a string.
Part 2 - BaseException vs Exception
This split is one of Python's most important design decisions. Understanding it prevents a class of subtle bugs.
Why SystemExit, KeyboardInterrupt, and GeneratorExit Are Special
These three are not program errors. They are signals that external forces want to stop execution:
SystemExit- raised bysys.exit(). Means "this process should terminate cleanly."KeyboardInterrupt- raised when the user hits Ctrl+C. Means "the user interrupted this."GeneratorExit- raised inside a generator when.close()is called.
If you write except Exception:, you catch every real error but still let these three propagate. That is almost always what you want.
If you write except BaseException: (or bare except:), you catch SystemExit too - your program will not exit when sys.exit() is called. This is almost always wrong.
import sys
# Dangerous - catches SystemExit and prevents exit
try:
sys.exit(0)
except BaseException as e:
print(f"Caught: {type(e).__name__}") # Caught: SystemExit
# sys.exit() did NOT work - program continues!
print("This runs when it should not!")
# Correct - lets SystemExit propagate normally
try:
some_operation()
except Exception as e:
print(f"Caught real error: {e}")
# SystemExit, KeyboardInterrupt, GeneratorExit pass through
:::danger Never Swallow SystemExit
A bare except: or except BaseException: that does not re-raise will catch SystemExit and prevent your application from shutting down. This is a production bug that can cause processes to hang indefinitely in Docker containers and Kubernetes pods.
:::
Part 3 - The Exception Lifecycle
Step 1: raise Creates and Throws the Exception
def validate_age(age):
if not isinstance(age, int):
raise TypeError(f"age must be int, got {type(age).__name__}")
if age < 0:
raise ValueError(f"age must be non-negative, got {age}")
return age
When raise ValueError(...) executes:
- Python instantiates a
ValueErrorobject - Python attaches the current stack frame to
__traceback__ - Python begins the unwinding process
Step 2: Call Stack Unwinding
Python unwinds the call stack frame by frame, checking each frame for a matching except clause:
def validate_age(age):
if age < 0:
raise ValueError(f"age must be non-negative, got {age}")
def process_user(data):
age = validate_age(data["age"]) # no except here
return {"user": data["name"], "age": age}
def handle_request(data):
try:
user = process_user(data)
return user
except ValueError as e:
# Catches the ValueError from validate_age
# even though it was raised 2 frames down
return {"error": str(e)}
result = handle_request({"name": "Alice", "age": -5})
print(result) # {'error': 'age must be non-negative, got -5'}
Step 3: Handler Executes, Stack Unwinds Completely
Once a handler is found, Python:
- Exits all intermediate frames cleanly (running their
finallyblocks if any) - Binds the exception to the
asname in theexceptclause - Executes the
exceptblock - Deletes the
asbinding after the block completes (Python 3 behavior)
Step 4: Unhandled Exception - Termination
If no handler is found anywhere in the call stack, Python:
- Calls
sys.excepthook(which by default prints the traceback) - Terminates the program with a non-zero exit code
# This program terminates with a traceback
def a():
b()
def b():
c()
def c():
raise RuntimeError("deep error")
a()
Output:
Traceback (most recent call last):
File "example.py", line 11, in <module>
a()
File "example.py", line 2, in a
b()
File "example.py", line 6, in b
c()
File "example.py", line 9, in c
raise RuntimeError("deep error")
RuntimeError: deep error
Read tracebacks from bottom to top: the last line is the error, the lines above it are the call chain leading to it.
Part 4 - The Traceback Object
The traceback is not just text printed to the terminal. It is a Python object you can inspect programmatically.
import traceback
import sys
def inner():
raise ValueError("something broke")
def outer():
inner()
try:
outer()
except ValueError as e:
tb = e.__traceback__
# Navigate the linked list of frames
while tb is not None:
frame = tb.tb_frame
print(f"File: {frame.f_code.co_filename}")
print(f"Function: {frame.f_code.co_name}")
print(f"Line: {tb.tb_lineno}")
print("---")
tb = tb.tb_next
Output:
File: example.py
Function: <module>
Line: 10
---
File: example.py
Function: outer
Line: 8
---
File: example.py
Function: inner
Line: 5
---
The traceback Module
For most production use, the traceback module provides higher-level tools:
import traceback
try:
raise ValueError("bad data")
except ValueError:
# Get traceback as a string (for logging)
tb_str = traceback.format_exc()
print(tb_str)
# Print traceback to stderr (default behavior)
traceback.print_exc()
# Get a list of FrameSummary objects
frames = traceback.extract_tb(sys.exc_info()[2])
for frame in frames:
print(f"{frame.filename}:{frame.lineno} in {frame.name}")
print(f" {frame.line}")
:::tip Logging Tracebacks in Production
In production code, never print tracebacks. Use logger.exception("message") instead - it automatically captures the current traceback and includes it in the log record at ERROR level.
:::
Part 5 - Python Has No Checked Exceptions
In Java, the compiler forces you to declare which exceptions a method can throw, and callers must either catch them or declare them in turn. This is the "checked exceptions" system.
Python has no checked exceptions. Any function can raise any exception at any time, and the caller is not required to handle it.
| Feature | Java (checked) | Python (unchecked) |
|---|---|---|
| Exception declaration | throws IOException required | Nothing declared |
| Compiler enforcement | Yes - must handle or re-declare | No compiler involvement |
| Caller requirement | Must catch or propagate | Handles if it wants to |
| Example | void readFile() throws IOException | def read_file(path): |
| Runtime behaviour | Checked at compile time | All exceptions are runtime |
Pros of Python's Unchecked Model
- Less boilerplate - you do not declare exceptions in every function signature
- Easier to add new exceptions without breaking callers
- Works well with duck typing - you often do not know what an object might raise
Cons of Python's Unchecked Model
- Callers do not know what to expect without reading documentation or source
- Easy to accidentally swallow exceptions
- Requires good documentation (docstrings listing possible exceptions)
The Professional Response: Document Your Exceptions
def parse_config(path: str) -> dict:
"""Parse a JSON configuration file.
Args:
path: Path to the config file.
Returns:
Parsed configuration as a dictionary.
Raises:
FileNotFoundError: If the file does not exist at ``path``.
PermissionError: If the process cannot read the file.
json.JSONDecodeError: If the file contains invalid JSON.
KeyError: If required configuration keys are missing.
"""
import json
with open(path) as f:
config = json.load(f)
required = ["host", "port", "database"]
for key in required:
if key not in config:
raise KeyError(f"Missing required config key: {key!r}")
return config
This is the standard practice: document every exception your function can raise in the docstring.
Part 6 - Exception Chaining
Exception chaining lets you preserve the original cause when raising a new exception. This is critical for debugging - you want to see both what the library raised and what your code raised in response.
Explicit Chaining: raise X from Y
def load_config(path):
try:
with open(path) as f:
import json
return json.load(f)
except FileNotFoundError as original:
raise RuntimeError(
f"Cannot start: config file not found at {path!r}"
) from original
load_config("/etc/app/config.json")
Output:
FileNotFoundError: [Errno 2] No such file or directory: '/etc/app/config.json'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "example.py", line 9, in <module>
load_config("/etc/app/config.json")
File "example.py", line 6, in load_config
raise RuntimeError(f"Cannot start: config file not found at {path!r}") from original
RuntimeError: Cannot start: config file not found at '/etc/app/config.json'
With explicit chaining, the new exception's __cause__ is set to the original:
try:
load_config("/etc/app/config.json")
except RuntimeError as e:
print(e.__cause__) # [Errno 2] No such file or directory: ...
print(e.__suppress_context__) # True (explicit chain hides __context__)
Implicit Chaining: Raising Inside except
When you raise a new exception inside an except block without from, Python still preserves the original via __context__:
try:
int("not a number")
except ValueError:
raise RuntimeError("conversion failed")
Output:
ValueError: invalid literal for int() with base 10: 'not a number'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
...
RuntimeError: conversion failed
The new exception's __context__ is the original; __suppress_context__ is False.
Suppressing the Chain: raise X from None
Sometimes the original exception is an implementation detail you do not want to expose:
class UserNotFound(Exception):
pass
def get_user(user_id):
try:
# Internal database call
return db_lookup(user_id) # raises KeyError internally
except KeyError:
raise UserNotFound(f"No user with id {user_id}") from None
get_user(999)
Output:
Traceback (most recent call last):
...
UserNotFound: No user with id 999
The from None sets __suppress_context__ = True, hiding the internal KeyError. The caller sees a clean, domain-specific error without internal implementation details leaking through.
| Syntax | __cause__ | __context__ | __suppress_context__ | Message shown |
|---|---|---|---|---|
raise New from Original | Original | set | True | "direct cause of..." |
raise New (inside except) | None | Original | False | "during handling of..." |
raise New from None | None | Original | True | (nothing shown) |
Part 7 - Why Bare except: Is Almost Always Wrong
# Dangerous - catches EVERYTHING including SystemExit, KeyboardInterrupt
try:
do_something()
except:
pass # Silently swallows all errors, including Ctrl+C
This is one of the most common Python bugs. Here is why it is so dangerous:
import time
def long_running_task():
try:
for i in range(1_000_000):
time.sleep(0.001) # simulates work
except: # catches KeyboardInterrupt!
print("caught something, ignoring it")
# User presses Ctrl+C - it is ignored because bare except caught it
long_running_task()
The bare except: catches KeyboardInterrupt, so Ctrl+C does nothing. The process can only be killed with SIGKILL from outside.
# Slightly better but still bad - catches BaseException
try:
do_something()
except BaseException:
pass # Still catches SystemExit and KeyboardInterrupt
# Correct - catches only real program errors
try:
do_something()
except Exception:
pass # Lets SystemExit and KeyboardInterrupt propagate
# Best - catch the most specific exception you expect
try:
value = int(user_input)
except ValueError:
print("Please enter a valid integer")
The only valid use of bare except or except BaseException is at the very top of a program, in a "last resort" handler that logs the error and re-raises:
# At the top level of your application - acceptable
try:
main()
except BaseException as e:
logger.critical("Unhandled exception", exc_info=True)
raise # Re-raise - do NOT suppress it
Part 8 - Reading Production Tracebacks
Django Traceback (Debug Mode)
Internal Server Error: /api/users/
Traceback (most recent call last):
File "/env/lib/python3.11/site-packages/django/core/handlers/exception.py",
line 55, in inner
response = get_response(request)
File "/env/lib/python3.11/site-packages/django/core/handlers/base.py",
line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "/app/views.py", line 23, in user_detail
user = User.objects.get(pk=user_id)
File "/env/lib/python3.11/site-packages/django/db/models/manager.py",
line 87, in manager_method
return getattr(self.get_queryset(), name)(*args, **kwargs)
File "/env/lib/python3.11/site-packages/django/db/models/query.py",
line 637, in get
raise self.model.DoesNotExist(...)
app.models.User.DoesNotExist: User matching query does not exist.
Reading strategy:
- Last line: the exception type and message -
User.DoesNotExist - Your code: look for frames from your application (
/app/views.py) - that is where the bug is - Framework frames: above your code - Django's internal call chain
- Root cause: your view called
User.objects.get()without handling the case where the user does not exist
FastAPI Traceback
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/env/lib/python3.11/site-packages/uvicorn/protocols/http/h11_impl.py",
line 408, in run_asgi
result = await app(scope, receive, send)
...
File "/app/routers/users.py", line 41, in get_user
user = await db.execute(select(User).where(User.id == user_id))
File "/env/lib/python3.11/site-packages/sqlalchemy/ext/asyncio/session.py",
line 218, in execute
return await greenlet_spawn(self.sync_session.execute, ...)
sqlalchemy.exc.OperationalError: (psycopg2.OperationalError) could not connect
to server: Connection refused
Reading strategy:
- Look for your file:
/app/routers/users.pyline 41 - The real error:
psycopg2.OperationalError: Connection refused- the database is down - Everything above your frame is FastAPI/SQLAlchemy internals - usually not the root cause
:::tip The 30-Second Traceback Rule Always go straight to the bottom of the traceback first (exception type + message), then find the first frame that is YOUR code. That is where you start debugging. Framework frames are almost never the root cause. :::
Interview Questions
Q1: What is a Python exception, at the object level?
Answer: A Python exception is an instance of a class that inherits from BaseException. When you write raise ValueError("bad input"), Python instantiates a ValueError object on the heap with args = ('bad input',), attaches a traceback object recording the current frame, and begins unwinding the call stack looking for a matching except clause. The exception is an ordinary Python object - it has a type, attributes (args, __traceback__, __cause__, __context__), and obeys normal class inheritance.
Q2: Why does except Exception not catch KeyboardInterrupt, but except: does?
Answer: KeyboardInterrupt inherits from BaseException directly, not from Exception. The except Exception clause only catches exceptions whose class is Exception or any subclass of Exception. Since KeyboardInterrupt is not in that subtree, it passes through unmatched. A bare except: has no class constraint at all - it matches any exception at the BaseException level, which includes KeyboardInterrupt, SystemExit, and GeneratorExit. This is why bare except: can prevent Ctrl+C from working.
Q3: What is the difference between __cause__ and __context__ on an exception?
Answer: Both attributes record a "previous" exception, but they arise differently. __context__ is set automatically by Python whenever you raise a new exception while already handling another - it is the "implicit" chain created by raising inside an except block. __cause__ is set explicitly when you write raise NewException from original_exception - it is the "explicit" chain you intend to show the user. When __cause__ is set, __suppress_context__ is True, which tells Python to show the explicit chain and hide the implicit __context__. raise X from None sets __suppress_context__ = True without setting __cause__, effectively hiding the chain entirely.
Q4: How does Python unwind the call stack when an exception is raised?
Answer: Python maintains the call stack as a linked list of frame objects. When an exception is raised, Python looks at the current frame's code for a matching except clause. If not found, it moves up to the calling frame and checks again. This continues frame by frame up the stack. At each frame, finally blocks execute as the stack unwinds, even though no handler has been found yet. If Python reaches the top-level frame without finding a handler, it calls sys.excepthook (which prints the traceback by default) and terminates the process.
Q5: What does the traceback object (__traceback__) contain?
Answer: The traceback is a linked list of traceback objects (C-level PyTracebackObject). Each node in the list has: tb_frame (the frame object for that stack level, containing local variables and the code object), tb_lineno (the line number where execution was at that frame), and tb_next (the next traceback object deeper in the stack, or None at the innermost frame). You navigate the traceback by following tb_next links. The traceback module provides higher-level tools: traceback.format_exc() returns the formatted traceback as a string, and traceback.extract_tb() returns a list of FrameSummary objects.
Q6: How would you explain Python's lack of checked exceptions to a Java developer?
Answer: In Java, the compiler enforces a contract: methods must declare which checked exceptions they throw (throws IOException), and callers must either catch them or re-declare them. Python has no such mechanism - any function can raise any exception, and there is no compile-time check. The Python community compensates with documentation conventions: well-written Python functions list their possible exceptions in docstrings under a Raises: section. The practical trade-off is less boilerplate and more flexibility in exchange for weaker static guarantees. Tools like mypy with type stubs can partially check exceptions at analysis time, but it is not enforced by the runtime.
Practice Challenges
Beginner - Inspect a Live Exception Object
Write a function inspect_exception(exc) that accepts any exception object and prints:
- Its type name
- Its message (from
args) - Whether it is a subclass of
Exception - Whether it is a subclass of
OSError - Its
__cause__(or "none" if absent)
Test it on at least three different exception types.
Solution
def inspect_exception(exc):
"""Print detailed information about an exception object."""
print(f"Type: {type(exc).__name__}")
print(f"Message: {str(exc)}")
print(f"Args: {exc.args}")
print(f"Is Exception: {isinstance(exc, Exception)}")
print(f"Is OSError: {isinstance(exc, OSError)}")
print(f"Cause (__cause__): {exc.__cause__ or 'none'}")
print(f"Context (__context__): {exc.__context__ or 'none'}")
print()
# Test 1: Simple ValueError
try:
raise ValueError("expected positive number, got -5")
except ValueError as e:
inspect_exception(e)
# Test 2: FileNotFoundError (subclass of OSError)
try:
open("/nonexistent/path/file.txt")
except FileNotFoundError as e:
inspect_exception(e)
# Test 3: Explicit chaining
def risky():
try:
int("abc")
except ValueError as original:
raise RuntimeError("conversion pipeline failed") from original
try:
risky()
except RuntimeError as e:
inspect_exception(e)
print(f"Chained cause type: {type(e.__cause__).__name__}")
print(f"Chained cause msg: {e.__cause__}")
Output:
Type: ValueError
Message: expected positive number, got -5
Args: ('expected positive number, got -5',)
Is Exception: True
Is OSError: False
Cause (__cause__): none
Context (__context__): none
Type: FileNotFoundError
Message: [Errno 2] No such file or directory: '/nonexistent/path/file.txt'
Args: (2, 'No such file or directory')
Is Exception: True
Is OSError: True
Cause (__cause__): none
Context (__context__): none
Type: RuntimeError
Message: conversion pipeline failed
Args: ('conversion pipeline failed',)
Is Exception: True
Is OSError: False
Cause (__cause__): invalid literal for int() with base 10: 'abc'
Context (__context__): invalid literal for int() with base 10: 'abc'
Chained cause type: ValueError
Chained cause msg: invalid literal for int() with base 10: 'abc'
Intermediate - Traceback Walker
Write a function walk_traceback(exc) that accepts an exception object and returns a list of dictionaries, one per frame, each containing filename, function, lineno, and line. Then write a function format_traceback(exc) that formats the result as a human-readable string similar to Python's built-in traceback output.
Solution
import traceback as tb_module
import linecache
def walk_traceback(exc):
"""Walk an exception's traceback and return frame info as dicts."""
frames = []
current = exc.__traceback__
while current is not None:
frame = current.tb_frame
lineno = current.tb_lineno
filename = frame.f_code.co_filename
function = frame.f_code.co_name
# Get the source line
line = linecache.getline(filename, lineno).strip()
frames.append({
"filename": filename,
"function": function,
"lineno": lineno,
"line": line,
})
current = current.tb_next
return frames
def format_traceback(exc):
"""Format an exception's traceback as a readable string."""
frames = walk_traceback(exc)
lines = ["Traceback (most recent call last):"]
for f in frames:
lines.append(f' File "{f["filename"]}", line {f["lineno"]}, in {f["function"]}')
if f["line"]:
lines.append(f' {f["line"]}')
lines.append(f"{type(exc).__name__}: {exc}")
return "\n".join(lines)
# Test
def level_3():
raise ValueError("something went wrong at level 3")
def level_2():
level_3()
def level_1():
level_2()
try:
level_1()
except ValueError as e:
frames = walk_traceback(e)
print(f"Number of frames: {len(frames)}")
for f in frames:
print(f" {f['function']}() at line {f['lineno']}: {f['line']}")
print()
print(format_traceback(e))
Output:
Number of frames: 4
<module>() at line 30: level_1()
level_1() at line 26: level_2()
level_2() at line 23: level_3()
level_3() at line 20: raise ValueError("something went wrong at level 3")
Traceback (most recent call last):
File "example.py", line 30, in <module>
level_1()
File "example.py", line 26, in level_1
level_2()
File "example.py", line 23, in level_2
level_3()
File "example.py", line 20, in level_3
raise ValueError("something went wrong at level 3")
ValueError: something went wrong at level 3
Advanced - Exception Middleware for FastAPI
Write an exception middleware class for FastAPI that:
- Catches any unhandled
Exception(but letsSystemExitandKeyboardInterruptthrough) - Logs the full traceback using Python's
loggingmodule at ERROR level - Records the request path, method, and a unique request ID in the log
- Returns a JSON error response with a sanitized message (no internal details exposed to the client)
- Includes the exception chain in the log (both
__cause__and__context__)
Solution
import logging
import traceback
import uuid
from typing import Callable
# FastAPI/Starlette imports (install: pip install fastapi)
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
def _collect_chain(exc: BaseException) -> list[str]:
"""Collect the full exception chain for logging."""
parts = []
current = exc
while current is not None:
tb_str = "".join(
traceback.format_exception(type(current), current, current.__traceback__)
)
parts.append(tb_str)
# Follow the chain: __cause__ takes priority over __context__
if current.__cause__ is not None:
current = current.__cause__
elif current.__context__ is not None and not current.__suppress_context__:
current = current.__context__
else:
break
return parts
class ErrorHandlingMiddleware(BaseHTTPMiddleware):
"""Middleware that catches unhandled exceptions and returns JSON errors."""
async def dispatch(self, request: Request, call_next: Callable):
request_id = str(uuid.uuid4())[:8]
request.state.request_id = request_id
try:
response = await call_next(request)
return response
except (SystemExit, KeyboardInterrupt):
# Let these propagate - they are not program errors
raise
except Exception as exc:
# Build a structured log message
chain = _collect_chain(exc)
log_parts = [
f"Unhandled exception",
f" request_id={request_id}",
f" method={request.method}",
f" path={request.url.path}",
f" exception_type={type(exc).__name__}",
f" exception_msg={exc}",
]
if len(chain) > 1:
log_parts.append(f" chain_depth={len(chain)}")
# Log each frame of the chain
for i, tb_str in enumerate(chain):
label = "primary" if i == 0 else f"cause[{i}]"
logger.error(
"\n".join(log_parts) + f"\n [{label} traceback]\n{tb_str}"
)
# Return a sanitized response - no internal details exposed
return JSONResponse(
status_code=500,
content={
"error": "internal_server_error",
"message": "An unexpected error occurred. Please try again later.",
"request_id": request_id,
},
)
# Usage
app = FastAPI()
app.add_middleware(ErrorHandlingMiddleware)
@app.get("/api/users/{user_id}")
async def get_user(user_id: int):
# Simulate a database error with exception chaining
try:
# Simulate DB failure
raise ConnectionError("PostgreSQL connection refused at localhost:5432")
except ConnectionError as db_err:
raise RuntimeError(f"Failed to load user {user_id}") from db_err
# When you hit /api/users/42:
# - Client receives: {"error": "internal_server_error", "message": "...", "request_id": "a1b2c3d4"}
# - Server logs: full traceback chain including the ConnectionError cause
Key design decisions:
except (SystemExit, KeyboardInterrupt): raise- these are not program errors, never suppress themexcept Exception- catches all real program errors- Log with full chain (
_collect_chain) - you need the__cause__to diagnose the root problem - Sanitized JSON response - internal stack traces must never reach the client in production
request_idin both the log and the response - lets you correlate client-reported errors with server logs
Quick Reference
| Concept | Code | Notes |
|---|---|---|
| Raise an exception | raise ValueError("msg") | Creates instance and throws |
| Re-raise current exception | raise | Inside an except block only |
| Explicit chain | raise New from original | Sets __cause__, shows "direct cause" |
| Suppress chain | raise New from None | Hides the previous exception |
| Exception message | str(e) or e.args[0] | args is a tuple |
| Exception type name | type(e).__name__ | e.g. 'ValueError' |
| Check type | isinstance(e, ValueError) | Works with inheritance |
| Get traceback string | traceback.format_exc() | Requires import traceback |
| Log with traceback | logger.exception("msg") | Auto-captures current exception |
| Catch real errors only | except Exception: | Lets SystemExit through |
| All exceptions | except BaseException: | Rare; almost always wrong |
| Bare except | except: | Equivalent to except BaseException: - avoid |
| Chained cause | e.__cause__ | Set by raise X from Y |
| Implicit context | e.__context__ | Set automatically when raising in except |
| Full traceback object | e.__traceback__ | Navigate with .tb_next |
Key Takeaways
- An exception is an object - an instance of a class inheriting from
BaseException- with attributes you can inspect, modify, and pass around like any other value - The exception lifecycle is: create object → attach traceback → unwind stack frame by frame → find handler or terminate
BaseExceptionhas three direct subclasses (SystemExit,KeyboardInterrupt,GeneratorExit) that are not program errors - they are process-level signals; never swallow them with bareexcept:- Python has no checked exceptions - document your exceptions in docstrings and catch specifically
- Exception chaining (
raise X from Y) preserves the original cause and is essential for production debugging - never lose the original exception without recording it - The traceback is a linked list of frame objects you can navigate programmatically with
__traceback__and thetracebackmodule - Read tracebacks bottom to top: the last line is the error, your code frames are where the bug lives
