Python uses exceptions to signal that something went wrong at runtime. Understanding how to catch, raise, and design exceptions is essential for writing robust code. This guide covers everything from basic try/except to custom exception hierarchies and production patterns.
Quick Reference
| Syntax | Purpose |
|---|---|
try: ... except ValueError: |
Catch a specific exception |
except (TypeError, KeyError): |
Catch multiple exception types |
except Exception as e: |
Catch any exception and bind it |
else: |
Run if no exception was raised |
finally: |
Always runs (cleanup) |
raise ValueError("msg") |
Raise an exception |
raise |
Re-raise the current exception |
raise RuntimeError("x") from e |
Chain exceptions |
Basic try / except
try:
result = int("abc")
except ValueError as e:
print(f"Conversion failed: {e}")
Catch multiple exception types in one handler:
try:
data = config["timeout"]
timeout = int(data)
except (KeyError, ValueError) as e:
print(f"Config error: {e}")
timeout = 30 # fallback
Catch multiple types separately (different handling):
try:
value = process(data)
except ValueError as e:
print(f"Bad value: {e}")
except TypeError as e:
print(f"Wrong type: {e}")
except Exception as e:
# Catch-all — log and re-raise, don't swallow silently
logger.error("Unexpected error", exc_info=True)
raise
else and finally
try:
file = open("data.txt")
except FileNotFoundError:
print("File not found")
else:
# Runs only if no exception was raised in try
content = file.read()
print(f"Read {len(content)} bytes")
finally:
# Always runs — perfect for cleanup
file.close()
Context managers replace most finally blocks
# Preferred over manual try/finally for file handling
with open("data.txt") as file:
content = file.read()
# file.close() is called automatically
Built-in Exception Hierarchy
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── KeyError
├── IndexError
├── AttributeError
├── FileNotFoundError (→ OSError)
├── PermissionError (→ OSError)
├── TimeoutError (→ OSError)
├── RuntimeError
├── StopIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
└── ...
Rule: Catch the most specific exception type that makes sense. Never catch BaseException (that includes KeyboardInterrupt and SystemExit).
Common Built-in Exceptions
| Exception | When it occurs |
|---|---|
ValueError |
Right type, wrong value — int("abc") |
TypeError |
Wrong type — "a" + 1 |
KeyError |
Missing dict key — d["missing"] |
IndexError |
List index out of range — lst[99] |
AttributeError |
Missing attribute — None.upper() |
FileNotFoundError |
File doesn't exist — open("x.txt") |
PermissionError |
No filesystem permission |
TimeoutError |
Operation timed out |
ZeroDivisionError |
Division by zero |
OverflowError |
Number too large |
RecursionError |
Max recursion depth exceeded |
MemoryError |
Out of memory |
StopIteration |
Iterator exhausted (usually internal) |
NotImplementedError |
Abstract method not implemented |
AssertionError |
assert statement failed |
Raising Exceptions
def divide(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("Denominator cannot be zero")
return a / b
Re-raise after partial handling:
try:
result = fetch_data(url)
except TimeoutError:
logger.warning("Request timed out, retrying...")
raise # re-raise the original exception
Exception Chaining
Use raise ... from to preserve the original cause:
def load_config(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise RuntimeError(f"Config file missing: {path}") from e
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in config: {path}") from e
Output shows both exceptions:
RuntimeError: Config file missing: config.json
The above exception was the direct cause of:
FileNotFoundError: [Errno 2] No such file or directory: 'config.json'
Suppress the chain with from None when the original cause is irrelevant:
try:
value = mapping[key]
except KeyError:
raise KeyError(f"Unknown setting: {key!r}") from None
Custom Exceptions
Basic custom exception
class AppError(Exception):
"""Base exception for this application."""
pass
class ValidationError(AppError):
"""Input validation failed."""
pass
class NotFoundError(AppError):
"""Resource not found."""
pass
Usage:
def get_user(user_id: int):
user = db.find(user_id)
if user is None:
raise NotFoundError(f"User {user_id} not found")
return user
try:
user = get_user(42)
except NotFoundError as e:
return {"error": str(e)}, 404
except AppError as e:
return {"error": "Application error"}, 500
Custom exceptions with extra context
class ValidationError(Exception):
def __init__(self, message: str, field: str, value=None):
super().__init__(message)
self.field = field
self.value = value
def __str__(self):
return f"[{self.field}] {super().__str__()}"
# Raise with context
raise ValidationError("Must be positive", field="age", value=-5)
# Catch and inspect
try:
validate(data)
except ValidationError as e:
print(f"Field: {e.field}, Value: {e.value}, Error: {e}")
Exception hierarchy for an API
class APIError(Exception):
"""Base exception for API errors."""
status_code: int = 500
def __init__(self, message: str, details: dict | None = None):
super().__init__(message)
self.details = details or {}
def to_dict(self) -> dict:
return {"error": str(self), "details": self.details}
class BadRequestError(APIError):
status_code = 400
class UnauthorizedError(APIError):
status_code = 401
class NotFoundError(APIError):
status_code = 404
class RateLimitError(APIError):
status_code = 429
def __init__(self, retry_after: int):
super().__init__("Too many requests")
self.retry_after = retry_after
Real-World Patterns
Retry with exponential backoff
import time
import random
from typing import TypeVar, Callable
T = TypeVar("T")
def retry(
func: Callable[[], T],
*,
attempts: int = 3,
delay: float = 1.0,
backoff: float = 2.0,
exceptions: tuple = (Exception,),
) -> T:
last_error: Exception | None = None
wait = delay
for attempt in range(1, attempts + 1):
try:
return func()
except exceptions as e:
last_error = e
if attempt < attempts:
jitter = random.uniform(0, wait * 0.1)
time.sleep(wait + jitter)
wait *= backoff
else:
raise RuntimeError(
f"All {attempts} attempts failed"
) from last_error
raise last_error # unreachable, satisfies type checkers
# Usage
result = retry(
lambda: fetch_data(url),
attempts=3,
delay=1.0,
exceptions=(TimeoutError, ConnectionError),
)
Collecting multiple errors
def validate_user(data: dict) -> dict:
errors: list[str] = []
if not data.get("name"):
errors.append("name is required")
if len(data.get("password", "")) < 8:
errors.append("password must be at least 8 characters")
if "@" not in data.get("email", ""):
errors.append("email is invalid")
if errors:
raise ValidationError(
f"{len(errors)} validation error(s)",
field="__all__",
value=errors,
)
return data
Exception logging pattern
import logging
import traceback
logger = logging.getLogger(__name__)
def safe_process(item):
try:
return process(item)
except ValidationError as e:
# Expected, log as warning
logger.warning("Validation failed for item %s: %s", item, e)
return None
except Exception:
# Unexpected, log full traceback
logger.error("Unexpected error processing item %s", item, exc_info=True)
raise
Context manager for error translation
from contextlib import contextmanager
@contextmanager
def translate_db_errors():
"""Translate database exceptions to application exceptions."""
try:
yield
except DatabaseUniqueViolation as e:
raise ValidationError("Duplicate entry") from e
except DatabaseConnectionError as e:
raise ServiceUnavailableError("Database unreachable") from e
# Usage
with translate_db_errors():
user = db.create_user(email=email)
ExceptionGroup (Python 3.11+)
Python 3.11 introduced ExceptionGroup for handling multiple concurrent errors (e.g., from asyncio.TaskGroup):
import asyncio
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(failing_task_1())
tg.create_task(failing_task_2())
except* ValueError as eg:
print(f"Value errors: {eg.exceptions}")
except* TypeError as eg:
print(f"Type errors: {eg.exceptions}")
The except* syntax catches all exceptions of that type from the group.
Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
except Exception: pass |
Silently swallows all errors | At minimum log the error |
except: (bare) |
Catches SystemExit and KeyboardInterrupt |
Use except Exception: |
Catching BaseException |
Prevents Ctrl+C from working | Use except Exception: |
except ValueError or TypeError: |
Always evaluates to except ValueError: |
Use except (ValueError, TypeError): |
Catching too broadly (Exception) before specific types |
Specific handlers never run | Order from specific → general |
| Using exceptions for control flow | Slow and hard to read | Use if/else for expected paths |
raise Exception(e) instead of raise e from e |
Loses original traceback | Use raise NewError(...) from e |
TypeScript / Python Comparison
| TypeScript | Python |
|---|---|
try { } catch (e) { } |
try: ... except Exception as e: |
finally { } |
finally: |
throw new Error("msg") |
raise ValueError("msg") |
class MyError extends Error {} |
class MyError(Exception): pass |
| No built-in chaining | raise B from A |
| No equivalent | except* (ExceptionGroup, Py 3.11+) |
FAQ
Should I always catch the most specific exception?
Yes. Catching Exception is a last resort. Specific types let you handle each failure mode correctly and don't accidentally hide bugs.
When should I use else vs putting code after try?
Use else for code that should only run if the try succeeded — it makes the intent clear and won't accidentally catch exceptions from your post-success code.
What's the difference between raise and raise e?
Bare raise re-raises the current exception and preserves the original traceback. raise e creates a new traceback starting at that line, making debugging harder.
When should I create a custom exception vs use a built-in?
Use built-ins when the semantics match (ValueError, KeyError, etc.). Create custom exceptions when callers need to distinguish your errors from other library errors, or when you need to attach extra context.
What does exc_info=True do in logging?
It attaches the current exception's traceback to the log record so it appears in log output — equivalent to logger.error("...", exc_info=sys.exc_info()).
Can I catch an exception and return a default value?
Yes, this is the EAFP (Easier to Ask Forgiveness than Permission) pattern — common in Python:
def safe_int(value, default=0):
try:
return int(value)
except (ValueError, TypeError):
return default