Skip to content

How to Use Function Annotations in Python

Learn how Python function annotations work with syntax examples for parameters, return types, and advanced typing with the typing module, plus how to use them with mypy for static type checking.

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

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

result = multiply("hello", 3)
print(result)

Output:

hellohellohello

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

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]

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)

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."""
    return {
        "users": [{"name": "Alice"}, {"name": "Bob"}],
        "page": page,
        "total": 42
    }

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.

Navigation