Roman Imankulov

Roman Imankulov

Full-stack Python web developer from Porto

search results (esc to close)
26 Dec 2023

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!

Roman Imankulov

Hey, I am Roman, and you can hire me.

I am a full-stack Python web developer who loves helping startups and small teams turn their ideas into products.

More about me and my skills