Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Dependency Models created from Form input data are loosing metadata(field set) and are enforcing validation on default values. #13399

Copy link
Copy link
@luzzodev

Description

@luzzodev
Issue body actions

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.

  1. This leads to a loss of information regarding which fields have been explicitly set, since default values are now considered as having been provided.
  2. 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

No one assigned

    Labels

    questionQuestion or problemQuestion or problem

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      Morty Proxy This is a proxified and sanitized view of the page, visit original site.