Python Annotated Types: Syntax, Runtime Behavior, and Tooling
python annotated type: Learn how Python annotated types work, their runtime behavior, and how to use them with static type checkers like mypy.
What Python Annotated Types Are (and What They Are Not)
Python annotated types, commonly called type hints, let you declare the expected type of a variable, function parameter, or return value. They were introduced in Python 3.5 with PEP 484 and have since become a standard practice in many codebases. Annotations are optional and do not change how your code runs. They exist as metadata that tools can read to catch mistakes before execution.
Consider a simple function without annotations:
def add(a, b): return a + b
With annotations, the same function becomes:
def add(a: int, b: int) -> int: return a + b
The annotations int and -> int are not enforced at runtime. You can still call add("x", "y") and it will concatenate strings. The value of annotations lies in what tools do with them.
Variable Annotations and Function Signatures
You can annotate variables at the module or function level. The syntax is straightforward:
count: int = 0 name: str = "Alice" items: list[str] = []
For function signatures, annotations appear after each parameter and after the arrow for the return type:
def process(data: dict[str, int]) -> list[str]: return list(data.keys())
Here, data is expected to be a dictionary with string keys and integer values, and the function returns a list of strings. These annotations are stored in the __annotations__ attribute of the function object, but they are not evaluated at runtime unless you explicitly access them.
Common Types from the typing Module
The typing module provides types that go beyond built-in primitives. For example, List, Dict, Optional, and Union are commonly used.
from typing import Optional, Union def find_user(user_id: int) -> Optional[str]: # Returns a name or None return None def parse(value: Union[int, str]) -> int: return int(value)
Optional[str] is equivalent to Union[str, None]. In Python 3.10 and later, you can use str | None instead. The typing module also includes Tuple, Set, Callable, and many others. For container types, you can use the built-in generics like list[str] from Python 3.9 onward, which avoids importing List from typing.
How Annotations Behave at Runtime
Annotations are not executed. They are stored as expressions in the __annotations__ dictionary. For example:
def greet(name: str) -> str: return f"Hello, {name}" print(greet.__annotations__)
This outputs {'name': <class 'str'>, 'return': <class 'str'>}. The annotation str is evaluated at definition time and the resulting object is stored. If you use from __future__ import annotations, annotations are stored as strings instead, which can prevent issues with forward references and reduce import time.
Because annotations are not enforced, they do not affect the runtime behavior of your code. You can still pass any object to a function regardless of its annotation. This is by design; annotations are meant for static analysis, not runtime checks.
Static Type Checking with mypy
The most common use of annotations is with a static type checker like mypy. Mypy reads your Python files and uses the annotations to detect type mismatches without running the code.
def repeat(text: str, times: int) -> str: return text * times result = repeat(3, "a") # mypy will flag this
When you run mypy your_file.py, it will report that the first argument is an int but expected str. This catches bugs early in development. Mypy supports a wide range of typing features, including generics, overloads, and protocol types. To use it, you install it with pip install mypy and run it from the command line.
Performance and Overhead Considerations
Annotations have minimal runtime overhead. When a function is defined, Python evaluates the annotation expressions and stores them in __annotations__. This happens once at definition time. For most applications, the cost is negligible. However, if you have a module with thousands of functions and complex annotation expressions, you might notice a small delay during import.
Using from __future__ import annotations changes the behavior: annotations are not evaluated at all. They are stored as strings, which can speed up import and avoid issues with forward references. This is often recommended for codebases that heavily use annotations.
The real performance benefit of annotations is indirect: they enable static analysis, which catches bugs before runtime. This reduces debugging time and can prevent costly production errors.
Common Pitfalls and How to Avoid Them
One common mistake is using list instead of List in older Python versions. In Python 3.8 and earlier, you need from typing import List and use List[int]. From Python 3.9, list[int] works natively. Similarly, dict[str, int] is valid from 3.9 onward.
Another pitfall is misunderstanding Optional. Optional[str] means str or None, not an optional parameter. If a parameter has a default value of None, you should annotate it as Optional[str] or str | None.
A third issue is using Union when a simpler type would do. For example, Union[int, float] can often be replaced with float because int is a subtype of float in Python's type system.
Finally, remember that annotations are not enforced. If you rely on them for runtime validation, you need to use a library like pydantic or write your own checks.
Advanced: TypeVars and Generic Functions
The typing module includes TypeVar for creating generic functions and classes. A generic function can work with multiple types while preserving type information.
from typing import TypeVar, Sequence T = TypeVar("T") def first_element(seq: Sequence[T]) -> T: return seq[0]
Now first_element([1, 2, 3]) returns an int, and first_element(["a", "b"]) returns a str. Mypy can infer the correct type based on the argument. This is useful for functions that operate on containers without losing type safety.
Generic types become particularly powerful with classes. You can define a Stack[T] class that works with any type, and the type checker will ensure consistency across methods.