Chapter 6: Status Code with Custom Responses and Error Handling in FastAPI

Let’s Start With a Real Problem

Imagine you’re the frontend developer consuming an API. You call GET /users/99, the user doesn’t exist, and the server calmly replies with 200 OK and an empty body.

What do you do with that? Is it an error? Is the user just… blank? You can’t tell — because the status code lied to you.

This is one of the most common (and most avoidable) mistakes in API design: treating the HTTP status code as an afterthought. In this chapter, you’ll build four small, complete, runnable programs that each teach you one piece of the puzzle: setting status codes dynamically, returning non-JSON responses, adding custom headers, and handling errors manually — before we upgrade error handling properly in Chapter 7.

Every program below is fully self-contained — you can build any one of them starting from a completely empty folder and run it independently.


What You’ll Learn

  • Why HTTP status codes are part of your API’s contract, not decoration
  • The most common status codes you’ll actually use
  • How to set status codes dynamically at runtime using the injected Response object
  • How to use FastAPI’s custom response classes (PlainTextResponse and friends)
  • How to add custom headers to a response
  • A manual approach to returning error responses (and why it doesn’t scale — setting up Chapter 7)
  • Theory + hands-on assignment questions to lock in what you’ve learned

Theory: Understanding HTTP Status Codes

Every HTTP response starts with a 3-digit status code. The first digit tells you the category:

RangeCategoryMeaning
1xxInformationalRequest received, still processing
2xxSuccessThe request worked
3xxRedirectionThe client needs to look somewhere else
4xxClient ErrorThe client sent something wrong
5xxServer ErrorSomething broke on the server

Codes you’ll use constantly as an API developer:

CodeNameWhen to Use It
200OKA successful GET, PUT, or PATCH
201CreatedA successful POST that created a resource
204No ContentSuccess, but nothing to return
400Bad RequestThe client sent malformed data
404Not FoundThe requested resource doesn’t exist
409ConflictThe request conflicts with existing data
422Unprocessable EntityValidation failed (FastAPI does this automatically)
500Internal Server ErrorSomething unexpected broke on the server

💡 Rule of thumb: If unsure which code to return, ask: “If I were the client, what would I want to know just from the status code, before reading the body?”


Program 1: Returning Dynamic Status Codes

Program Objective

Build a small FastAPI project with a POST /users/register endpoint and a GET /users/ endpoint. The list endpoint should return 200 OK when users exist, and 204 No Content when the list is empty — decided at runtime, not fixed in advance.

Step-by-Step Implementation

Step 1: Create the project with uv

uv init status-code-demo
cd status-code-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app/core app/schemas app/routers app/services
touch app/__init__.py app/core/__init__.py app/schemas/__init__.py app/routers/__init__.py app/services/__init__.py

Final structure:

status-code-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── __init__.py
│   │   └── config.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── routers/
│   │   ├── __init__.py
│   │   └── users.py
│   └── services/
│       ├── __init__.py
│       └── user_service.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code shown below.

Step 4: Run and test the app (commands provided after the code).

Complete Working Source Code

app/core/config.py

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "Status Code Demo"
    app_version: str = "1.0.0"


settings = Settings()

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

app/services/user_service.py

from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id
    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": f"hashed_{user_in.password}",
        "is_active": True,
    }
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user


def list_users() -> list[dict]:
    return _fake_users_db

app/routers/users.py

from fastapi import APIRouter, Response, status

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, list_users

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate) -> UserOut:
    return create_user(user_in)


@router.get("/", response_model=list[UserOut])
def get_all_users(response: Response):
    users = list_users()

    if not users:
        response.status_code = status.HTTP_204_NO_CONTENT
        return []

    response.status_code = status.HTTP_200_OK
    return users

app/main.py

from fastapi import FastAPI

from app.core.config import settings
from app.routers import users

app = FastAPI(title=settings.app_name, version=settings.app_version)

