Skip to content
intermediate

Modern Python Type Hints: What Annotations Do—and Do Not Do

Here is a function that lies to every tool you own and still runs in production:

Published 2026-09-11Updated 2026-09-1213 min read
A conceptual image blending technology and nature, symbolizing AI's role in sustainable energy.
A conceptual image blending technology and nature, symbolizing AI's role in sustainable energy. Photo by Google DeepMind on Pexels.

Here is a function that lies to every tool you own and still runs in production:

def get_user_id(name: str) -> int:
    return "user-" + name

print(get_user_id("ada"))

Run it. Python prints user-ada. No warning, no exception, no complaint from the interpreter. The annotation promised an int; the function returned a str; the program did not care.

If you have ever shipped a bug a type checker should have caught, you already know the uncomfortable part: the annotation was never a call-time contract. But the correction most people reach for—"annotations are just notes"—is also wrong, and it will mislead you the moment you touch FastAPI, pydantic, or any library that reads your hints. Annotations are runtime metadata. Whether that metadata has force depends entirely on who reads it.

That is the model this article builds: three layers, three different consumers, three different failure modes.

The Three Layers

A central annotated function branches to three observers: the interpreter stores annotations without enforcement, a static checker reports mismatches before runtime, and a runtime consumer can validate only when a framework or validator implements that behavior.
Annotations are shared metadata; storage, static checking, and runtime validation are separate mechanisms.

Every annotation passes through the same pipeline. The confusion comes from collapsing the layers.

LayerWho reads itWhat it can doWhat it cannot do
StorageThe interpreterEvaluate and store the annotation in __annotations__Check anything on call, assignment, or return
Static analysismypy, pyright, pyre, IDEsReport inconsistencies before runtimeChange runtime behavior; guarantee coverage
Runtime consumptionFrameworks, validators, your own codeEnforce a contract built from the metadataEnforce anything the consumer does not implement

The rest of this article is that table, unpacked.

What the Interpreter Actually Does With Annotations

Annotations are expressions. When Python defines a function, class, or module, it evaluates the annotation expressions and stores the results in a __annotations__ dictionary attached to that object. That is the entire runtime behavior. No check on call, no check on assignment, no check on return.

Watch the mechanism directly:

def add(a: int, b: int) -> int:
    return a + b

print(add("hello ", "world"))
print(add.__annotations__)

Output:

hello world
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

The function concatenated two strings and returned a string. The annotations dictionary still says int. Both facts are true at the same time because the annotation is data about the code, not a gate on the code. The interpreter stored a reference to the int class object next to the parameter name and moved on.

The Python runtime does not enforce function and variable type annotations. It stores them. Enforcement is a separate, optional layer built by other tools.

That separation is deliberate. PEP 484 was accepted specifically to enable static checkers and IDEs, not to add runtime type enforcement to the language. The design goal was tooling; the runtime was left alone.

One wrinkle worth knowing: when annotations are evaluated can change. With from __future__ import annotations, annotations are stored as strings and resolved later rather than evaluated eagerly at definition time. This changes timing and can avoid import-time errors from forward references. It does not change enforcement. Deferred or eager, the interpreter still checks nothing on call.

Knowledge check

Check your understanding

Answer this question before you continue.

What does this code print when it runs?
Output Prediction

Focus: Distinguish annotation storage by the interpreter from runtime enforcement during a function call.

def add(a: int, b: int) -> int:
    return a + b

print(add("hello ", "world"))

Where Enforcement Actually Lives: Static Checkers

If the interpreter will not enforce your hints, something else has to. That something is an external static type checker: mypy, pyright, pyre, pytype. These tools parse your source, build a model of your types, and report inconsistencies. They are not part of the language. They are programs that read your code the way a compiler front-end would.

This matters because "the type checker says so" is not a portable guarantee. Checkers disagree on real cases. A well-known example is an unannotated override of an annotated method:

class A:
    def handle(self, value: str) -> bytes: ...

class B(A):
    def handle(self, value):
        ...

mypy treats B.handle as (value: Any) -> Any — it assumes nothing. Pyright infers argument types from the base class and infers the return type from the body. Same code, two different verdicts. Neither is wrong; they made different assumptions about what an unannotated override means. If your team's safety argument depends on a specific checker's behavior, that argument is tied to that tool, that version, and that configuration.

There is a second, quieter problem: gradual typing. Python's type system is designed so you can annotate part of a codebase and leave the rest untyped. The escape hatch is Any, and Any is contagious. A single untyped function at a boundary can silently disable checking along every path that flows through it. A clean checker run is evidence that the annotated paths are consistent. It is not proof that the program is type-safe, because large parts of the program may not be under the checker's view at all.

Empirically, most Python code is unannotated. A large-scale study of thousands of Python projects found that only a small fraction use type hints at all, and even within those projects, most parameters and return types are unannotated. That is the environment your hints live in: sparse coverage, tool-dependent verdicts, and no runtime backstop.

My working rule: treat the checker as a linter with strong opinions and a good memory. It catches real mistakes. It is not a guard, and it is not the language.

