From f214cd34627aa7cff092cb1b7b8f40589e97252a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 19 Oct 2026 19:10:12 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Treat=20empty=20scalar=20values?= =?UTF-8?q?=20as=20missing=20in=20form=20models=20(#16529)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 + fastapi/dependencies/utils.py | 114 +++--- .../test_form/test_nullable_and_defaults.py | 370 +++++++++++++++--- 3 files changed, 373 insertions(+), 115 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 25d7c4061e..0167f789c6 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +Browsers submit empty text inputs as empty strings (`""`). FastAPI now treats empty scalar form values as missing in form models, matching individual `Form` parameters. Required fields return a validation error. Fields with defaults or default factories use them and remain unset in `model_dump(exclude_unset=True)`. + +Empty strings inside lists are preserved. Omitted boolean form fields still become `False`. JSON requests and direct Pydantic validation are unchanged. + ## 0.148.0 (2026-10-19) Defaults for omitted `Query`, `Header`, `Cookie`, and `Form` parameters and model fields are no longer treated as user input. Defaults are not validated unless `validate_default=True` is enabled. If you rely on validators running for omitted values, enable default validation with `Field(validate_default=True)` for model fields or, for example, `Query(validate_default=True)` for individual parameters. `Body(validate_default=True)` is now honored too. diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index a834249bcf..0ca505841c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -69,7 +69,7 @@ from fastapi.logger import logger from fastapi.security.oauth2 import SecurityScopes from fastapi.types import DependencyCacheKey from fastapi.utils import create_model_field, get_path_param_names -from pydantic import AliasChoices, BaseModel, Json +from pydantic import AliasChoices, AliasPath, BaseModel, Json from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool @@ -762,7 +762,7 @@ def _get_multidict_value( if ( value is None or ( - isinstance(field.field_info, params.Form) + isinstance(values, FormData) and isinstance(value, str) # For type checks and value == "" ) @@ -916,65 +916,69 @@ async def _extract_form_body( field_aliases: set[str] = set() for field in body_fields: - alias = get_validation_alias(field) + aliases = [get_validation_alias(field)] validation_alias = field.field_info.validation_alias - # Interpret missing boolean form fields as unchecked HTML checkboxes. - infer_false_if_missing = field.field_info.annotation in ( - bool, - bool | None, - ) and ( - validation_alias is None - or isinstance(validation_alias, str) - or ( - isinstance(validation_alias, AliasChoices) - and all(isinstance(choice, str) for choice in validation_alias.choices) + has_alias_path = isinstance(validation_alias, AliasPath) or ( + isinstance(validation_alias, AliasChoices) + and any( + isinstance(choice, AliasPath) for choice in validation_alias.choices ) ) - if infer_false_if_missing: + if not has_alias_path: if isinstance(validation_alias, AliasChoices): - aliases = cast(list[str], validation_alias.choices) - alias = next( - (name for name in aliases if name in received_body), aliases[0] - ) + aliases = list(cast(list[str], validation_alias.choices)) config = field.config or {} - if config.get( - "validate_by_name", config.get("populate_by_name", False) - ) and ( - alias not in received_body or config.get("validate_by_alias") is False - ): - alias = field.name - field_aliases.add(alias) - if infer_false_if_missing and alias not in received_body: - value: Any = False - else: - value = _get_multidict_value(field, received_body, alias=alias) - field_info = field.field_info - if ( - isinstance(field_info, params.File) - and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation) - and isinstance(value, UploadFile) - ): - value = await value.read() - elif ( - is_bytes_sequence_annotation(field.field_info.annotation) - and isinstance(field_info, params.File) - and value_is_sequence(value) - ): - # For types - assert isinstance(value, sequence_types) - results: list[bytes | str] = [] - for sub_value in value: - if isinstance(sub_value, UploadFile): - results.append(await sub_value.read()) - else: - results.append(sub_value) - value = serialize_sequence_value(field=field, value=results) - if field.config is None: - # Individual parameters are later looked up by their declared alias. - alias = get_validation_alias(field) + if config.get("validate_by_name", config.get("populate_by_name", False)): + if config.get("validate_by_alias") is False: + aliases = [field.name] + elif field.name not in aliases: + aliases.append(field.name) + # Interpret missing boolean form fields as unchecked HTML checkboxes. + infer_false_if_missing = ( + not has_alias_path + and field.field_info.annotation in (bool, bool | None) + and not any(alias in received_body for alias in aliases) + ) + if field.config is None or infer_false_if_missing: + selected_alias = aliases[0] + for alias in aliases: + if _get_multidict_value(field, received_body, alias=alias) is not None: + selected_alias = alias + break + aliases = [selected_alias] + for alias in aliases: field_aliases.add(alias) - if value is not None: - values[alias] = value + if infer_false_if_missing: + value: Any = False + else: + value = _get_multidict_value(field, received_body, alias=alias) + field_info = field.field_info + if ( + isinstance(field_info, params.File) + and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation) + and isinstance(value, UploadFile) + ): + value = await value.read() + elif ( + is_bytes_sequence_annotation(field.field_info.annotation) + and isinstance(field_info, params.File) + and value_is_sequence(value) + ): + # For types + assert isinstance(value, sequence_types) + results: list[bytes | str] = [] + for sub_value in value: + if isinstance(sub_value, UploadFile): + results.append(await sub_value.read()) + else: + results.append(sub_value) + value = serialize_sequence_value(field=field, value=results) + if field.config is None: + # Individual parameters are later looked up by their declared alias. + alias = get_validation_alias(field) + field_aliases.add(alias) + if value is not None: + values[alias] = value for key in received_body.keys(): if key not in field_aliases: param_values = received_body.getlist(key) diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 0795ba9431..38427b61ad 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -16,19 +16,7 @@ from pydantic import ( ) -@pytest.mark.parametrize( - "path", - [ - "/parameter", - pytest.param( - "/model", - marks=pytest.mark.xfail( - reason="Empty scalar form strings are not treated as missing in models", - raises=AssertionError, - ), - ), - ], -) +@pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize("default", [None, "default"]) @pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) def test_empty_string_uses_default(path, default, encoding): @@ -60,19 +48,7 @@ def test_empty_string_uses_default(path, default, encoding): } -@pytest.mark.parametrize( - "path", - [ - "/parameter", - pytest.param( - "/model", - marks=pytest.mark.xfail( - reason="Empty scalar form strings are not treated as missing in models", - raises=AssertionError, - ), - ), - ], -) +@pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize("default", [None, 10]) @pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) def test_empty_integer_uses_default(path, default, encoding): @@ -104,27 +80,17 @@ def test_empty_integer_uses_default(path, default, encoding): } -@pytest.mark.parametrize( - "path", - [ - "/parameter", - pytest.param( - "/model", - marks=pytest.mark.xfail( - reason="Empty required scalar form strings are not treated as missing in models", - raises=AssertionError, - ), - ), - ], -) -def test_empty_required_string_is_missing(path): +@pytest.mark.parametrize("path", ["/parameter", "/model"]) +@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) +@pytest.mark.parametrize("annotation", [str, str | None]) +def test_empty_required_string_is_missing(path, encoding, annotation): app = FastAPI() class Model(BaseModel): - value: str | None + value: annotation @app.post("/parameter") - def read_parameter(value: Annotated[str | None, Form()]): + def read_parameter(value: Annotated[annotation, Form()]): return {"value": value, "fields_set": None} @app.post("/model") @@ -135,34 +101,27 @@ def test_empty_required_string_is_missing(path): supplied = client.post(path, data={"value": "hello"}) assert supplied.status_code == 200 assert supplied.json()["value"] == "hello" - response = client.post(path, data={"value": ""}) + if encoding == "urlencoded": + response = client.post(path, data={"value": ""}) + else: + response = client.post(path, files={"value": (None, "")}) assert response.status_code == 422 assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ ("missing", ["body", "value"]), ] -@pytest.mark.parametrize( - "path", - [ - "/parameter", - pytest.param( - "/model", - marks=pytest.mark.xfail( - reason="Empty required scalar form strings are not treated as missing in models", - raises=AssertionError, - ), - ), - ], -) -def test_empty_required_integer_is_missing(path): +@pytest.mark.parametrize("path", ["/parameter", "/model"]) +@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) +@pytest.mark.parametrize("annotation", [int, int | None]) +def test_empty_required_integer_is_missing(path, encoding, annotation): app = FastAPI() class Model(BaseModel): - value: int | None + value: annotation @app.post("/parameter") - def read_parameter(value: Annotated[int | None, Form()]): + def read_parameter(value: Annotated[annotation, Form()]): return {"value": value, "fields_set": None} @app.post("/model") @@ -173,13 +132,303 @@ def test_empty_required_integer_is_missing(path): supplied = client.post(path, data={"value": "1"}) assert supplied.status_code == 200 assert supplied.json()["value"] == 1 - response = client.post(path, data={"value": ""}) + if encoding == "urlencoded": + response = client.post(path, data={"value": ""}) + else: + response = client.post(path, files={"value": (None, "")}) assert response.status_code == 422 assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ ("missing", ["body", "value"]), ] +@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) +def test_empty_form_model_value_uses_default_factory(encoding): + app = FastAPI() + default_factory = Mock(return_value=10) + + class Model(BaseModel): + value: Annotated[int, Field(default_factory=default_factory)] + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return { + "values": params.model_dump(), + "supplied": params.model_dump(exclude_unset=True), + } + + default_factory.assert_not_called() + client = TestClient(app) + if encoding == "urlencoded": + response = client.post("/", data={"value": ""}) + else: + response = client.post("/", files={"value": (None, "")}) + assert response.status_code == 200 + assert response.json() == {"values": {"value": 10}, "supplied": {}} + default_factory.assert_called_once_with() + + +@pytest.mark.parametrize("validate_default,expected", [(False, 10), (True, 20)]) +def test_empty_form_model_value_respects_default_validation(validate_default, expected): + app = FastAPI() + + class Model(BaseModel): + value: Annotated[ + int, + AfterValidator(lambda value: value * 2), + Field(validate_default=validate_default), + ] = 10 + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return { + "values": params.model_dump(), + "supplied": params.model_dump(exclude_unset=True), + } + + client = TestClient(app) + response = client.post("/", data={"value": ""}) + assert response.status_code == 200 + assert response.json() == {"values": {"value": expected}, "supplied": {}} + + +@pytest.mark.parametrize( + "field", + [ + Field(alias="input"), + Field(alias="legacy", validation_alias="input"), + Field(validation_alias=AliasChoices("first", "input")), + ], +) +def test_empty_form_model_value_uses_alias(field): + app = FastAPI() + + class Model(BaseModel): + model_config = ConfigDict(extra="forbid") + value: Annotated[int, field] = 10 + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return { + "values": params.model_dump(), + "supplied": params.model_dump(exclude_unset=True), + } + + client = TestClient(app) + response = client.post("/", data={"input": "5"}) + assert response.status_code == 200 + assert response.json() == {"values": {"value": 5}, "supplied": {"value": 5}} + response = client.post("/", data={"input": ""}) + assert response.status_code == 200 + assert response.json() == {"values": {"value": 10}, "supplied": {}} + + +@pytest.mark.parametrize( + "config", + [ + ConfigDict(populate_by_name=True, extra="forbid"), + pytest.param( + ConfigDict(validate_by_name=True, validate_by_alias=False, extra="forbid"), + marks=pytest.mark.skipif( + PYDANTIC_VERSION_MINOR_TUPLE < (2, 11), + reason="validate_by_name and validate_by_alias require Pydantic 2.11", + ), + ), + ], +) +def test_empty_form_model_value_accepts_field_name(config): + app = FastAPI() + + class Model(BaseModel): + model_config = config + value: Annotated[int, Field(alias="input")] = 10 + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return { + "values": params.model_dump(), + "supplied": params.model_dump(exclude_unset=True), + } + + client = TestClient(app) + response = client.post("/", data={"value": "5"}) + assert response.status_code == 200 + assert response.json() == {"values": {"value": 5}, "supplied": {"value": 5}} + response = client.post("/", data={"value": ""}) + assert response.status_code == 200 + assert response.json() == {"values": {"value": 10}, "supplied": {}} + + +@pytest.mark.parametrize("path", ["/parameter", "/model"]) +@pytest.mark.parametrize( + "data,expected", + [ + ({"first": "", "second": ""}, 10), + ({"first": "", "second": "5"}, 5), + ({"first": "5", "second": ""}, 5), + ({"first": "5", "second": "6"}, 5), + ], +) +def test_empty_form_values_with_alias_choices(path, data, expected): + app = FastAPI() + + class Model(BaseModel): + value: Annotated[ + int, Field(validation_alias=AliasChoices("first", "second")) + ] = 10 + + @app.post("/parameter") + def read_parameter( + value: Annotated[ + int, Form(validation_alias=AliasChoices("first", "second")) + ] = 10, + ): + return {"value": value} + + @app.post("/model") + def read_model(params: Annotated[Model, Form()]): + return params.model_dump() + + client = TestClient(app) + response = client.post(path, data=data) + assert response.status_code == 200 + assert response.json() == {"value": expected} + + +@pytest.mark.parametrize( + "path,error_alias", [("/parameter", "value"), ("/model", "first")] +) +def test_required_form_alias_choices_preserve_validation_precedence(path, error_alias): + app = FastAPI() + + class Model(BaseModel): + value: Annotated[int, Field(validation_alias=AliasChoices("first", "second"))] + + @app.post("/parameter") + def read_parameter( + value: Annotated[int, Form(validation_alias=AliasChoices("first", "second"))], + ): + return {"value": value} + + @app.post("/model") + def read_model(params: Annotated[Model, Form()]): + return params.model_dump() + + client = TestClient(app) + response = client.post(path, data={"first": "", "second": "5"}) + assert response.status_code == 200 + assert response.json() == {"value": 5} + + response = client.post(path, data={"first": "invalid", "second": "5"}) + assert response.status_code == 422 + assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ + ("int_parsing", ["body", error_alias]), + ] + + response = client.post(path, data={"first": "", "second": ""}) + assert response.status_code == 422 + assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ + ("missing", ["body", error_alias]), + ] + + +@pytest.mark.parametrize( + "data,expected,supplied", + [ + ({"first": "", "value": ""}, 10, {}), + ({"first": "", "value": "5"}, 5, {"value": 5}), + ], +) +def test_empty_form_model_alias_falls_back_to_field_name(data, expected, supplied): + app = FastAPI() + + class Model(BaseModel): + model_config = ConfigDict(populate_by_name=True) + value: Annotated[ + int, Field(validation_alias=AliasChoices("first", "second")) + ] = 10 + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return { + "value": params.value, + "supplied": params.model_dump(exclude_unset=True), + } + + client = TestClient(app) + response = client.post("/", data=data) + assert response.status_code == 200 + assert response.json() == {"value": expected, "supplied": supplied} + + +@pytest.mark.parametrize("path", ["/parameter", "/model"]) +@pytest.mark.parametrize( + "data,expected", + [ + ({"second": [""]}, [""]), + ({"second": ["", "hello"]}, ["", "hello"]), + ({"first": [""], "second": ["hello"]}, [""]), + ], +) +def test_form_list_alias_choices_preserve_empty_items(path, data, expected): + app = FastAPI() + + class Model(BaseModel): + value: Annotated[ + list[str], Field(validation_alias=AliasChoices("first", "second")) + ] + + @app.post("/parameter") + def read_parameter( + value: Annotated[ + list[str], Form(validation_alias=AliasChoices("first", "second")) + ], + ): + return {"value": value} + + @app.post("/model") + def read_model(params: Annotated[Model, Form()]): + return params.model_dump(exclude_unset=True) + + client = TestClient(app) + response = client.post(path, data=data) + assert response.status_code == 200 + assert response.json() == {"value": expected} + + +def test_empty_form_model_value_preserves_extra_and_json_strings(): + app = FastAPI() + + class Model(BaseModel): + model_config = ConfigDict(extra="allow") + value: str = "default" + + @app.post("/form") + def read_form(params: Annotated[Model, Form()]): + return { + "values": params.model_dump(), + "supplied": params.model_dump(exclude_unset=True), + } + + @app.post("/json") + def read_json(params: Model): + return params.model_dump(exclude_unset=True) + + client = TestClient(app) + response = client.post("/form", data={"value": "", "extra": ""}) + assert response.status_code == 200 + assert response.json() == { + "values": {"value": "default", "extra": ""}, + "supplied": {"extra": ""}, + } + response = client.post("/json", json={"value": "", "extra": ""}) + assert response.status_code == 200 + assert response.json() == {"value": "", "extra": ""} + assert Model.model_validate({"value": ""}).model_dump(exclude_unset=True) == { + "value": "" + } + + @pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize("values", [[""], ["", "hello"]]) def test_empty_required_string_list_item_is_preserved(path, values): @@ -538,6 +787,7 @@ def test_checkbox_model_accepts_field_name(config, data, expected, validation_al ({"enabled": "on"}, True), ({"checkbox": "on"}, True), ({"checkbox": ["off", "on"]}, True), + ({"enabled": "", "checkbox": "on"}, True), ({}, False), ], )