unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
backwards-compatibilityapi-designtesting-strategy

♻️ Update internal annotation usage for compatibilty with Pydantic 2.11

Project: tiangolo/fastapi | Language: Python | Concepts: backwards-compatibility, api-design, testing-strategy | Difficulty: mid
Link: https://github.com/fastapi/fastapi/pull/13314

The problem

Pydantic 2.11 changes how Annotated types are handled internally, specifically removing an automatic unwrapping step in FieldInfo.__init__. FastAPI's internal parameter analysis relied on this behavior to extract the underlying type for parameters annotated with Annotated and Field. With the change, FastAPI would see different annotation values, potentially breaking code that inspects or depends on the exact type annotation, especially in integrations or advanced use cases.

Why it's non-trivial

A naive fix would just use the annotation as-is, but that would leave Annotated[...] wrappers in places where FastAPI and its ecosystem expect a bare type (like int instead of Annotated[int, ...]). This could break downstream code, especially libraries or tools that introspect FastAPI's internal parameter structures, or that depend on the precise type for schema generation or validation logic.

The approach

The PR updates how FastAPI constructs its internal FieldInfo objects for parameters. Instead of passing the possibly-wrapped annotation (use_annotation), it now consistently passes the unwrapped, base type (type_annotation):

-            field_info = params.Path(annotation=use_annotation)
+            field_info = params.Path(annotation=type_annotation)

Explanation:
When creating a Path parameter, the code now ensures the annotation is the base type, not the Annotated wrapper. This prevents internal confusion and maintains consistent expectations for downstream code.

-            field_info = params.File(annotation=use_annotation, default=default_value)
+            field_info = params.File(annotation=type_annotation, default=default_value)

Explanation:
Similarly, for file uploads, the annotation passed to File is now always the base type.

-            field_info = params.Body(annotation=use_annotation, default=default_value)
+            field_info = params.Body(annotation=type_annotation, default=default_value)

Explanation:
For body parameters, the annotation is unwrapped for consistency.

-            field_info = params.Query(annotation=use_annotation, default=default_value)
+            field_info = params.Query(annotation=type_annotation, default=default_value)

Explanation:
Query parameters also use the base type annotation.

A new test is added to ensure this behavior:

def func(user: Annotated[int, Field(strict=True)]): ...

@needs_pydanticv2
def test_analyze_param():
    result = analyze_param(
        param_name="user",
        annotation=signature(func).parameters["user"].annotation,
        value=object(),
        is_path_param=False,
    )
    assert isinstance(result, ParamDetails)
    assert result.field.field_info.annotation is int

Explanation:
This test checks that, even when the function parameter is annotated with Annotated[int, Field(strict=True)], the internal field info annotation is just int, confirming the unwrapping works as intended.

From the review thread

tiangolo:
I was hoping to get a failing small FastAPI app that is fixed by this, but I couldn't make one, and it seems the report you had on Pydantic was not really using FastAPI but extending internals in some way, I couldn't make a minimal breaking FastAPI app from that report.
...
The current test checks that an internal function returns some specific value, which is better than nothing (thanks @svlandeg!), but still, it seems it is only testing that an internal function returns a specific shape. But it could be refactored in the future and there wouldn't be a way to know what could be broken in an app if not done correctly. :thinking:

Viicos:
I don't think this is reproducible only using FastAPI. The issue came from https://github.com/airtai/faststream, which is manually inspecting the FastAPI dependants to create a OpenAPI-like schema.
Perhaps a better test would be to build an API route with func, and assert on the dependants instead.

Takeaways

  • When upstream dependencies change their API or internal behaviors, projects that subclass or deeply integrate with those internals must carefully adapt, not just patch over the surface symptoms.
  • Always prefer storing and exposing the canonical, unwrapped type annotation internally, to avoid confusion and bugs in downstream consumers or integrations.
  • Tests that assert on internal structures are helpful, but higher-level integration tests that simulate real-world usage are more robust against future refactoring.