Excluding Fields Per Endpoint in FastAPI

Key takeaways:

  • response_model_exclude drops named fields; response_model_include is its allow-list twin.
  • response_model_exclude_unset drops anything never explicitly set — defaults vanish, including None defaults.
  • response_model_exclude_none drops anything whose value is None, whether set or not.
  • Nested exclusion uses a dict: {"password_hash": True, "address": {"internal_geocode"}}.
  • Exclusion filters the output: an excluded computed_field is skipped, but an excluded ordinary field was already fetched and validated — measured below.

This page is part of nested model serialization, where the same options apply through arbitrarily deep structures.

The Problem This Solves

One User model is used by five endpoints. The admin listing should show everything, the public profile must not show the password hash, the "changes since last sync" endpoint should send only the fields that were actually set, and the mobile client wants nulls omitted to save bandwidth. Defining five near-identical response models is a maintenance cost nobody wants.

FastAPI's per-route serialization parameters solve this without new classes — and they come with a trap that costs people real incidents, which is a widespread confusion about when exclusion happens and therefore what it protects you from.

Why It Happens

When a path operation declares response_model, FastAPI does two things after your function returns. First it reconciles the returned object with that model. Then it serializes, passing the route's exclusion parameters straight through to model_dump.

Exclusion is therefore a parameter to the last step. Everything upstream of it has already happened: the row was selected, the column was read, the value was validated into a field, and it has been sitting in memory for the whole request. Dropping password_hash at serialization time does not mean the hash was never handled — it means the hash was handled and then not printed.

Inside that last step, Pydantic is smarter than people assume. The selection is resolved before the serializer walks the fields, so a computed_field that the selection excludes is never evaluated at all — its property function does not run. The measurement below shows exactly that, and it is the opposite of the folklore.

So the accurate mental model is two-tier. Exclusion can save you the cost of a derived value. It cannot save you the cost, or the exposure, of a stored one.

Where per-endpoint exclusion happens in the response pipelineThe row is loaded and the model built, the endpoint returns it, then serialization applies the exclusion parameters and only the filtered result is sent. Everything before the exclusion step has already happened.load and buildevery field heldendpoint returnsresponse_modelapply exclusionfilter the outputJSON bodysentstored values already read and heldexcluded computed fields skipped here
Exclusion is the last step, so it can skip a derived value but never unwinds the work that produced a stored one.

Prerequisites

  • FastAPI 0.139.2 and Pydantic 2.13.4, the versions all output below was produced on.
  • A response model with a nested submodel, an optional field, and a computed_field — so each parameter has something visible to act on.

The Model Under Test

COMPUTED and VALIDATED are module-level lists the model appends to, so the measurement later on can count how often each piece of work actually ran.

class Address(BaseModel):
    street: str
    city: str
    postcode: str | None = None
    internal_geocode: str = "51.5,-0.12"


class User(BaseModel):
    id: int
    email: str
    display_name: str | None = None
    password_hash: str
    address: Address

    @field_validator("password_hash")
    @classmethod
    def record_validation(cls, value: str) -> str:
        # Fires whenever the response model is validated, excluded or not.
        VALIDATED.append("validated password_hash")
        return value

    @computed_field
    @property
    def domain(self) -> str:
        # Fires only when the field survives the exclusion selection.
        COMPUTED.append(f"computed domain for user {self.id}")
        return self.email.split("@")[-1]

Two instances are served: FULL has every field set explicitly, and PARTIAL omits display_name and postcode so the "unset" and "none" variants have something to differ on.

The Baseline

$ GET /users/full
200 OK
{
  "id": 1,
  "email": "ada@example.com",
  "display_name": "Ada",
  "password_hash": "$2b$12$abcdefghijklmnopqrstuv",
  "address": {
    "street": "12 Bridge St",
    "city": "London",
    "postcode": "EC1A 1BB",
    "internal_geocode": "51.5,-0.12"
  },
  "domain": "example.com"
}

Everything, including the hash and the internal geocode. All output on this page comes from _verify/output/pyd-exclude-per-endpoint.txt.

Variant 1: response_model_exclude

@app.get("/users/exclude", response_model=User, response_model_exclude={"password_hash"})
async def read_excluded() -> User:
    return FULL
