Response Models and Status Codes
FastAPI response models and status codes define what a client is allowed to see and how the client should interpret the result. A path operation may work internally with database rows, ORM objects, dictionaries, or Pydantic models, but the public HTTP response should be smaller and more deliberate. In this lesson you will use response_model, status_code, the Response object, and related options to return predictable JSON while hiding implementation-only fields.
Purpose and Outcome
In the Routing and Schemas section, request models describe incoming data. Response models do the matching job on the way out. They turn endpoint return values into the documented output schema in OpenAPI, validate that returned data has the expected shape, serialize Python values into JSON-compatible values, and filter fields that are not part of the public model. Status codes add the outcome: created, replaced, deleted, not found, rejected, or failed.
The result is a route whose behavior can be understood without reading its storage code. A client that calls POST /articles and receives 201 knows a new resource was created. A client that calls DELETE /articles/1 and receives 204 knows there is no response body to parse. A client that sees the response schema in generated docs knows which fields are stable enough to consume.
How FastAPI Applies Response Models
When a request reaches a path operation, FastAPI first resolves dependencies and validates request data. After your function returns, FastAPI inspects the route metadata created by decorators such as @app.get(..., response_model=UserOut). If a response model is configured, the returned value is passed through Pydantic serialization for that model. Extra fields are discarded, aliases and JSON encoders are applied, and incompatible values produce a server-side validation error because the application failed to honor its declared output contract.
This filtering step is important because the returned object is not necessarily the same as the response body. Returning an object with password_hash, internal_notes, or cost_basis does not expose those fields when the response model excludes them. Conversely, fields required by the response model must be present or computable. FastAPI then builds a Starlette response with the selected status code, headers, media type, and serialized body.
Status codes can be static or dynamic. The decorator’s status_code value is the default success code for that route. Raising HTTPException exits the handler with an error code and error body. Returning a Response lets you take direct control of body and headers. Accepting a Response parameter lets normal model serialization still happen while you adjust status or headers for the current request.
API Anatomy
The most common parameters are response_model and status_code. response_model accepts a Pydantic model, a collection type such as list[ItemOut], or another supported type annotation. status_code accepts an integer or a named constant from fastapi.status. Constants such as status.HTTP_201_CREATED make intent clear and avoid magic numbers.
FastAPI also provides response-model options for common serialization choices. response_model_exclude_none=True omits fields whose value is None. response_model_exclude_unset=True omits fields that were not explicitly set on the model. response_model_by_alias=True, the usual default, emits alias names when a model uses aliases. These options change output shape, so treat them as part of the API contract.
Example 1: Filter a Stored User
The endpoint below returns a full internal user record, but declares UserOut as the response model. The public body contains only id, email, and display_name. The stored password_hash field is accepted inside the service but removed during response serialization.
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserRecord(BaseModel):
id: int
email: str
display_name: str
password_hash: str
class UserOut(BaseModel):
id: int
email: str
display_name: str
@app.get("/users/{user_id}", response_model=UserOut)
def read_user(user_id: int):
stored = UserRecord(
id=user_id,
email="ada@example.com",
display_name="Ada",
password_hash="not-for-clients",
)
return stored
A request to GET /users/7 returns status 200 by default and a body like {"id":7,"email":"ada@example.com","display_name":"Ada"}. The deterministic behavior is the absence of password_hash. This is not cosmetic: it lets database-facing code use rich internal objects without publishing every attribute.
Example 2: Creation, Optional Fields, and Empty Bodies
This example uses three response choices. Creating an article returns 201 Created and the created resource. Reading an article omits summary when it is None. Deleting an article returns 204 No Content, which must not include a JSON body.
from fastapi import FastAPI, Response, status
from pydantic import BaseModel, Field
app = FastAPI()
class ArticleIn(BaseModel):
title: str = Field(min_length=1)
body: str
class ArticleOut(BaseModel):
id: int
title: str
body: str
summary: str | None = None
ARTICLES: dict[int, ArticleOut] = {}
@app.post("/articles", response_model=ArticleOut, status_code=status.HTTP_201_CREATED)
def create_article(payload: ArticleIn):
article_id = len(ARTICLES) + 1
article = ArticleOut(id=article_id, title=payload.title, body=payload.body)
ARTICLES[article_id] = article
return article
@app.get("/articles/{article_id}", response_model=ArticleOut, response_model_exclude_none=True)
def read_article(article_id: int):
return ARTICLES[article_id]
@app.delete("/articles/{article_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_article(article_id: int):
ARTICLES.pop(article_id, None)
return Response(status_code=status.HTTP_204_NO_CONTENT)
After posting {"title":"FastAPI","body":"Schemas matter"}, the expected status is 201 and the response contains the assigned id. Reading the same item returns 200 and omits summary because response_model_exclude_none=True. Deleting it returns 204; clients should check the status and skip JSON parsing.
Example 3: Dynamic Status for Upsert
An upsert endpoint can create a resource on the first call and replace it on later calls. A single decorator status code cannot describe both outcomes. By accepting response: Response, the handler can set 201 only when the SKU is new while still letting FastAPI serialize the returned WidgetOut.
from fastapi import FastAPI, Response, status
from pydantic import BaseModel
app = FastAPI()
class WidgetIn(BaseModel):
name: str
stock: int
class WidgetOut(BaseModel):
sku: str
name: str
stock: int
WIDGETS: dict[str, WidgetOut] = {}
@app.put("/widgets/{sku}", response_model=WidgetOut, status_code=status.HTTP_200_OK)
def upsert_widget(sku: str, payload: WidgetIn, response: Response):
created = sku not in WIDGETS
widget = WidgetOut(sku=sku, name=payload.name, stock=payload.stock)
WIDGETS[sku] = widget
if created:
response.status_code = status.HTTP_201_CREATED
response.headers["Location"] = f"/widgets/{sku}"
return widget
The first PUT /widgets/A100 with {"name":"Adapter","stock":5} returns 201, includes Location: /widgets/A100, and serializes the widget. A later PUT to the same SKU returns 200 with the updated body. The design tells clients whether they created a new resource or merely changed an existing one.
Design Choices and Trade-offs
Separate input and output models when data has different meanings at each boundary. A create model might accept a password, but a read model should never return it. A database model might contain audit columns, soft-delete flags, or foreign-key details that are useful internally and noisy or dangerous externally. Separate models do require more code, but they keep accidental schema changes from leaking into clients.
Choose status codes based on client behavior, not personal preference. Use 200 when a resource is returned after a successful read or replacement, 201 when a new resource is created, 202 when work has been accepted but is not complete, and 204 when success intentionally has no body. Use 400 for malformed semantics, 401 for missing or invalid authentication, 403 for authenticated users without permission, and 404 when the resource is unavailable to the caller.
Be careful with response_model_exclude_none. It makes payloads smaller and cleaner, but it changes the meaning of missing fields. If clients must distinguish "known empty" from "not included", return explicit null instead. For public APIs, consistency is often more valuable than shaving a few bytes.
Failure Modes and Troubleshooting
Symptom: the endpoint raises a server error after your function returns. Cause: the returned value does not satisfy the response model, often because a required field is missing or has the wrong type. Diagnostic steps: reproduce with a test client, inspect the returned Python object before serialization, and compare it to the output model. Correction: construct the declared output model explicitly or adjust the model only if the public contract was wrong.
Symptom: a delete endpoint returns 204 but clients complain about JSON parsing. Cause: the handler returned a dictionary or None that caused an unexpected body. Diagnostic steps: check the raw response content length and headers. Correction: return Response(status_code=status.HTTP_204_NO_CONTENT) or design the route to return 200 with a body instead.
Symptom: generated clients expect one schema but production returns another. Cause: output models, exclude options, or dynamic responses diverged from OpenAPI documentation. Diagnostic steps: compare /openapi.json with integration-test responses for each status code. Correction: document alternate responses with the route’s responses parameter or split routes when outcomes are too different.
Security, Performance, and Reliability
Response models are a practical security control against accidental field exposure, but they are not a substitute for authorization. Filtered fields still existed in memory, logs, traces, and return values before serialization. Avoid logging full internal objects that contain secrets. Use output models to minimize what crosses the HTTP boundary and permission checks to decide whether the caller can see the resource at all.
Serialization has a cost. Large lists with deeply nested response models can spend noticeable time validating and encoding. Prefer pagination, narrow output models, and database queries that fetch only fields needed for the response. Reliability improves when status codes match retry behavior: clients may retry a 503, should fix input after a 422, and should not retry a successful 201 without idempotency.
Hands-on Lab
Prerequisites: a Python environment with FastAPI and its test client dependencies installed. Create response_models_lab.py using the upsert example above. Then create a test file with the following checks.
from fastapi.testclient import TestClient
from response_models_lab import app
client = TestClient(app)
def test_upsert_create_then_replace():
created = client.put("/widgets/A100", json={"name": "Adapter", "stock": 5})
assert created.status_code == 201
assert created.headers["location"] == "/widgets/A100"
assert created.json() == {"sku": "A100", "name": "Adapter", "stock": 5}
replaced = client.put("/widgets/A100", json={"name": "Adapter", "stock": 8})
assert replaced.status_code == 200
assert replaced.json()["stock"] == 8
- Run the app with your usual ASGI server and open
/docs. Verify thatPUT /widgets/{sku}documentsWidgetOutas the successful response body. - Run the test. The first request must return
201, aLocationheader, and the serialized widget. The second request must return200and the updated stock. - Add an internal field such as
warehouse_binto stored data but not toWidgetOut. Verify that it does not appear in the JSON response. - Cleanup by deleting the lab files or resetting them to the previous exercise state. If you used an in-memory dictionary, restarting the process clears the sample data.
Assessment Exercises
- An endpoint returns an ORM object with
email,hashed_password, androles. Design separate input and output models, and explain which fields belong in each response. - A
POST /exportsroute starts a background export that may take ten minutes. Which status code and response body would help a client poll for progress? - Find the bug: a route declares
status_code=204and returns{"deleted": true}. What should change, and why? - Write a test that proves
response_modelfilters an internal field even when the handler returns an object containing that field.
Summary
FastAPI response models are the outbound half of schema-driven routing. They validate, serialize, document, and filter the values returned by path operations. Status codes describe the outcome that clients should act on. Use explicit output models, choose status codes by HTTP semantics, test the actual serialized body, and treat every response option as part of the public API.
