Error Handling — HTTPException and Custom Error Responses

Every API needs to communicate failure clearly. FastAPI provides HTTPException for raising standard HTTP error responses — 404 for not found, 403 for forbidden, 409 for conflict. Beyond HTTPException, you can register global exception handlers to transform any Python exception into a structured HTTP response, and override FastAPI’s default 422 validation error format to match your API’s error schema. Consistent, well-structured error responses are as important as correct success responses for a production API.

HTTPException

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()

@app.get("/posts/{post_id}")
def get_post(post_id: int):
    post = db.query(Post).filter(Post.id == post_id).first()

    if post is None:
        raise HTTPException(
            status_code = status.HTTP_404_NOT_FOUND,
            detail      = f"Post with id {post_id} not found",
        )

    return post

# HTTPException with custom headers
@app.get("/secure-posts/{id}")
def get_secure_post(id: int):
    if not is_authenticated():
        raise HTTPException(
            status_code = status.HTTP_401_UNAUTHORIZED,
            detail      = "Not authenticated",
            headers     = {"WWW-Authenticate": "Bearer"},
        )

# Common status codes
# 400 — Bad Request (invalid input that passed Pydantic validation)
# 401 — Unauthorized (not authenticated)
# 403 — Forbidden (authenticated but not permitted)
# 404 — Not Found
# 409 — Conflict (duplicate email, optimistic lock failure)
# 422 — Unprocessable Entity (Pydantic validation — automatic)
# 500 — Internal Server Error (unhandled exception)
Note: The detail parameter of HTTPException accepts any JSON-serialisable value — a string, dict, or list. FastAPI wraps it in a {"detail": ...} JSON response body. For simple error messages, a string is fine: detail="Post not found". For richer error responses with error codes, field names, or links to documentation, pass a dict: detail={"code": "POST_NOT_FOUND", "message": "Post with id 42 not found"}.
Tip: Define your own exception classes and register exception handlers to produce consistent error responses across your API without repeating HTTPException raising code everywhere. A ResourceNotFoundError(resource="post", id=42) exception can be caught by a handler that formats it as {"error": "NOT_FOUND", "resource": "post", "id": 42}. This keeps error formatting logic in one place and keeps route handlers clean.
Warning: When a FastAPI exception handler catches an exception, it must return a JSONResponse (or Response) — not raise another exception or return a Pydantic model. The exception handler is the last line of defence; if it fails, FastAPI falls back to a 500 Internal Server Error with no structured body. Always wrap exception handler code in try/except to prevent handlers from failing silently.

Custom Exception Handlers

from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError

app = FastAPI()

# ── Custom exception class ────────────────────────────────────────────────────
class AppException(Exception):
    def __init__(self, status_code: int, code: str, message: str):
        self.status_code = status_code
        self.code        = code
        self.message     = message

class NotFoundException(AppException):
    def __init__(self, resource: str, id: int | str):
        super().__init__(
            status_code = 404,
            code        = "NOT_FOUND",
            message     = f"{resource.capitalize()} with id '{id}' was not found",
        )

# ── Register exception handler ────────────────────────────────────────────────
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
    return JSONResponse(
        status_code = exc.status_code,
        content     = {"error": exc.code, "message": exc.message},
    )

# ── Override default 422 validation error format ─────────────────────────────
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code = status.HTTP_422_UNPROCESSABLE_ENTITY,
        content     = {
            "error":  "VALIDATION_ERROR",
            "detail": [
                {
                    "field":   " → ".join(str(loc) for loc in e["loc"]),
                    "message": e["msg"],
                    "type":    e["type"],
                }
                for e in exc.errors()
            ],
        },
    )

# ── Use in route handler ───────────────────────────────────────────────────────
@app.get("/posts/{post_id}")
def get_post(post_id: int):
    post = db.query(Post).get(post_id)
    if not post:
        raise NotFoundException("post", post_id)   # clean, readable
    return post

Handling Database Errors

from fastapi import FastAPI, HTTPException, status
from sqlalchemy.exc import IntegrityError
import psycopg2.errors

app = FastAPI()

@app.post("/users", status_code=201)
def create_user(user: UserCreate, db = Depends(get_db)):
    try:
        db_user = User(**user.model_dump())
        db.add(db_user)
        db.commit()
        db.refresh(db_user)
        return db_user
    except IntegrityError as e:
        db.rollback()
        # Parse the specific constraint that was violated
        if "users_email_unique" in str(e.orig):
            raise HTTPException(
                status_code = status.HTTP_409_CONFLICT,
                detail      = "A user with this email already exists",
            )
        raise HTTPException(
            status_code = status.HTTP_400_BAD_REQUEST,
            detail      = "Database constraint violation",
        )

Common Mistakes

Mistake 1 — Returning error details that reveal internal implementation

❌ Wrong — exposing stack traces or SQL errors to clients:

except Exception as e:
    raise HTTPException(500, detail=str(e))   # "UNIQUE constraint failed: users.email" exposed!

✅ Correct — log the error, return a generic message:

except Exception as e:
    logger.error(f"Unexpected error: {e}")
    raise HTTPException(500, detail="An internal error occurred")   # ✓

Mistake 2 — Using 400 for all errors (not using specific codes)

❌ Wrong — all errors return 400:

raise HTTPException(400, "Something went wrong")   # too vague

✅ Correct — use the appropriate status code:

raise HTTPException(404, "Post not found")        # ✓ 404 for not found
raise HTTPException(403, "Permission denied")     # ✓ 403 for forbidden
raise HTTPException(409, "Email already taken")   # ✓ 409 for conflict

Mistake 3 — Not rolling back the session after a database error

❌ Wrong — session left in error state after IntegrityError:

except IntegrityError:
    raise HTTPException(409, "Conflict")   # session not rolled back!

✅ Correct:

except IntegrityError:
    db.rollback()   # ✓ must rollback before next operation
    raise HTTPException(409, "Conflict")

Quick Reference

Error Situation Status Code Code
Resource not found 404 raise HTTPException(404, "Not found")
Not authenticated 401 raise HTTPException(401, "Not authenticated")
Not permitted 403 raise HTTPException(403, "Forbidden")
Duplicate / conflict 409 raise HTTPException(409, "Already exists")
Invalid input (manual) 400 raise HTTPException(400, "Invalid ...")
Pydantic validation 422 Automatic — no code needed
Register handler Any @app.exception_handler(ExcType)

🧠 Test Yourself

A user tries to register with an email already in the database. Your handler catches an IntegrityError from SQLAlchemy. What HTTP status code and response should you return?