From 158d52945a256d66369a98a0d667121905290bf5 Mon Sep 17 00:00:00 2001 From: Sai Tarrun Pitta Date: Fri, 27 Mar 2026 23:40:53 -0700 Subject: [PATCH] Fix form model losing model_fields_set for unsubmitted fields When a Pydantic BaseModel is used as a Form parameter, fields absent from the form submission were being pre-filled with their default values before the model was constructed. This caused Pydantic to treat those defaults as explicitly-set values, so model_fields_set incorrectly included fields the client never sent. The fix skips absent fields in _extract_form_body entirely, letting Pydantic apply defaults through its own mechanism (which correctly leaves them out of model_fields_set), matching the behaviour of JSON body endpoints. Fixes #13399 --- fastapi/dependencies/utils.py | 11 +++- tests/test_form_model_fields_set.py | 81 +++++++++++++++++++++++++++++ tests/test_forms_single_model.py | 4 +- 3 files changed, 92 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 6b14dac8dc..15191986c2 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -916,8 +916,15 @@ async def _extract_form_body( values = {} for field in body_fields: - value = _get_multidict_value(field, received_body) + alias = get_validation_alias(field) field_info = field.field_info + # Skip fields not present in the form data so that Pydantic uses its + # own default mechanism. This preserves correct model_fields_set + # semantics: fields the client didn't submit should not appear as + # "set" on the resulting model (fixes #13399). + if alias not in received_body: + continue + value = _get_multidict_value(field, received_body) if ( isinstance(field_info, params.File) and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation) @@ -936,7 +943,7 @@ async def _extract_form_body( results.append(await sub_value.read()) value = serialize_sequence_value(field=field, value=results) if value is not None: - values[get_validation_alias(field)] = value + values[alias] = value field_aliases = {get_validation_alias(field) for field in body_fields} for key in received_body.keys(): if key not in field_aliases: diff --git a/tests/test_form_model_fields_set.py b/tests/test_form_model_fields_set.py new file mode 100644 index 0000000000..ca1e052cba --- /dev/null +++ b/tests/test_form_model_fields_set.py @@ -0,0 +1,81 @@ +""" +Tests for issue #13399: Form models should preserve correct model_fields_set. + +Fields not submitted in the form data must NOT appear in model_fields_set, +matching the behaviour of JSON body endpoints. +""" + +from typing import Annotated + +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +class ExampleModel(BaseModel): + field_1: bool = True + field_2: str = "default" + field_3: int | None = None + + +@app.post("/body") +async def body_endpoint(model: ExampleModel): + return {"fields_set": sorted(model.model_fields_set)} + + +@app.post("/form") +async def form_endpoint(model: Annotated[ExampleModel, Form()]): + return {"fields_set": sorted(model.model_fields_set)} + + +client = TestClient(app) + + +def test_json_empty_body_no_fields_set(): + """Baseline: JSON body with no fields → fields_set should be empty.""" + resp = client.post("/body", json={}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == [] + + +def test_form_empty_submission_no_fields_set(): + """Form with no fields submitted → fields_set should be empty (like JSON).""" + resp = client.post("/form", data={}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == [] + + +def test_form_partial_submission_only_submitted_fields_set(): + """Only fields actually submitted appear in fields_set.""" + resp = client.post("/form", data={"field_1": "false"}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == ["field_1"] + + +def test_form_all_fields_submitted(): + """All submitted fields appear in fields_set.""" + resp = client.post( + "/form", data={"field_1": "false", "field_2": "hello", "field_3": "42"} + ) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == ["field_1", "field_2", "field_3"] + + +def test_form_defaults_still_applied(): + """Default values are still applied for fields not submitted.""" + resp = client.post("/form", data={}) + assert resp.status_code == 200, resp.text + # The endpoint just returns fields_set; check defaults via a richer endpoint + # by confirming 200 and correct fields_set — defaults are tested in + # test_forms_single_model.py. + + +def test_form_fields_set_matches_json_behavior(): + """Form and JSON endpoints must agree on fields_set for equivalent inputs.""" + json_resp = client.post("/body", json={"field_2": "hi"}) + form_resp = client.post("/form", data={"field_2": "hi"}) + assert json_resp.status_code == 200 + assert form_resp.status_code == 200 + assert json_resp.json()["fields_set"] == form_resp.json()["fields_set"] diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py index 4575e3335e..d8451e6f0d 100644 --- a/tests/test_forms_single_model.py +++ b/tests/test_forms_single_model.py @@ -99,13 +99,13 @@ def test_no_data(): "type": "missing", "loc": ["body", "username"], "msg": "Field required", - "input": {"tags": ["foo", "bar"], "with": "nothing"}, + "input": {}, }, { "type": "missing", "loc": ["body", "lastname"], "msg": "Field required", - "input": {"tags": ["foo", "bar"], "with": "nothing"}, + "input": {}, }, ] }