Thomas Berdy Thomas Berdy
April 14, 2026 · 8 min read

Python 3.14 in Production: What PEP 649 Actually Breaks

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:

  1. Bump FastAPI (or whatever framework inspects annotations at runtime) to a version with PEP 649 support
  2. Bump your Python version to 3.14
  3. Update your linter target (ruff target-version = "py314")
  4. Remove quoted annotations and from __future__ import annotations
  5. Remove parentheses from multi-exception except clauses 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.

Stay ahead in CI/CD

Blog posts, release news, and automation tips straight in your inbox.

Recommended posts

A Markdown File Became Our Company-Wide On-Call Cheat Code
April 14, 2026 · 11 min read

A Markdown File Became Our Company-Wide On-Call Cheat Code

How a staff engineer turned scattered tribal knowledge into a git repo with Claude Code that lets any team member run a six-system support investigation in two minutes.

Julian Maurin Julian Maurin
How We Formally Verified Our Merge Queue with TLA+ (and Found Bugs That Tests Missed)
April 13, 2026 · 8 min read

How We Formally Verified Our Merge Queue with TLA+ (and Found Bugs That Tests Missed)

We wrote a TLA+ specification for our merge queue state machine. TLC explored 468,000 states and found two bugs that our test suite and years of production traffic had missed.

Julien Danjou Julien Danjou
Diving into pytest Finalizers
April 10, 2026 · 5 min read

Diving into pytest Finalizers

While building pytest-mergify, we hit a wall with fixture teardown during test reruns. The fix was two helper functions and a trick borrowed from pytest-rerunfailures.

Rémy Duthu Rémy Duthu