Skip to content
aviral gupta

// A2.6 · ~45 min · Advanced

Annotations at runtime in 3.14

After this lesson you know when Python 3.14 evaluates annotations, you can read them safely with annotationlib, and you have typed a whole module step by step until mypy --strict reports no issues.

Lesson 6 of 6 in A2 Typing

End of the module

You will be able to

  • Explain deferred evaluation in 3.14: when annotations run, and what from __future__ import annotations changes
  • Read annotations with annotationlib.get_annotations in the VALUE, FORWARDREF and STRING formats
  • Type an untyped module step by step until mypy --strict reports no issues
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson A2.1: the hint says int, but what does this print?

    def half(x: int) -> int:
        return x / 2
    
    
    print(half(3))
  2. Predict · Activity 2 of 7

    Predict before you read on: Cls is used in an annotation before it is defined. What does Python 3.14 print?

    def func(a: Cls) -> None:
        print(a)
    
    
    class Cls:
        pass
    
    
    print(func.__annotations__)
  3. Practice · Activity 3 of 7

    Missing is not defined anywhere. Fill in the format that returns the annotations as source text, without raising.

    get_annotations(f, format=Format.____)
    Format.)
  4. Practice · Activity 4 of 7

    The annotation calls a function that prints. What does the program print?

    def expensive():
        print("evaluating", end=" ")
        return int
    
    
    def f(x: expensive()) -> None:
        pass
    
    
    print("defined", end=" ")
    f.__annotations__
    f.__annotations__
    print("done")
  5. Practice · Activity 5 of 7

    An annotation names Missing, which is not defined. Match each way of reading annotations to what you get.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The module still has the old __future__ import. What does this print?

    from __future__ import annotations
    
    from annotationlib import get_annotations
    
    
    def scale(n: int) -> int:
        return n
    
    
    print(get_annotations(scale)["n"] is int, get_annotations(scale, eval_str=True)["n"] is int)
  7. Apply · Activity 7 of 7

    Mini-task. Invoice has the fields number: int, amount: Decimal and paid: bool = False, where Decimal is imported only under if TYPE_CHECKING:. Write describe(cls), which returns one "name: type" line per field, and print them. It must work although Decimal does not exist at runtime, and pass mypy --strict.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Three ways to read the same annotations

Node refers to itself without quotes, which works because annotations are evaluated lazily. price uses Decimal, which is imported only for the type checker. Reading its annotations in the STRING and FORWARDREF formats works; the default VALUE format raises NameError. The program passes mypy --strict.

main.py

from annotationlib import Format, get_annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from decimal import Decimal


class Node:
    def __init__(self, value: int, next: Node | None = None) -> None:
        self.value = value
        self.next = next


def price(amount: Decimal) -> str:
    return f"{amount:.2f}"


print(get_annotations(Node.__init__))
print(get_annotations(price, format=Format.STRING))
ref = get_annotations(price, format=Format.FORWARDREF)["amount"]
print(type(ref).__name__, ref.__forward_arg__)
try:
    get_annotations(price)
except NameError as error:
    print("VALUE:", error)

Run it with

python main.py

Output

{'value': <class 'int'>, 'next': __main__.Node | None, 'return': None}
{'amount': 'Decimal', 'return': 'str'}
ForwardRef Decimal
VALUE: name 'Decimal' is not defined
  • Node | None is evaluated when the annotations are read, after the class exists.
  • TYPE_CHECKING is False at runtime, so Decimal is never imported; mypy treats it as True.
  • A ForwardRef keeps the text of the name in __forward_arg__ and can be evaluated later.
  • Only the VALUE format needs every name to exist.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 4

Build step 1: annotate the module

This module reads shop items from lines like "pen; 1.50" and has no hints. Annotate every parameter and return value: name is a str, price a float, tags a list[str] or None, and cheapest returns an Item or None. total returns a float. Do not change the code itself.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Methods need -> None when they return nothing: def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:

  2. Hint 2

    Item is defined above the functions, and even if it were not, 3.14 would evaluate the hints lazily.

  3. Hint 3

    cheapest returns None for an empty list, so its return hint is Item | None.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class Item:
    def __init__(self, name, price, tags=None):
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line):
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text):
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items):
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items):
    return sum(item.price for item in items)

test_main.py

from annotationlib import get_annotations

import main

PARAMETERS = {"parse_item": ["line"], "load": ["text"], "cheapest": ["items"], "total": ["items"]}


def test_every_function_is_annotated():
    """Every parameter and every return value has an annotation"""
    for name, params in PARAMETERS.items():
        hints = get_annotations(getattr(main, name))
        missing = [p for p in [*params, "return"] if p not in hints]
        assert not missing, f"{name} has no annotation for {missing}"
    hints = get_annotations(main.Item.__init__)
    missing = [p for p in ["name", "price", "tags", "return"] if p not in hints]
    assert not missing, f"Item.__init__ has no annotation for {missing}"


