Python Type Hints and mypy: A Practical Guide
Type hints document what your code expects, and mypy checks them before anything runs. Learn the syntax, the config, and how to adopt both gradually.
A function returned a user’s name, or None when the user did not exist. For two years, every caller happened to pass a valid ID. Then a new feature called it with an ID from a deleted account, and production logged AttributeError: 'NoneType' object has no attribute 'upper' a few thousand times before anyone woke up. A type checker would have flagged that line the day it was written.
Python type hints are optional annotations on variables, parameters, and return values that describe the expected types. Tools called static type checkers, such as mypy and pyright, read the annotations and report mismatches by analyzing the source code.
This guide uses Python 3.10 and mypy 0.96x. You should know how to write functions and classes. Run python --version first, because the X | Y syntax used here needs Python 3.10.
My position: type hints pay for themselves in any codebase that more than one person touches, or that lives longer than a few months. However, hints without a checker in CI are comments that slowly become wrong.
How Python type hints work
An annotation follows a colon after a name, and a return type follows an arrow.
def total_price(unit_price: float, quantity: int = 1) -> float:
return unit_price * quantity
retries: int = 3
names: list[str] = ['asha', 'ben']
stock: dict[str, int] = {'keyboard': 14}
point: tuple[float, float] = (12.5, 48.1)
Since Python 3.9, the built-in containers accept type parameters directly, so you write list[str] and not typing.List[str]. Local variables rarely need annotations, because the checker infers their types from the assigned value. Good function signatures are where annotations matter most.
The myth: type hints change what Python does at runtime
Many developers assume that annotating a parameter as int makes Python reject a string. It does not. The interpreter stores annotations and otherwise ignores them.
def double(n: int) -> int:
return n * 2
print(double('ab'))
abab
No error occurs, and the function returns a string despite its declared return type. Hints do not validate, do not convert, and do not make code faster. All their value comes from a separate tool that reads them. Consequently, input from outside your program still needs runtime validation.
Install and run mypy
Install mypy inside a virtual environment, which keeps the tool and its version tied to the project.
python --version
python -m venv .venv
source .venv/bin/activate # macOS and Linux
.venv\Scripts\Activate.ps1 # Windows PowerShell
python -m pip install mypy
Now save this file as lookup.py. It contains two bugs.
USERS = {1: 'asha', 2: 'ben'}
def find_user(user_id: int) -> str | None:
return USERS.get(user_id)
def greeting(user_id: int) -> str:
name = find_user(user_id)
return 'Hello, ' + name.upper()
print(greeting(1))
print(greeting('2'))
Run it normally, and the first call works while the second crashes:
Hello, ASHA
Traceback (most recent call last):
File "lookup.py", line 14, in <module>
print(greeting('2'))
File "lookup.py", line 10, in greeting
return 'Hello, ' + name.upper()
AttributeError: 'NoneType' object has no attribute 'upper'
Now run mypy on the same file. It executes nothing, and it reports both problems:
python -m mypy lookup.py
lookup.py:10: error: Item "None" of "Optional[str]" has no attribute "upper"
lookup.py:14: error: Argument 1 to "greeting" has incompatible type "str"; expected "int"
Found 2 errors in 1 file (checked 1 source file)
The first error would exist even if every caller passed a valid integer today. mypy reasons about every possible path, not just the ones your tests exercise. The trade-off is that it also complains about paths you know cannot happen, and you must then prove it or restructure the code.
Handle None with narrowing
The type str | None means “a string or None“. Older code writes the same thing as Optional[str], which is why mypy’s message uses that name. To use the value as a string, you must first rule out None. The checker follows your if statements, a behavior called narrowing.
def greeting(user_id: int) -> str:
name = find_user(user_id)
if name is None:
return 'Hello, stranger'
return 'Hello, ' + name.upper() # here mypy knows name is str
Narrowing also works with isinstance(), with early raise statements, and with assert name is not None. Prefer the explicit if, because an assertion crashes at runtime when you are wrong.
Note the difference between two ideas that share the word “optional”. name: str | None means the value may be None. name: str = 'x' means the argument may be omitted. A parameter can be either, both, or neither.
The types you will use every day
| Need | Annotation | Notes |
|---|---|---|
| One of several types | int | str |
Python 3.10 syntax, defined by PEP 604 |
| May be missing | str | None |
Same as Optional[str] |
| Any read-only sequence | Sequence[str] |
Accepts lists and tuples, from collections.abc |
| Anything you can loop over | Iterable[str] |
Also accepts generators and sets |
| Any read-only mapping | Mapping[str, int] |
Accepts dict and similar types |
| A function | Callable[[int, str], bool] |
Argument types, then return type |
| A fixed set of values | Literal['asc', 'desc'] |
From typing |
| A dictionary with known keys | TypedDict |
Useful for JSON-shaped data |
| Opt out of checking | Any |
Compatible with everything, checks nothing |
A useful rule comes from that table: accept general types and return specific ones. A parameter typed Iterable[str] lets callers pass a list, a tuple, or a generator. A return type of list[str] tells callers exactly what they can do with the result.
Any versus object
These two look similar and behave in opposite ways. Any turns the checker off: every operation on the value is allowed. object is the base of all types, so every value fits, but you can do almost nothing with it until you narrow it. Use object when you truly accept anything, and treat Any as a temporary escape hatch.
Generics, Protocol, and type aliases
Three more tools cover most remaining cases. A TypeVar links an input type to an output type. A Protocol describes what an object can do, without requiring inheritance. A TypeAlias names a type so that signatures stay readable.
The complete example below uses all three, together with dataclasses, which work well with type checkers because their fields are already annotated. Save it as tasks.py.
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Protocol, TypeAlias, TypeVar
TaskId: TypeAlias = int
T = TypeVar('T')
@dataclass(frozen=True)
class Task:
id: TaskId
title: str
assignee: str | None = None
class HasTitle(Protocol):
@property
def title(self) -> str: ...
def first(items: Sequence[T]) -> T | None:
return items[0] if items else None
def titles(items: Iterable[HasTitle]) -> list[str]:
return [item.title for item in items]
def find_task(tasks: Sequence[Task], task_id: TaskId) -> Task | None:
for task in tasks:
if task.id == task_id:
return task
return None
def assignee_label(task: Task) -> str:
if task.assignee is None:
return 'unassigned'
return task.assignee.upper()
def main() -> None:
tasks = [Task(1, 'Write tests', 'asha'), Task(2, 'Fix login')]
print(titles(tasks))
found = find_task(tasks, 2)
if found is not None:
print(assignee_label(found))
print(first(tasks))
if __name__ == '__main__':
main()
python tasks.py
['Write tests', 'Fix login']
unassigned
Task(id=1, title='Write tests', assignee='asha')
python -m mypy tasks.py
Success: no issues found in 1 source file
Look at what each tool buys you. Because of T, mypy knows that first(tasks) returns Task | None, not some unknown object. Because of the protocol, titles() accepts any object with a title attribute, and Task never has to mention HasTitle. That style is called structural typing, and it matches how Python code already behaves.
For decorators, Python 3.10 added ParamSpec (PEP 612), which lets a wrapper keep the signature of the function it wraps. You can see it applied in a guide to writing decorators.
If you must support Python versions older than 3.10, the typing_extensions package backports newer features such as TypeAlias and ParamSpec. Adding from __future__ import annotations at the top of a file also lets you write str | None in annotations on Python 3.7 and later.
The blind spot: unannotated functions are skipped
Here is the fact that surprises nearly every team, and it is the most important insight in this guide. By default, mypy does not check the body of a function that has no annotations. It treats the parameters and the result as Any.
def greeting(user_id): # no annotations
name = find_user(user_id)
return 'Hello, ' + name.upper() # same bug as before
Success: no issues found in 1 source file
The bug is still there, and mypy reports success. Therefore, “mypy passes” tells you nothing until you know how much of the code it actually examined. A green check on a mostly unannotated codebase is a false signal.
Two settings close the gap. check_untyped_defs makes mypy analyze unannotated bodies. disallow_untyped_defs reports every function that lacks annotations. My heuristic for an existing project: run mypy once with --disallow-untyped-defs and read the error count as your to-do list, not as a failure.
python -m mypy --disallow-untyped-defs src/
Configure mypy in pyproject.toml
mypy reads its settings from a [tool.mypy] table in pyproject.toml. Start with a configuration that is strict for new code and lenient where you must be.
[tool.mypy]
python_version = "3.10"
check_untyped_defs = true
disallow_untyped_defs = true
warn_return_any = true
warn_unused_ignores = true
no_implicit_optional = true
[[tool.mypy.overrides]]
module = "legacy.*"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "some_untyped_library.*"
ignore_missing_imports = true
The overrides tables relax rules per module, which lets you enforce annotations in new packages while old ones catch up. For a new project, replace the individual flags with strict = true.
Third-party libraries sometimes ship without type information. mypy then prints a message such as this:
error: Library stubs not installed for "requests" (or incompatible with Python 3.10)
note: Hint: "python3 -m pip install types-requests"
Install the suggested stub package into your virtual environment. If no stubs exist, add an override with ignore_missing_imports = true for that one library, never globally.
When mypy is wrong about a single line, silence only that error and say which one:
value = legacy_call() # type: ignore[no-any-return]
A bare # type: ignore hides every error on the line, including ones added later. To see what mypy has inferred, insert reveal_type(value) temporarily, run mypy, and read the note it prints.
mypy, pyright, and runtime validation compared
| Tool | When it runs | Strength | Limit |
|---|---|---|---|
| mypy | On demand and in CI | The reference checker, with a plugin system and broad library support | Slower on large codebases, lenient defaults |
| pyright | In the editor (it powers Pylance in VS Code) and in CI | Fast feedback as you type, quick support for new typing features | Needs Node.js for the command-line tool |
| Runtime validation (for example pydantic) | While the program runs | Checks real data from requests, files, and environment variables | Costs CPU time, and finds errors only on executed paths |
These are not competitors. A static checker verifies that your code is consistent with itself. Runtime validation verifies that the outside world matches your assumptions. Most services need both.
How real systems adopt type checking
- The checker runs in CI. A pull request cannot merge while mypy reports errors. Without this gate, annotations drift away from the code within weeks.
- Boundaries first. Teams annotate public functions, data models, and the layers that talk to databases and HTTP. Internal helpers follow, since inference covers much of them.
- Strict for new code, lenient for old. Per-module overrides hold new packages to
disallow_untyped_defswhile legacy modules are exempt. The exempt list shrinks over time. - Typed data models at the core. Dataclasses and
TypedDictreplace loose dictionaries, so a misspelled key becomes a checker error. - A pinned checker version. New mypy releases find new errors. Pinning the version in the development requirements keeps CI from breaking on an unrelated change.
In my experience adding mypy to a four-year-old billing service, the first strict run reported several hundred errors. Most were missing annotations, but about a dozen were real: functions that could return None and callers that never checked. Two of those matched error reports we had been unable to reproduce. We fixed the real ones first, then enabled disallow_untyped_defs one package at a time over a few months.
Adopting type hints: a decision framework
- Is this a new project? Set
strict = truefrom the first commit. Strictness is cheap on day one and expensive later. - Is it an existing codebase? Enable
check_untyped_defsfirst, fix what it finds, and add CI. Then enforce full annotations per module. - Which functions get annotations first? Start with those called from many places, and those that return
Nonein some cases. - Does data cross a process boundary here? Add runtime validation at that point, then convert to typed objects.
- Is the checker fighting you on dynamic code? Isolate that code behind a small typed function, and use
Anyinside it only.
When NOT to invest in type hints
- Throwaway scripts and notebooks. A fifty-line script that you run once and delete gains little. Exploration moves faster without them.
- Heavily dynamic code. Code that builds classes at runtime or relies on
__getattr__cannot be described accurately. Annotations there become false statements, which are worse than none. - As a replacement for validation or tests. Hints prove that types line up. They do not prove that a discount is calculated correctly, or that a JSON payload has the fields you expect.
Common mistakes
- Annotating without running a checker. Unchecked hints go stale, and readers trust signatures that no longer match the code.
- Trusting a pass on unannotated code. mypy skips unannotated function bodies by default. The success message hides real bugs.
- Reaching for Any to silence errors.
Anyspreads: every value derived from it is also unchecked. One shortcut can disable checking across a module. - Using a mutable container type for parameters. A parameter typed
list[float]rejects alist[int], because lists are invariant. UseSequence[float]for read-only inputs. - Writing a bare type: ignore. It suppresses every current and future error on that line. Include the error code in brackets.
- Setting ignore_missing_imports globally. A typo in an import of your own module then passes silently and resolves to
Any.
Key takeaways
- Type hints do nothing at runtime. A checker such as mypy or pyright gives them value.
- Write
X | Nonefor values that may be missing, and narrow with anifbefore use. - Accept general types such as
IterableandSequence, and return specific ones. - Turn on
check_untyped_defs, because mypy otherwise skips unannotated functions. - Keep configuration in
[tool.mypy], and relax rules per module with overrides. - Run the checker in CI and pin its version.
- Use runtime validation for external data, and static typing for internal consistency.
FAQ
What are type hints in Python?
Type hints are optional annotations that describe the expected types of variables, parameters, and return values. Tools such as mypy read them to find type errors without running the code.
Does Python enforce type hints at runtime?
No. The interpreter ignores type hints when it runs your program. Passing a string to a parameter annotated as int raises no error unless the code itself fails.
What is mypy used for?
mypy is a static type checker. It analyzes annotated Python source code and reports mismatches, such as calling a method on a value that may be None.
What is the difference between Optional[str] and str | None?
They mean the same thing: a string or None. The str | None form is the newer syntax, available from Python 3.10.
Should I use mypy or pyright?
Both work well. pyright gives fast feedback in editors such as VS Code, while mypy is the long-established checker with plugins. Many teams use pyright in the editor and one of the two in CI.
Annotate the boundaries, then let the checker work
Type hints turn assumptions that lived in your head into statements a machine can verify. Start where the risk is highest: public functions and anything that can return None. Then make the checker a required step, so the statements stay true.
Rule of thumb: a type hint that no tool checks is a comment, and comments lie.
