Shipping Inline Types with py.typed
TL;DR — Annotating your .py files is not enough to make a published package typed. You must add an empty marker file named py.typed inside the package directory and configure your build so it ships in the wheel. That PEP 561 marker is the signal that tells downstream mypy and pyright to trust your inline annotations; without it they treat every import from your package as Any.
You annotated OrderRepository, ran mypy locally, everything passed. Then a teammate installs your wheel, imports it, and mypy reports [import-untyped]. The annotations are right there in the source — so why does the checker ignore them? Because a published package is opaque: the checker will not read annotations out of an installed third-party package unless that package explicitly opts in with a py.typed marker.
The package layout
Place the marker inside the importable package, next to __init__.py — not at the repository root:
orderkit/
├── __init__.py
├── repo.py
├── models.py
└── py.typed ← empty file, this is the PEP 561 marker
pyproject.toml
The file is genuinely empty (a partial-stubs flavor exists for stub-only packages, but for an inline-typed library you leave it blank). For a namespace package or a project with several top-level packages, every distributed package that carries types needs its own py.typed.
Including it in the build
A file on disk is not a file in the wheel. Build backends only package what you tell them to, and py.typed has no .py extension, so it is easy to leave behind. The fix depends on your backend.
setuptools (pyproject.toml):
# pyproject.toml — setuptools >= 61
[tool.setuptools.package-data]
orderkit = ["py.typed"]
[tool.setuptools.packages.find]
where = ["."]
setuptools (setup.cfg) — legacy but still common:
[options.package_data]
orderkit = py.typed
[options]
zip_safe = False
Set zip_safe = False for zipped eggs: checkers cannot read a marker inside a zip. Hatchling:
# pyproject.toml — hatchling
[tool.hatch.build.targets.wheel]
include = ["orderkit/py.typed"]
Poetry picks up py.typed automatically when it sits inside the package, but verify — older versions did not.
Verifying it actually shipped
Do not trust the config; inspect the built artifact. The marker must appear inside the wheel’s package directory:
$ python -m build
$ unzip -l dist/orderkit-1.2.0-py3-none-any.whl | grep py.typed
0 2026-07-21 10:00 orderkit/py.typed
Zero bytes, correct path — that is what a downstream checker needs. Then confirm the annotations flow through:
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from orderkit.repo import OrderRepository
repo = OrderRepository(dsn="postgres://…")
order = repo.get(42)
reveal_type(order) # note: Revealed type is "orderkit.models.Order"
If reveal_type shows your real class instead of Any, the marker is doing its job.
py.typed marker changes nothing at runtime — Python never reads it, and your package imports and runs identically with or without it. It exists purely so a static checker analyzing a downstream project knows your inline annotations are intentional. A test suite that only executes code will never catch a missing marker; only running mypy or pyright against a consumer of your package will.
Common packaging mistakes
- Marker present in the repo, absent from the wheel. The most frequent failure:
py.typedexists in your source tree but nopackage-data/includerule ships it, so consumers still hit mypy[import-untyped]. Alwaysunzip -lthe built wheel. - Marker at the project root instead of inside the package. A
py.typednext topyproject.tomlis never imported. It must sit beside__init__.py, inside theorderkit/directory. MANIFEST.inincludes it in the sdist but not the wheel.MANIFEST.ingoverns the source distribution; wheels are built frompackage-data/includerules. Consumers install the wheel, so aMANIFEST.in-only fix silently does nothing.- Namespace package with the marker in only one sub-package. With PEP 420 namespace packages, each distributed package needs its own marker; a checker treats a missing one as mypy
[import-untyped]for just that sub-package.
FAQ
Does py.typed need any content?
No. For an inline-typed package it is a zero-byte file; its mere presence is the signal. (The single-line partial\n content is only for stub-only or partially-typed distributions, which is a separate case.)
How do I confirm downstream checkers see my types without publishing?
Build the wheel, install it into a throwaway virtualenv (pip install dist/orderkit-*.whl), then run mypy against a small script that imports your package. If reveal_type() reports concrete classes rather than Any, the marker shipped correctly.