Excluding Fields Per Endpoint in FastAPI
Key takeaways:
response_model_excludedrops named fields;response_model_includeis its allow-list twin.response_model_exclude_unsetdrops anything never explicitly set — defaults vanish, includingNonedefaults.response_model_exclude_nonedrops anything whose value isNone, whether set or not.- Nested exclusion uses a dict:
{"password_hash": True, "address": {"internal_geocode"}}. - Exclusion filters the output: an excluded
computed_fieldis 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.
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, includingpassword_hash. Generated clients will declare fields the server never sends. If the contract matters, use separate response models. response_model_excludeis per route, not per router. There is no inherited default; every path operation repeats the parameter, and every omission is a leak.exclude_unsetneeds 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.
Related Reading
- Up to the topic: nested model serialization.
- For structures where exclusion has to recurse indefinitely, see self-referencing and recursive models and handling deeply nested JSON models efficiently.
- The cost of computing fields you then drop is quantified in Pydantic model serialization performance.
- To change the shape of what is emitted rather than which keys survive, see replacing json_encoders with field_serializer.