Python Module 11 – Type hints and static type checking
Typing libraries: py.typed and stub files
Ship type information with a package using py.typed, write .pyi stub files, see where checkers find types, and check code that uses untyped libraries.
What you will learn
- Package a library's inline types for checkers with a py.typed marker
- Write .pyi stub files for a module that has no annotations
- Explain where a checker looks for the types of an import, from your own stubs to installed packages
- Generate a draft stub with stubgen and compare it with the module using stubtest
- Check code that imports untyped libraries without hiding errors
Before you start
What you’ll learn · Before you start
What you will learn
- Package a library's inline types for checkers with a py.typed marker
- Write .pyi stub files for a module that has no annotations
- Explain where a checker looks for the types of an import, from your own stubs to installed packages
- Generate a draft stub with stubgen and compare it with the module using stubtest
- Check code that imports untyped libraries without hiding errors
Before you start
On this page
Two installed packages define the same annotated function, and the same mistake is made with each: in this lesson’s
run, mypy 2.4.0 reported it for one package and said nothing for the other, because only the reported package
contains an empty marker file called py.typed. A type checker can only check a call into a library if it knows the library’s
types.
It learns them in one of three ways. A package can carry inline annotations and say so with the
py.typed marker: An empty file named py.typed in a package's folder (PEP 561). It tells type checkers that the package's inline annotations or .pyi files are meant for them; without it, mypy treats an installed package as untyped. (PEP 561). A stub file: A file with the suffix .pyi that holds only a module's interface: signatures with ... as their bodies, and annotated variables. Type checkers read it in place of the .py module beside it; Python never imports it., .pyi, holds
only signatures, written in Python syntax with ... for every body, and checkers read it instead of the .py module.
And The shared collection of stub files for the standard library and many third-party packages. Checkers bundle its standard-library part, and its third-party stubs are published as types-… packages., the shared collection of stubs for the standard library and many third-party
packages, fills in the rest: checkers bundle its standard-library part, and its third-party stubs are published as
types-… packages. When none of these has a module, the checker reports the import and treats everything from it as
Any, so calls into it are not checked at all.
Inline types count only with a py.typed marker
Two installed packages, shopkit and legacy, have the same annotated function. Only shopkit has the marker:
"""An installed package with inline annotations and a py.typed marker."""
def price_with_tax(price: int, rate: float = 0.18) -> int:
return round(price * (1 + rate)) """An installed package with the same annotations, but without a py.typed marker."""
def price_with_tax(price: int, rate: float = 0.18) -> int:
return round(price * (1 + rate)) from legacy import price_with_tax as legacy_price
from shopkit import price_with_tax
label: str = price_with_tax(100)
other: str = legacy_price(100) """Runs `mypy --strict checkout.py` with site/ as a folder of installed packages, as site-packages is after pip install."""
import sys
from mypy import api
sys.path.append("site")
print("$ mypy --version")
print(api.run(["--version"])[0], end="")
print("$ mypy --strict checkout.py")
report, errors, status = api.run(["--strict", "checkout.py"])
print(report + errors, end="") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict checkout.py checkout.py:1: error: Skipping analyzing "legacy": module is installed, but missing library stubs or py.typed marker [import-untyped] checkout.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports checkout.py:4: error: Incompatible types in assignment (expression has type "int", variable has type "str") [assignment] Found 2 errors in 1 file (checked 1 source file)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_checkout.py
Like the other check files here, it calls mypy 2.4.0 from Python; with that version installed, the command after the
dollar sign does the same job in a terminal. The check script puts the folder site/ on the path where Python finds
installed packages, the role that site-packages plays after pip install, so mypy treats both packages as installed.
It used the inline types of shopkit and reported the int assigned to a str. It skipped legacy with “missing
library stubs or py.typed marker”, so the same mistake two lines later went unreported. Step through the file as mypy
checked it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first import. Line 1 imports price_with_tax from legacy, an installed package whose function is fully annotated.
- No marker, no types. mypy finds no stub for legacy and no py.typed file in its folder, so it skips the package and reports the import as untyped, with "missing library stubs or py.typed marker".
- The second import. Line 2 imports the same function from shopkit, whose folder holds an empty py.typed file beside __init__.py.
- The marker opts in. The marker says that the package's annotations are meant for checkers, so mypy reads its inline hints and learns that price_with_tax() returns an int.
- A mistake reported. Line 4 stores that int in a variable annotated str, and mypy reported it as an incompatible assignment.
- The same mistake, missed. Line 5 makes the same mistake with legacy's function. Because legacy was skipped, the function is Any for mypy, and nothing was reported.
The annotations in legacy are fine; the package simply never said that they are meant for checkers. That is why a
library with inline hints needs the py.typed file in its package folder, and why the file must reach the wheel that
users install.
Checkpoint
Check: The py.typed marker
A .pyi stub file describes a module for the checker
geo.py was written without annotations, and nobody wants to change it. A stub beside it, geo.pyi, describes it:
from collections.abc import Mapping
type _LatLon = tuple[float, float]
EARTH_RADIUS_KM: float
def distance_km(a: _LatLon, b: _LatLon) -> float: ...
def nearest(origin: _LatLon, places: Mapping[str, _LatLon], limit: int = 1) -> list[str]: ... from geo import distance_km, nearest
PUNE = (18.52, 73.86)
MUMBAI = (19.08, 72.88)
places = {"Mumbai": MUMBAI, "Goa": (15.50, 73.83), "Nashik": (20.00, 73.79)}
print(f"{distance_km(PUNE, MUMBAI):.0f} km to Mumbai")
print("nearest:", nearest(PUNE, places, limit=2))
try:
nearest(PUNE, ["Mumbai", "Goa"])
except TypeError as error:
print("TypeError:", error)
km: int = distance_km(PUNE, MUMBAI)
print("km holds a", type(km).__name__) Output
120 km to Mumbai nearest: ['Mumbai', 'Nashik'] TypeError: list indices must be integers or slices, not str km holds a float
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trip.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
"""Runs `mypy --strict trip.py` through mypy's Python API and prints what the command prints."""
from mypy import api
print("$ mypy --version")
print(api.run(["--version"])[0], end="")
print("$ mypy --strict trip.py")
report, errors, status = api.run(["--strict", "trip.py"])
print(report + errors, end="") Output
$ mypy --version mypy 2.4.0 (compiled: yes) $ mypy --strict trip.py trip.py:10: error: Argument 2 to "nearest" has incompatible type "list[str]"; expected "Mapping[str, tuple[float, float]]" [arg-type] trip.py:13: error: Incompatible types in assignment (expression has type "float", variable has type "int") [assignment] Found 2 errors in 1 file (checked 1 source file)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_trip.py
"""Distances between places: a helper module written without annotations."""
import math
EARTH_RADIUS_KM = 6371.0
def distance_km(a, b):
lat1, lon1 = map(math.radians, a)
lat2, lon2 = map(math.radians, b)
h = math.sin((lat2 - lat1) / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(h))
def nearest(origin, places, limit=1):
return sorted(places, key=lambda name: distance_km(origin, places[name]))[:limit] A stub next to a module takes its place for the checker, so mypy read geo.pyi and reported both mistakes in
trip.py: a list passed where a mapping of places is expected, and a float stored in a variable annotated int.
Python never reads the stub. It ran the real module, which failed on the list and returned a float, exactly as the
two reports predicted. Step through the import with each of its two readers:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The import. trip.py imports distance_km() and nearest() from geo, a module written without annotations. Two of its lines are wrong.
- mypy finds the stub. mypy looks for geo, finds geo.pyi beside geo.py, and reads the stub in place of the module.
- Two mistakes reported. With the stub's types, mypy reported line 10, a list passed where a mapping of places is expected, and line 13, a float stored in a variable annotated int.
- Python runs the module. Python never reads the stub. import geo runs geo.py, the module without annotations, as if the stub were not there.
- The run agrees. The real nearest() failed on the list with TypeError, and km held a float, exactly the two problems mypy had reported before the run.
- Stub-only names. The alias _LatLon exists only in geo.pyi, and its underscore keeps it out of geo's public names. The checker uses it to describe the coordinates; Python never sees it.
The stub’s alias, type _LatLon = ..., exists only in the stub. Its underscore follows the stub-writing guide, which
asks for one on names a stub adds only to be readable: geo.py has no such name, so nobody should import it from
geo. The ... bodies are all a stub file ever contains. The standard library is described the same way: when an
editor shows the parameters of json.loads(), it reads a stub from typeshed that ships with the checker, not the
module’s source.
Key idea
The checker reads the stub and Python runs the module, so a stub can give types to code you cannot change, and a wrong stub misleads only the checker.
Checkpoint
Check: What a .pyi stub file is
A checker looks for types in a fixed order
Where a type checker looks for the types of an import
Text description of the diagram
The diagram is a list of steps from top to bottom, starting with the statement import shopkit. The checker takes the types from the first place that has them.
- Your own search path first: the folders in MYPYPATH or the mypy_path option, where you can put your own stubs or patch broken ones.
- The code being checked: your project's own files.
- Typeshed's stubs for the standard library, which the checker bundles.
- A stub-only package named shopkit-stubs, which is used before the package itself.
- The installed package: its inline hints or the .pyi files inside it, but only when it contains a py.typed marker.
- Stubs that the checker itself ships for third-party packages, if it ships any; these come last.
If none of them has the module, the checker reports the import, and the module and everything from it is treated as Any.
The typing specification fixes this order, and mypy’s documentation lists the same places for its own search path.
Two consequences matter in practice. Your own search path comes first, so a stub you write for a broken or
untyped library wins over everything else, without editing the library. And a
stub package: A package that adds type hints for a library that ships without them, such as types-PyYAML for PyYAML. It is installed beside the type checkers, as a development dependency. More in “mypy and pyright: running type checkers”, named after the runtime package with -stubs added, wins over the
package’s own inline types, which is how typeshed’s types-… packages add types to libraries that ship none. mypy
also knows which libraries have such packages: for those, its report says “Library stubs not installed” and names the
package to install, such as types-PyYAML for yaml, instead of the general message about a missing marker. Follow
four imports of this lesson through the order:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- import geo. trip.py imports geo. Nothing on the search path has it, but the code being checked does, geo.pyi beside trip.py, so the search stops at the second place.
- import json. json is part of the standard library. The first two places have no json, and the copy of typeshed that the checker bundles does, at the third place.
- import shopkit. shopkit is an installed package with no stubs anywhere and no stub-only package, so the search reaches the package itself, whose py.typed marker makes its inline hints count.
- import legacy. legacy is installed without py.typed, and no place has stubs for it. The search runs out, mypy reports the import, and everything from legacy is Any.
- Your own stubs win. A stub for any of these packages in a folder on MYPYPATH would be found at the first place, before everything else, which is how you correct a library's types without editing it.
- Stub-only packages. A package called shopkit-stubs would win at the fourth place over shopkit's own hints. typeshed's types-… packages add types to libraries that ship none in that place.
The four sources a library’s users meet, side by side:
| Source of types | Who writes it | What the checker needs |
|---|---|---|
| Inline annotations in a package | The library’s authors | A py.typed marker in the package |
.pyi files inside a package |
The library’s authors | A py.typed marker as well |
A stub-only package, name-stubs |
Anyone, often typeshed’s contributors | Nothing more: it is used when installed |
| Your own stubs on the search path | You | A folder listed in MYPYPATH or mypy_path |
Checkpoint
Check: Where a checker looks for types
Check yourself
One question at a time, beginning with the source of types you know least.
Practice that adapts to you
Practise this lesson
9 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Writing stubs that checkers trust: stubgen, stubtest and a stub that lies, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A stub is a second description of a module, and two descriptions can disagree. This part drafts a stub with stubgen, compares a stub with its module using stubtest, and reproduces the kind of disagreement that neither tool can see.
In this part:
- Drafting a stub with stubgen
- Stub syntax rules worth knowing
- Comparing a stub with its module using stubtest
- What stubtest cannot see: a wrong return type, reproduced
- Exercise: a stub for an untyped module
- Write the stub file for an untyped module
- Results of the sample tests
1 diagram · 9 runnable examples (PYI, Python) · 1 exercise with sample tests · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Shipping types and living with untyped code: packaging, stub-only packages and the options compared Pro: opens with a Pro, Premium or Ultimate pass
Types help only when they reach the people who install the package, and most projects depend on at least one
library without them. This part ships py.typed in a build, finds a stub-only package, compares the ways to handle
an untyped dependency, and modernises old hints.
In this part:
- Shipping py.typed and stubs in a build
- A stub-only package, found before the package itself
- An untyped dependency: the options compared, for your case
- Modernising superseded hints
7 runnable examples (IN, PYI, Python, TOML) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What is a .pyi stub file, and when does a type checker read it instead of the .py module?
A stub file has the same name as a module with the suffix .pyi and holds only its interface: function signatures with ... as their body, annotated module variables and classes with annotated methods. Python never imports or runs it. A type checker that finds a stub reads it instead of the module beside it, so a stub can describe a module written without annotations, a C extension, or a library you cannot change. Checkers find stubs next to the module, in a folder on their own search path such as MYPYPATH, in a stub-only package named after the library with -stubs added, and in typeshed, whose stubs for the standard library are bundled with each checker.
-
Mid to senior
Your team's internal library has complete inline type hints, but in other teams' projects mypy reports it as an untyped module and checks nothing that comes from it. Why, and what do you change in the library and its build?
-
Mid to staff
A strict mypy run in CI fails because a third-party dependency has no types. Compare the options: ignoring the import, writing stubs in your repository, installing or publishing a stub package, and contributing types upstream. Which do you choose, and when?
-
Senior to staff
A stub says a function returns int, but at run time it sometimes returns None, and a crash in production was traced to that. How do you find such disagreements between stubs and code, and keep stubs honest in CI?
Key takeaways
- A package’s inline types count for mypy only when the package contains a
py.typedmarker. - A
.pyistub holds signatures with...bodies, and checkers read it instead of the module beside it. - Checkers look in a fixed order: your own stubs, your code, typeshed’s standard library, stub-only packages, then the package.
- Without any of them, the import is reported and everything from it is
Any.
Terms in this lesson
- py.typed marker
- An empty file named py.typed in a package's folder (PEP 561). It tells type checkers that the package's inline annotations or .pyi files are meant for them; without it, mypy treats an installed package as untyped.
- stub file
- A file with the suffix .pyi that holds only a module's interface: signatures with ... as their bodies, and annotated variables. Type checkers read it in place of the .py module beside it; Python never imports it.
References
- Typing specification: distributing type information (The Python Typing Team)
- Typing guides: writing and maintaining stub files (The Python Typing Team)
- Typing guides: modernizing superseded typing features (The Python Typing Team)
- PEP 561: Distributing and Packaging Type Information (Python Software Foundation)
- mypy documentation: running mypy and managing imports (The mypy project)
- mypy documentation: using installed packages (The mypy project)
- mypy documentation: automatic stub generation (stubgen) (The mypy project)
- mypy documentation: automatic stub testing (stubtest) (The mypy project)
- typeshed: collection of library stubs for Python (The typeshed project)
- setuptools: data files support (Python Packaging Authority)
- setuptools history: v69.0.0, type information included by default (Python Packaging Authority)
- Pyright documentation: configuration (useLibraryCodeForTypes) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress