Khi client chỉ muốn đổi tên sản phẩm, endpoint sửa của bạn không được ghi đè giá và mô tả thành None. Cách đúng là dùng PATCH với một model mà mọi field đều Optional, rồi gọi model_dump(exclude_unset=True) để chỉ lấy đúng những field client đã gửi và cập nhật đúng những field đó. Bài này áp dụng cho mọi endpoint sửa một phần trong FastAPI với Pydantic v2.
Vấn đề
Giả sử bạn tái sử dụng model tạo mới cho endpoint sửa, và gán thẳng từng field như dưới đây (ở đây db là dict lưu sản phẩm theo id). Client gửi {"name": "Bàn phím Pro"} để đổi mỗi tên, nhưng vì price và description không có trong body nên Pydantic gán cho chúng giá trị mặc định None — và dòng gán sau đó ghi đè luôn giá cũ trong database bằng 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 nếu client không gửi product["price"] = data.price # Ghi đè giá cũ bằng None! product["description"] = data.description return product
Lỗi này rất khó phát hiện vì API vẫn trả về 200, không có exception nào — chỉ khi mở lại sản phẩm mới thấy giá đã biến mất.
Giải pháp
Pydantic ghi nhớ field nào client thực sự gửi trong request. model_dump(exclude_unset=True) chỉ trả về đúng những field đó, bỏ qua mọi field chỉ mang giá trị mặc định. Vòng lặp setattr sau đó chỉ chạm tới những field có trong dict này:
@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
Gửi {"name": "Bàn phím Pro"} thì update_data chỉ là {"name": "Bàn phím Pro"} — giá và mô tả giữ nguyên. Gửi {"price": 300000} thì chỉ giá đổi. Cần lưu ý một điểm tinh tế: field nào client gửi tường minh null, ví dụ {"description": null}, vẫn được tính là “đã gửi” nên vẫn vào update_data — nhờ đó client vẫn xóa được một field khi cần. Đây chính là khác biệt giữa “không gửi” và “gửi null”, và exclude_unset là cách duy nhất phân biệt hai trường hợp này.
Áp dụng vào dự án của bạn
Rà soát mọi endpoint sửa trong dự án: chỗ nào gán obj.field = data.field cho từng field thì đều có nguy cơ này. Thay bằng vòng lặp model_dump(exclude_unset=True) như trên, kết hợp với Field(gt=0) để validate ngay cả khi chỉ cập nhật một field — ví dụ gửi {"price": -5} sẽ bị trả về 422 trước khi chạm tới database. Quy ước thêm cho team: PUT dùng để thay toàn bộ resource, PATCH dùng để sửa một phần, và chỉ PATCH mới dùng model toàn-Optional này.
Bài viết liên quan
- FastAPI: nhận nhiều tham số body bằng Body() — khi nào cần embed=True trong 5 phút
- FastAPI: validate query string bằng Query() — chặn tham số sai ngay từ cổng API trong 5 phút
- FastAPI: validate dữ liệu đầu vào chuẩn chỉnh bằng Pydantic Field và validator tùy chỉnh
Câu hỏi thường gặp
PATCH và PUT khác nhau thế nào?
PUT thay thế toàn bộ resource: client phải gửi đủ mọi field, field nào thiếu coi như bị xóa hoặc về mặc định. PATCH chỉ sửa những field được gửi, các field còn lại giữ nguyên. Vì vậy endpoint sửa một phần luôn nên dùng PATCH.
Gửi null có xóa được field không?
Có. exclude_unset=True chỉ loại field client không gửi, còn field gửi tường minh null vẫn được giữ lại và ghi đè thành None. Nếu bạn không muốn cho phép xóa, hãy thêm validation chặn giá trị None cho field đó.
exclude_unset khác exclude_none thế nào?
exclude_none=True loại mọi field có giá trị None, kể cả field client gửi tường minh null — nên không dùng được để xóa field. exclude_unset=True chỉ loại field không được gửi, giữ lại cả field gửi null. Khi cập nhật một phần, luôn dùng exclude_unset.
Dùng được với SQLAlchemy không?
Được. Thay product[field] = value bằng setattr(product, field, value) rồi db.commit(), phần còn lại giữ nguyên. Cách này cũng dùng được với mọi ORM khác vì nó chỉ là vòng lặp trên dict.
💬 Bạn đã thử cách này trong dự án của mình chưa? Nếu gặp lỗi hoặc có cách làm gọn hơn, để lại bình luận bên dưới — mình đọc hết và sẽ bổ sung những ý hay vào bài.
Nguồn tham khảo
- FastAPI docs — Body – Updates: https://fastapi.tiangolo.com/tutorial/body-updates/
- Pydantic docs — Serialization (model_dump): https://docs.pydantic.dev/latest/concepts/serialization/
