Python Type Hints: From PEP 484 to Modern Syntax, with Style Rules

Type hints let you write down what kind of values a function expects and returns. Python itself does not enforce them; separate tools read them to find mistakes before the code runs, and editors use them for completion. Since PEP 484 introduced them for Python 3.5, the syntax has become noticeably lighter.
What an annotation does, and does not do
An annotation is an expression attached to a parameter, a return value or a variable. Python evaluates and stores it, and otherwise ignores it:
def label(code: int) -> str:
return f"#{code}"
print(label("A7"))
print(label.__annotations__)
#A7
{'code': <class 'int'>, 'return': <class 'str'>}
The call with a string runs without complaint. PEP 8 is explicit that this is by design: "Python interpreters by default should not issue any messages due to type checking and should not alter their behavior based on annotations." A type checker, run as a separate step, does report the mistake. mypy, given the same file, printed:
lab.py:5: error: Argument 1 to "label" has incompatible type "str"; expected "int" [arg-type]
That is the whole bargain: you write a little more, and a tool catches a class of errors early, often ones that would otherwise surface far from their cause. Some libraries, such as data validation frameworks, do read annotations at runtime for their own purposes, but that is their choice, not the language's.
The syntax, step by step
The type hint PEPs added syntax in stages. Most code today can use the newest forms:
| Need | Modern form | Since |
|---|---|---|
| Function signature | def f(x: int) -> str: | 3.5 (PEP 484) |
| Variable annotation | count: int = 0 | 3.6 (PEP 526) |
| Built-in collections | list[int], dict[str, float] | 3.9 (PEP 585) |
| Unions and optional values | int | None | 3.10 (PEP 604) |
| Generic functions and classes | def first[T](items: list[T]) -> T: | 3.12 (PEP 695) |
Older code spells the same ideas with imports from the typing module: List[int], Optional[int], Union[int, str] and TypeVar. They still work, but the built-in forms need no import.
A few other tools from typing come up constantly: TypedDict for dictionaries with a fixed set of keys (PEP 589), Protocol for structural "duck" typing, where any object with the right methods qualifies (PEP 544), Literal for specific values, and Final for names that should not be reassigned. The example below type-checks cleanly with mypy:
from typing import Protocol, TypedDict
def total(prices: list[float], discount: float | None = None) -> float:
subtotal = sum(prices)
if discount is not None:
subtotal *= 1 - discount
return round(subtotal, 2)
class Item(TypedDict):
name: str
price: float
class SupportsClose(Protocol):
def close(self) -> None: ...
def first[T](items: list[T]) -> T:
return items[0]
def shutdown(resource: SupportsClose) -> None:
resource.close()
Style rules for annotations
PEP 8 has short rules for formatting annotations, which pycodestyle checks:
- Use the normal rules for colons: no space before, one space after (
x: int, notx:intorx : int). - Always put spaces around the return arrow:
def f() -> int:. - Without an annotation, a default takes no spaces (
limit=1000); with one, it takes spaces on both sides (sep: str = ","). pycodestyle reports the two mistakes as E251 and E252. - For variable annotations, one space after the colon and exactly one on each side of
=.
Beyond formatting, a few habits keep hints readable: give complex types a name with a type alias, prefer abstract types such as Iterable[str] or Mapping[str, int] for parameters so callers can pass any suitable object, and return concrete types.
When annotations are evaluated
Annotations used to be evaluated when the function was defined, which caused problems with forward references, names defined later in the module. Two mechanisms changed that. PEP 563 added from __future__ import annotations in Python 3.7, which stores annotations as strings. PEP 649, implemented in Python 3.14, makes deferred evaluation the default: annotations are computed only when something asks for them. In practice, on 3.14 you can refer to a class inside its own methods' annotations without quotes.
Type checkers
Several independent type checkers read the same annotations: mypy, the reference checker developed alongside PEP 484, as well as others including Microsoft's pyright. They mostly agree on the standard, but differ in defaults, speed and in how they treat unannotated code. mypy, for example, does not check the body of an unannotated function unless you pass --check-untyped-defs or enable strict mode.
For third-party libraries, checkers rely on annotations shipped with the package (marked by a py.typed file) or on separate stub files (.pyi) such as those collected in the typeshed repository, which PEP 8 mentions.
Adopting hints gradually
- Start with public interfaces: function signatures that other modules call. They give the most value to readers and tools.
- Run a checker in CI with permissive settings, then tighten module by module.
- Use escape hatches deliberately:
Anywhere a type is truly dynamic, and# type: ignore[code]with the specific error code, never a bare ignore. - Let types replace some documentation: once a signature says
timeout: float, the docstring can explain the unit and edge cases instead of the type; see our guide to docstrings.
The checker on our home page reports E251 and E252 for spacing around defaults. Type errors themselves need a type checker; our comparison of flake8 and pylint explains where linting ends and type checking begins.
Sources
- PEP 484 – Type Hints (Python 3.5); PEP 526, PEP 585, PEP 604, PEP 695, PEP 544, PEP 589, PEP 563 and PEP 649.
- Python documentation,
typingmodule. - PEP 8, "Function Annotations" and "Variable Annotations".
- mypy documentation (untyped definitions, error codes). Output above produced with mypy 2.4 on Python 3.13.
Docstrings, comments & typesDocstrings and PEP 257
One-line summaries in the imperative, a blank line, then the details: what PEP 257 asks of docstrings, the common formats and the tools that check them.
PEP 8, section by sectionPEP 8 programming recommendations
Why `is None` beats `== None`, why bare `except:` is risky and why a lambda should not get a name: the habits at the end of PEP 8, with tested examples.
Linters & formattersflake8 and pylint compared
Same file, two linters: flake8 found nothing, pylint found six things. What each tool checks, how to configure it and when to use both.