From 32240026ad8bdea0b78fd06ac607bba7e4d9da8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 19 Oct 2026 14:14:40 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20default=20handling=20for?= =?UTF-8?q?=20request=20parameters=20and=20models=20(#16527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 6 + fastapi/dependencies/utils.py | 11 +- tests/test_forms_single_model.py | 4 +- .../test_default_validation.py | 91 ++++++++++-- .../test_nullable_and_defaults.py | 135 ++++++++---------- .../test_tutorial001.py | 1 - .../test_tutorial002.py | 2 +- .../test_tutorial003.py | 2 - 8 files changed, 151 insertions(+), 101 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 1f16e970c5..6b478cba03 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,12 @@ hide: ## Latest Changes +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. + +For parameter models, applying a default no longer marks a field as explicitly set, so `model_dump(exclude_unset=True)` excludes it. Default factories run once, and validation errors no longer include injected defaults in `input`. + +Omitted boolean form fields still become `False` and count as explicitly set. + ## 0.147.0 (2026-10-19) Omitted `bool` and `bool | None` form fields are now interpreted as `False`, matching unchecked HTML checkboxes. This applies to individual `Form` parameters and form models, including required fields and fields with defaults of `True` or `None`. The inferred value is considered explicitly set, including in `model_dump(exclude_unset=True)`. diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index c3abca3b47..a834249bcf 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -737,8 +737,9 @@ def _validate_value_with_model_field( if value is None: if field.field_info.is_required(): return None, [get_missing_field_error(loc=loc)] - else: - return deepcopy(field.default), [] + value = deepcopy(field.default) + if not field.field_info.validate_default: + return value, [] return field.validate(value, values, loc=loc) @@ -770,10 +771,8 @@ def _get_multidict_value( and len(value) == 0 ) ): - if field.field_info.is_required(): - return - else: - return deepcopy(field.default) + # Apply defaults during validation, not while extracting request data. + return None return value 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": {}, }, ] } diff --git a/tests/test_request_params/test_default_validation.py b/tests/test_request_params/test_default_validation.py index 2b746c6bde..a562e40138 100644 --- a/tests/test_request_params/test_default_validation.py +++ b/tests/test_request_params/test_default_validation.py @@ -1,4 +1,5 @@ from typing import Annotated +from unittest.mock import Mock import pytest from fastapi import Body, Cookie, FastAPI, Form, Header, Query @@ -13,20 +14,7 @@ def double(value): ValidatedInt = Annotated[int | None, BeforeValidator(double)] -@pytest.mark.parametrize( - "parameter", - [ - pytest.param( - parameter, - marks=pytest.mark.xfail( - reason="Missing values are validated as explicitly supplied defaults", - raises=AssertionError, - ), - ) - for parameter in (Query, Header, Cookie, Form) - ] - + [Body], -) +@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_missing_value_uses_default_without_validation(parameter, path): app = FastAPI() @@ -183,3 +171,78 @@ def test_model_can_validate_default(parameter, default, expected): response = client.post("/") assert response.status_code == 200 assert response.json() == expected + + +@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) +@pytest.mark.parametrize("default,expected", [(None, 0), (10, 20)]) +def test_parameter_can_validate_default(parameter, default, expected): + app = FastAPI() + field = ( + Body(embed=True, validate_default=True) + if parameter is Body + else parameter(validate_default=True) + ) + + @app.post("/") + def read_parameter(value: Annotated[ValidatedInt, field] = default): + return value + + client = TestClient(app) + if parameter is Body: + response = client.post("/", json={}) + else: + response = client.post("/") + assert response.status_code == 200 + assert response.json() == expected + + +@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) +@pytest.mark.parametrize("default,expected", [(None, 0), (10, 20)]) +def test_parameter_validates_default_factory_once(parameter, default, expected): + app = FastAPI() + default_factory = Mock(return_value=default) + field = ( + Body(embed=True, default_factory=default_factory, validate_default=True) + if parameter is Body + else parameter(default_factory=default_factory, validate_default=True) + ) + + @app.post("/") + def read_parameter(value: Annotated[ValidatedInt, field]): + return value + + default_factory.assert_not_called() + client = TestClient(app) + if parameter is Body: + response = client.post("/", json={}) + else: + response = client.post("/") + assert response.status_code == 200 + assert response.json() == expected + default_factory.assert_called_once_with() + + +def test_parameter_rejects_invalid_default(): + app = FastAPI() + + @app.get("/") + def read_parameter(value: Annotated[int, Query(gt=0, validate_default=True)] = -1): + return value + + client = TestClient(app) + response = client.get("/") + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "greater_than", + "loc": ["query", "value"], + "msg": "Input should be greater than 0", + "input": -1, + "ctx": {"gt": 0}, + } + ] + } + response = client.get("/", params={"value": "1"}) + assert response.status_code == 200 + assert response.json() == 1 diff --git a/tests/test_request_params/test_nullable_and_defaults.py b/tests/test_request_params/test_nullable_and_defaults.py index 52e678a994..379c83a009 100644 --- a/tests/test_request_params/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_nullable_and_defaults.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest from fastapi import Body, Cookie, FastAPI, Form, Header, Query +from fastapi._compat import PYDANTIC_VERSION_MINOR_TUPLE from fastapi.testclient import TestClient from pydantic import BaseModel, Field @@ -331,27 +332,15 @@ def test_missing_none_default_is_unset(parameter): @pytest.mark.parametrize( "parameter,annotation,default", [ - pytest.param( - source, - annotation, - default, - marks=pytest.mark.xfail( - source is not Body, - reason="Missing model fields are populated with defaults before validation", - raises=AssertionError, - ), - ) - for source, annotation, default in [ - (Query, int | None, 10), - (Header, int | None, 10), - (Cookie, int | None, 10), - (Form, int | None, 10), - (Body, int | None, 10), - (Query, list[int] | None, [10]), - (Header, list[int] | None, [10]), - (Form, list[int] | None, [10]), - (Body, list[int] | None, [10]), - ] + (Query, int | None, 10), + (Header, int | None, 10), + (Cookie, int | None, 10), + (Form, int | None, 10), + (Body, int | None, 10), + (Query, list[int] | None, [10]), + (Header, list[int] | None, [10]), + (Form, list[int] | None, [10]), + (Body, list[int] | None, [10]), ], ) def test_missing_non_null_default_is_unset(parameter, annotation, default): @@ -376,27 +365,15 @@ def test_missing_non_null_default_is_unset(parameter, annotation, default): @pytest.mark.parametrize( "parameter,annotation,default", [ - pytest.param( - source, - annotation, - default, - marks=pytest.mark.xfail( - source is not Body, - reason="Missing model fields are populated with defaults before validation", - raises=AssertionError, - ), - ) - for source, annotation, default in [ - (Query, int | None, 10), - (Header, int | None, 10), - (Cookie, int | None, 10), - (Form, int | None, 10), - (Body, int | None, 10), - (Query, list[int] | None, [10]), - (Header, list[int] | None, [10]), - (Form, list[int] | None, [10]), - (Body, list[int] | None, [10]), - ] + (Query, int | None, 10), + (Header, int | None, 10), + (Cookie, int | None, 10), + (Form, int | None, 10), + (Body, int | None, 10), + (Query, list[int] | None, [10]), + (Header, list[int] | None, [10]), + (Form, list[int] | None, [10]), + (Body, list[int] | None, [10]), ], ) def test_missing_factory_default_is_unset(parameter, annotation, default): @@ -418,25 +395,9 @@ def test_missing_factory_default_is_unset(parameter, annotation, default): assert response.json() == {"value": default, "fields_set": []} -@pytest.mark.parametrize( - "parameter,path,default", - [ - pytest.param( - source, - path, - default, - marks=pytest.mark.xfail( - source is not Body - and (default is None or (path == "/parameter" and source is not Form)), - reason="The default factory is called more than once for an omitted value", - raises=AssertionError, - ), - ) - for source in (Query, Header, Cookie, Form, Body) - for path in ("/parameter", "/model") - for default in (None, 10) - ], -) +@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) +@pytest.mark.parametrize("path", ["/parameter", "/model"]) +@pytest.mark.parametrize("default", [None, 10]) def test_missing_value_calls_default_factory_once(parameter, path, default): app = FastAPI() default_factory = Mock(return_value=default) @@ -468,6 +429,43 @@ def test_missing_value_calls_default_factory_once(parameter, path, default): default_factory.assert_called_once_with() +@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) +@pytest.mark.skipif( + PYDANTIC_VERSION_MINOR_TUPLE < (2, 10), + reason="Default factories receiving validated data require Pydantic 2.10", +) +def test_model_default_factory_receives_validated_data(parameter): + app = FastAPI() + + class Model(BaseModel): + value: int + doubled: Annotated[int, Field(default_factory=lambda data: data["value"] * 2)] + + @app.post("/") + def read_model(params: Annotated[Model, parameter()]): + return { + "values": params.model_dump(), + "supplied": params.model_dump(exclude_unset=True), + } + + client = TestClient(app) + if parameter is Query: + response = client.post("/", params={"value": "5"}) + elif parameter is Header: + response = client.post("/", headers={"value": "5"}) + elif parameter is Cookie: + response = client.post("/", headers={"cookie": "value=5"}) + elif parameter is Form: + response = client.post("/", data={"value": "5"}) + else: + response = client.post("/", json={"value": 5}) + assert response.status_code == 200 + assert response.json() == { + "values": {"value": 5, "doubled": 10}, + "supplied": {"value": 5}, + } + + @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_supplied_value_does_not_call_default_factory(parameter, path): @@ -532,20 +530,7 @@ def test_value_equal_to_default_is_explicitly_set(parameter): assert response.json() == {"value": 0, "fields_set": ["value"]} -@pytest.mark.parametrize( - "parameter", - [ - pytest.param( - parameter, - marks=pytest.mark.xfail( - reason="Omitted model fields are marked as explicitly set", - raises=AssertionError, - ), - ) - for parameter in (Query, Header, Cookie, Form) - ] - + [Body], -) +@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) def test_partial_model_submission(parameter): app = FastAPI() diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial001.py b/tests/test_tutorial/test_header_param_models/test_tutorial001.py index cc274cd64d..86bd8c509b 100644 --- a/tests/test_tutorial/test_header_param_models/test_tutorial001.py +++ b/tests/test_tutorial/test_header_param_models/test_tutorial001.py @@ -66,7 +66,6 @@ def test_header_param_model_invalid(client: TestClient): "loc": ["header", "save_data"], "msg": "Field required", "input": { - "x_tag": [], "host": "testserver", "accept": "*/*", "accept-encoding": IsOneOf( diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial002.py b/tests/test_tutorial/test_header_param_models/test_tutorial002.py index 3d66dca08d..77c80dbcd8 100644 --- a/tests/test_tutorial/test_header_param_models/test_tutorial002.py +++ b/tests/test_tutorial/test_header_param_models/test_tutorial002.py @@ -65,7 +65,7 @@ def test_header_param_model_invalid(client: TestClient): "type": "missing", "loc": ["header", "save_data"], "msg": "Field required", - "input": {"x_tag": [], "host": "testserver"}, + "input": {"host": "testserver"}, } ] } diff --git a/tests/test_tutorial/test_header_param_models/test_tutorial003.py b/tests/test_tutorial/test_header_param_models/test_tutorial003.py index de2fb87ec6..ad7bef8b27 100644 --- a/tests/test_tutorial/test_header_param_models/test_tutorial003.py +++ b/tests/test_tutorial/test_header_param_models/test_tutorial003.py @@ -65,7 +65,6 @@ def test_header_param_model_no_underscore(client: TestClient): "input": { "host": "testserver", "traceparent": "123", - "x_tag": [], "accept": "*/*", "accept-encoding": IsOneOf( "gzip, deflate", "gzip, deflate, zstd" @@ -105,7 +104,6 @@ def test_header_param_model_invalid(client: TestClient): "loc": ["header", "save_data"], "msg": "Field required", "input": { - "x_tag": [], "host": "testserver", "accept": "*/*", "accept-encoding": IsOneOf(