Response Model and Serialization Order

Key takeaways:

  • response_model= overrides the return annotation; without it, the annotation is the response model.
  • Returning a Pydantic model does not skip validation — FastAPI re-validates and rebuilds it.
  • Re-validation is what strips extra fields, which is why a subclass with a password hash is safe to return.
  • exclude_unset drops never-assigned fields; exclude_none drops null-valued fields. They are not interchangeable.
  • A response that fails its own model is a 500, not a 422, because the fault is server-side.

Between your return statement and the bytes on the wire, FastAPI does considerably more than call json.dumps. This page is about that gap. It is the detailed view of stage five in The FastAPI Request/Response Lifecycle, and it exists because the gap contains three genuinely surprising behaviours that bite production applications.

What happens to a handler's return value before it becomes bytes A left-to-right pipeline of four stages. The handler's return value enters, is validated against the response field, then filtered by the exclude options, then passed through jsonable_encoder, then encoded to JSON bytes. A separate lower path shows that returning a Response instance bypasses all four stages. return value dict / model / list validate against response field filter exclude_unset / none encode jsonable + bytes Response instance returned directly bypasses validation, filtering and encoding entirely All four stages run inside the router, before any middleware sees the response. Middleware only ever observes the bytes.

The scenario

A code review flags that your handler returns UserInDb, which carries password_hash, while the endpoint is documented as returning UserOut, which does not. Someone says "that leaks the hash." Someone else says "no, response_model filters it." Both have seen it go both ways, because the answer depends on a keyword argument two lines above the function.

Separately, an endpoint that has worked for months starts returning 500s after a schema change, and the log says ResponseValidationError — a class half your team has never seen. And an integration partner complains that fields they were told are optional keep arriving as null.

These are three faces of one mechanism.

Why it happens: what serialize_response really does

When you declare a route, FastAPI decides on a response field. The rule is short: if response_model= is passed, that is the response field. If it is not passed, FastAPI reads the return type annotation and uses that. If response_model=None is passed explicitly, there is no response field and validation is skipped entirely — which is precisely why that escape hatch exists, for handlers whose return annotation is something FastAPI cannot or should not turn into a schema.

At request time, whatever your handler returned is passed through serialize_response(), which validates the value against that field. The critical word is validates. It does not check whether the object is already an instance of the right class and shortcut. It runs Pydantic validation, producing a new object built only from the fields the response model declares.

That single fact explains almost everything else on this page. Extra attributes are dropped because they were never copied into the new object. A different model class works if its data happens to satisfy the target, and fails if it does not, regardless of how valid it was on its own terms. And the cost is real, because it is a full validation pass on every response — the topic of Pydantic Model Serialization Performance.

Only then do the exclude_* options apply, and only then does jsonable_encoder reduce the result to primitives that the response class can encode.

The demonstration

One application, ten endpoints, each isolating a single behaviour. The only unusual thing here is the ResponseValidationError handler, which exists purely so the transcript shows the real error detail instead of an opaque 500.

"""What response_model actually does: re-validation, filtering, and the exclude_* switches."""
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import ResponseValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel

app = FastAPI()


@app.exception_handler(ResponseValidationError)
async def response_validation_handler(request: Request, exc: ResponseValidationError):
    # Registered only so the transcript can show the real error instead of a bare 500.
    errors = jsonable_encoder(exc.errors())
    return JSONResponse(status_code=500, content={"response_validation_errors": errors})


class UserOut(BaseModel):
    id: int
    name: str
    nickname: str | None = None
    tier: str = "free"


class UserInDb(UserOut):
    password_hash: str


STORED = UserInDb(id=1, name="Ada", password_hash="pbkdf2$deadbeef")


@app.get("/annotated", response_model=None)
async def annotated_only() -> UserOut:
    """No response_model kwarg: the return annotation IS the response model."""
    return STORED


@app.get("/annotated-enforced")
async def annotated_enforced() -> UserOut:
    return STORED


@app.get("/kwarg-wins", response_model=UserOut)
async def kwarg_wins() -> UserInDb:
    """response_model= overrides the return annotation, so password_hash is stripped."""
    return STORED


@app.get("/from-dict", response_model=UserOut)
async def from_dict() -> dict:
    return {"id": 2, "name": "Grace", "password_hash": "leaked?", "tier": "pro"}


@app.get("/exclude-unset", response_model=UserOut, response_model_exclude_unset=True)
async def exclude_unset() -> UserOut:
    return UserOut(id=3, name="Katherine")


