Skip to main content

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, and GeneratorExit inherit from BaseException directly
  • 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)
note

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 by sys.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:

  1. Python instantiates a ValueError object
  2. Python attaches the current stack frame to __traceback__
  3. 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:

  1. Exits all intermediate frames cleanly (running their finally blocks if any)
  2. Binds the exception to the as name in the except clause
  3. Executes the except block
  4. Deletes the as binding after the block completes (Python 3 behavior)

Step 4: Unhandled Exception - Termination

If no handler is found anywhere in the call stack, Python:

  1. Calls sys.excepthook (which by default prints the traceback)
  2. 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.

FeatureJava (checked)Python (unchecked)
Exception declarationthrows IOException requiredNothing declared
Compiler enforcementYes - must handle or re-declareNo compiler involvement
Caller requirementMust catch or propagateHandles if it wants to
Examplevoid readFile() throws IOExceptiondef read_file(path):
Runtime behaviourChecked at compile timeAll 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 OriginalOriginalsetTrue"direct cause of..."
raise New (inside except)NoneOriginalFalse"during handling of..."
raise New from NoneNoneOriginalTrue(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:

  1. Last line: the exception type and message - User.DoesNotExist
  2. Your code: look for frames from your application (/app/views.py) - that is where the bug is
  3. Framework frames: above your code - Django's internal call chain
  4. 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:

  1. Look for your file: /app/routers/users.py line 41
  2. The real error: psycopg2.OperationalError: Connection refused - the database is down
  3. 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:

  1. Catches any unhandled Exception (but lets SystemExit and KeyboardInterrupt through)
  2. Logs the full traceback using Python's logging module at ERROR level
  3. Records the request path, method, and a unique request ID in the log
  4. Returns a JSON error response with a sanitized message (no internal details exposed to the client)
  5. 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 them
  • except 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_id in both the log and the response - lets you correlate client-reported errors with server logs

Quick Reference

ConceptCodeNotes
Raise an exceptionraise ValueError("msg")Creates instance and throws
Re-raise current exceptionraiseInside an except block only
Explicit chainraise New from originalSets __cause__, shows "direct cause"
Suppress chainraise New from NoneHides the previous exception
Exception messagestr(e) or e.args[0]args is a tuple
Exception type nametype(e).__name__e.g. 'ValueError'
Check typeisinstance(e, ValueError)Works with inheritance
Get traceback stringtraceback.format_exc()Requires import traceback
Log with tracebacklogger.exception("msg")Auto-captures current exception
Catch real errors onlyexcept Exception:Lets SystemExit through
All exceptionsexcept BaseException:Rare; almost always wrong
Bare exceptexcept:Equivalent to except BaseException: - avoid
Chained causee.__cause__Set by raise X from Y
Implicit contexte.__context__Set automatically when raising in except
Full traceback objecte.__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
  • BaseException has three direct subclasses (SystemExit, KeyboardInterrupt, GeneratorExit) that are not program errors - they are process-level signals; never swallow them with bare except:
  • 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 the traceback module
  • Read tracebacks bottom to top: the last line is the error, your code frames are where the bug lives
© 2026 EngineersOfAI. All rights reserved.