Python Type Hints
Python is dynamically typed, but since Python 3.5 you can annotate variables, parameters, and return values with types. The interpreter ignores these annotations at runtime; type checkers such as mypy and pyright read them to find bugs before your code runs: None where a string was expected, a misspelled attribute, a function called with the wrong arguments.
Type hints are now standard in serious Python codebases. They power editor autocompletion and refactoring, and they drive runtime tools like Pydantic, FastAPI, and dataclasses. This page covers the syntax you'll use daily and the features that make typing Python pleasant rather than painful.
TL;DR
- Annotate function signatures first:
def greet(name: str) -> str:. That's where most of the value is. - Use built-in generics (
list[int],dict[str, float]) andX | Nonefor optional values (3.10+). - Generics use the 3.12 syntax
def first[T](xs: list[T]) -> T, orTypeVaron older versions. - Protocol gives structural ("duck") typing; TypedDict types dict shapes; Literal restricts values.
- Checkers narrow types after
isinstance,is None, and equality checks. - Run mypy or pyright in CI with strict settings on new code. Hints do nothing unless something checks them.
Quick Example
mypy or pyright will reject User(id="1", email="a@b.c"), display_name(None), a status="banned", and any notifier object lacking a compatible send method, all without running the code.
Core Concepts
Basic Annotations
Use X | Y for unions and X | None for optional values. Optional[X] and Union[X, Y] from typing are the older spellings. Any opts out of checking; object means "anything, but you must narrow before using it", which is the safer choice.
Generics
Generic functions and classes work over a type parameter:
Bounds ([T: Number]) and constraints restrict what T can be. Prefer abstract parameter types (Sequence[T], Mapping[K, V], Iterable[T] from collections.abc) over concrete list and dict for inputs, so callers can pass any compatible collection.
Protocols: Structural Typing
A Protocol describes the shape an object needs, not its class hierarchy. Any object with matching methods and attributes satisfies it, with no inheritance required:
This is Python's duck typing made checkable, the same idea as Go interfaces.
TypedDict, Literal, and Friends
- TypedDict types JSON-like dicts with known keys:
class Movie(TypedDict): title: str; year: int. Mark optional keys withNotRequired[...]. - Literal restricts to specific values:
Literal["GET", "POST"]. - Final marks constants; ClassVar marks class-level attributes.
- Callable[[int, str], bool] types functions; ParamSpec preserves signatures through decorators.
- Self types methods that return their own class; TypeAlias or the 3.12
typestatement names complex types:type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None.
Narrowing
Checkers track what a value can be in each branch:
Write your own narrowing functions with TypeIs (3.13) or TypeGuard, and use assert_never in the final else of exhaustive matches so adding a new variant becomes a type error.
Type Checkers
Enable them gradually: start with default settings, then turn on strict (mypy) or "typeCheckingMode": "strict" (pyright) for new modules. Third-party libraries ship types inline (py.typed) or through types-* stub packages.
Best Practices
Annotate Boundaries First
Public functions, class attributes, and module interfaces give the biggest return. Local variables are usually inferred, so annotating x: int = 5 adds noise without value.
Accept Abstract, Return Concrete
Take Iterable[str] or Mapping[str, int] as parameters so callers have flexibility; return list[str] or dict[str, int] so callers know exactly what they get.
Avoid Any Leaks
Any spreads silently: anything derived from it is unchecked too. Prefer object, a Protocol, or a TypedDict. Turn on disallow_any_generics and warn_return_any as you tighten up.
Validate at Runtime Where Data Enters
Type hints don't validate input. For request bodies, config, and API responses, use a runtime validator such as Pydantic, msgspec, or attrs with validators, which reads the same annotations. The TypeScript world does the same with Zod.
Common Mistakes
Mutable Defaults Hidden Behind Types
Ignoring None in Return Types
Silencing Errors With # type: ignore Everywhere
Blanket ignores hide real bugs. Use targeted codes (# type: ignore[attr-defined]), enable warn_unused_ignores, and fix the underlying type where you can.
FAQ
Do type hints make Python faster?
No. CPython ignores annotations at runtime. Tools like mypyc and Cython can compile typed code for speed, but ordinary hints are for correctness and tooling, not performance.
mypy or pyright?
Both are excellent. Pyright is faster and is what VS Code's Pylance uses, so running it in CI keeps editor and CI diagnostics aligned. mypy has the longest history and plugins for frameworks like Django. Pick one, run it in CI, and configure your editor to match.
Should I use List[int] or list[int]?
list[int]. Built-in generics work from Python 3.9, and X | Y from 3.10. The capitalized typing.List, Dict, and Optional are legacy aliases kept for compatibility.
How do I type a decorator?
Use ParamSpec and a TypeVar for the return type so the decorated function keeps its signature: def deco[**P, R](fn: Callable[P, R]) -> Callable[P, R]. See Python decorators.
Related Topics
- Python — The language overview
- Type Systems — Static vs dynamic typing in general
- Python Decorators — Typing wrappers with ParamSpec
- TypeScript — Gradual typing for JavaScript, a close cousin
- Linting & Formatting — Running checkers alongside linters