Python Type Hints and mypy: Write Self-Documenting Code (2026)
Python's dynamic typing is one of its strengths — you can prototype quickly without declaring types for everything. But as codebases grow, the absence of type information becomes a liability. You forget what a function returns, what shape a dict has, or whether a value can be None. You discover these bugs at runtime, often in production.
Type hints, introduced in Python 3.5 and steadily improved since, let you annotate your code without changing how it executes. Run mypy over the annotated code and it catches type errors before your tests even run. FastAPI generates OpenAPI documentation from type hints. Pydantic v2 uses them for runtime validation. Your IDE provides accurate autocomplete.
This tutorial covers type hints from basic to advanced, with practical mypy usage and integration with modern Python frameworks.
Why Type Hints
Four concrete benefits:
1. IDE autocomplete that actually works. With def process(user: User) -> dict[str, int]:, your IDE knows the exact shape of the return value. Without annotations, it guesses.
2. mypy catches bugs before runtime. Passing a None where a str is expected, calling a method that does not exist on a type, returning the wrong type — mypy flags all of these statically.
3. Living documentation. Function signatures with type hints tell the next developer (or future you) what the function accepts and returns without reading the body.
4. Framework integration. FastAPI generates API docs automatically from annotated function parameters. Pydantic v2 uses class-level annotations for validation models. Both require type hints to function.
Basic Types
Python's built-in types are used directly as annotations:
def greet(name: str) -> str:
return f"Hello, {name}"
def add(x: int, y: int) -> int:
return x + y
def is_valid(value: float) -> bool:
return value > 0.0
# Variables can also be annotated
count: int = 0
label: str = "active"
ratio: float = 3.14
The -> ReturnType in the function signature specifies the return type. Use -> None for functions that do not return a value.
Collections with Generics
Since Python 3.9, built-in collection types support generic parameters directly:
# List of integers
def get_scores() -> list[int]:
return [85, 92, 78, 95]
# Dict mapping strings to any value
from typing import Any
def get_config() -> dict[str, Any]:
return {"host": "localhost", "port": 5432, "debug": True}
# Tuple with fixed types
def get_point() -> tuple[int, int]:
return (10, 20)
# Variable-length tuple of a single type
def get_ids() -> tuple[int, ...]:
return (1, 2, 3, 4, 5)
# Set of strings
def get_tags() -> set[str]:
return {"python", "web", "api"}
Before Python 3.9, you had to import these from typing: List[int], Dict[str, Any], Tuple[int, int]. In 2026, target Python 3.10+ and use the built-in syntax.
Optional and Union Types
Optional[str] means the value is either a str or None. The Python 3.10+ syntax uses the | operator:
# Python 3.10+ syntax (preferred)
def find_user(user_id: int) -> str | None:
if user_id == 0:
return None
return f"user_{user_id}"
# Equivalent pre-3.10 syntax
from typing import Optional
def find_user_old(user_id: int) -> Optional[str]:
...
# Union of multiple types
def process(value: int | str | float) -> str:
return str(value)
# mypy will catch this error:
result = find_user(42)
print(result.upper()) # ERROR: result might be None
# Fix: check first
if result is not None:
print(result.upper()) # OK: mypy narrows the type
mypy performs type narrowing — inside the if result is not None: block, it knows result is str, not str | None.
TypedDict: Typed Dicts with Known Keys
TypedDict lets you annotate dictionaries where you know the exact keys and their types — common when working with JSON API responses or configuration dictionaries.
from typing import TypedDict
class UserRecord(TypedDict):
id: int
name: str
email: str
active: bool
class UserRecordPartial(TypedDict, total=False):
# total=False makes all keys optional
name: str
email: str
def get_user(user_id: int) -> UserRecord:
return {
"id": user_id,
"name": "Alice",
"email": "[email protected]",
"active": True,
}
user = get_user(1)
print(user["name"]) # OK
print(user["nonexistent"]) # mypy ERROR: TypedDict has no key 'nonexistent'
Dataclasses with Type Annotations
Dataclasses use field annotations directly:
from dataclasses import dataclass, field
@dataclass
class Product:
id: int
name: str
price: float
tags: list[str] = field(default_factory=list)
description: str | None = None
def discounted_price(self, pct: float) -> float:
return self.price * (1 - pct / 100)
p = Product(id=1, name="Widget", price=9.99)
print(p.discounted_price(10)) # 8.991
print(p.nonexistent) # mypy ERROR: has no attribute 'nonexistent'
Dataclasses generate __init__, __repr__, and __eq__ automatically from the annotated fields. mypy type-checks the generated __init__, so passing the wrong type to the constructor is caught statically.
TypeVar and Generic Classes
TypeVar creates a type variable — a placeholder that gets resolved to a specific type when the function or class is used. Use it for functions that preserve the type of their argument:
from typing import TypeVar, Generic
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
result = first([1, 2, 3]) # result: int
result2 = first(["a", "b"]) # result2: str
# Generic class
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
s: Stack[int] = Stack()
s.push(42)
s.push("oops") # mypy ERROR: str vs int
Protocol: Structural Subtyping
Protocol defines an interface by the methods a type must have — duck typing with type safety. No inheritance required; a class satisfies a Protocol if it has the right methods.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def get_area(self) -> float: ...
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
def draw(self) -> None:
print(f"Circle r={self.radius}")
def get_area(self) -> float:
return 3.14159 * self.radius ** 2
class Rectangle:
def __init__(self, w: float, h: float) -> None:
self.w, self.h = w, h
def draw(self) -> None:
print(f"Rect {self.w}x{self.h}")
def get_area(self) -> float:
return self.w * self.h
def render_all(shapes: list[Drawable]) -> None:
for shape in shapes:
shape.draw()
# Both Circle and Rectangle satisfy Drawable without inheriting from it
render_all([Circle(5.0), Rectangle(3.0, 4.0)]) # OK
Protocols are the correct way to write type-safe code against interfaces you do not own — standard library types, third-party objects, or any class you cannot modify.
Running mypy
Install and run:
pip install mypy
# Check a directory
mypy src/
# Ignore missing stubs for packages without type info
mypy src/ --ignore-missing-imports
# Strict mode: enables all optional checks
mypy src/ --strict
Configuring mypy in pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
warn_return_any = true
warn_unused_ignores = true
Common mypy Errors and Fixes
# ERROR: Item "None" of "str | None" has no attribute "upper"
def bad(s: str | None) -> str:
return s.upper() # mypy: s might be None
# FIX 1: Guard with None check
def good1(s: str | None) -> str:
if s is None:
return ""
return s.upper()
# FIX 2: Use assert (for functions that should never get None)
def good2(s: str | None) -> str:
assert s is not None
return s.upper()
# ERROR: Incompatible return value type
def get_count() -> int:
return "five" # mypy: expected int, got str
# ERROR: Argument of type "str" cannot be assigned to "int"
def add(x: int, y: int) -> int:
return x + y
add(1, "2") # mypy: str vs int
To suppress a specific mypy error on a line:
result = some_dynamic_function() # type: ignore[no-any-return]
Use # type: ignore sparingly — it silences the checker entirely on that line.
Pydantic v2 Integration
Pydantic v2 uses type hints to define validation models. The annotations drive both the schema and the runtime validation:
from pydantic import BaseModel, EmailStr, field_validator
class User(BaseModel):
id: int
name: str
email: EmailStr
age: int | None = None
tags: list[str] = []
@field_validator("age")
@classmethod
def age_must_be_positive(cls, v: int | None) -> int | None:
if v is not None and v < 0:
raise ValueError("age must be positive")
return v
# Pydantic validates at construction time
user = User(id=1, name="Alice", email="[email protected]")
print(user.model_dump())
# Type error caught at runtime by Pydantic
try:
bad = User(id="not-an-int", name="Bob", email="bad-email")
except Exception as e:
print(e) # ValidationError with field-level details
mypy understands Pydantic models natively (via the mypy-pydantic plugin):
pip install pydantic mypy
# Add to pyproject.toml:
# [tool.mypy]
# plugins = ["pydantic.mypy"]
FastAPI Integration
FastAPI uses type hints on path operation functions to generate request parsing, validation, and OpenAPI documentation automatically:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
available: bool = True
@app.get("/items/{item_id}", response_model=Item)
async def get_item(item_id: int) -> Item:
# item_id is already an int (FastAPI converts and validates from path)
if item_id == 0:
raise HTTPException(status_code=404, detail="Not found")
return Item(name="Widget", price=9.99)
@app.post("/items/", response_model=Item)
async def create_item(item: Item) -> Item:
# item is parsed and validated from the request body
return item
FastAPI reads the response_model=Item annotation to generate the response schema in /docs. It reads the item: Item parameter to parse the JSON body. It reads item_id: int to convert the URL path segment from string to int and validate it.
Type hints in FastAPI are not decoration — they are executable specifications.