How from future import annotations Works in Python
Introduced in Python 3.7 via PEP 563, the
from __future__ import annotations directive alters how the
Python runtime treats type hints by deferring their evaluation. Instead
of executing annotation expressions at runtime when modules, classes,
and functions are loaded, the Python compiler intercepts these
expressions and stores them as raw, lazy string literals in the
__annotations__ dictionary. This article explains the
internal mechanics of this feature, how the compiler modifies bytecode
generation, the problems it solves, and how to evaluate these lazy
annotations at runtime.
Eager Evaluation vs. Deferred Evaluation
By default, Python treats type annotations as standard executable
Python expressions. When the Python interpreter defines a function or
class, it evaluates the types immediately and places the results into
the object's __annotations__ mapping:
def process_data(items: list[int]) -> bool:
return TrueIn standard execution, the interpreter evaluates
list[int] immediately as bytecode. This eager evaluation
causes two major issues:
- Forward References: Referencing a class inside its
own definition or referencing a class defined later in the file results
in a
NameErrorbecause the name does not yet exist in the current namespace. - Runtime Overhead: Complex type annotations incur an execution cost during module import time, even if those annotations are only utilized by static type checkers like mypy.
How the Compiler Converts Annotations to String Literals
When from __future__ import annotations is added at the
top of a file, Python’s Abstract Syntax Tree (AST) compiler changes its
behavior.
During compilation, the parser identifies AST nodes located inside
type annotation contexts (function parameter annotations, return types,
and class/module variable annotations). Rather than emitting bytecode
instructions to evaluate those expressions (such as
LOAD_NAME, BINARY_SUBSCR, or function calls),
the compiler converts the AST nodes back into their string
representations.
The compiler then emits a single LOAD_CONST instruction
holding that string, storing it directly into the
__annotations__ dictionary.
Consider the following example:
from __future__ import annotations
class Node:
def link(self, target: Node) -> None:
passBehind the scenes, Python acts as though the code were written with explicit string literals:
class Node:
def link(self, target: "Node") -> None:
passBecause 'Node' is stored as a string constant, Python
does not look up the symbol Node during the definition of
link(). This eliminates NameError exceptions
on forward references and removes the performance penalty of
constructing parameterized generics at runtime.
Inspecting Bytecode Differences
The difference is observable via Python's dis
module.
Without the future import, assigning an annotation generates instructions to load the target types and execute any subscripting or operations:
LOAD_NAME 0 (list)
LOAD_NAME 1 (int)
BINARY_SUBSCR
With from __future__ import annotations, the compiler
completely bypasses the lookup and subscript operations. The generated
bytecode is reduced to loading a single constant:
LOAD_CONST 1 ('list[int]')
This transforms type annotations into inert metadata at the bytecode level, making them lazy by design.
Resolving Annotations at Runtime
Because annotations are stored as strings, libraries that rely on
runtime type inspection (such as Pydantic, dataclasses, or dependency
injection frameworks) cannot directly read Python objects from
__annotations__.
To inspect the underlying types as actual Python objects, use
typing.get_type_hints(), which evaluates the string
literals using the corresponding global and local namespaces:
from __future__ import annotations
import typing
class User:
age: int
# Accessing __annotations__ directly returns a string
print(User.__annotations__["age"]) # Output: 'int'
# get_type_hints() evaluates the string into the type object
print(typing.get_type_hints(User)["age"]) # Output: <class 'int'>typing.get_type_hints() calls eval() on the
stored string literals, using the scope where the class or function was
originally defined, allowing lazy annotations to be resolved safely only
when required.