Function annotations are Python's built-in syntax for attaching metadata to function parameters and return values. You write a colon after each parameter name followed by an expression, and an arrow before the ending colon followed by an expression for the return value. While annotations can technically hold any Python expression, their primary and overwhelmingly most common use is for type hints: declaring the expected types of parameters and the type of the value the function returns.
Python does not enforce annotations at runtime. The interpreter stores them in the function's annotations dictionary and otherwise ignores them. This might seem to make annotations useless, but the value comes from tools that read them: static type checkers like mypy and pyright, IDE features like autocompletion and inline error detection, and documentation generators that produce API references from annotated source code. Annotations are a contract between you and your tools, not between you and the Python runtime.
The syntax of function annotations
Parameter annotations go after the parameter name, separated by a colon. Return annotations go after the closing parenthesis and before the colon, separated by an arrow (a hyphen followed by a greater-than sign):
def greet(name: str, times: int = 1) -> str:
return f"Hello, {name}! " * timesThe parameter name is annotated as str, meaning the function expects a string. The parameter times is annotated as int with a default value of 1, so callers can omit it and get a single greeting. The return annotation -> str says the function returns a string. None of these annotations are enforced; calling greet(42, "oops") is valid Python and will produce a runtime error when the multiplication by "oops" fails, not when the integer 42 is passed as name.
Annotations can be any expression, not just type names. Python evaluates the annotation expressions at function definition time and stores the results. This means you can annotate a parameter as 42 or "hello" or some_function(), and Python will store those values in annotations without complaint. In practice, you should only use type expressions as annotations, because that is what every tool in the Python ecosystem expects. Using non-type annotations will confuse type checkers and IDEs without providing any benefit.
Built-in types as annotations
For simple cases, Python's built-in types work directly as annotations. The types int, float, str, bool, bytes, list, dict, set, tuple, and frozenset are all valid annotations without any imports. For functions that accept or return None, use None as the annotation. A parameter annotated as None expects exactly the None object, which is a valid but uncommon use case. More commonly, a parameter that can be None or a specific type uses Optional from the typing module:
def find_user(user_id: int) -> dict | None:
if user_id > 0:
return {"id": user_id, "name": "Ada"}
return NoneThe return annotation dict | None, valid in Python 3.10 and later, says the function returns either a dictionary or None. Earlier Python versions would use Optional[dict] or Union[dict, None] from the typing module. The pipe syntax for union types is cleaner and has become the preferred style in modern Python code.
The typing module for complex annotations
The typing module provides types that cannot be expressed with built-in names alone. List[int] specifies a list of integers, not just any list. Dict[str, float] specifies a dictionary with string keys and float values. Callable[[int, str], bool] specifies a function that takes an int and a str and returns a bool. These parameterized generics let you describe the shape of your data, not just its top-level type:
from typing import Callable
def apply_transform(
data: list[int],
transform: Callable[[int], int]
) -> list[int]:
return [transform(x) for x in data]The data parameter is a list of integers, the transform parameter is a callable that takes an integer and returns an integer, and the function returns a list of integers. A type checker can verify that callers pass compatible arguments and can flag errors if someone passes a transform function that returns a string. Without annotations, these mismatches would only surface at runtime, possibly in production, long after the code was written.
The typing module has evolved significantly across Python versions. Python 3.9 made list, dict, and other built-in collection types directly usable as generics, so list[int] works without importing List from typing. Python 3.10 added the pipe operator for unions, so int | str replaces Union[int, str]. Python 3.12 simplified several typing constructs further. When writing annotations, use the most modern syntax your target Python version supports, because the older typing module imports exist primarily for backward compatibility.
Annotations for documentation and tooling
Annotations serve as machine-readable documentation that tools consume automatically. An IDE with Python support reads annotations and provides autocompletion for method calls on annotated objects. If a function is annotated to return a list of strings and you assign its result to a variable, the IDE knows that variable is a list of strings and suggests string methods when you iterate over it. This feedback loop helps you catch type errors before running any code.
Annotations also serve as documentation for human readers. A function signature like def process(items: list[dict[str, Any]], config: ProcessConfig | None = None) -> Report tells you far more about what the function expects and returns than def process(items, config=None). The annotations answer the questions that every reader of the function has: what shape should the items have, what is the config object, and what kind of result do I get back. The article on documenting Python functions with docstrings explains how to complement annotations with prose descriptions so that both the types and the intent are clear. Used together with the calling conventions from positional and keyword arguments in Python, annotations and docstrings form a complete picture of a function's interface.
When annotations are not enough
Annotations describe types but not constraints. Annotating a parameter as int does not say whether negative values are allowed, whether zero is valid, or whether the value must be less than 100. These constraints belong in docstrings, in validation code inside the function body, or in runtime assertion libraries like pydantic that use annotations for validation rather than just documentation.
Annotations also cannot describe complex relationships between parameters. If a function requires that two parameters have the same length, or that one parameter must be larger than another, these are value-level constraints that the type system cannot express. Annotations give you the shape of the data; the meaning of the values within those shapes is the domain of documentation, testing, and runtime validation.
Rune AI
Key Insights
- Annotations are written after parameter names and before the colon in the function signature: def foo(x: int) -> str.
- Python does not enforce annotations at runtime; they are metadata for type checkers and IDEs.
- Use the typing module for complex types: List, Dict, Optional, Union, Callable, and others.
- Annotations and docstrings serve different purposes; use annotations for types and docstrings for explanations.
- The -> arrow before the colon specifies the return type annotation.
Frequently Asked Questions
Do Python function annotations enforce types at runtime?
What is the difference between function annotations and docstrings?
How do I annotate a parameter that can be multiple types?
Conclusion
Function annotations are Python's syntax for attaching type metadata to parameters and return values. They are ignored by the interpreter but are invaluable for static type checkers, IDE autocompletion, and code documentation. Adding annotations to your functions is a low-cost habit that pays off as your codebase grows and as tooling improves.
More in this topic
Define and Call Python Functions
Learn the exact syntax for defining and calling Python functions with the def keyword, including naming rules, the function body, and how the call stack works.
Python Function Parameters and Arguments
Learn the difference between parameters and arguments, how to pass values by position and by keyword, and the rules for default parameter values.
Python Functions Explained
Learn what Python functions are, why they matter for organizing code, and how they form the foundation of non-trivial programs.