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
Responseobject - How to use FastAPI’s custom response classes (
PlainTextResponseand 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:
| Range | Category | Meaning |
|---|---|---|
1xx | Informational | Request received, still processing |
2xx | Success | The request worked |
3xx | Redirection | The client needs to look somewhere else |
4xx | Client Error | The client sent something wrong |
5xx | Server Error | Something broke on the server |
Codes you’ll use constantly as an API developer:
| Code | Name | When to Use It |
|---|---|---|
200 | OK | A successful GET, PUT, or PATCH |
201 | Created | A successful POST that created a resource |
204 | No Content | Success, but nothing to return |
400 | Bad Request | The client sent malformed data |
404 | Not Found | The requested resource doesn’t exist |
409 | Conflict | The request conflicts with existing data |
422 | Unprocessable Entity | Validation failed (FastAPI does this automatically) |
500 | Internal Server Error | Something 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.pyandschemas/user.pyset 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.pyis a plain, FastAPI-free module holding an in-memory list acting as our database, plus two functions:create_userandlist_users.- The real concept of this program lives in
get_all_users:response: Response— FastAPI lets you inject the actual outgoingResponseobject 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 OKand 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.pywires 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:
- Call
GET /users/immediately — with a freshly started server, the list is empty. Confirm the response code shown is204. - Call
POST /users/registerwith a sample body, e.g.{"username": "amitkumar", "email": "amit@example.com", "password": "SuperSecret123"}. - Call
GET /users/again — confirm the response code is now200and 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=PlainTextResponsetells 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 OKwith minimal processing, not a complex JSON payload. - FastAPI also ships
JSONResponse,HTMLResponse,RedirectResponse,FileResponse, andStreamingResponse— all imported fromfastapi.responsesand 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 withstr().- Prefixing custom headers with
X-(likeX-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_idreturnsNonewhen nothing matches — a completely normal Python pattern, not an error by itself.- In the router, when we return a
JSONResponsedirectly, we bypassresponse_modelentirely — 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
404with a helpful message. But notice the cost: every route that needs a “not found” check has to repeat this sameif user is None: return JSONResponse(...)block. In a real app with dozens of routes, that repetition adds up fast. - This repetition is exactly what
HTTPExceptionand 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:
- Call
GET /users/{id}with that real ID — confirm you get200with the user data. - Call
GET /users/999(a non-existent ID) — confirm you get404with a cleardetailmessage 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
statusmodule (status.HTTP_404_NOT_FOUND) instead of raw numbers — it’s self-documenting and typo-proof. - ✅ Inject
Responseinto 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 OKfor something that failed “just to keep things simple” — it breaks trust between your API and every client that consumes it.
Official FastAPI Documentation References
- Response Status Code
- Response Model – Return Type
- Custom Response Classes
- Response – Change Status Code Dynamically
- Response Headers
- Handling Errors
Theory Questions
- What’s the difference between a
4xxand a5xxstatus code, conceptually? - Why might an API return
204 No Contentinstead of200 OKwith an empty list? - How does injecting
Responseinto a route function differ from settingstatus_code=in the decorator? - When would you use
PlainTextResponseinstead of the default JSON response? - What’s a practical downside of manually returning
JSONResponsefor every possible error case across a large application?
Practical Assignment
- Add a DELETE-style status code: Starting from Program 4’s project, add a
DELETE /users/{user_id}endpoint that returns204 No Contenton success, and404 Not Found(manualJSONResponse) if the user doesn’t exist. - Custom header practice: Starting from Program 2’s project, add an
X-API-Versionheader with value"1.0.0"to the/healthresponse. - Status code review: Starting from Program 4’s project, add a check in
POST /users/registerthat 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
Responseobject - 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
