What does response_model cost on a hot list endpoint returning 5000 rows?
Every row is validated and re-serialized a second time — the ORM objects were already built, and the model rebuilds them field by field before the JSON is written. On large lists that pass dominates the request. Narrow the model, page the result, or return a response object directly and skip the model.
- ✗Assuming
response_modelis a startup-time cost rather than a per-row one - ✗Swapping in a faster JSON encoder while leaving the second validation pass in place
- ✗Serving unbounded lists and treating the result as a serialization problem
- →How would you prove the validation pass, not the query, dominates the request?
- →What do you give up by returning a response object directly?
Task
A list endpoint returns 5000 rows, spends tens of milliseconds in the database and hundreds answering. Where does the time go, and what do you do about it.
Analysis
class UserOut(BaseModel):
id: int
email: str
display_name: str
@app.get("/users", response_model=list[UserOut])
def list_users(session: SessionDep) -> list[User]:
return session.query(User).all() # 5000 ORM objects already builtThe rows are built twice. First the driver and the ORM turn the database reply into 5000 User objects. Then response_model takes each object, reads the declared fields, checks their types and assembles a fresh UserOut instance — only after that is the JSON written. The second pass is linear in rows and in fields, so on large results it, not the query, becomes the dominant term.
⚠️ A faster JSON encoder does not help here: it speeds up the final step, not the validation pass in front of it.
Solution
# 1. Stop fetching what you do not send — narrow the query and the model together
rows = session.query(User.id, User.email, User.display_name).all()
# 2. Bound the result — a page instead of the whole table
rows = rows[offset : offset + limit]
# 3. Hot path whose response shape is already guaranteed — bypass the model
return ORJSONResponse([{"id": r.id, "email": r.email} for r in rows])Key points
- Order of operations: bound the volume first (paging), narrow the model second, and drop
response_modellast — it is the final step, not the first. - ❌ Returning a response object directly disables field filtering: the protection against leaking
passworddisappears along with the validation pass, so the dictionary has to be assembled explicitly. - The OpenAPI schema is built once at startup — it has nothing to do with per-response cost.
- Prove the hypothesis by measurement: query time against total response time; the gap is the cost of the pass.