def test_precise_types():
    """cheapest may return None, total returns a float, and tags may be None"""
    assert get_annotations(main.cheapest)["return"] == (main.Item | None), "cheapest should return Item | None"
    assert get_annotations(main.total)["return"] is float, "total should return float"
    assert get_annotations(main.Item.__init__)["tags"] == (list[str] | None), "tags should be list[str] | None"


def test_behaviour():
    """load, cheapest and total still work"""
    items = main.load("pen; 1.50\nbook; 12\n")
    got = main.cheapest(items).name, main.total(items), main.cheapest([])
    assert got == ("pen", 13.5, None), f"cheapest and total gave {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 4

Build step 2: fix what mypy finds

The module now has hints, and a new function summary. mypy reports it: Item "None" of "Item | None" has no attribute "name" [union-attr]. The bug is real: summary([]) crashes. Narrow the result of cheapest so that an empty list gives "no items", and keep the text for a non-empty list.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    cheapest returns Item | None, and None has no attribute name.

  2. Hint 2

    Store the result in best, then test if best is None: return "no items".

  3. Hint 3

    After that test, mypy narrows best to Item, and the last line type-checks.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)


def summary(items: list[Item]) -> str:
    best = cheapest(items)
    if best is None:
        return "no items"
    return f"{len(items)} items, {total(items):.2f} in total, cheapest: {best.name}"
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)


def summary(items: list[Item]) -> str:
    best = cheapest(items)
    return f"{len(items)} items, {total(items):.2f} in total, cheapest: {best.name}"

test_main.py

from main import load, summary


def test_summary():
    """Two items give a count, the total and the cheapest name"""
    got = summary(load("pen; 1.50\nbook; 12\n"))
    assert got == "2 items, 13.50 in total, cheapest: pen", f"summary returned {got!r}"


def test_empty():
    """An empty list gives "no items\""""
    got = summary([])
    assert got == "no items", f"summary([]) returned {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 3 of 4

Build step 3: document the API from its annotations

Write signature_table(functions): for each function, one line such as parse_item(line: str) -> Item, built from annotationlib.get_annotations with format=Format.STRING. Every function passed has a return annotation. It must also work for functions whose annotations name classes that do not exist at runtime.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    hints = annotationlib.get_annotations(function, format=annotationlib.Format.STRING) gives a dict of strings.

  2. Hint 2

    hints.pop("return") removes the return annotation, so the rest are the parameters, in order.

  3. Hint 3

    Join the parameters with ", ".join(f"{name}: {hint}" for name, hint in hints.items()) and use function.__name__ for the name.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import annotationlib
from collections.abc import Callable


class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)


def summary(items: list[Item]) -> str:
    best = cheapest(items)
    if best is None:
        return "no items"
    return f"{len(items)} items, {total(items):.2f} in total, cheapest: {best.name}"


def signature_table(functions: list[Callable[..., object]]) -> list[str]:
    rows: list[str] = []
    for function in functions:
        hints = annotationlib.get_annotations(function, format=annotationlib.Format.STRING)
        returns = hints.pop("return")
        params = ", ".join(f"{name}: {hint}" for name, hint in hints.items())
        rows.append(f"{function.__name__}({params}) -> {returns}")
    return rows
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import annotationlib
from collections.abc import Callable


class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)


def summary(items: list[Item]) -> str:
    best = cheapest(items)
    if best is None:
        return "no items"
    return f"{len(items)} items, {total(items):.2f} in total, cheapest: {best.name}"


def signature_table(functions: list[Callable[..., object]]) -> list[str]:
    return []

test_main.py

from main import cheapest, parse_item, signature_table, summary


def test_table():
    """One line per function, with parameter and return annotations"""
    got = signature_table([parse_item, cheapest, summary])
    expected = ["parse_item(line: str) -> Item", "cheapest(items: list[Item]) -> Item | None", "summary(items: list[Item]) -> str"]
    assert got == expected, f"signature_table returned {got!r}"


def test_undefined_names():
    """Undefined names in annotations do not raise"""

    def later(x: Missing) -> Missing2:
        pass

    got = signature_table([later])
    assert got == ["later(x: Missing) -> Missing2"], f"signature_table returned {got!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 4 of 4

Build step 4: mypy --strict

Run this one on your own Python: python -m pip install mypy, then python -m mypy --strict main.py. It reports four errors: an unused # type: ignore, a bare list, and the new untyped helper label, which labels calls. Fix them without changing what the code does, until mypy --strict reports no issues.

This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    Unused "type: ignore" comment: the ignore on the min() line is no longer needed, so delete it.

  2. Hint 2

    Missing type arguments for generic type "list": write list[Callable[..., object]] and list[str] again.

  3. Hint 3

    Function is missing a type annotation: def label(item: Item) -> str:. That also fixes the call from labels.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import annotationlib
from collections.abc import Callable


class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)


def summary(items: list[Item]) -> str:
    best = cheapest(items)
    if best is None:
        return "no items"
    return f"{len(items)} items, {total(items):.2f} in total, cheapest: {best.name}"


def signature_table(functions: list[Callable[..., object]]) -> list[str]:
    rows: list[str] = []
    for function in functions:
        hints = annotationlib.get_annotations(function, format=annotationlib.Format.STRING)
        returns = hints.pop("return")
        params = ", ".join(f"{name}: {hint}" for name, hint in hints.items())
        rows.append(f"{function.__name__}({params}) -> {returns}")
    return rows


