-
-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Open
Labels
questionQuestion or problemQuestion or problem
Description
Discussed in #13380
Originally posted by sneakers-the-rat February 16, 2025
First Check
- I added a very descriptive title here.
- I used the GitHub search to find a similar question and didn't find it.
- I searched the FastAPI documentation, with the integrated search.
- I already searched in Google "How to X in FastAPI" and didn't find any information.
- I already read and followed all the tutorial in the docs and didn't find an answer.
- I already checked if it is not related to FastAPI but to Pydantic.
- I already checked if it is not related to FastAPI but to Swagger UI.
- I already checked if it is not related to FastAPI but to ReDoc.
Commit to Help
- I commit to help with one of those options 👆
Example Code
Short version (UPD by @YuriiMotov):
from typing import Annotated
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from pydantic import BaseModel
class ExampleModel(BaseModel):
field_1: bool = True
app = FastAPI()
@app.post("/body")
async def body_endpoint(model: ExampleModel):
return {"fields_set": list(model.model_fields_set)}
@app.post("/form")
async def form_endpoint(model: Annotated[ExampleModel, Form()]):
return {"fields_set": list(model.model_fields_set)}
client = TestClient(app)
def test_body():
resp = client.post("/body", json={})
assert resp.status_code == 200, resp.text
fields_set = resp.json()["fields_set"]
assert fields_set == [] # Ok
def test_form():
resp = client.post("/form", data={})
assert resp.status_code == 200, resp.text
fields_set = resp.json()["fields_set"]
assert fields_set == [] # AssertionError: assert ['field_1'] == []
Original code (in details):
File: fastapi_defaults_bug.py
import uvicorn
from typing import Annotated
from pydantic import BaseModel, Field
from fastapi import FastAPI, Form
class ExampleJsonModel(BaseModel):
sample_field_1: Annotated[bool, Field(default=True)]
sample_field_2: Annotated[bool, Field(default=False)]
sample_field_3: Annotated[bool, Field(default=None)]
sample_field_4: Annotated[str, Field(default=0)] # This is dangerous but can be used with a validator
class ExampleFormModel(BaseModel):
sample_field_1: Annotated[bool, Form(default=True)]
sample_field_2: Annotated[bool, Form(default=False)]
sample_field_3: Annotated[bool, Form(default=None)]
sample_field_4: Annotated[str, Form(default=0)] # This is dangerous but can be used with a validator
class ResponseSampleModel(BaseModel):
fields_set: Annotated[list, Field(default_factory=list)]
dumped_fields_no_exclude: Annotated[dict, Field(default_factory=dict)]
dumped_fields_exclude_default: Annotated[dict, Field(default_factory=dict)]
dumped_fields_exclude_unset: Annotated[dict, Field(default_factory=dict)]
app = FastAPI()
@app.post("/form")
async def form_endpoint(model: Annotated[ExampleFormModel, Form()]) -> ResponseSampleModel:
return ResponseSampleModel(
fields_set=list(model.model_fields_set),
dumped_fields_no_exclude=model.model_dump(),
dumped_fields_exclude_default=model.model_dump(exclude_defaults=True),
dumped_fields_exclude_unset=model.model_dump(exclude_unset=True)
)
@app.post("/json")
async def form_endpoint(model: ExampleJsonModel) -> ResponseSampleModel:
return ResponseSampleModel(
fields_set=list(model.model_fields_set),
dumped_fields_no_exclude=model.model_dump(),
dumped_fields_exclude_default=model.model_dump(exclude_defaults=True),
dumped_fields_exclude_unset=model.model_dump(exclude_unset=True)
)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
Test File: test_fastapi_defaults_bug.py
import pytest
from fastapi.testclient import TestClient
from fastapi_defaults_bug import (
app,
ExampleFormModel,
ExampleJsonModel,
ResponseSampleModel
)
@pytest.fixture(scope="module")
def fastapi_client():
with TestClient(app) as test_client:
yield test_client
################
# Section 1: Tests on Form model -> no fastapi, pydantic model
################
def test_form_model_pydantic_only_defaults():
f_model = ExampleFormModel()
for field_name, field in f_model.model_fields.items():
assert getattr(f_model, field_name) == field.default
def test_form_model_pydantic_all_unset():
f_model = ExampleFormModel()
assert not f_model.model_fields_set
def test_form_model_pydantic_set_1():
f_model = ExampleFormModel(sample_field_1=True) # Those set have the same value of default
assert "sample_field_1" in f_model.model_fields_set
assert len(f_model.model_fields_set) == 1
def test_form_model_pydantic_set_2():
f_model = ExampleFormModel(sample_field_1=True, sample_field_2=False) # Those set have the same value of default
assert "sample_field_1" in f_model.model_fields_set
assert "sample_field_2" in f_model.model_fields_set
assert len(f_model.model_fields_set) == 2
def test_form_model_pydantic_set_all():
f_model = ExampleFormModel(
sample_field_1=True,
sample_field_2=False,
sample_field_3=True,
sample_field_4=""
) # Those set could have different values from default
assert not set(f_model.model_fields).difference(f_model.model_fields_set)
################
# Section 2: Same Tests of Form on Json model -> they are the same on different model
################
def test_json_model_pydantic_only_defaults():
j_model = ExampleJsonModel()
for field_name, field in j_model.model_fields.items():
assert getattr(j_model, field_name) == field.default
def test_json_model_pydantic_all_unset():
j_model = ExampleJsonModel()
assert not j_model.model_fields_set
def test_json_model_pydantic_set_1():
j_model = ExampleJsonModel(sample_field_1=True) # Those set have the same value of default
assert "sample_field_1" in j_model.model_fields_set
assert len(j_model.model_fields_set) == 1
def test_json_model_pydantic_set_2():
j_model = ExampleJsonModel(sample_field_1=True, sample_field_2=False) # Those set have the same value of default
assert "sample_field_1" in j_model.model_fields_set
assert "sample_field_2" in j_model.model_fields_set
assert len(j_model.model_fields_set) == 2
def test_json_model_pydantic_set_all():
j_model = ExampleJsonModel(
sample_field_1=True,
sample_field_2=False,
sample_field_3=True,
sample_field_4=""
) # Those set could have different values from default
assert not set(j_model.model_fields).difference(j_model.model_fields_set)
def test_form_json_model_share_same_default_behaviour():
f_model = ExampleFormModel()
j_model = ExampleJsonModel()
for field_name, field in f_model.model_fields.items():
assert getattr(f_model, field_name) == getattr(j_model, field_name)
################
# Section 3: Tests on Form model with fastapi
################
def test_submit_form_with_all_values(fastapi_client: TestClient):
form_content = {
"sample_field_1": "False",
"sample_field_2": "True",
"sample_field_3": "False",
"sample_field_4": "It's a random string"
}
response = fastapi_client.post("/form", data=form_content)
assert response.status_code == 200
response_model = ResponseSampleModel(**response.json())
assert len(response_model.fields_set) == 4
assert not set(form_content).symmetric_difference(set(response_model.fields_set))
def test_submit_form_with_not_all_values(fastapi_client: TestClient):
"""
This test should pass but fails because fastapi is preloading default and pass those values
on model creation, losing the ability to know if a field has been set.
:param fastapi_client:
:return:
"""
form_content = {
"sample_field_1": "False",
"sample_field_3": "False",
"sample_field_4": "It's a random string"
}
response = fastapi_client.post("/form", data=form_content)
assert response.status_code == 200
response_model = ResponseSampleModel(**response.json())
assert len(response_model.fields_set) == 3 # test will fail here and below
assert not set(form_content).symmetric_difference(set(response_model.fields_set))
def test_submit_form_with_no_values(fastapi_client: TestClient):
"""
This test should pass but fails because fastapi is preloading default and pass those values
on model creation, losing the ability to not have validation on default value.
:param fastapi_client:
:return:
"""
form_content = {}
response = fastapi_client.post("/form", data=form_content)
assert response.status_code == 200 # test will fail here and below -> will raise 422
response_model = ResponseSampleModel(**response.json())
assert len(response_model.fields_set) == 0
assert not set(form_content).symmetric_difference(set(response_model.fields_set))
################
# Section 4: Tests on Json model with fastapi
################
def test_submit_json_with_all_values(fastapi_client: TestClient):
json_content = {
"sample_field_1": False,
"sample_field_2": True,
"sample_field_3": False,
"sample_field_4": "It's a random string"
}
response = fastapi_client.post("/json", json=json_content)
assert response.status_code == 200
response_model = ResponseSampleModel(**response.json())
assert len(response_model.fields_set) == 4
assert not set(json_content).symmetric_difference(set(response_model.fields_set))
def test_submit_json_with_not_all_values(fastapi_client: TestClient):
"""
This test will pass but the same not happen with Form.
:param fastapi_client:
:return:
"""
json_content = {
"sample_field_1": False,
"sample_field_3": False,
"sample_field_4": "It's a random string"
}
response = fastapi_client.post("/json", json=json_content)
assert response.status_code == 200
response_model = ResponseSampleModel(**response.json())
assert len(response_model.fields_set) == 3 # This time will not fail
assert not set(json_content).symmetric_difference(set(response_model.fields_set))
def test_submit_json_with_no_values(fastapi_client: TestClient):
"""
This test will pass but the same not happen with Form.
:param fastapi_client:
:return:
"""
json_content = {}
response = fastapi_client.post("/json", json=json_content)
assert response.status_code == 200 # This time will not fail
response_model = ResponseSampleModel(**response.json())
assert len(response_model.fields_set) == 0
assert not set(json_content).symmetric_difference(set(response_model.fields_set))
Description
This is a generalized version of the issue reported in #13380 .
UPD @YuriiMotov: discussion mentioned above is not exactly about this issue, but resolving this issue would help solve the problem in the discussion. To save time, go straight to the last comment
This issue do not affect body json data.
For models created from a form, during the parsing phase their default values are preloaded and passed to the validator to create the model.
- This leads to a loss of information regarding which fields have been explicitly set, since default values are now considered as having been provided.
- Consequently, validation is enforced on default values, which might not be the intended behavior and anyway different from the one from json body.
Operating System
macOS - Linux
Operating System Details
No response
FastAPI Version
0.115.8
Pydantic Version
2.10.6
Python Version
Python 3.11 - Python 3.13.1
sneakers-the-rat, Rahul-Tawar, bhardwaj-kushagra and YuriiMotov
Metadata
Metadata
Assignees
Labels
questionQuestion or problemQuestion or problem