$ GET /users/exclude
200 OK
{
  "id": 1,
  "email": "ada@example.com",
  "display_name": "Ada",
  "address": {
    "street": "12 Bridge St",
    "city": "London",
    "postcode": "EC1A 1BB",
    "internal_geocode": "51.5,-0.12"
  },
  "domain": "example.com"
}

password_hash is gone; the nested internal_geocode is not, because a top-level set says nothing about nested models.

Variant 2: Nested Exclusion

To reach inside, pass a dict instead of a set. Values are either True (drop the whole thing) or a nested selection.

@app.get(
    "/users/exclude-nested",
    response_model=User,
    response_model_exclude={"password_hash": True, "address": {"internal_geocode"}},
)
async def read_excluded_nested() -> User:
    return FULL
$ GET /users/exclude-nested
200 OK
{
  "id": 1,
  "email": "ada@example.com",
  "display_name": "Ada",
  "address": {
    "street": "12 Bridge St",
    "city": "London",
    "postcode": "EC1A 1BB"
  },
  "domain": "example.com"
}

The nesting composes to any depth the model has. For lists of submodels the same syntax applies with "__all__" as the key meaning "every element".

Variant 3: exclude_unset

Applied to PARTIAL, which was constructed without display_name and without a nested postcode.

$ GET /users/exclude-unset
200 OK
{
  "id": 2,
  "email": "grace@example.com",
  "password_hash": "$2b$12$zyxwvutsrqponmlkjihgfe",
  "address": {
    "street": "3 Navy Yard",
    "city": "Arlington"
  },
  "domain": "example.com"
}

Three things vanished: display_name, nested postcode, and nested internal_geocode. The last one is the instructive case — internal_geocode has a non-None default of "51.5,-0.12" and was still dropped, because "unset" is about whether a value was supplied, not about what the value is. Note also that the flag recursed into Address without being asked to.

This is what makes exclude_unset right for PATCH-style responses and delta payloads, where the distinction between "not mentioned" and "explicitly set to the default" is the whole point. It is wrong for a general read endpoint, where clients reasonably expect a stable set of keys and instead get a shape that varies per row depending on how it happened to be constructed.

Variant 4: exclude_none

Same PARTIAL instance, different flag:

$ GET /users/exclude-none
200 OK
{
  "id": 2,
  "email": "grace@example.com",
  "password_hash": "$2b$12$zyxwvutsrqponmlkjihgfe",
  "address": {
    "street": "3 Navy Yard",
    "city": "Arlington",
    "internal_geocode": "51.5,-0.12"
  },
  "domain": "example.com"
}

display_name and postcode are dropped because they are None. internal_geocode survives here, because its value is a string — the exact case where the two flags diverge. Choose exclude_none when the client treats absence and null identically and you want smaller payloads; choose exclude_unset when null is a meaningful, distinct signal.

Variant 5: response_model_include

The allow-list. Safer for narrow projections because adding a field to the model does not silently add it to the response.

$ GET /users/include-only
200 OK
{
  "id": 1,
  "email": "ada@example.com"
}

Note domain is absent even though it is a computed_field — include applies to computed fields as well.

Measuring What Exclusion Actually Skips

domain's property appends to a counter each time it is evaluated. The /audit endpoint clears the counter, runs model_dump with four different selections against the same instance, and reports what happened:

$ GET /audit
200 OK
{
  "no exclusion": {
    "keys": [
      "address",
      "display_name",
      "domain",
      "email",
      "id",
      "password_hash"
    ],
    "domain_property_evaluated": 1,
    "password_hash_validator_ran": 0
  },
  "exclude password_hash": {
    "keys": [
      "address",
      "display_name",
      "domain",
      "email",
      "id"
    ],
    "domain_property_evaluated": 1,
    "password_hash_validator_ran": 0
  },
  "exclude domain": {
    "keys": [
      "address",
      "display_name",
      "email",
      "id",
      "password_hash"
    ],
    "domain_property_evaluated": 0,
    "password_hash_validator_ran": 0
  },
  "include id+email only": {
    "keys": [
      "email",
      "id"
    ],
    "domain_property_evaluated": 0,
    "password_hash_validator_ran": 0
  }
}