@app.get("/exclude-none", response_model=UserOut, response_model_exclude_none=True)
async def exclude_none() -> UserOut:
    return UserOut(id=4, name="Radia", nickname=None, tier="pro")


@app.get("/exclude-fields", response_model=UserOut, response_model_exclude={"tier"})
async def exclude_fields() -> UserOut:
    return UserOut(id=5, name="Barbara", nickname="Barb")


class Partial(BaseModel):
    """Perfectly valid on its own terms — but not a UserOut."""

    id: int


@app.get("/revalidated", response_model=UserOut)
async def revalidated():
    """A *valid* Pydantic object of the wrong shape still fails: it is re-validated."""
    return Partial(id=6)


@app.get("/bad-type", response_model=UserOut)
async def bad_type():
    return {"id": "not-an-int", "name": "Hedy"}


@app.get("/no-model", response_model=None)
async def no_model() -> UserInDb:
    """response_model=None switches validation off entirely — the hash leaks."""
    return STORED

Real output from running this app in-process (_verify/examples/arch-response-model.py):

$ GET /annotated
200 OK
{
  "id": 1,
  "name": "Ada",
  "nickname": null,
  "tier": "free",
  "password_hash": "pbkdf2$deadbeef"
}

$ GET /annotated-enforced
200 OK
{
  "id": 1,
  "name": "Ada",
  "nickname": null,
  "tier": "free"
}

$ GET /kwarg-wins
200 OK
{
  "id": 1,
  "name": "Ada",
  "nickname": null,
  "tier": "free"
}

$ GET /from-dict
200 OK
{
  "id": 2,
  "name": "Grace",
  "nickname": null,
  "tier": "pro"
}

$ GET /exclude-unset
200 OK
{
  "id": 3,
  "name": "Katherine"
}

$ GET /exclude-none
200 OK
{
  "id": 4,
  "name": "Radia",
  "tier": "pro"
}

$ GET /exclude-fields
200 OK
{
  "id": 5,
  "name": "Barbara",
  "nickname": "Barb"
}

$ GET /revalidated
500 Internal Server Error
{
  "response_validation_errors": [
    {
      "type": "missing",
      "loc": [
        "response",
        "name"
      ],
      "msg": "Field required",
      "input": {
        "id": 6
      }
    }
  ]
}

$ GET /bad-type
500 Internal Server Error
{
  "response_validation_errors": [
    {
      "type": "int_parsing",
      "loc": [
        "response",
        "id"
      ],
      "msg": "Input should be a valid integer, unable to parse string as an integer",
      "input": "not-an-int"
    }
  ]
}

$ GET /no-model
200 OK
{
  "id": 1,
  "name": "Ada",
  "nickname": null,
  "tier": "free",
  "password_hash": "pbkdf2$deadbeef"
}

What each result proves

The annotation is enforced unless you opt out. /annotated and /annotated-enforced have the same body, the same -> UserOut annotation, and the same returned object — the only difference is response_model=None on the first. The first leaked password_hash. The second did not. If you have ever added a return annotation for type-checker friendliness and been surprised that fields vanished from your API, this pair is why.

response_model= beats the annotation. /kwarg-wins is annotated -> UserInDb but declared response_model=UserOut, and the hash is gone. The keyword argument is authoritative; the annotation is only a fallback.

Returning a Pydantic model does not skip validation. This is the headline. /kwarg-wins returned a real UserInDb instance — a fully constructed, already-validated Pydantic object — and FastAPI still ran it through UserOut validation and produced a new object with four fields. It did not check isinstance and pass the object along. The proof that it is genuinely re-validating rather than merely filtering is /revalidated: a Partial(id=6) object is perfectly valid Partial, but validating it as UserOut fails with missing on name. Nothing about "it is already a model" grants immunity.

/from-dict shows the same filtering applied to a plain dict, and it is worth noting that the stray password_hash key was silently dropped rather than raising. Extra data is discarded, missing data is an error.

exclude_unset and exclude_none are different tools. /exclude-unset constructed UserOut(id=3, name="Katherine") and got back only id and namenickname and tier were never assigned, so both defaults disappeared even though tier has the non-null value "free". /exclude-none constructed UserOut(id=4, name="Radia", nickname=None, tier="pro"), explicitly setting every field, and got back id, name and tier: nickname was dropped for being None despite having been set deliberately.

