Skip to content

Fix PydanticUserError when a generic BaseModel with Json[T] field is specialized with SerializeAsAny[SomeModel] - #15329

Open
shirayu wants to merge 1 commit into
fastapi:masterfrom
shirayu:fix_SerializeAsAny
Open

shirayu wants to merge 1 commit into
fastapi:masterfrom
shirayu:fix_SerializeAsAny

Conversation

@shirayu

@shirayu shirayu commented Apr 13, 2026

Copy link
Copy Markdown

Summary

get_model_fields() in fastapi/_compat/v2.py was passing model.model_config
to TypeAdapter for fields whose annotation is Json[SerializeAsAny[SomeModel]].
Pydantic 2.12 tightened its validation and now raises PydanticUserError in this
case, crashing app.openapi().

Root Cause

get_model_fields() decided whether to suppress config= by checking:

if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_):
    model_config = None
else:
    model_config = model.model_config   # forwarded to TypeAdapter

Pydantic raises PydanticUserError when config= is passed to TypeAdapter
whose resolved type is a BaseModel, dataclass, or TypedDict.

When Source[SerializeAsAny[Inner]] is resolved, the field annotation becomes
Json[SerializeAsAny[Inner]].
At runtime Json[X] expands to Annotated[X, Json] and SerializeAsAny[X]
expands to Annotated[X, SerializeAsAny()], so the full type is
Annotated[Annotated[Inner, SerializeAsAny()], Json]. lenient_issubclass
returns False for this (it is not a bare BaseModel, dataclass, or
TypedDict), so model_config was forwarded to TypeAdapter — which Pydantic
2.12 now rejects.

Source[Inner] (without SerializeAsAny) did not crash because in that case
field_info.annotation resolves to Inner itself (a bare BaseModel subclass),
so lenient_issubclass returns True and model_config is already suppressed
before TypeAdapter is ever called. The SerializeAsAny wrapper is what
prevented this guard from triggering: the annotation becomes
Json[SerializeAsAny[Inner]] rather than Inner, and lenient_issubclass
returns False for that composite type.

Changes

fastapi/_compat/v2.py

Extracted a _needs_no_config() helper that recursively unwraps Annotated
before checking the inner type:

def _needs_no_config(type_: Any) -> bool:
    if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_):
        return True
    if get_origin(type_) is Annotated:
        return _needs_no_config(get_args(type_)[0])
    return False

Because Json[X] is itself Annotated[X, Json] at runtime, and
SerializeAsAny[X] is Annotated[X, SerializeAsAny()], the recursive
Annotated-unwrapping branch covers all affected forms (two levels of
recursion are needed for the SerializeAsAny case):

  • Annotated[BaseModel subclass, ...]
  • Json[BaseModel subclass]Annotated[BaseModel subclass, Json]
  • Json[SerializeAsAny[BaseModel subclass]]
    Annotated[SerializeAsAny[BaseModel subclass], Json]
    Annotated[Annotated[BaseModel subclass, SerializeAsAny()], Json]
    → unwraps twice to reach BaseModel subclass

tests/test_json_field_serialize_as_any.py (new)

Two tests:

  • test_openapi_schema_generation_serialize_as_any_does_not_raise — regression for the reported crash
  • test_openapi_schema_generation_plain_inner_unaffected — baseline to confirm Source[Inner] continues to work

PoC

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "fastapi==0.135.3",
#   "pydantic==2.12.5",
# ]
# ///
"""
PoC: FastAPI 0.135.3 + Pydantic 2.12 — OpenAPI schema generation crash

The guard in `get_model_fields()` (fastapi/_compat/v2.py) checks whether the
field annotation is directly a BaseModel / dict / dataclass to decide whether
to pass `config=` to TypeAdapter:

    type_ = field_info.annotation
    if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_):
        model_config = None
    else:
        model_config = model.model_config   # ← forwarded to TypeAdapter

Pydantic 2.12 raises PydanticUserError when `config=` is passed to TypeAdapter
and the *resolved* inner type is a BaseModel / dataclass / TypedDict.

The guard correctly handles:
  - Source[Inner]               → type_ = Json[Inner]        → lenient_issubclass fails
                                  BUT TypeAdapter(Json[Inner], config=...) is also OK
                                  because Json[Inner] does not resolve to a bare BaseModel

The guard misses:
  - Source[SerializeAsAny[Inner]]
      type_ = Json[Annotated[Inner, SerializeAsAny]]
      lenient_issubclass fails (not a BaseModel)
      → TypeAdapter(Json[Annotated[Inner, SerializeAsAny]], config=ConfigDict())
      → Pydantic 2.12 rejects this: the inner Annotated[BaseModel, SerializeAsAny]
        is treated as a BaseModel-equivalent and config= is forbidden

Root cause: `Annotated[BaseModel subclass, SerializeAsAny]` is not caught by
`lenient_issubclass(..., BaseModel)`, so when it is further wrapped in Json[...]
the guard is bypassed and config= is passed through to TypeAdapter.

Run
---
    uv run poc.py
"""

from typing import Generic, TypeVar

import fastapi
from fastapi import FastAPI
from pydantic import BaseModel, Json, SerializeAsAny

T = TypeVar("T")

app = FastAPI()


class Inner(BaseModel):
    value: str


class Source(BaseModel, Generic[T]):
    payload: Json[T]


@app.get("/trigger")
def trigger() -> Source[SerializeAsAny[Inner]]: ...


if __name__ == "__main__":
    from typing import Generic as _Generic, TypeVar as _TypeVar

    from pydantic import BaseModel as _BaseModel, Json as _Json, SerializeAsAny as _SerializeAsAny

    _T = _TypeVar("_T")

    class _Inner(_BaseModel):
        value: str

    class _Source(_BaseModel, _Generic[_T]):
        payload: _Json[_T]

    def _try(label: str, fn):
        try:
            fn()
            print(f"OK : {label}")
        except Exception as e:
            print(f"NG : {label}")
            print(f"     {type(e).__name__}: {e}\n")

    ok_app = fastapi.FastAPI()

    @ok_app.get("/ok")
    def _ok() -> _Source[_Inner]: ...

    ng_app = fastapi.FastAPI()

    @ng_app.get("/ng")
    def _ng() -> _Source[_SerializeAsAny[_Inner]]: ...

    _try("Source[Inner]                  (no SerializeAsAny — should pass)", ok_app.openapi)
    _try("Source[SerializeAsAny[Inner]]  (SerializeAsAny wraps T — crashes)", ng_app.openapi)

…l_fields

Pydantic 2.12 raises PydanticUserError when config= is passed to TypeAdapter
and the resolved inner type is a BaseModel, dataclass, or TypedDict.

The guard in get_model_fields() only checked bare BaseModel/dataclass types
via lenient_issubclass, which does not unwrap Annotated. As a result,
Json[SerializeAsAny[Inner]] (which expands to Annotated[Inner, SerializeAsAny(),
Json] at runtime) slipped through and caused a crash in app.openapi().

Extract a _needs_no_config() helper that recursively unwraps Annotated before
checking the inner type, covering all affected forms:
- Annotated[BaseModel, ...]
- Json[BaseModel]  →  Annotated[BaseModel, Json]
- Json[SerializeAsAny[BaseModel]]  →  Annotated[BaseModel, SerializeAsAny(), Json]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codspeed

codspeed Bot commented Apr 13, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing shirayu:fix_SerializeAsAny (e774a05) with master (eba8942)

Open in CodSpeed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant