When a client only wants to rename a product, your update endpoint must not overwrite the price and description with None. The right approach is to use PATCH with a model where every field is Optional, then call model_dump(exclude_unset=True) to take only the fields the client actually sent and update exactly those. This applies to every partial-update endpoint in FastAPI with Pydantic v2.
The Problem
Suppose you reuse the creation model for the update endpoint and assign each field directly (here db is a dict storing products by id). The client sends {"name": "Pro Keyboard"} to change just the name, but because price and description are absent from the body, Pydantic assigns them their default value None — and the assignment below overwrites the stored price with None.
class ProductUpdate(BaseModel): name: str | None = None price: float | None = None description: str | None = None@app.patch("/products/{product_id}")def update_product_wrong(product_id: int, data: ProductUpdate): product = db[product_id] product["name"] = data.name # None if the client doesn't send it product["price"] = data.price # Overwrites the old price with None! product["description"] = data.description return product
This bug is hard to spot because the API still returns 200 with no exception — you only notice the price is gone when you open the product again.
The Solution
Pydantic remembers which fields the client actually sent in the request. model_dump(exclude_unset=True) returns exactly those fields, skipping every field that only carries its default value. The loop below then touches only the fields in that dict:
@app.patch("/products/{product_id}")def update_product(product_id: int, data: ProductUpdate): product = db.get(product_id) if product is None: raise HTTPException(status_code=404, detail="Không tìm thấy sản phẩm") update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): product[field] = value return product
Sending {"name": "Pro Keyboard"} makes update_data just {"name": "Pro Keyboard"} — price and description stay untouched. Sending {"price": 300000} changes only the price. One subtle point: a field the client explicitly sends as null, for example {"description": null}, still counts as “sent”, so it stays in update_data — which means the client can still clear a field when needed. That is exactly the difference between “not sent” and “sent as null”, and exclude_unset is the only way to tell the two apart.
Apply It to Your Project
Review every update endpoint in your project: anywhere you assign obj.field = data.field one field at a time carries this risk. Replace it with the model_dump(exclude_unset=True) loop above, and combine it with Field(gt=0) so validation still runs even when only one field is updated — for example, sending {"price": -5} returns 422 before touching the database. One more team convention: use PUT to replace a whole resource and PATCH for partial updates, and only PATCH uses this all-Optional model.
Related Posts
- FastAPI: Accept Multiple Body Parameters with Body() — When You Need embed=True in 5 Minutes
- FastAPI: Validate Query Strings with Query() — Block Bad Parameters at the API Gateway in 5 Minutes
- FastAPI: validate dữ liệu đầu vào chuẩn chỉnh bằng Pydantic Field và validator tùy chỉnh
Frequently Asked Questions
How do PATCH and PUT differ?
PUT replaces the whole resource: the client must send every field, and any missing field is treated as deleted or reset to its default. PATCH only changes the fields that are sent; the rest stay as they are. So a partial-update endpoint should always use PATCH.
Can sending null clear a field?
Yes. exclude_unset=True only drops fields the client did not send; a field explicitly sent as null is kept and overwritten with None. If you don’t want to allow clearing, add validation that rejects None for that field.
How is exclude_unset different from exclude_none?
exclude_none=True drops every field whose value is None, including fields the client explicitly sent as null — so it can’t be used to clear a field. exclude_unset=True only drops fields that weren’t sent, keeping even fields sent as null. For partial updates, always use exclude_unset.
Does it work with SQLAlchemy?
Yes. Replace product[field] = value with setattr(product, field, value) followed by db.commit(); the rest stays the same. This works with any ORM because it’s just a loop over a dict.
💬 Have you tried this in your project? If you hit an error or have a neater way, leave a comment below — I read them all and will add the best ideas to the post.
References
- FastAPI docs — Body – Updates: https://fastapi.tiangolo.com/tutorial/body-updates/
- Pydantic docs — Serialization (model_dump): https://docs.pydantic.dev/latest/concepts/serialization/
