Skip to content

Error Handling with HTTPException

When something goes wrong, you want to send a proper HTTP error back to the client — not crash or return a 500.

The classic pattern

from fastapi import FastAPI, HTTPException

app = FastAPI()

users = {1: "Alice", 2: "Bob", 3: "Charlie"}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    if user_id not in users:
        raise HTTPException(status_code=404, detail="User not found")
    return {"id": user_id, "name": users[user_id]}

Hit GET /users/999:

{ "detail": "User not found" }

Status: 404 Not Found. Status: 4xx means client's fault — they asked for something that doesn't exist.

How it executes

When you raise HTTPException(404, ...):

  1. Python stops executing your function.
  2. FastAPI catches the exception (it knows how to handle this type).
  3. It builds an HTTP response with the given status code.
  4. The detail becomes the JSON body.
  5. uvicorn sends the response.

You never have to return early or check error flags — just raise.

Use the status constants

from fastapi import HTTPException, status

raise HTTPException(
    status_code=status.HTTP_404_NOT_FOUND,
    detail="User not found"
)

Better than magic numbers.

Custom error detail — any JSON

detail can be a string OR a dict OR a list:

raise HTTPException(
    status_code=400,
    detail={
        "error": "validation_failed",
        "fields": ["email", "phone"],
        "hint": "email must be a valid address"
    }
)

Custom headers

raise HTTPException(
    status_code=401,
    detail="Token expired",
    headers={"WWW-Authenticate": "Bearer"},
)

When to use which status code

Situation Status When
Wrong path/missing resource 404 GET /users/999 and user 999 doesn't exist
Wrong input shape (caught automatically by Pydantic) 422 Field missing, wrong type
Logical input error (caught manually) 400 "Email already registered"
Need to log in 401 No auth token
Logged in but not allowed 403 Tried to delete another user
Conflict 409 Username already taken
Server bug 500 Unhandled exception
Slow / overloaded 503 Server busy

Practical patterns

Validate before doing work

from fastapi import HTTPException, status

@app.post("/users")
def create_user(user: User):
    if email_exists(user.email):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"email '{user.email}' already registered",
        )
    if not is_email_valid(user.email):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="invalid email format",
        )
    return save_user(user)

Guard with a tiny helper

def get_or_404(model_id: int):
    record = DB.get(model_id)
    if record is None:
        raise HTTPException(404, detail=f"id {model_id} not found")
    return record

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return get_or_404(user_id)

@app.delete("/users/{user_id}", status_code=204)
def delete_user(user_id: int):
    get_or_404(user_id)
    DB.pop(user_id)
    return None

DRY — no duplicated 404 logic.

Custom exception handlers

For app-wide consistent error responses, register a handler:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

class BusinessRuleError(Exception):
    def __init__(self, code: str, message: str):
        self.code = code
        self.message = message

@app.exception_handler(BusinessRuleError)
async def biz_error_handler(req: Request, exc: BusinessRuleError):
    return JSONResponse(
        status_code=400,
        content={"error_code": exc.code, "error_message": exc.message},
    )

@app.post("/transfer")
def transfer(amount: int):
    if amount > 100_000:
        raise BusinessRuleError("AMOUNT_TOO_HIGH", "exceeds daily limit")
    return {"transferred": amount}

Override Pydantic's default validation error

By default a Pydantic error returns a verbose 422. You can re-shape it:

from fastapi.exceptions import RequestValidationError

@app.exception_handler(RequestValidationError)
async def validation_handler(req: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=400,
        content={
            "error": "bad_request",
            "details": exc.errors(),
        },
    )

Catch-all error handler

@app.exception_handler(Exception)
async def all_errors(req: Request, exc: Exception):
    # Log the real error here — don't leak details to the client
    return JSONResponse(
        status_code=500,
        content={"error": "internal server error"},
    )

Real-world example — patient records (preview of next chapter)

from fastapi import FastAPI, HTTPException, status
import json

app = FastAPI()

def load_patients():
    with open("patients.json") as f:
        return json.load(f)

@app.get("/patient/{pid}")
def get_patient(pid: str):
    patients = load_patients()
    if pid not in patients:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"patient {pid} not found",
        )
    return patients[pid]

Common pitfalls

  • Returning an error dict instead of raisingreturn {"error": "..."} sends a 200 OK with an "error" field. Always raise HTTPException.
  • Wrong status code — "user not found" should be 404, not 400. "Invalid input" is 400.
  • Leaking internalsdetail=str(exception) may leak DB error messages or paths. Sanitize.
  • Catching Exception and silently swallowing — better to log and let it become 500 than hide a bug.
  • Using HTTPException for control flow — only use it at the boundary of the API. Internal validation should use regular exceptions or return values.

What's next

Practice

What does this print?

Expected: 404

# FastAPI's HTTPException sets the response status code
status_for_not_found = 404
print(status_for_not_found)

Use HTTPException (not a plain return) to signal an error to the client

Expected: True

# Wrong: returning a dict with 'error' field still gives 200 OK
response = {"error": "user not found"}
is_proper_error = "error" in response and response.get("status") == 404
print(not is_proper_error)

Quiz — Quick check

What you remember

Q1. How do you return a 404 in FastAPI?

  • raise HTTPException(status_code=404, detail="Not found")
  • return 404
  • return {"status": 404}
  • exit(404)

Why: HTTPException is the idiomatic way. FastAPI catches it, sets the response status, and returns a JSON body with the detail. Anything else returns 200 by default.

Q2. What's the right status code for a malformed request body?

  • 400 Bad Request
  • 422 Unprocessable Entity (FastAPI's default for validation errors)
  • 500 Internal Server Error
  • 404 Not Found

Why: 400 is "couldn't parse the request at all". 422 means "I parsed it but the content is invalid". FastAPI uses 422 for Pydantic validation failures. Custom checks may use either.

Q3. Where should validation errors NOT be caught?

  • FastAPI handles Pydantic validation errors automatically — don't try/except them
  • In a middleware
  • In an exception handler
  • In the handler

Why: Pydantic validation errors are converted to 422 responses by FastAPI. Catching them in your handler would defeat the purpose. Customize via @app.exception_handler(RequestValidationError) if needed.

Common doubts

How do I customize the JSON shape of errors?

Add a global exception handler: @app.exception_handler(HTTPException) returning your custom JSON shape. Useful for consistent error envelopes across all endpoints.

What's the difference between HTTPException and a Python exception?

HTTPException is FastAPI's hand-shake: "stop processing, return this HTTP response". Other exceptions become 500 Internal Server Error (unless caught by a handler). Use HTTPException for expected errors (404, 401, 422); let unexpected exceptions become 500s.

Should error messages reveal internal details?

No. In production, return generic messages to clients ("User not found", "Internal error") and log the full details server-side. Detailed errors can leak schema, paths, or vulnerabilities to attackers.

A Complete CRUD Example — Patient Records