The Runtime Escape Hatches: When Hints Do Change Behavior

Here is where the mental model usually breaks. You use FastAPI or pydantic, you annotate a request model, and suddenly a malformed payload produces a validation error. The hint appears to have teeth. Did the interpreter start enforcing types?

No. The framework read your annotations and built its own validator.

The mechanism is straightforward. A library like pydantic inspects __annotations__ on your class, constructs a schema from the types it finds, and on each instantiation validates the incoming data against that schema. If validation fails, the library raises. The hint was the input to that process. The library is the enforcer. Remove the library and the enforcement disappears with it.

This is why the same annotation can be inert in one context and load-bearing in another. A plain call to a pydantic model's __init__ goes through validation. A plain call to a function that happens to be annotated does not. The contract holds only inside the framework's boundary.

Annotated Is Metadata Transport, Not Enforcement

When you need to attach runtime-relevant information to a type — a constraint, a validator, a unit, a description — the explicit channel is Annotated:

from typing import Annotated

PositiveInt = Annotated[int, "must be > 0"]

Annotated[T, metadata] lets you carry metadata alongside the type T. Static checkers ignore the metadata and treat the annotation as plain T. Runtime tools can read the metadata and act on it.

Read that carefully: Annotated provides transport. It does not make the constraint executable. The string "must be > 0" is inert until some consumer parses it and enforces it. If no consumer does, you have written a comment that happens to live inside a type expression.

Two details bite people here. First, if a tool encounters Annotated[T, x] and has no special handling for x, it should ignore the metadata and treat the annotation as T. That is by design — Annotated is safe to use even when only some tools understand it. Second, get_type_hints() strips Annotated metadata by default. If you are writing a tool that needs the metadata, you must pass include_extras=True:

from typing import get_type_hints

get_type_hints(MyModel)                       # metadata stripped
get_type_hints(MyModel, include_extras=True)  # metadata preserved

Forget that flag and your validator silently sees int where you wrote Annotated[int, PositiveInt]. The constraint vanishes. The code still runs. That is the failure mode to watch for.

Knowledge check

Check your understanding

Answer this question before you continue.

A custom validator uses get_type_hints(MyModel) but cannot find the constraint metadata inside Annotated fields. What is the most direct fix?
Debugging

Focus: Recognize that preserving Annotated metadata for a runtime consumer requires the include_extras option.

from typing import get_type_hints

hints = get_type_hints(MyModel)

One Annotation, Three Observers

Here is the end-to-end trace that ties the layers together. Same source annotation, three different consumers, three different outcomes:

from typing import Annotated, get_type_hints

Age = Annotated[int, "must be >= 0"]

def make_user(age: Age) -> dict:
    return {"age": age}
ObserverWhat it seesWhat it does
Interpreter{'age': Annotated[int, 'must be >= 0']} in __annotations__Stores it. Nothing else.
Static checkerint (metadata ignored)Flags make_user("old") as a type error. Does not read "must be >= 0".
Runtime consumer using get_type_hints(..., include_extras=True)Annotated[int, 'must be >= 0']Can enforce the constraint — but only if you wrote the enforcement.

The checker error and the validation exception are produced by different observers, at different times, from the same source line. If you conflate them, you will assume one is doing the other's job. It is not.

Knowledge check

Check your understanding

Answer this question before you continue.

A pydantic model rejects an input whose field does not match its annotation. What is responsible for the rejection?
Scenario Interpretation

Focus: Identify which component enforces a runtime contract when a library builds validation from annotations.

Hints as Documentation: The Readability Tradeoff

Strip away the tooling and what remains is the original purpose: annotations are a reading aid. They express intent to the next person who opens the file, including you in six months. That value is real, and it is the reason to annotate at all.

But the value is not monotonic. Past a point, annotations make code harder to read, not easier. A signature like this communicates almost nothing:

def transform(records: list[dict[str, list[tuple[int, str]]]]) -> dict[str, list[int]]:
    ...

The reader has to decode a type expression before they can understand the function. A named type does the same job with a fraction of the cognitive load:

Record = dict[str, list[tuple[int, str]]]

def transform(records: list[Record]) -> dict[str, list[int]]:
    ...

The second version is not just prettier. It gives the concept a name, and names are how humans hold ideas.

There is a subtler cost. Annotating pushes you to think in types; Python's flexibility lives in behavior. When a function only needs to iterate, Iterable[T] says what you mean and accepts more inputs than list[T]. When it only needs to look things up, Mapping[K, V] is more honest than dict[K, V]. Concrete types in signatures quietly narrow your API and pull you away from duck typing — the practice of caring about what an object does, not what it is. The abstract types in the typing module exist precisely so you can keep the behavioral contract while still giving the reader a hint.

Knowledge check

Check your understanding

Answer this question before you continue.

A function only needs to iterate over its input and should accept lists, sets, and generators. Which signature best expresses that contract?
Comparison Reasoning

Focus: Choose an annotation that communicates a behavioral input contract without unnecessarily narrowing accepted inputs.

