Python Function Annotations: Syntax and Practical Use
python function annotation: Learn how Python function annotations work, what they do at runtime, and how to use them with type checkers and frameworks like FastAPI.
Python function annotation is a syntax feature that lets you attach type information to function parameters and return values. The syntax is compact: a colon after the parameter name introduces its annotation, and an arrow before the return type introduces the return annotation.
def calculate_total(items: list[float], discount: float = 0.0) -> float: return sum(items) * (1 - discount)
The annotations are stored in the function's __annotations__ attribute and are accessible at runtime:
print(calculate_total.__annotations__) # {'items': list[float], 'discount': float, 'return': float}
The critical thing to understand is that Python itself does not enforce these annotations. If you pass a string where a float is expected, the function runs normally. The annotation is metadata, not a constraint. This article explains what annotations actually do, where they are useful, and the practical implications of using them in real code.
Basic Syntax and Placement
Parameter annotations come after the parameter name and before the default value:
def connect(host: str, port: int = 5432, timeout: float | None = None) -> bool: # ... return True
Return type annotations use the arrow syntax. You can annotate regular parameters, parameters with default values, *args, **kwargs, and return values:
def process(*items: str, **options: int) -> dict[str, int]: result: dict[str, int] = {} for item in items: result[item] = options.get(item, 0) return result
Variable annotations work the same way outside function signatures:
cache: dict[str, list[int]] = {}
The annotation expression can be any valid Python expression, but in practice it should be a type expression. Using arbitrary expressions as annotations is legal but rarely useful and can cause surprising runtime behavior.
What Happens at Runtime
Annotations are evaluated at function definition time, not at call time. This matters because an annotation that references a name not yet defined raises a NameError when the module is imported:
def parse(data: CustomType) -> None: pass class CustomType: pass
This code fails at the def statement because CustomType is not yet bound. One solution is to define the class before the function. Another is to enable postponed evaluation of annotations:
from __future__ import annotations def parse(data: CustomType) -> None: pass class CustomType: pass
With this import, annotations are stored as strings and evaluated lazily, so forward references work without extra effort. The __annotations__ dictionary still exists, but the values are strings rather than type objects until something calls typing.get_type_hints().
Type Checking Without Runtime Cost
The primary practical value of function annotations is static type checking. Tools like mypy and pyright read the annotations and report type mismatches before the code runs:
def divide(a: float, b: float) -> float: return a / b result = divide("10", 0) # mypy: Argument 1 has incompatible type "str"; expected "float"
Type checkers catch real bugs: passing the wrong type, returning the wrong type, or calling a method that does not exist on the annotated type. The annotation itself adds no measurable runtime overhead because it is just a dictionary entry evaluated once at definition time.
For a codebase that relies on annotations for static checking, the annotation is the contract. Changing a parameter annotation from str to int is a breaking change for every caller, and type checkers will surface every affected call site.
Annotations as Metadata for Frameworks
Several Python frameworks read annotations at runtime to generate behavior. FastAPI uses parameter annotations to define request validation and OpenAPI schemas:
from fastapi import FastAPI app = FastAPI() @app.post("/items/") def create_item(name: str, price: float) -> dict[str, float]: return {"price": price}
Pydantic models use class-level annotations for validation and serialization. In these cases, the annotation is not just documentation; it drives actual behavior. The same annotation that mypy uses for static checking also tells FastAPI how to parse and validate an incoming request body.
This dual use is one of the strongest reasons to annotate functions consistently. A function that is only called internally may not need annotations, but a function exposed as an API endpoint needs them for both type safety and framework behavior.
Common Annotation Patterns
The typing module provides the building blocks for realistic annotations:
from typing import Callable, Optional def retry( operation: Callable[[], bool], attempts: int = 3, on_failure: Optional[Callable[[Exception], None]] = None, ) -> bool: for _ in range(attempts): if operation(): return True if on_failure: on_failure(Exception("operation failed")) return False
Modern Python (3.10+) supports the | union syntax directly, which reads more naturally than Optional:
def lookup(key: str) -> str | None: ...
Generics describe container relationships:
def group_by(records: list[dict[str, str]], key: str) -> dict[str, list[dict[str, str]]]: groups: dict[str, list[dict[str, str]]] = {} for record in records: groups.setdefault(record[key], []).append(record) return groups
For functions that accept or return functions, Callable is the right annotation. For iterators and generators, Iterator and Generator from typing are appropriate. The Any type should be used sparingly; it disables type checking at that boundary.
Performance and Runtime Considerations
Annotations are evaluated once at function definition time. The cost is a single expression evaluation per annotation, which is negligible for typical code. However, if an annotation references a large object or performs a computation, that work happens at import time:
def process(data: expensive_operation()) -> None: pass
This is poor practice. Annotations should be type expressions, not runtime computations. The __annotations__ dictionary itself is small, but if you annotate thousands of functions, the memory usage is proportional to the number of annotations. For most applications this is irrelevant.
The more significant performance consideration is that annotations enable tools like mypy and pyright, which add a separate checking step to the development workflow. The runtime behavior of the annotated function is identical to an unannotated version. There is no runtime validation, no type coercion, and no branch that checks the annotation at call time.
If you need actual runtime validation, use a library like pydantic or write explicit checks in the function body. Annotations alone will not protect you from bad input at runtime.
Version Compatibility and Forward References
Python 3.9 and earlier require importing types from typing:
from typing import List, Dict def process(items: List[str]) -> Dict[str, int]: ...
Python 3.9+ allows built-in generics:
def process(items: list[str]) -> dict[str, int]: ...
Python 3.10+ supports the X | Y union syntax. Python 3.12+ improved generic syntax further with PEP 695, allowing type parameters directly on function and class definitions:
def first[T](items: list[T]) -> T: return items[0]
The from __future__ import annotations import works in Python 3.7+ and postpones annotation evaluation. This helps with forward references and circular imports, but it changes the runtime value of __annotations__ from type objects to strings. Code that reads annotations at runtime must call typing.get_type_hints() to resolve them, which adds a small cost and requires that all referenced names are importable at that point. Frameworks like FastAPI handle this internally, but custom code that inspects annotations must account for it.