Fix Annotated ForwardRef dependencies with postponed annotations by abishekgiri · Pull Request #15234 · fastapi/fastapi

@abishekgiri

Summary

Fix dependency extraction and OpenAPI generation when using postponed annotations together with Annotated[...] and forward-referenced types.

Previously, when using from __future__ import annotations, FastAPI could leave the full annotation as an unresolved string (e.g. Annotated[Potato, Depends(get_potato)]). This prevented proper dependency extraction, causing the parameter to be treated as a required query parameter at runtime and leading to OpenAPI generation errors.

Changes

  • improve annotation resolution to preserve nested structure inside stringified annotations

  • resolve inner names as nested ForwardRef(...) instead of keeping the entire annotation unresolved

  • ensure Annotated[..., Depends(...)] works correctly with forward-declared types

  • add regression tests covering:

    • postponed annotations (from __future__ import annotations)
    • forward-referenced dependency types
    • correct dependency extraction and OpenAPI generation

Backward Compatibility

This change is backward compatible and only fixes incorrect handling of previously unsupported annotation patterns.

Verification

Tested with:

  • uv run pytest tests/test_stringified_annotation_forwardref_dependency.py
  • uv run pytest tests/test_stringified_annotations_simple.py
  • uv run pytest tests/test_stringified_annotation_dependency.py

Related

Fixes #13056

@abishekgiri

@codspeed-hq

Merging this PR will not alter performance

✅ 20 untouched benchmarks


Comparing abishekgiri:fix/annotated-forwardref-dependency (380c416) with master (d128a70)

Open in CodSpeed

@abishekgiri abishekgiri changed the title Fix Annotated ForwardRef dependencies with future annotations Fix Annotated ForwardRef dependencies with postponed annotations

Mar 27, 2026

@abishekgiri

This PR fixes a bug in annotation resolution and dependency handling, so the appropriate label should be bug.

Could a maintainer or triager please add the bug label so the required checks can pass?

1 similar comment

@abishekgiri

This comment was marked as duplicate.

@YuriiMotov

@abishekgiri, thanks for your interest!

Please, review existing PRs linked to the issue (including closed PRs, to understand the reasons they were closed) before opening your PRs. And provide an explanation why you think your approach is better.

@abishekgiri

@abishekgiri, thanks for your interest!

Please, review existing PRs linked to the issue (including closed PRs, to understand the reasons they were closed) before opening your PRs. And provide an explanation why you think your approach is better.

I reviewed the PRs linked to #13056: #14952, #14557, #14703, #14872, #15096, #15131, and #15142.

My understanding is:

I chose this approach because it fixes the problem at the earliest shared point: get_typed_annotation().

Instead of keeping the whole annotation unresolved, or converting missing inner names to Any, this PR evaluates with a lenient namespace that preserves unresolved inner names as nested ForwardRef(...) objects. That means FastAPI still sees the outer Annotated[...] structure, can still extract Depends(...), and does not lose the original type information unnecessarily.

I think that is better because:

  • it is a smaller and more local change
  • it fixes both runtime dependency extraction and OpenAPI generation from the same root cause
  • it avoids special-case parsing of Annotated[...]
  • it preserves unresolved types as ForwardRef(...) instead of degrading them to Any

I also added a focused regression test for the exact issue shape from #13056, covering both runtime behavior and OpenAPI generation.

@garagon

I looked into this and ended up with the same dict.__missing__ForwardRef idea.

One thing worth considering: using the lenient dict as a fallback instead of replacing the existing eval path. Right now you're routing all annotations through _LenientTypeResolutionDict, but evaluate_forwardref already works fine for the common case. The lenient path only needs to kick in when normal resolution returns an unresolved ForwardRef:

def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
    if isinstance(annotation, str):
        annotation = ForwardRef(annotation)
        annotation = evaluate_forwardref(annotation, globalns, globalns)
        if isinstance(annotation, ForwardRef):
            try:
                annotation = eval(
                    annotation.__forward_arg__,
                    _LenientTypeResolutionDict(globalns),
                )
            except Exception:
                pass
        if annotation is type(None):
            return None
    return annotation

Also — self[key] = value in __missing__ caches into the dict during eval. If evaluate_forwardref does multiple passes internally, a cached ForwardRef could shadow a name that would resolve on a later pass. Probably fine since the dict is single-use, but worth checking.

@abishekgiri

@abishekgiri

I looked into this and ended up with the same dict.__missing__ForwardRef idea.

One thing worth considering: using the lenient dict as a fallback instead of replacing the existing eval path. Right now you're routing all annotations through _LenientTypeResolutionDict, but evaluate_forwardref already works fine for the common case. The lenient path only needs to kick in when normal resolution returns an unresolved ForwardRef:

def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
    if isinstance(annotation, str):
        annotation = ForwardRef(annotation)
        annotation = evaluate_forwardref(annotation, globalns, globalns)
        if isinstance(annotation, ForwardRef):
            try:
                annotation = eval(
                    annotation.__forward_arg__,
                    _LenientTypeResolutionDict(globalns),
                )
            except Exception:
                pass
        if annotation is type(None):
            return None
    return annotation

Also — self[key] = value in __missing__ caches into the dict during eval. If evaluate_forwardref does multiple passes internally, a cached ForwardRef could shadow a name that would resolve on a later pass. Probably fine since the dict is single-use, but worth checking.

Thanks, that makes sense. I updated the implementation to keep the normal evaluate_forwardref(..., globalns, globalns) path first and only fall back to the lenient namespace when strict resolution still returns a ForwardRef.

I also removed the cache write from __missing__ so unresolved placeholders don’t get memoized during eval.

Added a focused unit test for the fallback branch and re-ran the regression tests plus mypy.