From 32ceeea42f00f48d3b2e7847097a57d84c722a3f Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 7 Feb 2026 18:05:02 -0800 Subject: [PATCH] Fix Form dependency models losing model_fields_set metadata Fixes #13399 **Problem:** When using Form-based dependency models, default values were being pre-filled during form parsing, causing `model_fields_set` to include all fields with defaults even when they weren't explicitly provided in the request. This made it impossible to distinguish between explicitly set fields and fields using their default values. JSON body models didn't have this issue. **Root Cause:** In `_get_multidict_value()`, when a form field value was `None` (not provided) and the field wasn't required, the function returned `deepcopy(field.default)`. This pre-filled default was then added to `params_to_process` and passed to Pydantic, which treated it as explicitly set. **Solution:** Changed `_get_multidict_value()` to return `None` instead of the default value when a field is not provided. This allows Pydantic to handle defaults during validation, correctly preserving `model_fields_set` to only include fields that were actually provided in the form data. **Testing:** - Added comprehensive test suite comparing JSON and Form behavior - Verified that Form models now match JSON model behavior for `model_fields_set` - All existing tests continue to pass This makes Form and JSON body parameter handling consistent, allowing developers to use `model_fields_set`, `model_dump(exclude_unset=True)`, and `model_dump(exclude_defaults=True)` correctly with Form-based models. --- fastapi/dependencies/utils.py | 7 +- tests/test_form_model_fields_set.py | 120 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/test_form_model_fields_set.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 80f9c76e9..ed5959f0c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -745,10 +745,9 @@ def _get_multidict_value( and len(value) == 0 ) ): - if field.field_info.is_required(): - return - else: - return deepcopy(field.default) + # Don't pre-fill defaults here - let Pydantic handle them during validation + # This preserves model_fields_set correctly for Form fields + return None return value diff --git a/tests/test_form_model_fields_set.py b/tests/test_form_model_fields_set.py new file mode 100644 index 000000000..9bae51e02 --- /dev/null +++ b/tests/test_form_model_fields_set.py @@ -0,0 +1,120 @@ +""" +Test that Form-based dependency models preserve model_fields_set correctly. + +This test addresses issue https://github.com/fastapi/fastapi/issues/13399 +where Form models were pre-filling default values, making it impossible +to distinguish between explicitly set fields and fields using defaults. +""" + +from typing import Annotated + +import pytest +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +class ModelWithDefaults(BaseModel): + field_bool: bool = True + field_str: str = "default_value" + field_int: int = 42 + + +app = FastAPI() + + +@app.post("/json") +async def json_endpoint(model: ModelWithDefaults): + return { + "fields_set": list(model.model_fields_set), + "field_bool": model.field_bool, + "field_str": model.field_str, + "field_int": model.field_int, + } + + +@app.post("/form") +async def form_endpoint(model: Annotated[ModelWithDefaults, Form()]): + return { + "fields_set": list(model.model_fields_set), + "field_bool": model.field_bool, + "field_str": model.field_str, + "field_int": model.field_int, + } + + +client = TestClient(app) + + +def test_json_empty_preserves_fields_set(): + """JSON with empty dict should have empty fields_set""" + response = client.post("/json", json={}) + assert response.status_code == 200 + data = response.json() + assert data["fields_set"] == [] + assert data["field_bool"] is True + assert data["field_str"] == "default_value" + assert data["field_int"] == 42 + + +def test_json_partial_preserves_fields_set(): + """JSON with partial data should only set provided fields""" + response = client.post("/json", json={"field_str": "custom"}) + assert response.status_code == 200 + data = response.json() + assert data["fields_set"] == ["field_str"] + assert data["field_bool"] is True + assert data["field_str"] == "custom" + assert data["field_int"] == 42 + + +def test_json_all_fields_preserves_fields_set(): + """JSON with all fields should mark all as set""" + response = client.post( + "/json", + json={"field_bool": False, "field_str": "custom", "field_int": 100}, + ) + assert response.status_code == 200 + data = response.json() + assert set(data["fields_set"]) == {"field_bool", "field_str", "field_int"} + assert data["field_bool"] is False + assert data["field_str"] == "custom" + assert data["field_int"] == 100 + + +def test_form_empty_preserves_fields_set(): + """Form with empty data should have empty fields_set (matching JSON behavior)""" + response = client.post("/form", data={}) + assert response.status_code == 200 + data = response.json() + # This is the key fix: Form should behave like JSON + assert data["fields_set"] == [] + assert data["field_bool"] is True + assert data["field_str"] == "default_value" + assert data["field_int"] == 42 + + +def test_form_partial_preserves_fields_set(): + """Form with partial data should only set provided fields (matching JSON behavior)""" + response = client.post("/form", data={"field_str": "custom"}) + assert response.status_code == 200 + data = response.json() + # This is the key fix: only field_str should be in fields_set + assert data["fields_set"] == ["field_str"] + assert data["field_bool"] is True + assert data["field_str"] == "custom" + assert data["field_int"] == 42 + + +def test_form_all_fields_preserves_fields_set(): + """Form with all fields should mark all as set""" + response = client.post( + "/form", + data={"field_bool": "false", "field_str": "custom", "field_int": "100"}, + ) + assert response.status_code == 200 + data = response.json() + assert set(data["fields_set"]) == {"field_bool", "field_str", "field_int"} + assert data["field_bool"] is False + assert data["field_str"] == "custom" + assert data["field_int"] == 100