5.8. Exceptions
Table of Contents
Why Exceptions Matter in Backend Python
Exceptions are how Python reports that something went wrong. In backend development, failures are normal, not rare. Network requests fail, database connections drop, user input is invalid, and files go missing.
If you ignore exceptions, your server will crash. If you handle them well, your API can respond with clear error messages and proper HTTP status codes instead of just dying.
Key idea: Exceptions are Python’s way to signal errors and unusual situations. You do not prevent every error, you handle them.
In this chapter you will learn how to:
- Trigger and catch exceptions
- Use
try,except,else, andfinally - Define custom exceptions
- Wrap low-level errors into higher-level backend errors
- Design exception hierarchies that work well in web apps
Basic Exception Concepts
What is an exception?
When Python encounters a problem that it cannot continue from, it "raises" an exception. If nobody handles it, the program stops.
Example, divide by zero:
result = 10 / 0
print(result)Running this prints something like:
Traceback (most recent call last):
File "example.py", line 1, in <module>
result = 10 / 0
ZeroDivisionError: division by zeroThe important parts:
| Part | Meaning |
|---|---|
ZeroDivisionError | Exception type |
division by zero | Exception message |
| Traceback | Shows where the error happened in your code |
In backend services, unhandled exceptions often show up in logs or error tracking tools and usually cause a 500 Internal Server Error response.
Common built-in exceptions useful in backends
A few built-in exceptions you will often meet:
| Exception | Typical backend situation |
|---|---|
ValueError | Invalid input value, parse failure |
TypeError | Wrong type passed to a function |
KeyError | Missing key in dict (for example JSON data) |
IndexError | List index out of range |
FileNotFoundError | Tried to read a config file or upload that does not exist |
PermissionError | No permission to read or write a file |
TimeoutError | Network or I/O operation timed out |
ConnectionError | Database or external API connection issues |
RuntimeError | Generic runtime error |
You should rarely raise these randomly. Use them when they match the problem, or better, create your own domain-specific exception types, which we will cover later.
Handling Exceptions with try and except
Basic try / except
You wrap code that might fail in a try block and handle some errors in except blocks.
def parse_int(value: str) -> int:
try:
return int(value)
except ValueError:
# Handle invalid integer
return 0Here:
int(value)might raiseValueError- If that happens, the
except ValueErrorblock runs - The function returns
0instead of crashing
Catching specific exceptions
You should almost always catch specific exceptions, not "everything". This keeps bugs visible instead of silently hidden.
def get_first_item(items: list[int]) -> int:
try:
return items[0]
except IndexError:
# List is empty
return -1Bad example:
def get_first_item(items: list[int]) -> int:
try:
return items[0]
except Exception:
# This hides all kinds of problems, not just empty list
return -1
Rule: Catch the smallest set of exception types you expect. Do not use except Exception unless you are at the app boundary (for example FastAPI global exception handler) and plan to log and re-raise or return a generic error.
Multiple except blocks
You can handle different exception types differently:
def parse_price(text: str) -> float:
try:
value = float(text)
if value < 0:
raise ValueError("Price cannot be negative")
return value
except ValueError as exc:
print(f"Invalid price: {exc}")
return 0.0
except TypeError:
print("Price must be a string or number")
return 0.0
The first except catches any ValueError raised inside the try. The second catches TypeError separately.
Catching multiple types in one except
If you want the same handling for several types:
def safe_divide(a, b):
try:
return a / b
except (ZeroDivisionError, TypeError) as exc:
print(f"Cannot divide: {exc}")
return None
Here, both division by zero and bad types result in None and a log message.
Accessing the exception object
You often need the exception object to log details:
def read_config(path: str) -> str:
try:
with open(path) as f:
return f.read()
except FileNotFoundError as exc:
print(f"Config file not found: {exc.filename}")
return ""
except PermissionError as exc:
print(f"No permission to read: {exc.filename}")
return ""
The as exc part gives you the exception instance, which can have attributes like filename, errno, or a message.
try, except, else, and finally
Using else
else runs only if the try block did not raise any exception.
def divide(a: float, b: float) -> float | None:
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero")
return None
else:
# Only executes if no ZeroDivisionError happened
print("Division successful")
return resultThis pattern is useful to separate:
- Code that might fail, inside
try - Code that depends on success, inside
else
Using finally
finally always runs, whether or not an exception occurred. This is important for cleanup: close files, database connections, locks, etc.
def process_file(path: str) -> str:
f = None
try:
f = open(path)
data = f.read()
# imagine some processing that might raise
return data
except FileNotFoundError:
return ""
finally:
if f is not None:
f.close()
Even if an exception occurs in the try block, finally runs and closes the file.
In practice, you often use context managers (with statements) instead of manual finally, but you still need finally in some backend situations, for example:
- Releasing a lock
- Closing a database transaction manually
- Cleaning temporary files
Combined example in a backend-like function
def fetch_user_from_file(user_id: int, path: str) -> dict | None:
file = None
try:
file = open(path)
data = file.read()
except FileNotFoundError:
print("User data file not found")
return None
except OSError as exc:
print(f"OS error while reading users file: {exc}")
return None
else:
# Only run if no exception
users = data.splitlines()
for row in users:
uid, name = row.split(",")
if int(uid) == user_id:
return {"id": uid, "name": name}
return None
finally:
if file is not None:
file.close()Raising Exceptions Yourself
Using raise
You can raise exceptions to signal that something is wrong. For example, inside business logic:
def validate_age(age: int) -> None:
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age is unrealistically high")
In a backend API, you might later convert this ValueError into an HTTP 400 Bad Request.
Rule: Use raise when your function cannot fulfill its contract and the caller must decide what to do next.
Raising with a custom message
You can provide detailed messages:
def get_user(users: dict[int, dict], user_id: int) -> dict:
if user_id not in users:
raise KeyError(f"User with id={user_id} not found")
return users[user_id]Re-raising exceptions
Sometimes you want to handle an exception a bit, for example log it, and then re-raise it.
def parse_json(text: str) -> dict:
import json
try:
return json.loads(text)
except json.JSONDecodeError as exc:
print(f"Failed to parse JSON: {exc}")
raise # Re-raise the same exception
You can also raise a different exception, which is common when you want to convert low-level errors to higher-level ones (for example, database errors to DomainError):
class UserNotFoundError(Exception):
pass
def find_user_in_db(db, user_id: int) -> dict:
try:
row = db.query("SELECT * FROM users WHERE id=%s", (user_id,))
except ConnectionError as exc:
# Wrap low-level connection errors
raise RuntimeError("Database unavailable") from exc
if row is None:
raise UserNotFoundError(f"User {user_id} not found")
return row
Note the from exc part, which preserves the original exception as the cause, helpful in debugging.
Creating Custom Exceptions
Why create custom exceptions?
In backend apps, you often need to express specific error conditions:
- "User does not exist"
- "Order is already paid"
- "Payment failed"
- "Email already in use"
- "Invalid authentication token"
Using built-in exceptions like ValueError and RuntimeError everywhere makes it hard to distinguish these cases.
Custom exceptions let you:
- Express your domain concepts
- Handle related errors in one place
- Map them to HTTP status codes easily
Example table:
| Situation | Built-in only | Custom exception |
|---|---|---|
| User not found in database | ValueError("User not found") | UserNotFoundError() |
| Invalid login credentials | RuntimeError("Invalid login") | InvalidCredentialsError() |
| Product out of stock | ValueError("Out of stock") | OutOfStockError() |
Custom names are more descriptive and easier to catch.
Defining a basic custom exception
You usually subclass Exception:
class UserNotFoundError(Exception):
passYou can use it like any other exception:
def get_user(user_id: int) -> dict:
# Imagine some DB query...
user = None
if user is None:
raise UserNotFoundError(f"User {user_id} not found")
return user
try:
user = get_user(123)
except UserNotFoundError as exc:
print(exc) # "User 123 not found"Adding extra data to exceptions
You can store structured information, which is very useful in backend services when you want to log or transform errors into HTTP responses.
class ValidationError(Exception):
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message
super().__init__(f"{field}: {message}")Usage:
def validate_email(email: str) -> None:
if "@" not in email:
raise ValidationError("email", "must contain '@'")
try:
validate_email("invalid-email")
except ValidationError as exc:
print(exc.field) # "email"
print(exc.message) # "must contain '@'"
print(str(exc)) # "email: must contain '@'"In a web API, you might convert this to JSON:
{
"error": "validation_error",
"field": "email",
"message": "must contain '@'"
}Designing an exception hierarchy
You can define a base exception and more specific ones.
class AppError(Exception):
"""Base class for all application-specific errors."""
pass
class NotFoundError(AppError):
pass
class PermissionDeniedError(AppError):
pass
class ConflictError(AppError):
passNow you can:
- Catch all application errors using
except AppError - Or catch specific ones like
NotFoundError
Example:
def get_order(order_id: int) -> dict:
# Imagine DB lookup logic...
raise NotFoundError(f"Order {order_id} not found")
try:
order = get_order(1)
except NotFoundError:
print("Not found, return HTTP 404")
except AppError:
print("App-specific error, return HTTP 400 or 409, etc.")Exceptions in Backend Workflows
Wrapping low-level exceptions into domain errors
Backend systems call many lower-level libraries: databases, HTTP clients, message queues, etc. These libraries raise their own exceptions which you often do not want to leak throughout your business logic.
You can wrap them:
class ExternalAPIError(AppError):
pass
def get_exchange_rate(client, currency: str) -> float:
try:
response = client.get(f"/rate/{currency}")
response.raise_for_status()
return response.json()["rate"]
except TimeoutError as exc:
raise ExternalAPIError("Rate service timeout") from exc
except ConnectionError as exc:
raise ExternalAPIError("Cannot connect to rate service") from exc
Now the rest of your code only deals with ExternalAPIError, not with all possible networking errors.
Mapping exceptions to HTTP responses
In backend frameworks like FastAPI (covered later), you register exception handlers. Here is a simplified example without FastAPI:
def handle_request():
try:
# some logic that may raise
user = get_user(123)
return 200, {"user": user}
except UserNotFoundError as exc:
return 404, {"error": "user_not_found", "message": str(exc)}
except ValidationError as exc:
return 400, {"error": "validation_error", "field": exc.field, "message": exc.message}
except AppError as exc:
return 400, {"error": "app_error", "message": str(exc)}
except Exception as exc:
# Unexpected error, log and return generic 500
print(f"Unexpected error: {exc}")
return 500, {"error": "internal_server_error"}This pattern is fundamental in backend development:
- Your business functions raise custom exceptions.
- Framework-level handlers translate them into JSON responses and proper status codes.
When not to use exceptions
You should not use exceptions for normal control flow when return values are more natural.
Example of overusing exceptions:
def user_exists(user_id: int) -> bool:
try:
get_user(user_id)
return True
except UserNotFoundError:
return FalseBetter approach without try/except:
def user_exists(user_id: int) -> bool:
user = get_user_or_none(user_id)
return user is not NoneExceptions are relatively expensive and make the code harder to follow when used for simple branches.
Practical Backend-style Examples
Example: Input validation in a backend service
class InputError(AppError):
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
def validate_registration(data: dict) -> None:
username = data.get("username")
email = data.get("email")
if not username:
raise InputError("username", "is required")
if len(username) < 3:
raise InputError("username", "must be at least 3 characters")
if not email:
raise InputError("email", "is required")
if "@" not in email:
raise InputError("email", "must contain '@'")Later in a view or route:
def register_user_endpoint(request_body: dict):
try:
validate_registration(request_body)
# create user, etc.
return 201, {"status": "ok"}
except InputError as exc:
return 400, {
"error": "invalid_input",
"field": exc.field,
"message": exc.message,
}Example: Database operation with cleanup
class DatabaseError(AppError):
pass
def create_user(conn, username: str, email: str) -> int:
try:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO users (username, email) VALUES (%s, %s) RETURNING id",
(username, email),
)
user_id = cursor.fetchone()[0]
conn.commit()
return user_id
except Exception as exc:
conn.rollback()
raise DatabaseError("Failed to create user") from exc
finally:
# Always close the cursor
cursor.close()In this snippet:
- Any SQL or connection error is caught.
- Transaction is rolled back in
except. - Cursor is closed in
finallyeven if an exception occurred. - A custom
DatabaseErrorkeeps the public surface of the function clean.
Example: Aggregating multiple validation errors
Sometimes you want to collect multiple problems and raise once.
class MultipleValidationError(AppError):
def __init__(self, errors: list[ValidationError]) -> None:
self.errors = errors
super().__init__("Multiple validation errors")
def validate_product(data: dict) -> None:
errors: list[ValidationError] = []
if not data.get("name"):
errors.append(ValidationError("name", "is required"))
if (price := data.get("price")) is None:
errors.append(ValidationError("price", "is required"))
elif price < 0:
errors.append(ValidationError("price", "must be non-negative"))
if errors:
raise MultipleValidationError(errors)Handler example:
def create_product_endpoint(body: dict):
try:
validate_product(body)
# create product...
return 201, {"status": "ok"}
except MultipleValidationError as exc:
return 400, {
"error": "validation_error",
"details": [
{"field": e.field, "message": e.message}
for e in exc.errors
],
}Summary
- Exceptions signal errors and unusual situations in Python.
- Use
tryandexceptto handle expected error conditions. elseruns when no exception occurs,finallyalways runs.- Raise exceptions (
raise) when your function cannot do its job. - Define custom exceptions that match your domain, and group them under a common base like
AppError. - In backends, wrap low-level exceptions and map custom exceptions to HTTP responses.
- Catch specific exceptions, not
Exception, except at the outermost layers where you log and fail gracefully.
These concepts are the building blocks for robust error handling in your future Python backend projects and will connect directly to how frameworks like FastAPI handle errors and responses.
Views: 18
KAHIBARO