Put the two side by side and the rule is clear. exclude_unset asks "did anyone assign this?" and exclude_none asks "is this null?" Use exclude_unset for PATCH-style responses where the distinction between "unspecified" and "explicitly null" carries meaning. Use exclude_none to keep payloads compact for clients that treat a missing key and a null the same way. Do not use exclude_unset on a response built from a database row, because ORM-derived models generally have every field assigned and the option will do nothing. /exclude-fields shows the blunter instrument, response_model_exclude={"tier"}, which is per-endpoint rather than per-value; managing that across many endpoints is covered in Excluding Fields Per Endpoint.

A bad response is a 500, not a 422. Both /revalidated and /bad-type returned 500. This is a deliberate distinction: a 422 tells the caller their input was wrong, and the shape of that body is discussed in Customising Validation Error Responses. A response that violates its own contract is nobody's fault but yours, so it raises ResponseValidationError, which has no default handler and reaches the outermost frame as an unhandled error. Note the loc in both transcripts begins with "response" rather than "body" — that prefix is the fastest way to identify which side of the request failed when you are reading a log at 3am.

Ordering is fixed. In every transcript above, null values appear only where the model kept them, and filtering was applied after validation. There is no configuration that lets filtering run first, and exclude_unset cannot rescue a response that fails validation — /revalidated would have failed identically with exclude_unset=True, because validation happens first.

Verification

Two assertions are worth having permanently in your test suite for any endpoint that touches sensitive fields.

def test_internal_fields_never_leak(client):
    body = client.get("/kwarg-wins").json()
    # Assert absence explicitly. A schema change that widens the response model
    # will fail here rather than in a security review six months later.
    assert "password_hash" not in body
    assert set(body) == {"id", "name", "nickname", "tier"}


def test_response_matches_its_own_schema(client):
    # A 500 with ResponseValidationError is invisible in a smoke test that only
    # checks status < 400 on the happy path; assert the exact code.
    assert client.get("/revalidated").status_code == 500

The second test is the one people skip. Response validation failures are conditional on data — an endpoint can pass every test and still 500 in production the first time a row has a null in a column your model declares non-optional. If you run structured logging, alert on ResponseValidationError by name; the correlation approach is in Structured JSON Logging with Request IDs.

Trade-offs and when not to

response_model is not free. Every response pays a full Pydantic validation pass plus a jsonable_encoder walk, and both scale with payload size and nesting depth. For an endpoint returning ten thousand rows, that is a measurable slice of the request. The options, in rough order of preference:

  • Keep it. For almost every endpoint, the schema documentation and the leak prevention are worth more than the CPU.
  • Return a Response directly. If you have already produced correct JSON — from a cache, or from a database that serialized it for you — returning JSONResponse(content=...) or a Response with pre-encoded bytes skips the entire pipeline. You lose validation, and your OpenAPI schema is then a promise rather than a guarantee.
  • Skip validation deliberately with model_construct when you can prove the data is already valid, as discussed in Model Construct: When to Skip Validation.

The honest trade-off is that every one of these optimisations moves you from "the framework guarantees the response matches the schema" to "I guarantee it." That is a fine trade for one hot endpoint and a bad default for an API.

One more limit worth stating plainly: response_model does nothing at all when your handler returns a Response instance. FastAPI passes it through untouched, and the declared model becomes documentation with no enforcement behind it. That is the entire basis of Streaming and File Responses, and it is a trap when a handler returns a model on one branch and a JSONResponse on another.

FAQ

Does returning a Pydantic model skip response validation? No. FastAPI validates whatever the handler returns against the response field, even when the returned object is already an instance of the declared model. Returning an instance of a different model that happens to be valid on its own terms still fails if it does not satisfy the response model, and a subclass has its extra fields stripped.

Which wins, response_model or the return type annotation? The response_model keyword argument wins. FastAPI uses the return annotation as the response model only when response_model is not supplied. Passing response_model=None disables response validation entirely and is the documented way to keep a return annotation that FastAPI should not enforce.

What is the difference between response_model_exclude_unset and response_model_exclude_none?exclude_unset drops fields that were never explicitly assigned, so defaults disappear even if their value is not None. exclude_none drops fields whose value is None regardless of whether they were set explicitly. A field explicitly set to None survives exclude_unset but is removed by exclude_none.

Why does a response validation failure return 500 rather than 422? A 422 means the client sent something invalid. A response that does not match its own declared model is a server bug, so FastAPI raises ResponseValidationError, which is unhandled by default and surfaces as a 500. The status code is telling you the fault is yours, not the caller's.

Does response_model cost measurable CPU on large list responses? Yes. Validation builds a fresh object graph and jsonable_encoder walks it again before encoding, so the cost scales with the size and nesting depth of the payload. It is a correctness and documentation feature you pay for on every response, and it is usually worth paying.