From eec2fe903382c48a1dbbd7582b11e04d8c781cf1 Mon Sep 17 00:00:00 2001 From: Alberto Zambrano Date: Wed, 21 Jan 2026 13:41:52 +0100 Subject: [PATCH 01/10] docs: clarify required but nullable request body fields --- docs/en/docs/tutorial/body.md | 30 +++++++++++++++++++ docs_src/body/tutorial005_py310.py | 11 +++++++ docs_src/body/tutorial005_py39.py | 12 ++++++++ .../test_body_required_nullable/__init__.py | 0 .../test_tutorial001.py | 28 +++++++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 docs_src/body/tutorial005_py310.py create mode 100644 docs_src/body/tutorial005_py39.py create mode 100644 tests/test_tutorial/test_body_required_nullable/__init__.py create mode 100644 tests/test_tutorial/test_body_required_nullable/test_tutorial001.py diff --git a/docs/en/docs/tutorial/body.md b/docs/en/docs/tutorial/body.md index 2d0dfcbb5..c03fcb4a5 100644 --- a/docs/en/docs/tutorial/body.md +++ b/docs/en/docs/tutorial/body.md @@ -55,6 +55,36 @@ For example, this model above declares a JSON "`object`" (or Python `dict`) like } ``` +### Required fields that can be `None` + +In Python type hints, a parameter can be **required** and still allow the value `None`. + +This means that the field must be present in the request body, but its value can be `null` +(`None` in Python). + +This typically happens when you use `Optional[T]` **without** providing a default value. + +For example: + +{* ../../docs_src/body/tutorial005_py39.py hl[6] *} + +And for Python 3.10+: + +{* ../../docs_src/body/tutorial005_py310.py hl[6] *} + +In this example: + +* The `description` field is **required** (the client must include it in the JSON body). +* Its value can still be `null` (`None` in Python). +* This is different from a truly optional field, which would have a default value: + +```python +class Item(BaseModel): + description: Optional[str] = None +``` + +Here, `description` can be omitted entirely in the request body. + ## Declare it as a parameter { #declare-it-as-a-parameter } To add it to your *path operation*, declare it the same way you declared path and query parameters: diff --git a/docs_src/body/tutorial005_py310.py b/docs_src/body/tutorial005_py310.py new file mode 100644 index 000000000..2476a23f3 --- /dev/null +++ b/docs_src/body/tutorial005_py310.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + +class Item(BaseModel): + description: str | None + +@app.post("/items/") +async def create_item(item: Item): + return item diff --git a/docs_src/body/tutorial005_py39.py b/docs_src/body/tutorial005_py39.py new file mode 100644 index 000000000..4529234d0 --- /dev/null +++ b/docs_src/body/tutorial005_py39.py @@ -0,0 +1,12 @@ +from typing import Optional +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + +class Item(BaseModel): + description: Optional[str] + +@app.post("/items/") +async def create_item(item: Item): + return item diff --git a/tests/test_tutorial/test_body_required_nullable/__init__.py b/tests/test_tutorial/test_body_required_nullable/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py b/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py new file mode 100644 index 000000000..2c60356c3 --- /dev/null +++ b/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py @@ -0,0 +1,28 @@ +import importlib +import pytest +from fastapi.testclient import TestClient + +from ...utils import needs_py310 + +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial005_py39"), + pytest.param("tutorial005_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.body.{request.param}") + client = TestClient(mod.app) + return client + + +def test_required_nullable_field(client: TestClient): + response = client.post("/items/", json={"description": None}) + assert response.status_code == 200 + assert response.json() == {"description": None} + + +def test_required_field_missing(client: TestClient): + response = client.post("/items/", json={}) + assert response.status_code == 422 From ee496848c893d84351547a60fc9187b6ddbc6bc8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:23:43 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/tutorial/body.md | 2 +- docs_src/body/tutorial005_py310.py | 2 ++ docs_src/body/tutorial005_py39.py | 3 +++ .../test_body_required_nullable/test_tutorial001.py | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/tutorial/body.md b/docs/en/docs/tutorial/body.md index c03fcb4a5..91f4b8ae7 100644 --- a/docs/en/docs/tutorial/body.md +++ b/docs/en/docs/tutorial/body.md @@ -55,7 +55,7 @@ For example, this model above declares a JSON "`object`" (or Python `dict`) like } ``` -### Required fields that can be `None` +### Required fields that can be `None` { #required-fields-that-can-be-none } In Python type hints, a parameter can be **required** and still allow the value `None`. diff --git a/docs_src/body/tutorial005_py310.py b/docs_src/body/tutorial005_py310.py index 2476a23f3..7a9c9c9d9 100644 --- a/docs_src/body/tutorial005_py310.py +++ b/docs_src/body/tutorial005_py310.py @@ -3,9 +3,11 @@ from pydantic import BaseModel app = FastAPI() + class Item(BaseModel): description: str | None + @app.post("/items/") async def create_item(item: Item): return item diff --git a/docs_src/body/tutorial005_py39.py b/docs_src/body/tutorial005_py39.py index 4529234d0..32420e282 100644 --- a/docs_src/body/tutorial005_py39.py +++ b/docs_src/body/tutorial005_py39.py @@ -1,12 +1,15 @@ from typing import Optional + from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() + class Item(BaseModel): description: Optional[str] + @app.post("/items/") async def create_item(item: Item): return item diff --git a/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py b/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py index 2c60356c3..ed1268866 100644 --- a/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py +++ b/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py @@ -1,9 +1,11 @@ import importlib + import pytest from fastapi.testclient import TestClient from ...utils import needs_py310 + @pytest.fixture( name="client", params=[ From 59d708fc4d7ba0898d825b07e418c690cf1d513f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:32:57 +0000 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs_src/body/tutorial005_py39.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs_src/body/tutorial005_py39.py b/docs_src/body/tutorial005_py39.py index 32420e282..7a9c9c9d9 100644 --- a/docs_src/body/tutorial005_py39.py +++ b/docs_src/body/tutorial005_py39.py @@ -1,5 +1,3 @@ -from typing import Optional - from fastapi import FastAPI from pydantic import BaseModel @@ -7,7 +5,7 @@ app = FastAPI() class Item(BaseModel): - description: Optional[str] + description: str | None @app.post("/items/") From 2f17a7a79aed2756a5df9166241e0ef33cc97c29 Mon Sep 17 00:00:00 2001 From: Alberto Zambrano Date: Fri, 13 Feb 2026 15:54:23 +0100 Subject: [PATCH 04/10] docs: remove Python 3.9 example for tutorial005 --- docs_src/body/tutorial005_py39.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 docs_src/body/tutorial005_py39.py diff --git a/docs_src/body/tutorial005_py39.py b/docs_src/body/tutorial005_py39.py deleted file mode 100644 index 7a9c9c9d9..000000000 --- a/docs_src/body/tutorial005_py39.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import FastAPI -from pydantic import BaseModel - -app = FastAPI() - - -class Item(BaseModel): - description: str | None - - -@app.post("/items/") -async def create_item(item: Item): - return item From a3aa8583d8521120a6a39012a2a3a5e41b3d1b1b Mon Sep 17 00:00:00 2001 From: Alberto Zambrano Date: Fri, 13 Feb 2026 16:56:12 +0100 Subject: [PATCH 05/10] docs: update required but nullable request body example, remove py39 files, add test_tutorial005 --- docs/en/docs/tutorial/body.md | 8 ++------ docs_src/body/tutorial005_py310.py | 2 +- .../test_tutorial005.py} | 9 +++++---- .../test_body_required_nullable/__init__.py | 0 4 files changed, 8 insertions(+), 11 deletions(-) rename tests/test_tutorial/{test_body_required_nullable/test_tutorial001.py => test_body/test_tutorial005.py} (77%) delete mode 100644 tests/test_tutorial/test_body_required_nullable/__init__.py diff --git a/docs/en/docs/tutorial/body.md b/docs/en/docs/tutorial/body.md index 22fdf90dc..f7c2ae304 100644 --- a/docs/en/docs/tutorial/body.md +++ b/docs/en/docs/tutorial/body.md @@ -62,15 +62,11 @@ In Python type hints, a parameter can be **required** and still allow the value This means that the field must be present in the request body, but its value can be `null` (`None` in Python). -This typically happens when you use `Optional[T]` **without** providing a default value. +This typically happens when you use something like `str | None` (or `Optional[str]`) **without** providing a default value. For example: -{* ../../docs_src/body/tutorial005_py39.py hl[6] *} - -And for Python 3.10+: - -{* ../../docs_src/body/tutorial005_py310.py hl[6] *} +{* ../../docs_src/body/tutorial005_py310.py hl[8] *} In this example: diff --git a/docs_src/body/tutorial005_py310.py b/docs_src/body/tutorial005_py310.py index 7a9c9c9d9..073cde6be 100644 --- a/docs_src/body/tutorial005_py310.py +++ b/docs_src/body/tutorial005_py310.py @@ -5,7 +5,7 @@ app = FastAPI() class Item(BaseModel): - description: str | None + description: str | None = None @app.post("/items/") diff --git a/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial005.py similarity index 77% rename from tests/test_tutorial/test_body_required_nullable/test_tutorial001.py rename to tests/test_tutorial/test_body/test_tutorial005.py index ed1268866..8e12c0462 100644 --- a/tests/test_tutorial/test_body_required_nullable/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial005.py @@ -3,16 +3,15 @@ import importlib import pytest from fastapi.testclient import TestClient -from ...utils import needs_py310 @pytest.fixture( name="client", params=[ - pytest.param("tutorial005_py39"), - pytest.param("tutorial005_py310", marks=needs_py310), + pytest.param("tutorial005_py310"), ], ) + def get_client(request: pytest.FixtureRequest): mod = importlib.import_module(f"docs_src.body.{request.param}") client = TestClient(mod.app) @@ -27,4 +26,6 @@ def test_required_nullable_field(client: TestClient): def test_required_field_missing(client: TestClient): response = client.post("/items/", json={}) - assert response.status_code == 422 + assert response.status_code == 200 + assert response.json() == {"description": None} + diff --git a/tests/test_tutorial/test_body_required_nullable/__init__.py b/tests/test_tutorial/test_body_required_nullable/__init__.py deleted file mode 100644 index e69de29bb..000000000 From 5a639b7b0df44d3fd71001cac28c0f844d572560 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:01:36 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_tutorial/test_body/test_tutorial005.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_tutorial/test_body/test_tutorial005.py b/tests/test_tutorial/test_body/test_tutorial005.py index 8e12c0462..e0900a14a 100644 --- a/tests/test_tutorial/test_body/test_tutorial005.py +++ b/tests/test_tutorial/test_body/test_tutorial005.py @@ -4,14 +4,12 @@ import pytest from fastapi.testclient import TestClient - @pytest.fixture( name="client", params=[ pytest.param("tutorial005_py310"), ], ) - def get_client(request: pytest.FixtureRequest): mod = importlib.import_module(f"docs_src.body.{request.param}") client = TestClient(mod.app) @@ -28,4 +26,3 @@ def test_required_field_missing(client: TestClient): response = client.post("/items/", json={}) assert response.status_code == 200 assert response.json() == {"description": None} - From 6b2f87231612ff13fdeb8b893c1781e0c484c3f6 Mon Sep 17 00:00:00 2001 From: Alberto Zambrano Zarallo <123561034+azamzar@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:18:13 +0100 Subject: [PATCH 08/10] Apply suggestion from @YuriiMotov Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> --- docs/en/docs/tutorial/body.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/docs/tutorial/body.md b/docs/en/docs/tutorial/body.md index f7c2ae304..72ec94fe6 100644 --- a/docs/en/docs/tutorial/body.md +++ b/docs/en/docs/tutorial/body.md @@ -76,7 +76,7 @@ In this example: ```python class Item(BaseModel): - description: Optional[str] = None + description: str | None = None ``` Here, `description` can be omitted entirely in the request body. From 6c0d4110ae16c8816aaa8e13baf286e2dc461a62 Mon Sep 17 00:00:00 2001 From: Alberto Zambrano Date: Fri, 13 Feb 2026 19:01:00 +0100 Subject: [PATCH 09/10] Fix tutorial005_py310.py: make 'description' field required as intended --- docs_src/body/tutorial005_py310.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs_src/body/tutorial005_py310.py b/docs_src/body/tutorial005_py310.py index 073cde6be..cd487a75a 100644 --- a/docs_src/body/tutorial005_py310.py +++ b/docs_src/body/tutorial005_py310.py @@ -5,7 +5,7 @@ app = FastAPI() class Item(BaseModel): - description: str | None = None + description: str @app.post("/items/") From 2682f7d21d20ae4a3dbdc3e284f26c1965236b52 Mon Sep 17 00:00:00 2001 From: Alberto Zambrano Date: Fri, 13 Feb 2026 19:10:49 +0100 Subject: [PATCH 10/10] Fix tutorial005_py310: make 'description' required and update tests accordingly --- tests/test_tutorial/test_body/test_tutorial005.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_tutorial/test_body/test_tutorial005.py b/tests/test_tutorial/test_body/test_tutorial005.py index e0900a14a..4e0053af1 100644 --- a/tests/test_tutorial/test_body/test_tutorial005.py +++ b/tests/test_tutorial/test_body/test_tutorial005.py @@ -17,12 +17,11 @@ def get_client(request: pytest.FixtureRequest): def test_required_nullable_field(client: TestClient): - response = client.post("/items/", json={"description": None}) + response = client.post("/items/", json={"description": "Some description"}) assert response.status_code == 200 - assert response.json() == {"description": None} + assert response.json() == {"description": "Some description"} def test_required_field_missing(client: TestClient): response = client.post("/items/", json={}) - assert response.status_code == 200 - assert response.json() == {"description": None} + assert response.status_code == 422