How to Use Function Annotations in Python
Function annotations are a feature introduced in Python 3 (via PEP 3107) that lets you attach metadata to function parameters and return values. They provide a standardized way to document expected types, add descriptions, or supply hints that tools like type checkers, IDEs, and documentation generators can use to improve your development experience.
Importantly, Python itself does not enforce function annotations at runtime; they are purely informational. Their real power comes from third-party tools like mypy for static type checking and IDEs like PyCharm or VS Code for intelligent code completion.
Basic Syntax
Function annotations use a simple syntax with colons (:) for parameters and an arrow (->) for the return type.
Annotating Parameters
Add a colon after the parameter name, followed by the annotation expression:
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
print(greet("Alice", 2))
Output:
Hello, Alice! Hello, Alice!
Annotating Return Types
Use the -> arrow before the colon that starts the function body:
def add(a: int, b: int) -> int:
return a + b
print(add(3, 5))
Output:
8
Annotating *args and **kwargs
You can also annotate variadic parameters:
def log_messages(*messages: str, **metadata: str) -> None:
for msg in messages:
print(f"LOG: {msg}")
for key, value in metadata.items():
print(f" {key}={value}")
log_messages("Starting process", "Loading data", source="API", version="2.1")
Output:
LOG: Starting process
LOG: Loading data
source=API
version=2.1
Annotations on *args and **kwargs describe the type of each individual item, not the container itself. So *messages: str means each message is a str, not that messages is a str.
Accessing Function Annotations
Python stores annotations in a special __annotations__ attribute on the function object. There are several ways to access them.
Using __annotations__
The most direct way is accessing the dictionary attribute:
def calculate(x: int, y: float = 0.0) -> float:
return x + y
print(calculate.__annotations__)
Output:
{'x': <class 'int'>, 'y': <class 'float'>, 'return': <class 'float'>}
The dictionary maps parameter names to their annotations, with the special key 'return' for the return type annotation.
Using the inspect Module
The inspect module provides richer introspection through getfullargspec():
import inspect
def fib(n: int, output: list = []) -> list:
if n == 0:
return output
if len(output) < 2:
output.append(1)
else:
output.append(output[-1] + output[-2])
return fib(n - 1, output)
spec = inspect.getfullargspec(fib)
print("Arguments:", spec.args)
print("Defaults:", spec.defaults)
print("Annotations:", spec.annotations)
Output:
Arguments: ['n', 'output']
Defaults: ([],)
Annotations: {'return': <class 'list'>, 'n': <class 'int'>, 'output': <class 'list'>}
Using typing.get_type_hints()
For more complex annotations (especially with forward references or string annotations), typing.get_type_hints() resolves them properly:
from typing import get_type_hints
def process(data: "list[int]") -> "dict[str, int]":
return {"sum": sum(data), "count": len(data)}
print(get_type_hints(process))
Output:
{'data': list[int], 'return': dict[str, int]}
Annotations Are Not Enforced at Runtime
A critical point to understand is that Python completely ignores annotations during execution. They don't cause type errors, validations, or any behavioral changes.
def multiply(a: int, b: int) -> int:
return a * b
# Passing strings instead of ints (no runtime error!)
result = multiply("hello", 3)
print(result)
Output:
hellohellohello
Even though the annotations specify int for both parameters and the return type, Python happily accepts strings and returns a string. Annotations are metadata only: they do not enforce types unless you use a tool like mypy.
Using the typing Module for Complex Types
For annotations beyond simple built-in types, the typing module provides a rich set of type constructs:
from typing import List, Dict, Optional, Tuple, Union
def find_user(
user_id: int,
fields: Optional[List[str]] = None
) -> Dict[str, Union[str, int]]:
"""Look up a user by ID and return selected fields."""
user = {"name": "Alice", "age": 30, "id": user_id}
if fields:
return {k: v for k, v in user.items() if k in fields}
return user
print(find_user(101))
print(find_user(101, fields=["name"]))
Output:
{'name': 'Alice', 'age': 30, 'id': 101}
{'name': 'Alice'}
Common typing Constructs
| Type | Description | Example |
|---|---|---|
List[int] | List of integers | [1, 2, 3] |
Dict[str, int] | Dictionary with string keys, int values | {"age": 30} |
Tuple[int, str] | Tuple with specific element types | (1, "hello") |
Optional[str] | Either str or None | "hello" or None |
Union[int, float] | Either int or float | 42 or 3.14 |
Callable[[int], str] | Function taking int, returning str | str |
Starting with Python 3.10, you can use the | operator instead of Union, and built-in types like list and dict directly support subscripting, so you no longer need to import from typing:
# Python 3.10+
def process(data: list[int], label: str | None = None) -> dict[str, int]:
result = {"sum": sum(data), "count": len(data)}
return result
Static Type Checking with mypy
The primary practical application of function annotations is static type checking using tools like mypy. It analyzes your code before execution and reports type inconsistencies.
Installation
pip install mypy
Example
Save the following as example.py:
def slice_string(text: str, start: int, end: int) -> str:
return text[start:end]
# This should be flagged: passing a list instead of a str
result = slice_string([1, 2, 3, 4, 5], 2, 4)
print(result)
Run mypy:
mypy example.py
Output from mypy:
test.py:5: error: Argument 1 to "slice_string" has incompatible type "list[int]"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
The code would still run successfully in Python (since lists support slicing too), but mypy catches the type mismatch at analysis time, helping you find bugs before they reach production.
Annotations with Classes
Function annotations work naturally with class methods:
class Calculator:
def __init__(self, precision: int = 2) -> None:
self.precision = precision
def divide(self, a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return round(a / b, self.precision)
def history(self) -> list[str]:
return []
calc = Calculator(precision=3)
print(calc.divide(10, 3))
print(calc.divide.__annotations__)
Output:
3.333
{'a': <class 'float'>, 'b': <class 'float'>, 'return': <class 'float'>}
Practical Example: Self-Documenting API Function
Annotations make functions self-documenting, which is especially valuable in team environments:
from typing import Optional, Union
def fetch_users(
page: int = 1,
per_page: int = 20,
active_only: bool = True,
search: Optional[str] = None
) -> dict[str, Union[list, int]]:
"""Fetch paginated user list from the database."""
# Simulated response
return {
"users": [{"name": "Alice"}, {"name": "Bob"}],
"page": page,
"total": 42
}
# The annotations clearly communicate:
# - what types each parameter expects
# - which parameters are optional
# - what the function returns
print(fetch_users.__annotations__)
Output:
{'page': <class 'int'>, 'per_page': <class 'int'>, 'active_only': <class 'bool'>, 'search': typing.Optional[str], 'return': dict[str, typing.Union[list, int]]}
Summary
| Feature | Details |
|---|---|
| Syntax (parameters) | param: type |
| Syntax (return) | -> type |
| Enforced at runtime? | ❌ No; purely informational |
| Stored in | function.__annotations__ dict |
| Primary tool | mypy for static type checking |
| Complex types | Use the typing module |
| Python version | 3.0+ (enhanced in 3.5, 3.9, 3.10) |
Conclusion
Function annotations are a lightweight yet powerful feature that improves code quality without changing runtime behavior. They serve as inline documentation, enable static type checking with tools like mypy, and power IDE features like autocomplete and error highlighting. By adopting annotations, especially with the typing module, you make your code more readable, maintainable, and less prone to type-related bugs.