Essay

Nested models and partial updates

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.