Skip to content

Conversation

@frankie567
Copy link
Contributor

@frankie567 frankie567 commented Nov 15, 2024

Problem

When using a union type models with a tagged discriminator as an endpoint parameter, like the following:

class Pie(BaseModel):
    time_to_cook: int
    num_ingredients: int


class ApplePie(Pie):
    fruit: Literal["apple"] = "apple"


class PumpkinPie(Pie):
    filling: Literal["pumpkin"] = "pumpkin"


def get_discriminator_value(v: Any) -> str:
    if isinstance(v, dict):
        return v.get("fruit", v.get("filling"))
    return getattr(v, "fruit", getattr(v, "filling", None))


Dessert = Annotated[
    Annotated[ApplePie, Tag("apple")] | Annotated[PumpkinPie, Tag("pumpkin")],
    Discriminator(get_discriminator_value),
]


app = FastAPI()
@app.post("/dessert")
async def dinner(dessert: Dessert) -> dict[str, Any]:
    return dessert.model_dump()

FastAPI fails to identify it correctly as body payload. It incorrectly considers it's a query parameter, which then causes a validation error. The workaround currently is to explicitly tell FastAPI it's a body parameter using dessert: Annotated[Dessert, Body()]

Fix

The solution is to improve the function handling type annotations by handling the case where we can have Annotated
types inside an Annotated type.


Discussed in #12941

@frankie567 frankie567 force-pushed the fix/12941 branch 2 times, most recently from 868e06f to 8b292f1 Compare November 15, 2024 15:51
@svlandeg svlandeg changed the title Fix tagged discriminated union not recognized as body field 🐛 Fix tagged discriminated union not recognized as body field Nov 25, 2024
@svlandeg svlandeg added the bug Something isn't working label Nov 25, 2024
@frankie567 frankie567 force-pushed the fix/12941 branch 2 times, most recently from 1fdf0b6 to 83e02fd Compare May 22, 2025 07:38
@frankie567
Copy link
Contributor Author

Rebased/updated and modified the original comment to better explain the purpose of this PR 😄

Copy link
Member

@YuriiMotov YuriiMotov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Actually, this problem is related to Pydantic V1 as well.
It can be tested using the following dummy test (in details)

Details
from typing import Annotated, Union

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field


class Item(BaseModel):
    value: str

ItemUnion = Union[
    Annotated[Item, Field()],
    Annotated[Item, Field()],
]

app = FastAPI()


@app.post("/items/")
def save(item: ItemUnion): # Changing this to `item: ItemUnion = Body()` would fix
    return {"item": item}

# Tests


def test_() -> None:
    client = TestClient(app)
    response = client.post("/items/", json={"value": "item"})
    assert response.status_code == 200, response.text
    assert response.json() == {"item": {"value": "item"}}

But I don't think we need to fix this for Pydantic V1 now. I can't imagine any real-life use case with Pydantic V1.

Copy link
Contributor

@patrick91 patrick91 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tested this in our codebase and it works!

I also checked that the new tests were not passing on the main branch 😊

EDIT: Also updated the test to make sure query params still work when using Annotated

@patrick91
Copy link
Contributor

@frankie567 I've update this to use a different style of defining the discriminator, which also makes the OpenAPI schema better 😊

Previous schema:

{
    "openapi": "3.1.0",
    "info": {"title": "FastAPI", "version": "0.1.0"},
    "paths": {
        "/items/": {
            "post": {
                "summary": "Save Union Body Discriminator",
                "operationId": "save_union_body_discriminator_items__post",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "schema": {
                                "oneOf": [
                                    {"$ref": "#/components/schemas/FirstItem"},
                                    {"$ref": "#/components/schemas/OtherItem"},
                                ],
                                "title": "Item",  # No discriminator here
                            }
                        }
                    },
                    "required": True,
                },
                "responses": {
                    "200": {
                        "description": "Successful Response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "additionalProperties": True,
                                    "type": "object",
                                    "title": "Response Save Union Body Discriminator Items  Post",
                                }
                            }
                        },
                    },
                    "422": {
                        "description": "Validation Error",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/HTTPValidationError"
                                }
                            }
                        },
                    },
                },
            }
        }
    },
    "components": {
        "schemas": {
            "FirstItem": {
                "properties": {
                    "value": {"type": "string", "title": "Value"},
                    "price": {"type": "integer", "title": "Price"},
                },
                "type": "object",
                "required": ["value", "price"],
                "title": "FirstItem",
            },
            "HTTPValidationError": {
                "properties": {
                    "detail": {
                        "items": {"$ref": "#/components/schemas/ValidationError"},
                        "type": "array",
                        "title": "Detail",
                    }
                },
                "type": "object",
                "title": "HTTPValidationError",
            },
            "OtherItem": {
                "properties": {
                    "value": {"type": "string", "title": "Value"},
                    "price": {"type": "number", "title": "Price"},
                },
                "type": "object",
                "required": ["value", "price"],
                "title": "OtherItem",
            },
            "ValidationError": {
                "properties": {
                    "loc": {
                        "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
                        "type": "array",
                        "title": "Location",
                    },
                    "msg": {"type": "string", "title": "Message"},
                    "type": {"type": "string", "title": "Error Type"},
                },
                "type": "object",
                "required": ["loc", "msg", "type"],
                "title": "ValidationError",
            },
        }
    },
}

