KAHIBARO
Discord Login Register

5.8. Exceptions

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:

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:

python
result = 10 / 0
print(result)

Running this prints something like:

text
Traceback (most recent call last):
  File "example.py", line 1, in <module>
    result = 10 / 0
ZeroDivisionError: division by zero

The important parts:

PartMeaning
ZeroDivisionErrorException type
division by zeroException message
TracebackShows 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:

ExceptionTypical backend situation
ValueErrorInvalid input value, parse failure
TypeErrorWrong type passed to a function
KeyErrorMissing key in dict (for example JSON data)
IndexErrorList index out of range
FileNotFoundErrorTried to read a config file or upload that does not exist
PermissionErrorNo permission to read or write a file
TimeoutErrorNetwork or I/O operation timed out
ConnectionErrorDatabase or external API connection issues
RuntimeErrorGeneric 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.

python
def parse_int(value: str) -> int:
    try:
        return int(value)
    except ValueError:
        # Handle invalid integer
        return 0

Here:

Catching specific exceptions

You should almost always catch specific exceptions, not "everything". This keeps bugs visible instead of silently hidden.

python
def get_first_item(items: list[int]) -> int:
    try:
        return items[0]
    except IndexError:
        # List is empty
        return -1

Bad example:

python
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:

python
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:

python
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:

python
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.

python
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 result

This pattern is useful to separate:

Using finally

finally always runs, whether or not an exception occurred. This is important for cleanup: close files, database connections, locks, etc.

python
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:

Combined example in a backend-like function

python
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:

python
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:

python
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.

python
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):

python
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:

Using built-in exceptions like ValueError and RuntimeError everywhere makes it hard to distinguish these cases.

Custom exceptions let you:

Example table:

SituationBuilt-in onlyCustom exception
User not found in databaseValueError("User not found")UserNotFoundError()
Invalid login credentialsRuntimeError("Invalid login")InvalidCredentialsError()
Product out of stockValueError("Out of stock")OutOfStockError()

Custom names are more descriptive and easier to catch.

Defining a basic custom exception

You usually subclass Exception:

python
class UserNotFoundError(Exception):
    pass

You can use it like any other exception:

python
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.

python
class ValidationError(Exception):
    def __init__(self, field: str, message: str) -> None:
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")

Usage:

python
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:

json
{
  "error": "validation_error",
  "field": "email",
  "message": "must contain '@'"
}

Designing an exception hierarchy

You can define a base exception and more specific ones.

python
class AppError(Exception):
    """Base class for all application-specific errors."""
    pass
class NotFoundError(AppError):
    pass
class PermissionDeniedError(AppError):
    pass
class ConflictError(AppError):
    pass

Now you can:

Example:

python
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:

python
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:

python
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:

When not to use exceptions

You should not use exceptions for normal control flow when return values are more natural.

Example of overusing exceptions:

python
def user_exists(user_id: int) -> bool:
    try:
        get_user(user_id)
        return True
    except UserNotFoundError:
        return False

Better approach without try/except:

python
def user_exists(user_id: int) -> bool:
    user = get_user_or_none(user_id)
    return user is not None

Exceptions 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

python
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:

python
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

python
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:

Example: Aggregating multiple validation errors

Sometimes you want to collect multiple problems and raise once.

python
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:

python
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

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

Comments

Please login to add a comment.

Don't have an account? Register now!