← Back to FastAPI
Lesson 2.5 · Pydantic & Validation

Response Models

Control what leaves your API: a response model filters out everything else - like the password hash our "leaky" endpoint sent. Use a return type or response_model=, separate In and Out models, drop empty fields, and see what happens when you return data that does not fit.

Intermediate30 min

What you will be able to do

  • Explain why every endpoint should have a response model
  • Set it with a return type or with response_model=
  • Use separate input and output models
  • Drop None or unset fields from responses
  • Understand the 500 error when a response does not fit
  • Return objects (like database rows) and know when from_attributes is needed

The idea, in plain English

Input checking protects your API from bad data coming in. Output checking protects your users from data going out that should not. Our user records contain a password hash and an is_admin flag. An endpoint that simply returns the record sends them to anyone who asks.

A response model says exactly which fields a response may contain. FastAPI checks what your function returns against it, keeps only those fields, converts types, and documents the shape in /docs. Everything else is dropped - even if your function returned it.

In this lesson we build a user API, start with a leaky endpoint, fix it with response models, and break things on purpose. This completes Module 2: you can now describe both what comes in and what goes out. FastAPI 0.143.0, Pydantic 2.14.0.

Worked example: A user API: a leaky endpoint that sends the password hash and admin flag, then the same data through UserOut - plus a response that breaks its own model.

workflowThe response model is a filterstep 1 / 3

1 - Without a response model: a leak

/leaky/1 returns the stored dict as it is. The password hash and the admin flag go to whoever called it.

password_hash
sent!
is_admin
sent!
status
200
error
none

The same user record, sent with and without a response model. Real responses.

Words you will see in this lesson

A few words about responses.

Small dictionary
Response modelThe model that describes - and filters - what an endpoint returns.
Input modelWhat a client sends (UserIn). May contain a password.
Output modelWhat a client gets back (UserOut). Never contains secrets.
SerializationTurning your return value into JSON.
ResponseValidationErrorYour function returned data that does not fit the response model.
from_attributesLets a model read fields from object attributes (row.email), not only dict keys.

An everyday example: a bank statement

The bank knows a lot about you: your password, internal risk notes, staff comments. Your statement shows only what you should see: account number, transactions, balance. Someone decided once which fields go on the statement - and that list protects you every month, whoever prints it.

A response model is that list. Your code can return the whole record; the response model decides what the customer sees.

Example 1 - in, out, and a leak

UserIn is what a client sends when signing up - it includes a password. UserOut is what any client gets back - no password, no hash, no admin flag. USERS holds our stored record, with the secrets.

leaky has no response model and returns the record. get_user has the return type -> UserOut: FastAPI uses it as the response model. create_user uses response_model=UserOut in the decorator instead - the same effect. compact adds response_model_exclude_none=True. broken returns data that does not fit. from_row returns a dataclass object, like a database row.

