Declaring what an endpoint returns with response_model: enforcement rather than documentation, list responses, the serialisation options, and why a failure is a 500.
from sillo import HttpContext
class PostOut(BaseModel): model_config = ConfigDict(from_attributes=True)
id: int title: str published_at: datetime | None
@app.get("/posts/{id:int}", response_model=PostOut)async def get_post(ctx: HttpContext, id=Path(type=int)): return await Post.get(id=id)The return value is validated against PostOut, fields the model does not
declare are dropped, and the OpenAPI response schema is generated from the
same object.
Enforced, not documented
Section titled “Enforced, not documented”This is the distinction worth internalising. A response model is not a comment about what the endpoint returns. It is a filter the value passes through.
from sillo import HttpContext
class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True)
id: int email: str username: str
@app.get("/users/{id:int}", response_model=UserOut)async def get_user(ctx: HttpContext, id=Path(type=int)): return await User.get(id=id) # has password, is_staff, internal_notesThe user row carries a hashed password. UserOut does not declare it, so it
never reaches the client, and the same is true of every column added to that
table next year.
Compare with returning user.to_dict(), which publishes
every field the model has and quietly starts
publishing the new one too.
That is the argument for declaring response_model on every endpoint that
returns data derived from a database row.
from sillo import HttpContext
@app.get("/posts", response_model=PostOut, response_model_many=True)async def list_posts(ctx: HttpContext): return await Post.filter(status="published").limit(20)response_model_many=True says the handler returns a list of the model rather
than one. Each item is validated and shaped.
For an envelope, use a generic model instead:
from typing import Generic, TypeVarfrom sillo import HttpContext, paginate
T = TypeVar("T")
class Page(BaseModel, Generic[T]): items: list[T] total: int page: int
@app.get("/posts", response_model=Page[PostOut])async def list_posts(ctx: HttpContext, page=Query(1, type=int, ge=1)): result = await paginate(Post.filter(status="published"), page=page) return {"items": result.items, "total": result.total, "page": result.page}Which documents the envelope properly (PagePostOut appears in your schema
list) rather than describing it as an untyped object.
from_attributes
Section titled “from_attributes”model_config = ConfigDict(from_attributes=True)Required on any model built from an ORM instance: it lets Pydantic read
attributes instead of dict keys. Without it, returning a Post raises because
the model expects a mapping.
v1 called this orm_mode.
Serialisation options
Section titled “Serialisation options”@app.get( "/posts", response_model=PostOut, response_model_exclude_none=True, response_model_exclude_unset=False, response_model_exclude_defaults=False, response_model_by_alias=True,)| Option | Default | Effect |
|---|---|---|
response_model_many | False | The handler returns a list |
response_model_exclude_none | False | Omit fields whose value is None |
response_model_exclude_unset | False | Omit fields never explicitly set |
response_model_exclude_defaults | False | Omit fields still at their default |
response_model_by_alias | True | Serialise using field aliases |
response_model_by_alias defaults to True, so a model with
aliases emits the alias without further
configuration. That is the opposite of Pydantic’s own default, and it is the
right one for an API. The alias is the wire name.
exclude_none=True is tempting for tidy output and usually wrong: it makes
“absent” and “explicitly null” indistinguishable to the client, and it makes
the response shape vary per row, which a typed client cannot model.
When the handler returns something else
Section titled “When the handler returns something else”from sillo import HttpContext
@app.get("/posts/{id:int}", response_model=PostOut)async def get_post(ctx: HttpContext, id=Path(type=int)): return {"id": 1, "title": "Hello", "published_at": None}A dict works, as does a PostOut instance, as does an ORM object with
from_attributes=True. All three are validated against the model.
A failure is a 500
Section titled “A failure is a 500”{"error": "Internal Server Error", "detail": "Response validation failed"}Not a 422. The client sent a valid request; your application produced a response that does not match the contract it published. That is a server-side bug, and returning a 4xx would blame the caller and mislead clients that retry on 4xx.
The offending value is deliberately not echoed to the client. It may contain exactly the data the response model existed to filter out. It is logged with the method and path instead.
Status codes
Section titled “Status codes”response_model describes the success response. Document the others with
responses:
@app.post( "/posts", request_model=PostCreate, response_model=PostOut, responses={ 201: {"description": "Created"}, 409: {"description": "A post with that slug already exists"}, 422: {"description": "Validation failed"}, },)Several output shapes
Section titled “Several output shapes”A list endpoint rarely wants the same fields as a detail endpoint:
class PostSummary(BaseModel): model_config = ConfigDict(from_attributes=True) id: int title: str
class PostDetail(PostSummary): body: str author: AuthorOut tags: list[TagOut]@app.get("/posts", response_model=PostSummary, response_model_many=True)@app.get("/posts/{id:int}", response_model=PostDetail)Two models, two schemas in your documentation, and a list endpoint that does not transfer every post body. See Patterns.
Testing
Section titled “Testing”from sillo import json
def test_never_returns_the_password(client): response = client.get("/users/1") assert response.status_code == 200 assert "password" not in json()Worth writing once per sensitive model. It is the test that fails when somebody
swaps response_model for to_dict() in a hurry.