app.include_router(users.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": f"Welcome to {settings.app_name}"}

Detailed Code Explanation

  • config.py and schemas/user.py set up standard app settings and validated input/output shapes — the same In/Out pattern from Chapter 5, keeping the password out of every response.
  • user_service.py is a plain, FastAPI-free module holding an in-memory list acting as our database, plus two functions: create_user and list_users.
  • The real concept of this program lives in get_all_users:
    • response: Response — FastAPI lets you inject the actual outgoing Response object as a parameter. This is how you change the status code based on logic, instead of a fixed value.
    • response.status_code = status.HTTP_204_NO_CONTENT — when there are no users yet, we tell the client “this succeeded, but there’s genuinely nothing here,” which is a very different signal than an error.
    • Otherwise, we explicitly set 200 OK and return the list.
  • response_model=list[UserOut] applies our Chapter 5 filtering logic to every item in the list — no password ever leaks here either.
  • main.py wires the router into the app and exposes a simple root endpoint.

Execution & Testing

Run the app:

uv run fastapi dev app/main.py

Open http://127.0.0.1:8000/docs:

  1. Call GET /users/ immediately — with a freshly started server, the list is empty. Confirm the response code shown is 204.
  2. Call POST /users/register with a sample body, e.g. {"username": "amitkumar", "email": "amit@example.com", "password": "SuperSecret123"}.
  3. Call GET /users/ again — confirm the response code is now 200 and the user appears in the list, without a password field.

Program 2: Custom Response Classes

Program Objective

Build a small, standalone FastAPI project with a GET /health endpoint that returns plain text, not JSON — the kind of endpoint monitoring tools like Kubernetes or a load balancer call to check “is this service alive?”

Step-by-Step Implementation

Step 1: Create the project

uv init custom-response-demo
cd custom-response-demo
uv add fastapi --extra standard

Step 2: Create the project structure

mkdir -p app/routers
touch app/__init__.py app/routers/__init__.py
custom-response-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   └── routers/
│       ├── __init__.py
│       └── health.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/routers/health.py

from fastapi import APIRouter
from fastapi.responses import PlainTextResponse

router = APIRouter(tags=["Health"])


@router.get("/health", response_class=PlainTextResponse)
def health_check():
    return "OK"

app/main.py

from fastapi import FastAPI

from app.routers import health

app = FastAPI(title="Custom Response Demo", version="1.0.0")

app.include_router(health.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": "Welcome to Custom Response Demo"}

Detailed Code Explanation

  • response_class=PlainTextResponse tells FastAPI (and its automatic documentation) that this endpoint returns plain text, not JSON. Without this, FastAPI would wrap "OK" in quotes as a JSON string, which most monitoring tools don’t expect.
  • This endpoint is deliberately as simple as possible — monitoring tools just want a fast 200 OK with minimal processing, not a complex JSON payload.
  • FastAPI also ships JSONResponse, HTMLResponse, RedirectResponse, FileResponse, and StreamingResponse — all imported from fastapi.responses and used the same way. We’ll use several of these in later chapters (file uploads, downloads).

Execution & Testing

uv run fastapi dev app/main.py

Open http://127.0.0.1:8000/docs, expand GET /health, click Try it out, then Execute. Notice the response body shows OK as plain text, not "OK" wrapped in quotes — and the Content-Type response header reads text/plain.


Program 3: Adding Custom Headers to a Response

Program Objective

Build a small, standalone FastAPI project with a GET /users/ endpoint that returns a custom X-Total-Count header, telling the client how many total items exist — a pattern we’ll reuse later for pagination.

Step-by-Step Implementation

Step 1: Create the project

uv init custom-headers-demo
cd custom-headers-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app/schemas app/services app/routers
touch app/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
custom-headers-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       └── users.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

app/services/user_service.py

from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id
    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": f"hashed_{user_in.password}",
        "is_active": True,
    }
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user


def list_users() -> list[dict]:
    return _fake_users_db

app/routers/users.py

from fastapi import APIRouter, Response, status

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, list_users

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate) -> UserOut:
    return create_user(user_in)


@router.get("/", response_model=list[UserOut])
def get_all_users(response: Response):
    users = list_users()
    response.headers["X-Total-Count"] = str(len(users))

    if not users:
        response.status_code = status.HTTP_204_NO_CONTENT
        return []

    response.status_code = status.HTTP_200_OK
    return users

app/main.py

from fastapi import FastAPI

from app.routers import users

app = FastAPI(title="Custom Headers Demo", version="1.0.0")

app.include_router(users.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": "Welcome to Custom Headers Demo"}

Detailed Code Explanation

  • response.headers["X-Total-Count"] = str(len(users)) — custom headers are always plain strings, so the integer count is converted with str().
  • Prefixing custom headers with X- (like X-Total-Count, X-Request-ID) is a long-standing industry convention that instantly signals “this is a custom header, not a standard HTTP one,” even though it’s no longer an official requirement.
  • Headers and the response body are entirely separate channels — setting a header never changes what’s inside the JSON body.

Execution & Testing

uv run fastapi dev app/main.py

Register two or three users via POST /users/register, then call GET /users/. In Swagger UI, expand the Response headers section under the result — you should see x-total-count: 3 (or however many users you created).


Program 4: A Manual Approach to Error Responses

Program Objective

Build a small, standalone FastAPI project with a GET /users/{user_id} endpoint that returns 404 Not Found with a clear message when a user doesn’t exist — using only JSONResponse, without exceptions (which we formalize properly in Chapter 7).

Step-by-Step Implementation

Step 1: Create the project

uv init manual-error-demo
cd manual-error-demo
uv add fastapi --extra standard
uv add pydantic-settings

Step 2: Create the project structure

mkdir -p app/schemas app/services app/routers
touch app/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
manual-error-demo/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   └── user.py
│   ├── services/
│   │   ├── __init__.py
│   │   └── user_service.py
│   └── routers/
│       ├── __init__.py
│       └── users.py
├── pyproject.toml
└── uv.lock

Step 3: Create each file with the code below. Step 4: Run and test.

Complete Working Source Code

app/schemas/user.py

from typing import Annotated

from pydantic import BaseModel, ConfigDict, EmailStr, Field


class UserCreate(BaseModel):
    username: Annotated[str, Field(min_length=3, max_length=30)]
    email: Annotated[EmailStr, Field()]
    password: Annotated[str, Field(min_length=8, max_length=64)]


class UserOut(BaseModel):
    id: int
    username: str
    email: EmailStr
    is_active: bool = True

    model_config = ConfigDict(from_attributes=True)

app/services/user_service.py

from app.schemas.user import UserCreate

_fake_users_db: list[dict] = []
_next_id = 1


def create_user(user_in: UserCreate) -> dict:
    global _next_id
    new_user = {
        "id": _next_id,
        "username": user_in.username,
        "email": user_in.email,
        "hashed_password": f"hashed_{user_in.password}",
        "is_active": True,
    }
    _fake_users_db.append(new_user)
    _next_id += 1
    return new_user


def get_user_by_id(user_id: int) -> dict | None:
    for user in _fake_users_db:
        if user["id"] == user_id:
            return user
    return None

app/routers/users.py

from fastapi import APIRouter, status
from fastapi.responses import JSONResponse

from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user, get_user_by_id

router = APIRouter(prefix="/users", tags=["Users"])


@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate) -> UserOut:
    return create_user(user_in)


