From f818eac7d79ca6843d2cb7885eed1b52d5d41c34 Mon Sep 17 00:00:00 2001 From: Sai Tarrun Pitta Date: Sat, 28 Mar 2026 11:09:06 -0700 Subject: [PATCH] Fix form model pre-filling defaults, breaking model_fields_set When a Pydantic BaseModel is used as a Form() parameter, fields that were absent from the submission or submitted as blank strings ("") were being pre-filled with their default values before model construction. Pydantic then treated those pre-filled defaults as explicitly-set values, making model_fields_set incorrect and preventing model validators from distinguishing unset fields from fields set to their default. Two cases needed fixing: 1. Absent fields - the field key is not in the form data at all (e.g. an unchecked HTML checkbox, which browsers omit entirely). 2. Blank string fields - the field key is present but with value "" (e.g. a cleared text input, which browsers send as key=). Fix: add form_input=True parameter to _get_multidict_value. When set, both absent and blank-string values return None rather than the field default. _extract_form_body now calls _get_multidict_value(form_input=True) so neither case gets pre-populated into the values dict. Pydantic then applies defaults through its own mechanism, which correctly leaves those fields out of model_fields_set. Required fields are unaffected: Pydantic still raises a validation error when a required field is absent from the submitted data. Closes #13399 Co-authored-by: sneakers-the-rat --- fastapi/dependencies/utils.py | 13 +- tests/test_forms_defaults.py | 222 +++++++++++++++++++++++++++++++ tests/test_forms_single_model.py | 4 +- 3 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 tests/test_forms_defaults.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 6b14dac8dc..8568cb7ba2 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -751,7 +751,10 @@ def _is_json_field(field: ModelField) -> bool: def _get_multidict_value( - field: ModelField, values: Mapping[str, Any], alias: str | None = None + field: ModelField, + values: Mapping[str, Any], + alias: str | None = None, + form_input: bool = False, ) -> Any: alias = alias or get_validation_alias(field) if ( @@ -765,7 +768,7 @@ def _get_multidict_value( if ( value is None or ( - isinstance(field.field_info, params.Form) + (isinstance(field.field_info, params.Form) or form_input) and isinstance(value, str) # For type checks and value == "" ) @@ -774,8 +777,8 @@ def _get_multidict_value( and len(value) == 0 ) ): - if field.field_info.is_required(): - return + if form_input or field.field_info.is_required(): + return None else: return deepcopy(field.default) return value @@ -916,7 +919,7 @@ async def _extract_form_body( values = {} for field in body_fields: - value = _get_multidict_value(field, received_body) + value = _get_multidict_value(field, received_body, form_input=True) field_info = field.field_info if ( isinstance(field_info, params.File) diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py new file mode 100644 index 0000000000..658ed2e700 --- /dev/null +++ b/tests/test_forms_defaults.py @@ -0,0 +1,222 @@ +""" +Tests for correct model_fields_set and default handling in Form endpoints. + +Covers: +- Absent fields (never sent by the client) +- Blank strings (HTML forms send "" for empty text inputs) +- Required fields still return 422 when absent +- Behaviour matches JSON body endpoints +- Aliases work correctly + +Related: https://github.com/fastapi/fastapi/issues/13399 +""" + +from typing import Annotated + +import pytest +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field + +app = FastAPI() + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class BoolModel(BaseModel): + """Checkbox-style: bool with non-falsy default.""" + + checkbox: bool = True + + +class StringModel(BaseModel): + """Text input with a non-empty default.""" + + text: str = "default" + alias_field: str = Field(alias="with", default="nothing") + + +class MixedModel(BaseModel): + field_bool: bool = True + field_str: str = "hello" + field_nullable: str | None = None + + +class RequiredModel(BaseModel): + required_field: str + optional_field: str = "default" + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@app.post("/bool") +async def bool_endpoint(model: Annotated[BoolModel, Form()]): + return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} + + +@app.post("/string") +async def string_endpoint(model: Annotated[StringModel, Form()]): + return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump(by_alias=True)} + + +@app.post("/mixed") +async def mixed_endpoint(model: Annotated[MixedModel, Form()]): + return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} + + +@app.post("/required") +async def required_endpoint(model: Annotated[RequiredModel, Form()]): + return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} + + +# JSON body counterpart for parity checks +@app.post("/mixed-json") +async def mixed_json_endpoint(model: MixedModel): + return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()} + + +client = TestClient(app) + + +# --------------------------------------------------------------------------- +# Absent fields — never sent by the client +# --------------------------------------------------------------------------- + + +def test_absent_bool_field_not_in_fields_set(): + """Unchecked checkbox (absent from form) must NOT appear in model_fields_set.""" + resp = client.post("/bool", data={}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == [] + + +def test_absent_bool_field_default_still_applied(): + """Default value is still present in the response even when field not submitted.""" + resp = client.post("/bool", data={}) + assert resp.status_code == 200, resp.text + assert resp.json()["values"]["checkbox"] is True + + +def test_submitted_bool_field_in_fields_set(): + """Submitted checkbox appears in model_fields_set.""" + resp = client.post("/bool", data={"checkbox": "false"}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == ["checkbox"] + assert resp.json()["values"]["checkbox"] is False + + +# --------------------------------------------------------------------------- +# Blank string fields — HTML forms send "" for empty text inputs +# --------------------------------------------------------------------------- + + +def test_blank_string_treated_as_unset(): + """Empty text input ('') must NOT appear in model_fields_set; default applied.""" + resp = client.post("/string", data={"text": "", "with": ""}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["fields_set"] == [] + assert body["values"]["text"] == "default" + assert body["values"]["with"] == "nothing" + + +def test_non_empty_string_in_fields_set(): + """Non-empty submission must appear in model_fields_set.""" + resp = client.post("/string", data={"text": "hello"}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "text" in body["fields_set"] + assert body["values"]["text"] == "hello" + + +# --------------------------------------------------------------------------- +# Mixed model — no data submitted at all +# --------------------------------------------------------------------------- + + +def test_no_data_fields_set_empty(): + """Submitting no form data produces an empty model_fields_set.""" + resp = client.post("/mixed", data={}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == [] + + +def test_partial_data_only_submitted_fields_in_set(): + """Only submitted fields appear in model_fields_set.""" + resp = client.post("/mixed", data={"field_bool": "false"}) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == ["field_bool"] + + +def test_all_data_all_fields_in_set(): + """All submitted fields appear in model_fields_set.""" + resp = client.post( + "/mixed", + data={"field_bool": "true", "field_str": "world", "field_nullable": "x"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["fields_set"] == ["field_bool", "field_nullable", "field_str"] + + +# --------------------------------------------------------------------------- +# Required fields — must still return 422 when absent +# --------------------------------------------------------------------------- + + +def test_required_field_absent_returns_422(): + """Required field absent from form must still return a 422 validation error.""" + resp = client.post("/required", data={}) + assert resp.status_code == 422, resp.text + detail = resp.json()["detail"] + locs = [e["loc"] for e in detail] + assert ["body", "required_field"] in locs + + +def test_required_field_present_optional_absent(): + """Required field present, optional absent → only required field in fields_set.""" + resp = client.post("/required", data={"required_field": "value"}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["fields_set"] == ["required_field"] + assert body["values"]["optional_field"] == "default" + + +# --------------------------------------------------------------------------- +# Parity with JSON body +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "submitted, expected_set", + [ + ({}, []), + ({"field_bool": "true"}, ["field_bool"]), + ({"field_str": "world"}, ["field_str"]), + ( + {"field_bool": "false", "field_str": "hi", "field_nullable": "x"}, + ["field_bool", "field_nullable", "field_str"], + ), + ], +) +def test_form_fields_set_matches_json(submitted, expected_set): + """Form and JSON endpoints produce the same model_fields_set for equivalent input.""" + form_resp = client.post("/mixed", data=submitted) + json_payload = {} + if "field_bool" in submitted: + json_payload["field_bool"] = submitted["field_bool"].lower() == "true" + if "field_str" in submitted: + json_payload["field_str"] = submitted["field_str"] + if "field_nullable" in submitted: + json_payload["field_nullable"] = submitted["field_nullable"] + + json_resp = client.post("/mixed-json", json=json_payload) + + assert form_resp.status_code == 200, form_resp.text + assert json_resp.status_code == 200, json_resp.text + assert form_resp.json()["fields_set"] == json_resp.json()["fields_set"] == expected_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": {}, }, ] }