Current schema

{
    "openapi": "3.1.0",
    "info": {"title": "FastAPI", "version": "0.1.0"},
    "paths": {
        "/items/": {
            "post": {
                "summary": "Save Union Body Discriminator",
                "operationId": "save_union_body_discriminator_items__post",
                "parameters": [
                    {
                        "name": "q",
                        "in": "query",
                        "required": True,
                        "schema": {
                            "type": "string",
                            "description": "Query string",
                            "title": "Q",
                        },
                    }
                ],
                "requestBody": {
                    "required": True,
                    "content": {
                        "application/json": {
                            "schema": {
                                "oneOf": [
                                    {"$ref": "#/components/schemas/FirstItem"},
                                    {"$ref": "#/components/schemas/OtherItem"},
                                ],
                                "discriminator": { # 👈 Discriminator
                                    "propertyName": "value",
                                    "mapping": {
                                        "first": "#/components/schemas/FirstItem",
                                        "other": "#/components/schemas/OtherItem",
                                    },
                                },
                                "title": "Item",
                            }
                        }
                    },
                },
                "responses": {
                    "200": {
                        "description": "Successful Response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "object",
                                    "additionalProperties": True,
                                    "title": "Response Save Union Body Discriminator Items  Post",
                                }
                            }
                        },
                    },
                    "422": {
                        "description": "Validation Error",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/HTTPValidationError"
                                }
                            }
                        },
                    },
                },
            }
        }
    },
    "components": {
        "schemas": {
            "FirstItem": {
                "properties": {
                    "value": {"type": "string", "const": "first", "title": "Value"},
                    "price": {"type": "integer", "title": "Price"},
                },
                "type": "object",
                "required": ["value", "price"],
                "title": "FirstItem",
            },
            "HTTPValidationError": {
                "properties": {
                    "detail": {
                        "items": {"$ref": "#/components/schemas/ValidationError"},
                        "type": "array",
                        "title": "Detail",
                    }
                },
                "type": "object",
                "title": "HTTPValidationError",
            },
            "OtherItem": {
                "properties": {
                    "value": {"type": "string", "const": "other", "title": "Value"},
                    "price": {"type": "number", "title": "Price"},
                },
                "type": "object",
                "required": ["value", "price"],
                "title": "OtherItem",
            },
            "ValidationError": {
                "properties": {
                    "loc": {
                        "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
                        "type": "array",
                        "title": "Location",
                    },
                    "msg": {"type": "string", "title": "Message"},
                    "type": {"type": "string", "title": "Error Type"},
                },
                "type": "object",
                "required": ["loc", "msg", "type"],
                "title": "ValidationError",
            },
        }
    },
}

but the code works in both cases, the fix is mostly around using Annotated :)

@frankie567
Copy link
Contributor Author

Thank you @patrick91, good point 👍

Copy link
Member

@tiangolo tiangolo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks @frankie567! 🚀

And thanks @YuriiMotov and @patrick91 for the help!

I realized that the tests were also passing on master 😅

So, I updated them to make them fail on master (adding the Tag) and get fixed by this change.

This will be released in FastAPI 0.118.2 in the next hours. 🎉


Ups, it seems I broke the tests, let me check that first. Fixed. 😅

@tiangolo tiangolo enabled auto-merge (squash) October 8, 2025 14:46
@tiangolo tiangolo merged commit 185cecd into fastapi:master Oct 8, 2025
29 checks passed
@galain1994

This comment was marked as resolved.

@YuriiMotov