@router.get("/{user_id}", response_model=UserOut)
def get_user(user_id: int):
    user = get_user_by_id(user_id)

    if user is None:
        return JSONResponse(
            status_code=status.HTTP_404_NOT_FOUND,
            content={"detail": f"User with id {user_id} was not found."},
        )

    return user

app/main.py

from fastapi import FastAPI

from app.routers import users

app = FastAPI(title="Manual Error Demo", version="1.0.0")

app.include_router(users.router)


@app.get("/", tags=["Root"])
def read_root():
    return {"message": "Welcome to Manual Error Demo"}

Detailed Code Explanation

  • get_user_by_id returns None when nothing matches — a completely normal Python pattern, not an error by itself.
  • In the router, when we return a JSONResponse directly, we bypass response_model entirely — FastAPI sends exactly the dictionary we give it, with exactly the status code we specify.
  • This works, and it’s honest — the client gets a real 404 with a helpful message. But notice the cost: every route that needs a “not found” check has to repeat this same if user is None: return JSONResponse(...) block. In a real app with dozens of routes, that repetition adds up fast.
  • This repetition is exactly what HTTPException and a global error handler solve — which is where Chapter 7 picks up.

Execution & Testing

uv run fastapi dev app/main.py

Register a user via POST /users/register, note their id, then:

  1. Call GET /users/{id} with that real ID — confirm you get 200 with the user data.
  2. Call GET /users/999 (a non-existent ID) — confirm you get 404 with a clear detail message explaining what went wrong.

Tips & Instructions for Developers

  • ✅ Pick status codes based on what actually happened — don’t default everything to 200, even for errors.
  • ✅ Use the status module (status.HTTP_404_NOT_FOUND) instead of raw numbers — it’s self-documenting and typo-proof.
  • ✅ Inject Response into your function signature whenever the status code depends on runtime logic.
  • ✅ Reach for response_class= when your endpoint genuinely isn’t JSON (health checks, plain text, HTML, files).
  • ✅ Prefix custom headers clearly (e.g. X-Total-Count) so frontend developers instantly know they’re not standard HTTP headers.
  • ❌ Don’t repeat manual JSONResponse(status_code=..., content=...) error blocks across dozens of routes — that’s a strong signal you need centralized error handling.
  • ❌ Don’t return 200 OK for something that failed “just to keep things simple” — it breaks trust between your API and every client that consumes it.

Official FastAPI Documentation References


Theory Questions

  1. What’s the difference between a 4xx and a 5xx status code, conceptually?
  2. Why might an API return 204 No Content instead of 200 OK with an empty list?
  3. How does injecting Response into a route function differ from setting status_code= in the decorator?
  4. When would you use PlainTextResponse instead of the default JSON response?
  5. What’s a practical downside of manually returning JSONResponse for every possible error case across a large application?

Practical Assignment

  1. Add a DELETE-style status code: Starting from Program 4’s project, add a DELETE /users/{user_id} endpoint that returns 204 No Content on success, and 404 Not Found (manual JSONResponse) if the user doesn’t exist.
  2. Custom header practice: Starting from Program 2’s project, add an X-API-Version header with value "1.0.0" to the /health response.
  3. Status code review: Starting from Program 4’s project, add a check in POST /users/register that returns an appropriate status code (pick the best one from this chapter’s table) if someone tries to register a username that already exists.

Summary

In this chapter, you built four small, complete, runnable FastAPI projects that each taught you one concept:

  • Setting status codes dynamically using the injected Response object
  • Returning non-JSON responses using response_class
  • Adding custom response headers
  • Manually handling a “not found” error — and seeing exactly why that approach doesn’t scale
Scroll to Top