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 True

In standard execution, the interpreter evaluates list[int] immediately as bytecode. This eager evaluation causes two major issues:

  1. Forward References: Referencing a class inside its own definition or referencing a class defined later in the file results in a NameError because the name does not yet exist in the current namespace.
  2. 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:
        pass

Behind the scenes, Python acts as though the code were written with explicit string literals:

class Node:
    def link(self, target: "Node") -> None:
        pass

Because '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.