Example 1 - responses.py
from dataclasses import dataclass from fastapi import FastAPI, HTTPException from pydantic import BaseModel, EmailStr app = FastAPI() # What we store - including things that must NEVER leave the API USERS = {1: {"id": 1, "email": "ravi@example.com", "name": "Ravi", "password_hash": "$argon2id$v=19$m=65536$c2FsdA$aGFzaA", "is_admin": True, "nickname": None}} class UserIn(BaseModel): # what a client sends email: EmailStr name: str password: str class UserOut(BaseModel): # what a client gets back id: int email: EmailStr name: str nickname: str | None = None @app.get("/leaky/{user_id}") # no response model: everything is sent def leaky(user_id: int): return USERS[user_id] @app.get("/users/{user_id}") # the return type is the response model def get_user(user_id: int) -> UserOut: if user_id not in USERS: raise HTTPException(status_code=404, detail="User not found") return USERS[user_id] # a dict with extra keys -> filtered @app.post("/users", status_code=201, response_model=UserOut) # same thing, as an argument def create_user(user: UserIn): new = {"id": 2, **user.model_dump(), "password_hash": "hashed!", "is_admin": False} return new # password and hash are filtered out @app.get("/users/{user_id}/compact", response_model=UserOut, response_model_exclude_none=True) def compact(user_id: int): return USERS[user_id] # nickname is None -> left out @app.get("/broken") # returns something that does not fit def broken() -> UserOut: return {"id": "not-a-number", "email": "x"} @dataclass class UserRow: # like a database row object id: int email: str name: str password_hash: str @app.get("/rows/{user_id}") def from_row(user_id: int) -> UserOut: return UserRow(id=user_id, email="asha@example.com", name="Asha", password_hash="secret!")
Output
GET /leaky/1 -> 200 {"id":1,"email":"ravi@example.com","name":"Ravi","password_hash":"$argon2id$v=19$m=65536$c2FsdA$aGFzaA","is_admin":true,"nickname":null} GET /users/1 -> 200 {"id":1,"email":"ravi@example.com","name":"Ravi","nickname":null} POST /users -> 201 {"id":2,"email":"asha@example.com","name":"Asha","nickname":null} GET /users/1/compact -> 200 {"id":1,"email":"ravi@example.com","name":"Ravi"} GET /users/9 -> 404 {"detail":"User not found"} GET /broken -> 500 Internal Server Error GET /rows/3 -> 200 {"id":3,"email":"asha@example.com","name":"Asha","nickname":null}

Reading the output

/leaky/1 sent the password hash and is_admin: true. Nothing warned us - a leak is not an error. /users/1 returned the very same dict, but only the four UserOut fields arrived.

POST /users is the most important case: the client sent a password, our code put it (and a hash) into the dict it returned - and none of it was sent back. The response model protects you even from your own mistakes.

/rows/3 returned an object, not a dict, and it still worked: FastAPI read its attributes. The secret password_hash on that object was dropped too.

Return type or response_model=?

Both do the same job. The return type (-> UserOut) is shorter and your editor checks it too. Use response_model= when the function returns something else than the model type - for example, a dict or a database object, and your editor would complain about "-> UserOut".

If you set both, response_model= wins. And both also set the schema in /docs, so clients can see exactly what they will get.

Smaller responses: exclude options

response_model_exclude_none=True leaves out fields whose value is None - the compact endpoint did not send "nickname": null. There is also response_model_exclude_unset=True (only fields that were really set) and response_model_include / response_model_exclude for specific fields.

Use these with care: a client that expects "nickname" to always be there - even as null - may break when it disappears. Decide once per endpoint, and document it.

When the response does not fit: 500

/broken returned an id that is not a number and an email without @. FastAPI checked the return value against UserOut, found three problems, and answered 500 Internal Server Error. The client sees nothing more - and that is right: it is a bug in the server, not a mistake by the client.

In the server, FastAPI raised ResponseValidationError with all three problems, each with loc ("response", "id") and so on. You see it in the server log or in tests. A response model is therefore also a test of your own code: if you return the wrong thing, you find out at once instead of sending wrong data.

