Skip to main content

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
note

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
warning

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

TypeDescriptionExample
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 float42 or 3.14
Callable[[int], str]Function taking int, returning strstr
Python 3.10+ syntax

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)
note

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

FeatureDetails
Syntax (parameters)param: type
Syntax (return)-> type
Enforced at runtime?❌ No; purely informational
Stored infunction.__annotations__ dict
Primary toolmypy for static type checking
Complex typesUse the typing module
Python version3.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.