Designing Contracts That Do Not Lie

The mental model only pays off if it changes how you write code. The core discipline is to keep two contracts separate and honest:

ContractEnforced byFails when
Static contract (the hint)Type checker, IDE, reviewerChecker is not run, or Any hides the path
Runtime contract (the check)isinstance, schema validation, framework boundaryYou assumed the hint was doing this

If a value must be validated at runtime, validate it explicitly. Do not let the annotation stand in for the check. If a value is only documented by the annotation, do not expect it to be caught at runtime.

The most common way these contracts drift is a hint that no longer matches the code. A function annotated -> int that returns None on an error path is a documentation bug. The checker may miss it — especially if the path is behind an untyped call — but every caller who trusts the hint will eventually hit the None and fail somewhere far from the cause. When you find one of these, fix the hint or fix the code. Do not leave the lie in place.

The AI and Service Boundary

For AI and service code, the pattern I reach for is validate at the edges, trust hints internally. The edges are where untrusted data enters, and the edges are where the runtime contract has to be real. Here is what that looks like when a model returns structured output:

from pydantic import BaseModel, ValidationError

class UserRecord(BaseModel):
    name: str
    age: int

def parse_model_output(raw: dict) -> UserRecord | None:
    try:
        return UserRecord(**raw)
    except ValidationError as exc:
        log.warning("model output failed validation: %s", exc)
        return None

The annotation on UserRecord is what pydantic reads to build the schema. The enforcement is the try/except around construction. If the model returns {"name": "ada", "age": "old"}, pydantic raises, you log, and you return None — a signal your caller can act on. The annotation did not catch anything. The validator did.

Once a value is inside the system and has passed a real check, the hints on internal functions are documentation, and that is fine. The boundary is where the contract has to be real.

Annotated is the tool that keeps the two layers from diverging — when both consumers support it. Put the constraint in the annotation, and let both the checker and the runtime validator read from the same source. One place to change, one place to be wrong. But verify that your runtime library actually interprets the metadata you wrote. If it does not, you have a single source of description, not a single source of truth.

When Not to Reach for Annotations

Annotations are not free. They cost keystrokes, they cost reading time, and they cost maintenance when the code changes. Sometimes that cost buys nothing.

Short-lived scripts, notebooks, and exploratory code rarely benefit. The code will be deleted or rewritten before anyone reads the hints, and the hints will be stale by the time they matter. Skip them.

Highly dynamic code fights the type system. Metaprogramming, heavy **kwargs dispatch, plugin registries that build behavior at runtime — these produce Any-shaped noise when you try to annotate them. The annotations do not describe the actual behavior, and the checker cannot verify it. In that code, a clear docstring and a test often communicate more than a signature full of Any.

And the blunt one: if your team does not run a checker in CI, your hints are documentation only. That is a legitimate choice. But make it a choice. Annotating to satisfy a tool nobody runs is ceremony, not engineering.

A Three-Question Test

Before you add an annotation, ask:

  1. Will another component call this boundary? If yes, the hint is a contract for a reader or a tool. If no, the hint is probably restating the body.
  2. Does the annotation express behavior the body cannot reveal? Iterable[T] tells the caller they can pass a set, a generator, or a list. list[T] does not. That is information the body alone does not surface.
  3. Will a checker or runtime consumer actually read it? If neither will, you are writing a comment in a type-shaped costume.

Three yeses: annotate. Two or fewer: skip it, or write a docstring instead.

What to Do Next

The mental model is short: annotations are metadata the interpreter stores and never enforces; static checkers are optional tools with their own rules and blind spots; runtime validation is a separate contract you build deliberately, usually at the edges.

Here is the next action. Pick one boundary function in your own codebase — something another module imports and calls. Read its annotations. Then read its body and trace every return path. Ask two questions: does the hint match what the code actually returns, and does anything at runtime enforce the hint if a caller violates it? If the hint is wrong, fix it. If the contract matters and nothing enforces it, add the check. One function, ten minutes, and you will know which of your two contracts is real.

Knowledge check

Final check

Finish the article by checking the ideas you just learned.

A model returns {"name": "ada", "age": "old"} for a UserRecord whose age should be an int. Which design makes the runtime contract real at the service boundary?
Question 1 of 2Scenario Interpretation

Focus: Apply the validate-at-the-edge, trust-hints-internally pattern to structured output from an external model.

Which statement best corrects the misconception that a clean type-checker run proves the whole program is type-safe?
Question 2 of 2Misconception Check

Focus: Explain why a clean static-checker run is not proof that an entire Python program is type-safe.

Related sites

Build the foundations behind advanced AI systems

Use LearnLLMFast for practical LLM application foundations and LearnPyFast for the Python mechanisms that support implementation work.

LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast
Python tutorialstutorial

LearnPyFast

Beginner-friendly Python tutorials, examples, and learning paths for practical programming foundations.

PythonProgrammingBeginners
Visit LearnPyFast

Keep exploring

Related AI engineering tutorials

Continue with adjacent system layers, implementation patterns, and current AI engineering ideas.