This comment was marked as resolved.

@AlexanderFarkas
Copy link

AlexanderFarkas commented Jan 9, 2026

@YuriiMotov No, I don't think that's the case. I have a similar problem:

class CommunicateViaSmsDTO(BaseModel):
    message: str
    scheduled_at: Optional[datetime.datetime] = None

@dataclass
class SearchCustomerDTO:
    order_by: Optional[OrderByStatement] = None
    phone_number: Optional[str] = None
    max_last_active_datetime: Optional[datetime.datetime] = None
    
@communicate_router.post("/via_sms")
def communicate_via_sms(
    dto: CommunicateViaSmsDTO,
    search_params: SearchCustomerDTO = Depends(),
    customer_ids: list[int] = Query(default=None),
) -> APIResult[SendBatchMessageResultDTO]:
    # Implementation
    pass

And after upgrading to FastAPI 118.2 from 118.1, FastAPI doesn't see the body I send:

 admin_client = TestClient(.....)
 admin_client.post(
            f"/customers/communicate/via_email?max_last_active_datetime={last_active_datetime}",
            json={
                "subject": "hello",
                "message": "world",
            },
        )

It throws:

 AssertionError: {"ok":false,"error":{"type":"RequestValidationError","message":"[{'type': 'missing', 'loc': ('body', 'dto'), 'msg': 'Field required', 'input': None}]","errors":["{'type': 'missing', 'loc': ('body', 'dto'), 'msg': 'Field required', 'input': None}"]}}

For some reason it treats the dto as a scalar of the body (judging by the error) and changing the testing code to:

 admin_client.post(
           f"/customers/communicate/via_email?max_last_active_datetime={last_active_datetime}",
           json={
               "dto": {
                   "subject": "hello",
                   "message": "world",
               }
           },
       )

Fixes the problem

@YuriiMotov
Copy link
Member

YuriiMotov commented Jan 9, 2026

For some reason it treats the dto as a scalar of the body (judging by the error) and changing the testing code to ...
Fixes the problem

That's because FASTAPI treats one of other parameters (likely order_by: Optional[OrderByStatement]) as Body parameter. When you have several Body parameter models (or model + single Body parameter), FASTAPI embeds them

See https://fastapi.tiangolo.com/tutorial/body-multiple-params/#multiple-body-parameters

@AlexanderFarkas
Copy link

AlexanderFarkas commented Jan 9, 2026

@YuriiMotov But it used to work correctly. Why does it treat an order_by (which is located in the SearchCustomerDTO accessed via Depends()) as body?

Here is the order_by definition:

from typing import Annotated, TypeVar, Sequence, Generic, Type, Literal

import pydantic
from fastapi import Query
from pydantic import AfterValidator, PlainValidator, WithJsonSchema, ConfigDict

OrderByList = list[tuple[str, Literal["desc", "asc"]]]


def _validate_order_by_str(v):
    if isinstance(v, list):
        couples = v
    elif isinstance(v, str):
        couples = [couple.split(":") for couple in v.split(";")]
    else:
        raise TypeError()

    result = []
    for couple in couples:
        column, order = couple

        if not isinstance(column, str):
            raise TypeError("column should be str")
        if order not in ("desc", "asc"):
            raise TypeError("Order should be either desc or asc")
        result.append(couple)
    return result


OrderByStatement = Annotated[
    OrderByList,
    PlainValidator(_validate_order_by_str),
    WithJsonSchema(
        {
            "pattern": "column_name:desc|asc;",
            "examples": ["amount:desc", "sum:asc;experience:desc"],
            "type": "string",
        },
        mode="validation",
    ),
]

Marking order_by as Query doesn't help:

order_by: Annotated[Optional[OrderByStatement], Query] = None

But you're right - commenting out order_by fixes the issue. Due to the changes made in this PR, OrderByStatement is now considered complex. Though it's not clear how to bring back the old behavior.

@AlexanderFarkas
Copy link

AlexanderFarkas commented Jan 9, 2026

The fix was:

if TYPE_CHECKING:
    OrderByStatement = OrderByList
else:
    OrderByStatement = Annotated[
        str,
        PlainValidator(_validate_order_by_str),
        WithJsonSchema(
            {
                "pattern": "column_name:desc|asc;",
                "examples": ["amount:desc", "sum:asc;experience:desc"],
                "type": "string",
            },
            mode="validation",
        ),
    ]

I'd personally mention it as a breaking change in the changelog

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants