🐛 Fix default handling for request parameters and models (#16527)

This commit is contained in:
Sebastián Ramírez authored and GitHub committed 2026-10-19 14:14:40 +02:00
1 parent f63265848e
commit 32240026ad
8 files changed
+151 -101

No files matched your search

+6
View File
@@ -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)`.
+5 -6
View File
@@ -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
+2 -2
View File
@@ -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": {},
},
]
}
@@ -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
@@ -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()
@@ -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(
@@ -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"},
}
]
}
@@ -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(