From 986d0584fd698aab4d77172c04620e06fe28136c Mon Sep 17 00:00:00 2001 From: evan-hitobito <125259250+evan-hitobito@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:24:38 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20`Form()`=20with=20an=20`Op?= =?UTF-8?q?tional`=20Pydantic=20model=20silently=20discarding=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a form body parameter was annotated as `Optional[SomeModel]` (i.e. `SomeModel | None`), `is_union_of_base_models()` returned `False` because `NoneType` is not a `BaseModel` subclass. This forced the field to be embedded, so the submitted top-level form fields were never collected into the model and the value silently resolved to the default (`None`), with no validation error — unlike the equivalent JSON body, which parses correctly. Ignore `None` members when checking a union of models so that `Optional[SomeModel]` (and `SomeModel | OtherModel | None`) keeps being treated as a top-level model union. An empty form still validates the required fields (a 422, consistent with an empty JSON `{}` body). Co-Authored-By: Claude Opus 4.8 (1M context) --- fastapi/dependencies/utils.py | 14 ++- tests/test_union_forms.py | 183 +++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 40dffba64..2309bd45d 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -871,7 +871,11 @@ def request_params_to_args( def is_union_of_base_models(field_type: Any) -> bool: - """Check if field type is a Union where all members are BaseModel subclasses.""" + """Check if field type is a Union where all members are BaseModel subclasses. + + `None` members are ignored, so `Optional[SomeModel]` (i.e. `SomeModel | None`) + is still treated as a top-level model union. + """ from fastapi.types import UnionType origin = get_origin(field_type) @@ -882,11 +886,17 @@ def is_union_of_base_models(field_type: Any) -> bool: union_args = get_args(field_type) + found_base_model = False for arg in union_args: + # Ignore `None`, so that `Optional[SomeModel]` keeps being treated as a + # top-level model union instead of being forced to embed. + if arg is type(None): + continue if not lenient_issubclass(arg, BaseModel): return False + found_base_model = True - return True + return found_base_model def _should_embed_body_fields(fields: list[ModelField]) -> bool: diff --git a/tests/test_union_forms.py b/tests/test_union_forms.py index 8cd7b4f01..71616615b 100644 --- a/tests/test_union_forms.py +++ b/tests/test_union_forms.py @@ -23,6 +23,23 @@ def post_union_form(data: Annotated[UserForm | CompanyForm, Form()]): return {"received": data} +@app.post("/form-optional-required/") +def post_optional_required_form(data: Annotated[UserForm | None, Form()]): + return {"received": data} + + +@app.post("/form-optional/") +def post_optional_form(data: Annotated[UserForm | None, Form()] = None): + return {"received": data} + + +@app.post("/form-optional-union/") +def post_optional_union_form( + data: Annotated[UserForm | CompanyForm | None, Form()] = None, +): + return {"received": data} + + client = TestClient(app) @@ -59,6 +76,61 @@ def test_empty_form(): assert response.status_code == 422, response.text +def test_post_optional_required_form(): + # `UserForm | None` without a default is still required, but its top-level + # fields must be collected into the model instead of being silently dropped. + response = client.post( + "/form-optional-required/", + data={"name": "John Doe", "email": "john@example.com"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "received": {"name": "John Doe", "email": "john@example.com"} + } + + +def test_post_optional_required_form_missing(): + response = client.post("/form-optional-required/") + assert response.status_code == 422, response.text + + +def test_post_optional_form(): + # `UserForm | None = None`: submitted fields must still populate the model + # (previously they were silently discarded and the value resolved to `None`). + response = client.post( + "/form-optional/", data={"name": "Jane Doe", "email": "jane@example.com"} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "received": {"name": "Jane Doe", "email": "jane@example.com"} + } + + +def test_post_optional_form_empty(): + # An empty form is equivalent to an empty object (`{}`), so the required model + # fields are still validated (mirroring a JSON `{}` body), giving a 422. + response = client.post("/form-optional/") + assert response.status_code == 422, response.text + + +def test_post_optional_union_form(): + response = client.post( + "/form-optional-union/", + data={"company_name": "Tech Corp", "industry": "Technology"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "received": {"company_name": "Tech Corp", "industry": "Technology"} + } + + +def test_post_optional_union_form_empty(): + # As above, an empty form validates the (required) members, so it is a 422 + # rather than silently resolving to the `None` default. + response = client.post("/form-optional-union/") + assert response.status_code == 422, response.text + + def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200, response.text @@ -104,7 +176,116 @@ def test_openapi_schema(): }, }, } - } + }, + "/form-optional-required/": { + "post": { + "summary": "Post Optional Required Form", + "operationId": "post_optional_required_form_form_optional_required__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/UserForm"}, + {"type": "null"}, + ], + "title": "Data", + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/form-optional/": { + "post": { + "summary": "Post Optional Form", + "operationId": "post_optional_form_form_optional__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/UserForm"}, + {"type": "null"}, + ], + "title": "Data", + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/form-optional-union/": { + "post": { + "summary": "Post Optional Union Form", + "operationId": "post_optional_union_form_form_optional_union__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/UserForm"}, + { + "$ref": "#/components/schemas/CompanyForm" + }, + {"type": "null"}, + ], + "title": "Data", + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, }, "components": { "schemas": {