You validated the object on the way in. Then a PATCH arrives, you poke a few fields, and
ship the result. Six months later someone nests an address under a user, a client sends
"zip": 123 instead of a string, and nothing complains — because you never ran
validation again.
Nested models make that trap easy. Assignment looks local. The damage is structural.
What went wrong
Construction through model_validate (or FastAPI’s request parsing) runs the
full contract: types, nested models, constraints. Ordinary attribute assignment does not.
Once the instance exists, Python will let you set almost anything that fits the attribute
slot — including nested objects that never went through their own validators.
from pydantic import BaseModel, EmailStr
class Address(BaseModel):
city: str
zip: str
class User(BaseModel):
name: str
email: EmailStr
address: Address
user = User.model_validate({
"name": "Ada",
"email": "ada@example.com",
"address": {"city": "Porto", "zip": "4000"},
})
# Looks harmless. Skips Address validation.
user.address.zip = 4000
The original ingest was fine. The partial update quietly weakened the guarantee you thought you still had.
Prefer rebuild over poke
Treat patches as data that must cross the boundary again. Merge the change into a dict (or build a new payload), then validate:
patch = {"address": {"city": "Lisbon", "zip": "1000"}}
updated = user.model_copy(update=patch)
# Nested dicts in update= are assigned as-is in many cases —
# still re-validate when the patch is untrusted:
updated = User.model_validate({
**user.model_dump(),
**patch,
"address": {**user.address.model_dump(), **patch["address"]},
})
The second form is explicit: you decide how nested keys merge, then one
model_validate restores the contract for the whole tree. For APIs, a dedicated
update model with optional fields is often clearer than mutating the stored entity in
place.
Partial models are a design choice
“PATCH means every field is optional” is a different schema from “this is a complete
User.” Encode that difference. A UserUpdate with optional nested pieces, or
separate commands for “replace address” vs “rename user,” beats a shared mutable object
that every handler edits.
Deep updates need an explicit merge policy: replace the nested object entirely, or merge keys field by field. Pydantic will not invent that policy for you. If you leave it implicit, you get the mutate-in-place bug for free.
Rule of thumb
Validate at every trust boundary — including the one you create when you apply a patch. Nested models are not a reason to skip that step; they are the reason it matters. Build a new validated instance. Don’t hope yesterday’s validation still covers today’s edit.