FastAPI / Basic: HTTP and typed boundaries / Lesson 4
Responses, status, and serialization
Define the public response independently from internal records and return HTTP semantics that match the completed operation.
On this page
Lesson goals
- Explain the assumptions and boundaries of responses, status, and serialization.
- Interpret both complete examples and justify their observed outputs.
- Diagnose the listed failures and apply the worked solutions while keeping identity, state, and lifecycle ownership explicit.
The response is another trust boundary
A request model controls accepted input; a response model controls the declared output contract. Internal records can contain fields that must never be returned, such as credential material, private notes, or administrative flags. The first example returns a synthetic internal dictionary but exposes only a public name through response_model. This filtering is useful, yet deliberate response construction remains easier to audit than relying on an ever-growing internal object. Never assume that a property is hidden merely because the client did not request it or because its Python name begins with a familiar convention.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class PublicUser(BaseModel):
name: str
app = FastAPI()
@app.get("/profile", response_model=PublicUser)
def profile():
return {"name": "Ada", "internal_note": "public-synthetic-example-only"}
with TestClient(app) as client:
response = client.get("/profile")
print(response.json())
print("internal_note" in response.json())
{'name': 'Ada'}
False
Only name crosses the declared response boundary. The extra internal_note value is synthetic and removed from the client representation.
Choose a status from the operation contract
HTTP 200, 201, 202, and 204 express different outcomes. A completed create can use 201, while 202 means accepted work is not necessarily complete. A 204 response has no body, so do not attach an ordinary JSON success object to it. The second example explicitly returns an empty Response for a synthetic delete and checks the received byte length. A status code alone does not guarantee persistence or authorization; the handler and domain contract must establish those conditions. Keep the success and failure branches distinct and return one completed response.
from fastapi import FastAPI, Response
from fastapi.testclient import TestClient
app = FastAPI()
@app.delete("/draft", status_code=204)
def remove_draft():
return Response(status_code=204)
with TestClient(app) as client:
response = client.delete("/draft")
print(response.status_code, len(response.content))
204 0
HTTP 204 carries zero response-body bytes here. The route performs no actual deletion or storage operation; it tests transport semantics only.
Distinguish JSON conversion from arbitrary Python values
JSON has a narrower set of types than Python. FastAPI and Pydantic can serialize supported dates and model values, but the application must choose a stable representation for identifiers, money, binary data, and optional fields. Returning an arbitrary database object can trigger lazy loads, session-lifetime failures, or accidental exposure. A response model validates the output shape and can reveal a programming defect when the handler returns invalid data. Such a response-validation failure is a server-side contract problem, not automatically the client’s request error. Test invalid output separately from invalid input.
Use headers and special responses deliberately
Headers such as Location, Cache-Control, and content type affect how clients use a result. A JSON response, streaming response, file response, and redirect have different lifecycles and security requirements. Returning a Response directly can bypass ordinary response-model processing for that response, so it is an explicit responsibility rather than a shortcut to avoid understanding validation. Streaming also changes when errors can be reported: after headers or body bytes begin, a new clean JSON error is usually no longer possible. Later lessons connect that boundary to dependency cleanup and lifespan.
Verify output that clients actually receive
Assert status, headers where relevant, decoded content, absent sensitive fields, and no-body behavior using the isolated client. Do not validate only the Python return object because serialization and response filtering are part of the API. Keep synthetic secret-like strings visibly noncredential and avoid real user records in fixtures. These examples contact no storage, external service, or network server. Schema declarations are documentation and validation tools, not proof of business correctness. A response contract is complete only when authorized clients receive the intended representation and prohibited internal data remains absent on both success and error paths.
Common errors and fixes
Internal fields leak through a raw record
- Why it happens
- Input and output models were not separated.
- How to fix it
- Construct a public response and declare its model.
- Prevent it next time
- Assert sensitive-field absence in response tests.
A 204 route returns JSON
- Why it happens
- No-content semantics were ignored.
- How to fix it
- Return an empty response or choose a body-bearing status.
- Prevent it next time
- Test actual byte length and status.
Invalid server output is blamed on the client
- Why it happens
- Response validation and request validation were conflated.
- How to fix it
- Repair the handler’s output contract and log safe diagnostics.
- Prevent it next time
- Include intentional invalid-output tests at the correct boundary.
Check your understanding
1. What distinguishes 202 from a durable completed booking result?
Show worked solution
202 communicates acceptance for processing and requires a documented way to observe eventual outcome. Do not present it as proof the booking committed. A completed result needs the domain and persistence contract established before that response is returned.
2. How should a test protect against an internal field becoming public after refactoring?
Show worked solution
Request the endpoint through the client, assert the exact public shape or relevant allowed fields, and explicitly assert the sensitive field is absent. Testing only the response-model class misses a route that accidentally bypasses it with a direct response.
Key takeaways
- A request model controls accepted input; a response model controls the declared output contract.
- JSON has a narrower set of types than Python.
- Assert status, headers where relevant, decoded content, absent sensitive fields, and no-body behavior using the isolated client.