Output - what the server sees for /broken
server-side error: ResponseValidationError 3 validation errors: {'type': 'int_parsing', 'loc': ('response', 'id'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'not-a-number'} {'type': 'value_error', 'loc': ('response', 'email'), 'msg': 'value is not a valid email address: An email address must have an @-sign.', 'input': 'x', ...} {'type': 'missing', 'loc': ('response', 'name'), ...}

Objects and from_attributes

Database libraries (Module 5) give you objects, not dicts: row.email, not row["email"]. In FastAPI 0.143 a response model read a plain object’s attributes on its own - our test with a normal class answered 200 with or without from_attributes.

Outside FastAPI it is different. UserOut.model_validate(row) refused the object: "Input should be a valid dictionary or instance of UserOut". With model_config = ConfigDict(from_attributes=True) it worked. So when you convert database objects yourself - in a service function, a background job, a test - set from_attributes=True on the model.

Output - model_validate on a plain object
model_validate without from_attributes -> model_type - Input should be a valid dictionary or instance of UserOut with from_attributes -> id=5 email='kiran@example.com' name='Kiran'

A pattern for every resource

Most APIs use a small family of models for each kind of thing. A base with the shared fields, an In model for creating (with secrets like a password), an Update model with every field optional for PATCH (Lesson 1.3), and an Out model for responses (with the id and without secrets). Inheritance keeps them short.

The model family
class UserBase(BaseModel): # shared fields email: EmailStr name: str class UserIn(UserBase): # create: what the client sends password: str class UserUpdate(BaseModel): # PATCH: everything optional email: EmailStr | None = None name: str | None = None class UserOut(UserBase): # response: what the client gets id: int

Response models at a glance

Return type

Is the response model.

def get_user(user_id: int) -> UserOut:
In the decorator

Wins if both are set.

@app.post("/users", response_model=UserOut)
Drop None fields

Smaller responses.

response_model_exclude_none=True
Only set fields

Like exclude_unset.

response_model_exclude_unset=True
Read objects

Needed for model_validate(obj).

model_config = ConfigDict(from_attributes=True)
Bad return value

500 to the client.

ResponseValidationError in the server log

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Find leaks

“Remove "-> UserOut" from get_user and call /users/1. What appears?”

In the docs

“Compare the response schema of /leaky/{user_id} and /users/{user_id} in /docs.”

A list

“Add GET /users returning list[UserOut]. Return the raw records and check that no hash leaks.”

Break it

“Make get_user return {"id": 1}. What does the client get, and what does the server log say?”

What usually goes wrong

No response model

/leaky/1 sent the password hash and the admin flag, with no error.

✗ @app.get("/users/{user_id}")
def get_user(user_id: int):
    return USERS[user_id]
✓ @app.get("/users/{user_id}")
def get_user(user_id: int) -> UserOut:
    return USERS[user_id]
One model for input and output

A model with a password field, used as the response, sends the password back. Keep UserIn and UserOut separate.

Reading a 500 as the client’s fault

A ResponseValidationError is your bug. Look at the server log; the client cannot fix it.

model_validate(row) without from_attributes

Outside FastAPI, converting an object needs from_attributes=True.

Practice

Write these yourself before opening anything. Getting them wrong first is most of how this sticks.

1.

Build the model family for a Product: ProductBase (name, price), ProductIn (adds supplier_cost, which must never be returned), ProductUpdate (all optional) and ProductOut (adds id). Write GET /products/{id} that returns a stored dict containing supplier_cost, and check it is not sent.

Show hint

Inheritance for In and Out; -> ProductOut on the endpoint does the filtering.

Key points

  • A response model decides which fields leave the API; everything else is dropped.
  • Set it with a return type (-> UserOut) or response_model=; the decorator wins if both are set.
  • Use separate In and Out models - secrets go in, never out.
  • response_model_exclude_none / exclude_unset make responses smaller - consistently.
  • Returning data that does not fit gives 500 to the client and ResponseValidationError in the log.
  • FastAPI reads object attributes for responses; model_validate(obj) yourself needs from_attributes=True.

Quick check before you move on

What did /leaky/1 send that it should not?
The password hash and the is_admin flag.
Two ways to set a response model?
A return type (-> UserOut) or response_model=UserOut in the decorator.
What does the client see when a response does not fit the model?
500 Internal Server Error.
What did response_model_exclude_none=True remove?
The nickname field, because it was None.

Interview questions

Why use response models in FastAPI?

They filter output to declared fields (preventing leaks of secrets or internal data), validate and serialize return values, document the response schema in OpenAPI, and surface server bugs as ResponseValidationError.

What happens when an endpoint returns data that does not match its response model?

FastAPI raises ResponseValidationError and the client receives 500 Internal Server Error - it is treated as a server bug, with details logged server-side.

How do you structure models for a CRUD resource?

A base with shared fields, a create model (with write-only fields like passwords), an update model with optional fields for PATCH, and an output model with server-generated fields like id and no secrets.

Quiz

  1. 1.

    POST /users returned a dict with the password and a hash. What did the client receive?

  2. 2.

    Where do you find what went wrong with /broken?

  3. 3.

    Why separate UserIn and UserOut?

  4. 4.

    When do you need from_attributes=True?

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...