Type hints let you annotate variables, function parameters, and return values with the types they're expected to hold. They don't change how Python runs your code, but they give your editor, type checkers, and teammates a way to catch bugs before the program ever executes. This lesson covers what type hints are, the syntax for basic annotations, why Python ignores them at runtime, and how to inspect them programmatically.
Python is dynamically typed. A variable can hold a string today and a list tomorrow, and the interpreter won't complain until something blows up at runtime. That flexibility is great for quick scripts and prototypes. It's painful when a six-month-old function silently accepts the wrong shape of data and corrupts an order.
Type hints were standardized in PEP 484, shipped with Python 3.5 in 2015. The pitch was simple: let developers write down the types they intend, and let external tools (editors, linters, type checkers like mypy) read those annotations and flag mismatches. The interpreter itself stays out of it.
So when you write a function that computes a cart total, you can say "this takes a list of floats and returns a float" in the function signature itself. Your IDE then autocompletes accurately, your reviewer reads the contract without scrolling, and a type checker run in CI catches the moment somebody passes a list of strings by mistake.
The : list[float] after prices and the -> float after the parenthesis are the type hints. The function body is unchanged. Without the hints, this is exactly the same code; with them, your editor knows prices supports sum() and that the return value is a float.
The diagram below shows where type hints sit in the development loop. The Python interpreter ignores them; everything useful happens in the gray box on the left.
The interpreter parses annotations (it has to, they're part of the grammar) but never enforces them. Type checking is a separate, opt-in step that you run before the code ships.
The syntax for a variable annotation is name: type = value. The colon attaches a type to the name; the rest is a normal assignment.
You can also annotate a variable without assigning a value, which is useful when the assignment happens later, for example inside an if branch or in a class body:
The bare discount: float line doesn't create a variable at runtime. It only records the intended type. If you try to read discount before assigning it, you'll get a regular NameError, the same as any unassigned name.
Annotations work on any assignment target Python allows, but they only attach to a single name at a time. Multiple assignment doesn't support annotations:
For container types, the hint should describe what's inside the container, not just the container itself. A bare list is legal but uninformative; list[str] says "a list of strings" and lets the type checker catch the moment you append an int:
The next section covers built-in generic syntax in more depth, including the difference between list[int] and the older List[int] from the typing module.
Function annotations follow the same name: type pattern for each parameter, plus an arrow -> followed by the return type before the colon.
A function that doesn't return anything (or only uses a bare return) should be annotated -> None. This isn't decorative; type checkers use it to flag code that mistakenly tries to use the return value:
Default values come after the type, in the usual position. The annotation describes the parameter type; the default is the value used when the caller omits the argument:
For functions that can return more than one type, you'll typically reach for Union or the | operator. For now, the basic case is enough: a function either always returns one type, or returns None when it has no useful value to give back.
Annotations work on methods exactly the same way. The self parameter is conventionally left unannotated because the type checker infers it from the enclosing class:
The self.customer: str = customer and self.items: list[str] = [] are instance variable annotations. They tell the type checker the shape of an instance, which is what powers the autocomplete you see when you type cart. in an editor.
Container hints describe both the container and the type of its elements. Since Python 3.9 (PEP 585), the built-in container types support subscript syntax directly:
| Hint | Meaning |
|---|---|
list[int] | A list of integers |
list[str] | A list of strings |
dict[str, float] | A dict mapping strings to floats |
tuple[str, int] | A two-element tuple: string then int |
tuple[int, ...] | A tuple of any number of ints |
set[str] | A set of strings |
frozenset[int] | A frozen set of integers |
Each of these can appear anywhere a type hint can go: variable annotations, parameter annotations, return annotations.
Tuples have two distinct shapes. A fixed-length tuple lists each position's type:
A variable-length tuple of homogeneous elements uses ...:
tuple[int, ...] says "a tuple of any number of ints, possibly empty". tuple[int] (without the ellipsis) says "a one-element tuple containing exactly one int". The distinction matters because tuples in Python carry length information in their type.
Nesting works the way you'd expect. A list of carts, where each cart is a list of (product, quantity) pairs:
Before Python 3.9, the built-in containers couldn't be subscripted in annotations, so you had to import capitalized aliases from the typing module: List, Dict, Tuple, Set. Code written for Python 3.5 through 3.8 will look like this:
This style still works on Python 3.9+, but it's no longer the recommended form. The PEP 585 syntax (list[float], dict[str, int]) is shorter, doesn't need an import, and lines up with the runtime types. You may still need to import from typing for things like Optional, Union, and the abstract collection types.
Note: If you support Python 3.8 or older, you can't use list[int] in annotations without from __future__ import annotations at the top of the file. The __future__ import turns all annotations into strings, which sidesteps the runtime parser. Most projects today target 3.9+ and can drop this dance.
This is the single most important fact about type hints in Python. The interpreter parses them, stores them as metadata, and then ignores them. A function annotated to accept an int will happily accept a string, a list, or anything else, and run until the body actually does something the wrong type can't handle.
When Python runs that line, it doesn't check the annotations. It only fails inside the function body, where the actual operation can't handle strings:
The TypeError comes from the division, not from the call. Python checked nothing about the argument types when it entered the function.
A cleaner demonstration: a function whose body works on multiple types regardless of the annotation. The hint says int, but the function happily multiplies a string:
The annotations claim both parameters are int and the return is int. At runtime, "OUT OF STOCK " * 3 is perfectly valid Python (string repetition), so the function returns a string with no error. A type checker run on this file would scream; the interpreter doesn't.
That gap is intentional. Runtime type checking would slow Python down on every function call, and it would conflict with the language's duck-typing philosophy ("if it walks like a duck and quacks like a duck, it's a duck"). The design choice was to keep types as documentation and tooling input, not as runtime contracts.
The diagram below shows the lifecycle. The annotation is recorded but never consulted during execution.
If you need runtime validation, you have to add it yourself: an explicit isinstance() check, a library like pydantic or attrs that reads annotations and validates against them, or dataclasses with manual validation in __post_init__. None of that comes from the type hint syntax alone.
Note: Some libraries (FastAPI, Pydantic, SQLAlchemy 2.0) read annotations at runtime and enforce them, but only because that library deliberately introspects __annotations__ and runs its own checks. The annotation syntax is just data the library is free to use. Python itself never enforces it.
Any is the escape hatch. It's a special value from the typing module that tells the type checker "stop reasoning about this; assume any operation on it is fine". It's not the same as object. object is the base of all types and accepts any value, but the type checker still restricts what you can do with an object (you can't call arbitrary methods on it). Any is the type checker's white flag.
Inside this function, the type checker won't object to len(record), record + 1, record.upper(), or anything else. Any is contagious: anything assigned from an Any-typed expression also becomes Any, which means a single sloppy annotation can erase type information across a whole module.
When Any is the right call:
When Any is the wrong call:
Any to silence the type checker. Now nobody benefits from the annotation.Any for a function that always takes a str or always takes a dict[str, int]. Be specific.Any because the type might be str or int. The | union operator handles that case without giving up checking entirely.The rule of thumb: every Any should be a conscious choice you'd defend in code review, not a default. A codebase full of Any annotations gets the worst of both worlds: the visual noise of type hints without any of the benefit.
Both functions run identically. The first is invisible to the type checker; the second catches a bug the moment a caller passes a list of strings or a dict with the wrong key.
__annotations__Annotations are stored on the object they describe: functions have a __annotations__ dict, classes have a __annotations__ dict, and modules have one too (for top-level variable annotations). This is what tools like mypy and Pydantic use to read your hints.
For a function, __annotations__ maps each parameter name (plus return for the return type) to its annotation:
The dictionary keys are parameter names; the values are the actual type objects you wrote in the source. The special key return holds the return annotation.
For a class, __annotations__ collects the variables declared in the class body. Instance attributes set inside __init__ don't show up here unless you also declare them at class scope:
A common gotcha: an annotation without an assignment doesn't create a class attribute, only an entry in __annotations__. So Product.name would raise AttributeError until an instance sets self.name, but Product.__annotations__["name"] is <class 'str'> either way.
The recommended way to read annotations from a function or class is typing.get_type_hints(), not __annotations__ directly. get_type_hints() resolves forward references (annotations written as strings), evaluates anything wrapped in from __future__ import annotations, and merges class hierarchies:
For most introspection work, get_type_hints() is the safer choice. For a quick peek during debugging, __annotations__ is fine.
When does this matter in practice? Three cases come up most often:
get_type_hints() of a model class. FastAPI generates OpenAPI schemas from route handler annotations. SQLAlchemy 2.0 reads class annotations to build column types.pdoc read annotations to produce signature documentation without you writing it twice.For most application code, you never touch __annotations__ directly. The interpreter populates it, the type checker reads it, and you keep writing normal functions.
10 quizzes