Handling Unset Values in Fastapi With Pydantic
In the update API endpoints that allow for partial updates (aka PATCH updates), we need to know if a model field has been explicitly set by the caller.
Usually, we use None to indicate an unset value. But if None is also a valid value, how can we distinguish between None being explicitly set and None meaning “unset”?
For example, let’s say we have an optional field in the user profile, like a phone number. When using the user profile API endpoint, it’s not clear what to do if the phone number value is None. Should we reset it or keep it as it is?
class UserUpdateParams(BaseModel):
first_name: str | None = None
last_name: str | None = None
phone_number: str | None = None
@app.patch("/api/v1/user)
def update_user(update_params: UserUpdateParams):
if update_params.phone_number is None:
# Should I reset the phone number or keep it as is?
...
Another common use case is handling filtering in the API endpoint that returns a list of resources. There, you encounter the same problem when using a value object to group filter parameters together.
class GetTasksFilter(BaseModel):
text: str | None = None
assigned_by: int | None = None
due_date: datetime.datetime | None = None
@app.get_tasks("/api/v1/tasks)
def get_tasks(get_tasks_filter: GetTasksFilter):
if get_tasks_filter.due_date is None:
# Should I select tasks that don't have a due date set,
# or should I not filter by due date at all?
...
Turns out, Pydantic has a simple solution for that. Internally, it keeps a list of fields that were explicitly initialized, versus those that were initialized with a default or default_constructor args.
These values are stored in the __fields_set__
attribute of the model for Pydantic v1, and the model_fields_set
attribute for Pydantic v2. Both attributes are documented on the Pydantic website, so it’s safe to use them (v1, v2).
Also, there’s an option called exclude_unset
in the BaseModel.dict() method (for v1) or BaseModel.model_dump() method (for v2) that uses this parameter internally and returns the dict with only the fields that were explicitly set.
With this in mind, the first example above could be rewritten like this for Pydantic 2.x:
@app.patch("/api/v1/user")
def update_user(update_params: UserUpdateParams):
if "phone_number" in update_params.model_fields_set:
update_phone_number(update_params.phone_number)
That’s it!