def label(item: Item) -> str:
    return f"{item.name} ({item.price:.2f})"


def labels(items: list[Item]) -> list[str]:
    return [label(item) for item in items]
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import annotationlib
from collections.abc import Callable


class Item:
    def __init__(self, name: str, price: float, tags: list[str] | None = None) -> None:
        self.name = name
        self.price = price
        self.tags = tags or []


def parse_item(line: str) -> Item:
    name, price = line.split(";")
    return Item(name.strip(), float(price))


def load(text: str) -> list[Item]:
    return [parse_item(line) for line in text.splitlines() if line.strip()]


def cheapest(items: list[Item]) -> Item | None:
    if not items:
        return None
    return min(items, key=lambda item: item.price)  # type: ignore


def total(items: list[Item]) -> float:
    return sum(item.price for item in items)


def summary(items: list[Item]) -> str:
    best = cheapest(items)
    if best is None:
        return "no items"
    return f"{len(items)} items, {total(items):.2f} in total, cheapest: {best.name}"


def signature_table(functions: list) -> list:
    rows: list[str] = []
    for function in functions:
        hints = annotationlib.get_annotations(function, format=annotationlib.Format.STRING)
        returns = hints.pop("return")
        params = ", ".join(f"{name}: {hint}" for name, hint in hints.items())
        rows.append(f"{function.__name__}({params}) -> {returns}")
    return rows


def label(item):
    return f"{item.name} ({item.price:.2f})"


def labels(items: list[Item]) -> list[str]:
    return [label(item) for item in items]

test_main.py

from main import labels, load, summary


def test_mypy_strict():
    """mypy --strict reports no errors for main.py"""
    from mypy import api

    report, errors, status = api.run(["--strict", "--no-error-summary", "main.py"])
    assert status == 0, f"mypy found problems:\n{report}{errors}"


def test_labels():
    """labels gives the name and the price with two decimals"""
    got = labels(load("pen; 1.50\nbook; 12\n"))
    assert got == ["pen (1.50)", "book (12.00)"], f"labels returned {got!r}"


def test_summary_still_works():
    """summary is unchanged"""
    assert summary([]) == "no items", "summary([]) should still return 'no items'"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Reading VALUE annotations that use a TYPE_CHECKING import

from annotationlib import get_annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from decimal import Decimal


def total(amount: Decimal) -> Decimal:
    return amount


print(get_annotations(total))

What Python prints

NameError: name 'Decimal' is not defined

Why, and the fix

Decimal exists only for the type checker, so evaluating the annotation fails. Ask for a format that does not need the name: get_annotations(total, format=Format.FORWARDREF) or format=Format.STRING. If your tool really needs the class, import it normally.

Using annotations as converters with the __future__ import

from __future__ import annotations

from annotationlib import get_annotations


def scale(n: int) -> int:
    return n


converters = get_annotations(scale)
print(converters["n"]("3"))

What Python prints

TypeError: 'str' object is not callable

Why, and the fix

The __future__ import stores every annotation as a string, so converters["n"] is 'int', not the class. In Python 3.14 remove the import: annotations are lazy anyway. If it must stay, call get_annotations(scale, eval_str=True).

A misspelt name that fails only later

from typing import get_type_hints


def total(prices: List[float]) -> float:
    return sum(prices)


print(total([1.5, 2.5]))
print(get_type_hints(total))

What Python prints

NameError: name 'List' is not defined

Why, and the fix

The def and the call work, because nothing reads the annotation. The error appears only when a tool evaluates it, possibly far away. Write list[float], or import List from typing, and run mypy, which reports Name "List" is not defined at the def line.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Annotations are evaluated lazily

Since Python 3.14 (PEP 649), def f(a: Cls) stores a small function instead of evaluating Cls. The expression runs the first time something reads the annotations, and the result is cached. So a class can refer to itself (next: Node | None) and a function can name a class defined further down, without quotes. A misspelt name no longer fails at def time: it fails when a tool reads the annotations. With from __future__ import annotations, annotations are stored as strings instead.

annotationlib.get_annotations and Format

annotationlib.get_annotations(obj) works on functions, classes and modules, and replaces reading __annotations__ directly. Its format argument decides what you get. Format.VALUE, the default, evaluates everything and raises NameError for a name that does not exist at runtime, such as one imported only under TYPE_CHECKING. Format.FORWARDREF gives real objects where it can and ForwardRef proxies elsewhere. Format.STRING gives the source text. Documentation tools want STRING; tools that need real classes want VALUE.

Typing a module until mypy is clean

Add hints from the bottom up: parameter and return types of small helpers first, then the functions that call them. mypy then finds bugs where a hint and the code disagree, such as using a result that may be None. By default mypy skips the bodies of unannotated functions; --check-untyped-defs checks them, and --strict also requires every function to be annotated, forbids bare generics such as list, and reports unused # type: ignore comments.

Sources

Last reviewed September 29, 2026