Python 3.14 in Production: What PEP 649 Actually Breaks
PEP 649 defers annotation evaluation. That's great until FastAPI tries to resolve your TYPE_CHECKING imports at runtime and every endpoint throws NameError.
Python 3.14 ships PEP 649, the long-awaited deferred evaluation of annotations. We upgraded our production codebase to 3.14. The annotation cleanup took minutes. Figuring out why FastAPI was throwing NameError on endpoints that worked yesterday took considerably longer.
The promise
PEP 649 has been in discussion since 2021. The idea is simple: instead of evaluating type annotations at import time, Python defers evaluation until someone actually inspects them. This means you no longer need the from __future__ import annotations trick, and you can stop wrapping forward references in quotes.
If you’ve written Python with type hints, you’ve seen this pattern:
class SpanTestSummary:
@classmethod
def create_from_tests(
cls,
github_owner_id: "github_types.GitHubAccountIdType",
started_at: "datetime.datetime",
tests: list["span_test.SpanTest"],
) -> tuple[typing.Self, set["span_test.SpanTest"]]:
...
Those quotes around the type annotations exist because Python evaluates annotations at class definition time. If span_test.SpanTest is imported under TYPE_CHECKING, it doesn’t exist at runtime. Quotes turn the annotation into a string, deferring evaluation.
With PEP 649, the quotes become unnecessary:
class SpanTestSummary:
@classmethod
def create_from_tests(
cls,
github_owner_id: github_types.GitHubAccountIdType,
started_at: datetime.datetime,
tests: list[span_test.SpanTest],
) -> tuple[typing.Self, set[span_test.SpanTest]]:
...
Cleaner. No more forgetting quotes and getting NameError at import time. No more from __future__ import annotations at the top of every file.
The cleanup
We bumped our ruff target-version from py313 to py314, and ruff immediately flagged 55 UP037 violations across 25 files. UP037 is the rule for “unnecessary quoted annotation,” and with PEP 649 active, every single one of those quoted annotations was now dead weight.
The fix was mechanical. Remove quotes, let ruff reformat. Some multi-exception except clauses also got cleaned up thanks to PEP 758 (another 3.14 addition that drops the requirement for parentheses around multiple exception types):
# Before (Python 3.13)
except (ValueError, OSError, OverflowError):
pass
# After (Python 3.14, PEP 758)
except ValueError, OSError, OverflowError:
pass
Yes, that syntax used to mean something completely different in Python 2 (except ValueError as OSError). Python 3.14 reclaims it. Whether you find that elegant or cursed depends on how many Python 2 migrations you survived.
Total: 55 annotation unquotings, 17 files reformatted for except clauses. Ruff handled all of it in one pass. Boring, satisfying work.
Where it actually broke
The annotation cleanup landed. CI passed. We deployed to staging. Then our API endpoints started throwing NameError. Our test suite didn’t cover signature resolution for every endpoint (a gap we’ve since closed), so the error slipped through to staging.
The pattern that broke:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mergify_engine.models.github import user as gh_user_mod
from mergify_engine import subscription
async def get_filtered_base_query_stmt(
owner_id: github_types.GitHubAccountIdType,
logger_user: gh_user_mod.GitHubUser | None,
subscription: subscription.Subscription,
) -> ...:
Under Python 3.13 with from __future__ import annotations, this worked fine. Annotations were strings, never evaluated at runtime. FastAPI would parse the string annotations when building its dependency injection graph, and everything resolved.
Under Python 3.14 with PEP 649, annotations are no longer strings. They’re deferred, but when FastAPI inspects them (which it does, to build endpoint signatures), the runtime needs to resolve the actual types. The TYPE_CHECKING imports don’t exist at runtime. The annotation refers to a name that was never imported. NameError.
The fix was a one-line version bump: FastAPI 0.128.0 to 0.128.1. PEP 649 introduced a new annotationlib.Format.FORWARDREF mode that lets callers ask for annotations as special forward-reference objects instead of forcing full evaluation. FastAPI 0.128.1 uses this mode when inspecting endpoint signatures. The release notes bury it, but without this patch, any endpoint using TYPE_CHECKING imports in its signature is broken on Python 3.14.
What makes this tricky
The failure mode is subtle for two reasons.
First, your tests might pass. If your test suite doesn’t exercise every endpoint’s signature resolution (and ours didn’t exercise all of them), you won’t catch it. The NameError only fires when FastAPI inspects the annotation, not when Python loads the module. PEP 649’s whole point is lazy annotations, so the module imports fine and the function definition succeeds. The error surfaces only when FastAPI resolves the endpoint signature at startup or when a request arrives.
Second, the error message doesn’t point you toward PEP 649. You get a NameError: name 'gh_user_mod' is not defined, which looks like a regular import problem. Your first instinct is to check your imports, and they look correct, because they are correct for type checking purposes. The connection between “deferred annotations” and “FastAPI endpoint resolution” isn’t obvious until you’ve been burned by it.
The ordering matters
We got lucky in one respect: we bumped FastAPI before removing the quoted annotations. If we’d done it the other way around, removing quotes first while still on FastAPI 0.128.0, every endpoint using TYPE_CHECKING imports would have broken in CI. The order was accidental, not planned.
If you’re planning a Python 3.14 migration, here’s the sequence that worked for us:
- Bump FastAPI (or whatever framework inspects annotations at runtime) to a version with PEP 649 support
- Bump your Python version to 3.14
- Update your linter target (ruff
target-version = "py314") - Remove quoted annotations and
from __future__ import annotations - Remove parentheses from multi-exception
exceptclauses if you want (PEP 758)
Steps 3 through 5 are cosmetic. Steps 1 and 2 are where things break.
Who else gets bitten
FastAPI is not the only framework that inspects annotations at runtime. Pydantic does it heavily for validation models. SQLAlchemy’s Mapped[] type relies on annotation evaluation for ORM column definitions. Dataclasses and attrs both call typing.get_type_hints() under the hood, which triggers full annotation resolution.
We only hit the FastAPI case because that’s where our TYPE_CHECKING imports lived. But the failure mechanism is the same everywhere: any library that calls get_type_hints() in its default Format.VALUE mode will try to resolve every name in the annotation. If the name was only imported under TYPE_CHECKING, it doesn’t exist, and you get NameError. Libraries need to switch to Format.FORWARDREF to handle this gracefully.
Before upgrading, grep your codebase for TYPE_CHECKING imports used in places where a framework will inspect them at runtime. Those are your breakage points.
sequenceDiagram
participant Module as Module Import
participant Def as Function Definition
participant FA as FastAPI Startup
participant Req as First Request
Note over Module,Req: Python 3.13 with future annotations
Module->>Def: Annotations stored as strings
Def->>FA: FastAPI parses string annotations
FA->>Req: Works fine
Note over Module,Req: Python 3.14 (PEP 649) + FastAPI 0.128.0
Module->>Def: Annotations deferred (not strings)
Def->>FA: FastAPI evaluates annotations
FA--xReq: NameError - TYPE_CHECKING import missing
Note over Module,Req: Python 3.14 (PEP 649) + FastAPI 0.128.1
Module->>Def: Annotations deferred (not strings)
Def->>FA: FastAPI uses FORWARDREF format
FA->>Req: Works fine
The real story of PEP 649
PEP 649 is a good change. It solves a real, long-standing annoyance. But it also exposes something that was always fragile: the widespread practice of importing types under TYPE_CHECKING and assuming no runtime code would ever try to resolve them. For years, from __future__ import annotations papered over that assumption by turning all annotations into strings. Nobody had to think about which frameworks inspected annotations at runtime, because none of them ever saw the real types.
PEP 649 breaks that assumption. Annotations are real objects again, and every framework that touches them needs to know how to handle forward references. The real work isn’t removing quotes (ruff does that in seconds). It’s auditing your dependency chain for Format.FORWARDREF support.
Bump your frameworks first, bump Python second. Then enjoy deleting all those quotes.