🐛 Treat empty scalar values as missing in form models (#16529)

This commit is contained in:
Sebastián Ramírez authored and GitHub committed 2026-10-19 19:10:12 +02:00
1 parent e5fc7ef524
commit f214cd3462
3 files changed
+373 -115

No files matched your search

+4
View File
@@ -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.
+59 -55
View File
@@ -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)
@@ -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),
],
)