This lesson is the practical reference for day-to-day type annotations: every primitive built-in type, the container generics, the older typing.List and typing.Dict style found in legacy code, and the runtime APIs that read annotations back.
Python's core scalar types each have a single, lowercase name that works as a type hint with no import. The same name is both the runtime type and the annotation, which keeps the syntax tight.
| Type | What it represents | Example value |
|---|---|---|
int | Whole numbers, unbounded precision | 42, -1, 10_000 |
float | Double-precision floating point | 3.14, 19.99, 0.0 |
str | Text, immutable Unicode strings | "Alice", "Wireless Mouse" |
bool | Truth value, subclass of int | True, False |
bytes | Immutable sequence of raw byte values | b"PNG\x89" |
None | The only NoneType value | None |
A quick sweep of variable annotations using each one:
Two of these deserve a closer look. bool is a subclass of int, which means True == 1 and False == 0 everywhere. A function that takes an int will accept a bool and the type checker won't complain, because of inheritance. This can cause issues when the value flows somewhere that treats True as 1.
If a parameter must be exactly an int and not a bool, a runtime check is required; the type system won't help there.
bytes is the type used for file contents, network responses, image data, or anything binary. It's distinct from str and the two don't mix without an explicit .encode() or .decode(). A function that processes raw upload data uses bytes; one that reads a username uses str.
The annotation None is the trickiest of the primitives. Used as a value, None is a singleton. Used as a type, it's a shorthand for type(None), which type checkers accept anywhere a type is expected. The most common use is as a return annotation for functions that don't produce a useful value.
A -> None function should not have an explicit return some_value statement. Either omit the return entirely or use a bare return to exit early. Type checkers will flag a -> None function that returns anything other than None.
The full shape of a function annotation is def name(param: Type, ...) -> ReturnType:. Every piece is optional, but mixing annotated and unannotated parameters in the same function is a smell. Annotate everything or nothing. Half-annotated signatures confuse readers and give the type checker less to work with.
Default values sit in the usual place, after the annotation. The annotation describes the parameter's type; the default supplies a value when the caller omits the argument. The two are independent: a parameter can have a default without an annotation, or an annotation without a default.
Keyword-only and positional-only markers (* and /) work alongside annotations exactly as they do in plain function definitions:
The annotation dict[str, int | str | bool] is doing a lot of work there, and it shows why specific types are worth the effort. A reader can tell from the signature alone that the result is a dict with string keys whose values can be one of three primitive types, without scrolling through the body to find out.
Variable-argument parameters use their unpacked element type, not the container. *args is implicitly a tuple, so the annotation describes one element; **kwargs is implicitly a dict, so the annotation describes one value.
*categories: str means each positional argument is a string, so the tuple categories is implicitly tuple[str, ...]. **fields: str means each keyword argument value is a string, so the dict fields is implicitly dict[str, str].
Variables outside function signatures can also be annotated. The form is name: Type = value, and the value half is optional. A bare annotation declares intent without storing a value.
The bare last_order_id: int line doesn't create a runtime variable. It only records the intended type in the enclosing scope's __annotations__. Reading last_order_id before assigning to it raises NameError, the same as any unbound name.
Annotations at module scope go into the module's __annotations__ dict. Annotations inside a function are local: they're parsed and stored, but __annotations__ for a function body isn't exposed in the same way (CPython evaluates and discards them for performance, so they're useful as documentation but not introspectable).
Class-body annotations are the most useful of the three because they double as attribute declarations that type checkers and tools like dataclasses consume:
The in_stock: bool = True line both declares the attribute's type and gives it a default at the class level. The name: str and price: float lines declare types without creating class attributes, which is the standard pattern when __init__ will set them per instance.
Container annotations describe both the container and what it holds. Since Python 3.9 (PEP 585), the built-in container types support subscript syntax directly. This is the form to use in any code targeting 3.9 or later.
| Annotation | Meaning |
|---|---|
list[int] | List of integers |
list[str] | List of strings |
dict[str, float] | Dict mapping strings to floats |
tuple[str, int] | Two-element tuple: string then int |
tuple[int, ...] | Variable-length tuple of ints |
set[str] | Set of strings |
frozenset[int] | Frozen set of ints |
type[Product] | The Product class itself, not an instance |
These work anywhere a type goes: parameter annotations, return annotations, variable annotations, class-body annotations.
Tuples are the one container where annotation shape matters in a non-obvious way. A tuple's length is part of its type, so the type system has two distinct forms:
tuple[int, ...] says "any number of ints, possibly zero". tuple[int] says "exactly one int". The ellipsis is part of the typing grammar, not a placeholder you fill in later.
Container generics nest. A function that takes a dict mapping customer IDs to a list of their order totals looks like this:
dict[int, list[float]] reads naturally: a dict whose keys are ints and whose values are lists of floats. The return dict[int, str] is the same shape with different inner types.
Subscripting a built-in like list[int] creates a small GenericAlias object every time the code runs. It's cheap, but constructing annotations dynamically inside a hot loop is wasteful. Annotations on function signatures are evaluated once at function-definition time, so this only matters when building types in user code, which is unusual.
Before Python 3.9, the built-in containers couldn't be subscripted in type annotations. To say "list of strings", you imported a capitalized alias from the typing module:
Every capitalized alias maps to its lowercase built-in:
typing alias | Built-in equivalent | Available since |
|---|---|---|
List[T] | list[T] | 3.9 |
Dict[K, V] | dict[K, V] | 3.9 |
Tuple[T1, T2] | tuple[T1, T2] | 3.9 |
Set[T] | set[T] | 3.9 |
FrozenSet[T] | frozenset[T] | 3.9 |
Type[X] | type[X] | 3.9 |
The capitalized form still applies in three cases:
list[int] as an annotation outside of from __future__ import annotations. The capitalized aliases are the only portable option.List[int] and list[int] in the same file works but reads badly. Pick one.For everything else, the lowercase built-ins win. They're shorter, need no import, and match the runtime type. A common migration pattern in projects upgrading from 3.8 to 3.10+ is to grep for from typing import List and rewrite the imports to drop the legacy aliases.
PEP 585 was accepted with a deprecation period that explicitly states the typing aliases will not be removed until Python's typing maintainers decide enough code has migrated. As of Python 3.13, they still work without warnings, so legacy code can rely on them for the foreseeable future.
A parameter or return that can be None is annotated either with Optional[X] from typing, or with X | None since Python 3.10. The two forms are equivalent, and modern code prefers the pipe.
str | None says the return is either a string or None. A caller has to handle both cases, and the type checker will flag any code that tries to call .upper() on the result without first checking that it isn't None.
For now, knowing that X | None exists is enough.
Python stores every annotation you write. The storage location depends on where the annotation appears:
__annotations__.__annotations__.__annotations__.For a function, the dict maps each annotated parameter name (plus the special key 'return') to its annotation value:
The order in the dict matches the order in the source. Default values are not in __annotations__; they live separately on __defaults__ and __kwdefaults__.
For a class, __annotations__ collects every annotation directly in the class body:
Bare annotations (order_id: int without an assignment) populate __annotations__ but don't create class attributes. A print(Order.order_id) on the class itself raises AttributeError, because nothing was ever assigned. The annotation is metadata, not a definition.
Reading __annotations__ directly works for simple cases, but it has two real limitations. First, if any annotation is a forward reference (a string like "Order" used to refer to a class that isn't defined yet), __annotations__ returns the string instead of the resolved class. Second, when a file uses from __future__ import annotations, every annotation is stored as a string regardless of forward-reference issues.
The standard library's typing.get_type_hints() handles both of these. It evaluates string annotations in the right scope, resolves forward references, and returns ready-to-use type objects:
For everyday introspection or debugging, get_type_hints() is the safer choice. Libraries that do anything serious with annotations (Pydantic, FastAPI, SQLAlchemy 2.0, dataclasses) use get_type_hints() rather than reading __annotations__ directly.
The diagram shows the two reading paths. __annotations__ is the raw dictionary Python populates as it parses your source. get_type_hints() is a wrapper that evaluates anything stored as a string and returns the resolved types.
This point matters because it affects how annotations get written day to day. Python parses annotations, stores them in __annotations__, and otherwise ignores them. A function annotated -> int that returns a string runs without error. A parameter annotated list[float] will accept a dict and only fail when something inside the function tries to index it numerically.
The annotation says list[float], but sum() accepts any iterable of numbers, so a tuple or a set works fine at runtime. The interpreter never compares the actual argument against the declared type.
This is why the workflow for type hints is "annotate, then run a checker." Without mypy or pyright (or the type checker built into an editor), the annotations are documentation that nothing verifies. Treat annotations as a contract that needs an external tool to enforce.
10 quizzes