Read the domain_property_evaluated column down the page. When domain survives the selection it is evaluated once. When it is excluded — directly, or by being left out of an include set — it is evaluated zero times. Pydantic resolves the selection before walking the fields, so a computed_field you do not ask for genuinely costs nothing. If you have been avoiding computed_field for expensive derivations because you assumed it always runs, that assumption is wrong on Pydantic 2.13.4, and per-route include/exclude is a legitimate way to make an expensive projection optional.

Now read password_hash_validator_ran, which stays at zero even in the "no exclusion" row. That is the other half of the picture: serialization does not re-validate. The field validator on password_hash fired exactly once, when the instance was constructed, long before any of these dumps. By the time exclusion is applied the hash has been selected from the database, coerced, validated and held in memory for the duration of the request. Excluding it changes what is printed and nothing else.

Hence the security rule. Any exception raised between construction and serialization can surface that value in a traceback; any route where someone forgets the parameter ships it; and the OpenAPI schema advertises it on every one of these endpoints regardless. If a field must never leave the process, the model the endpoint returns must not have that field. Exclusion is a projection tool, not a boundary. For the cost side of the same question, see Pydantic model serialization performance.

Edge Cases and Gotchas

  • The schema does not change. All five endpoints document the full User, including password_hash. Generated clients will declare fields the server never sends. If the contract matters, use separate response models.
  • response_model_exclude is per route, not per router. There is no inherited default; every path operation repeats the parameter, and every omission is a leak.
  • exclude_unset needs a real model instance. Returning a plain dict from the endpoint means FastAPI validates it into the response model, and every key present in that dict counts as set. The "unset" information comes from the object you return, so construct the model yourself.
  • Sets versus dicts. {"a", "b"} is a set of two fields. {"a": True} is a dict. {"a"} inside a dict value is a nested set. Mixing them up produces confusing, silent results.
  • Interaction with model_serializer. A model whose output is produced by a custom model serializer builds its own dict, so field-name-based exclusion no longer lines up with the emitted keys.

Verification

Assert the exact key set, not just the absence of the field you were thinking about:

def test_public_profile_omits_hash():
    body = client.get("/users/exclude").json()
    assert set(body) == {"id", "email", "display_name", "address", "domain"}


def test_exclude_unset_recurses():
    body = client.get("/users/exclude-unset").json()
    assert "postcode" not in body["address"]

The equality assertion is the important one: it fails when someone adds a field to User, which is exactly the moment you want to be told that a route's projection has changed.

Trade-offs and When Not To

Per-route parameters are the right tool when the shape of one model is genuinely the same across endpoints and only the projection differs — a mobile variant, a compact list view, a delta response. They keep one source of truth and avoid a class explosion.

They are the wrong tool when the difference is a contract difference. Public and admin views of a user are two contracts, and expressing that as an argument on a decorator means the difference is invisible in the type system, absent from the schema, and one deleted parameter away from a disclosure incident. Two models with a shared base cost a few lines and make the boundary structural.

The practical rule: if the excluded field is merely uninteresting, or is a derived value you want to make optional, use the parameter — the measurement above shows the derivation is genuinely skipped. If the field is sensitive, use a different model, because no parameter can un-load a value the query already returned.

FAQ

What is the difference between response_model_exclude and exclude_unset?response_model_exclude names specific fields to drop, whatever their value. exclude_unset drops every field the object was never explicitly given, so defaults disappear regardless of what they are. They are independent and can be combined on the same route.

Does response_model_exclude remove a field from the OpenAPI schema? No. The generated schema still describes the full response model, so a generated client will expect a field the endpoint never sends. If the field must not appear in the contract, define a separate response model instead.

Are excluded fields still computed before they are removed? Yes. Validation against the response model runs first and exclusion is applied to the resulting output, so a computed_field runs and any expensive property is evaluated even when the field is then dropped. Exclusion saves bytes on the wire, not work.

How do I exclude a field inside a nested model? Pass a dict instead of a set, mapping the parent field name to the nested selection, for example {"password_hash": True, "address": {"internal_geocode"}}. The nesting can go as deep as the model does.

Is response_model_exclude safe for hiding secrets? It is not a security boundary. The value is loaded, validated and computed before removal, it remains in the OpenAPI schema, and one route missing the parameter leaks it. A dedicated response model that has no such field is the only structural guarantee.