Using future annotations for Forward References
TL;DR — from __future__ import annotations (PEP 563) stores every annotation in a module as an unevaluated string instead of a live object. That lets forward references and new-syntax unions like int | None work without quotes on older runtimes, because the annotation is never executed at definition time. The catch: any tool that reads annotations at runtime must now call typing.get_type_hints() to resolve those strings, and some libraries — notably Pydantic v1 — break on the deferred form.
A forward reference is a name used in an annotation before it exists — a method returning its own class, or two classes that reference each other. Without deferred evaluation you quote the name ("Node") so Python does not try to look it up too early. The future import automates that: it quotes everything for you. This page shows the before/after, a self-referencing class, and the runtime caveat that decides whether you can adopt it.
Before and after
Without the future import, a new-syntax union or an as-yet-undefined name in an annotation is evaluated when the function is defined. An annotation is an ordinary Python expression: writing -> Node | None actually runs Node.__or__(None) the moment the def statement executes, and references the name Node in the enclosing scope right then. On Python 3.9 that fails twice over — int | None raises TypeError: unsupported operand type(s) for |: 'type' and 'NoneType' because type.__or__ for the union syntax only arrives with PEP 604 in 3.10, and a not-yet-defined Node raises NameError. The historical fix is to quote the annotation so it becomes a string literal Python never evaluates:
# Python 3.9, WITHOUT the future import
from typing import Optional
def find(node_id: int) -> "Node | None": # must quote: Node not yet defined,
... # and | unsupported at runtime on 3.9
class Node:
...
Add the import at the very top of the module and the quotes and the runtime restriction both disappear — every annotation is now a string the interpreter never runs:
# Python 3.9+, WITH the future import
from __future__ import annotations
def find(node_id: int) -> Node | None: # no quotes; stored as the string "Node | None"
...
class Node:
...
from __future__ import annotations must be the first statement in the file (after the module docstring). It applies module-wide — you cannot enable it per function. It relates directly to the Union and Optional modernization story, since it is what lets pipe unions compile on pre-3.10 runtimes.
Two things are worth separating here, because they fail independently. The NameError from an undefined name can be fixed without the future import by quoting only that annotation — "Node" or "Node | None" — which is the manual, per-annotation form of exactly what the import does wholesale. The TypeError from int | None on a pre-3.10 runtime is a different problem: the | never runs there, so it too needs either a quote or the future import. The import is attractive precisely because it handles both at once and never asks you to remember which annotations are risky.
The mechanism is a compiler flag, not a library, so there is nothing to backport. from __future__ import annotations has existed since Python 3.7 (it was the release that introduced PEP 563), which covers every currently maintained interpreter; there is no typing_extensions equivalent because the behavior is baked into how the bytecode compiler emits annotations. A useful side effect the original PEP advertised is import speed: because annotation expressions are no longer evaluated at definition time, modules with heavy Annotated[...] or generic annotations import measurably faster and never pay the cost of constructing annotation objects that nothing reads. The one hard constraint is placement — the statement must come before any other code, or Python raises SyntaxError: from __future__ imports must occur at the beginning of the file.
A self-referencing class
The cleanest payoff is a recursive data structure. A tree node whose children are the same type would otherwise need every self-reference quoted:
# Python 3.10+, checked with mypy 1.10 / pyright 1.1.370
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class TreeNode:
value: int
parent: TreeNode | None = None # self-reference, unquoted
children: list[TreeNode] = field(default_factory=list)
def add(self, value: int) -> TreeNode: # returns its own type
child = TreeNode(value, parent=self)
self.children.append(child)
return child
Every mention of TreeNode inside its own body is a forward reference that the future import turns into a harmless string. mypy and pyright resolve those strings statically without any runtime cost, so the annotations are fully checked.
For the specific case of “returns its own type,” a more precise tool than naming the class is the Self type from PEP 673 (Python 3.11, or typing_extensions.Self on older versions). Annotating def add(self, value: int) -> Self makes the return type follow subclasses: a class BinaryNode(TreeNode) inherits add and both checkers infer it returns BinaryNode, not TreeNode. Hard-coding -> TreeNode would widen a subclass call back to the base. Self also sidesteps the forward-reference question entirely for that annotation, because it is a name that always exists.
Mutually recursive classes are the same pattern spread across two definitions and are where the future import saves the most quoting. A Package that holds Release objects while each Release points back to its Package would, without the import, force you to quote whichever name is defined second:
# Python 3.10+, mypy 1.10 / pyright 1.1.370
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Package:
name: str
releases: list[Release] = field(default_factory=list) # Release defined below
@dataclass
class Release:
version: str
package: Package # back-reference
Recursive type aliases work the same way and are worth knowing about: type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None (using PEP 695’s type statement on 3.12+, or a plain assignment with quotes earlier) leans on the exact deferral mechanism. Whichever form you use, the static checker walks the alias recursively; the runtime never has to, because a recursive alias is only meaningful to a type checker.
The runtime caveat
Deferring annotations to strings is free for static analysis but not for code that reads annotations while the program runs. Dataclasses store the raw strings in __annotations__; anything that needs the real types must resolve them:
# Python 3.10+, resolving deferred annotations at runtime
from __future__ import annotations
from typing import get_type_hints
class Handler:
retry: RetryPolicy
class RetryPolicy: ...
hints = get_type_hints(Handler) # {"retry": <class '...RetryPolicy'>} — resolved
raw = Handler.__annotations__ # {"retry": "RetryPolicy"} — just a string
get_type_hints() re-evaluates each string in the class’s module globals, so a name that is not importable in that scope raises NameError. This is why some frameworks stumble: Pydantic v1 reads annotations eagerly and does not always resolve deferred strings, so models can fail to build or silently mistype fields; Pydantic v2 handles PEP 563 correctly. Any library relying on __annotations__ directly — older serializers, some ORM layers — needs the same get_type_hints() treatment or it sees strings where it expects types.
The scope rule is stricter than it first appears. get_type_hints() resolves each string against the object’s __globals__ (its defining module) plus, for a class, the namespaces of its bases. It has no access to function locals, so a type defined inside a function body cannot be resolved from a deferred annotation that escapes that function — the classic trigger for NameError: name 'RetryPolicy' is not defined even though the name plainly exists somewhere. When the name lives in an unusual place you can hand it in explicitly: get_type_hints(Handler, globalns=..., localns={"RetryPolicy": RetryPolicy}). For lighter-weight access, inspect.get_annotations(obj, eval_str=True) (Python 3.10+) resolves strings the same way while giving you finer control than the typing helper, and it does not accidentally strip Annotated[...] metadata unless you ask.
A particularly common source of NameError is the typing.TYPE_CHECKING guard. Imports placed under if TYPE_CHECKING: exist only for the static checker; at runtime the name was never imported, so resolving a deferred annotation that references it raises. That is fine for pure static analysis but fatal the moment a runtime consumer calls get_type_hints() on the object. Frameworks that anticipated PEP 563 — FastAPI, for instance, resolves signatures through get_type_hints() and therefore works correctly with the future import — but the guarantee only holds when every referenced name is importable at runtime in the defining scope.
Analyzer behavior
Static checkers are unaffected by the deferral in a good way: mypy and pyright both resolve the string annotations against module scope exactly as if they were live, so a wrong self-reference or an unresolvable name still surfaces — mypy [name-defined], pyright reportUndefinedVariable. Neither checker requires the future import to validate pipe unions; it only matters for whether the annotation runs on your target interpreter. ruff can even add the import for you via the from-future-import-annotations rule family and rewrite quoted forward refs. Pair it with your mypy configuration so undefined forward references stay an error.
Both checkers treat a quoted forward reference and an unquoted-but-deferred one identically, so migrating from hand-quoted strings to the module-wide import changes nothing in their output — only the runtime representation changes. A malformed annotation still errors the same way it would without deferral: pyright reports a bad type expression as reportGeneralTypeIssues, and mypy reports an unresolved name as [name-defined] regardless of whether the future import is present. The one place the target version matters to a checker is bare runtime unions written without deferral, like IntOrStr = int | str at module level; there mypy --python-version 3.9 will flag [operator] because that assignment really does execute on the target. That interaction is exactly why version coverage matters — see matrix-testing mypy across Python versions.
There is a longer arc worth knowing. PEP 563 was proposed to become the default behavior in 3.10 and then deferred indefinitely, because stringizing every annotation broke too much runtime introspection. Its successor is PEP 649, which defers evaluation using a compiler-generated __annotate__ function rather than storing strings: annotations are computed lazily, on first access, and come back as real objects, so forward references resolve without the NameError fragility of string re-evaluation. PEP 649 landed as the mechanism in Python 3.14, coordinated by PEP 749, which adds the annotationlib module and its get_annotations() plus Format.VALUE, Format.FORWARDREF, and Format.STRING — the FORWARDREF format hands back a placeholder for names that are not yet defined instead of raising. from __future__ import annotations still selects the PEP 563 string behavior for backward compatibility, but the long-term direction is PEP 649 semantics; new code that only needs forward references (not runtime string stripping) will increasingly get them for free.
from __future__ import annotations, checkers see fully-resolved types but the interpreter sees only strings in __annotations__. Code that introspects annotations at runtime must call typing.get_type_hints(), which re-evaluates each string in the defining module's scope and raises NameError if a referenced name is not importable there. Pydantic v1 and other eager annotation readers can break; verify runtime consumers before enabling it globally.
Common mistakes
Most adoption failures come down to four recurring errors. Read them as a checklist before you flip the import on module-wide, because each has a distinct symptom and fix:
- Placing the import below other code.
from __future__ import annotationsmust be the first statement (after any docstring), or Python raisesSyntaxError: from __future__ imports must occur at the beginning of the file. Only a module docstring and comments may precede it — not even an unrelatedimport os. - Assuming runtime tools still see types. After the import,
Handler.__annotations__holds strings; frameworks that read it directly get strings, not classes. Resolve withget_type_hints()(orinspect.get_annotations(obj, eval_str=True)). A serializer that branches onannotation is intwill silently take the wrong branch, because"int" is intisFalse. - Forgetting a referenced name is out of scope.
get_type_hints()fails withNameErrorif a forward-referenced type is not importable in the module globals — a locally-defined type inside a function cannot be resolved, and names imported only underif TYPE_CHECKING:do not exist at runtime. Pass an explicitlocalnsor make the import real when a runtime consumer needs it. - Enabling it under Pydantic v1. Deferred annotations can leave v1 models mistyped or failing to build; upgrade to v2 or keep those modules on eager, quoted annotations. The same caution applies to any library that inspects
__annotations__without going throughget_type_hints().
FAQ
Does the future import change what mypy or pyright report?
No. Both resolve the string annotations against module scope and check them exactly as if they were evaluated. Undefined forward references still error (mypy [name-defined]); the import only affects whether the annotation executes on your runtime.
Will PEP 563 become the default so I can drop the import? It was proposed as a default and then deferred indefinitely because of the runtime-introspection breakage described above. For now you must opt in per module with the explicit import; do not assume a future Python turns it on automatically.