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 marker lives in the package’s top-level directory because that is what a checker sees once your wheel is installed under site-packages/. PEP 561 defines three ways a project can distribute types — inline annotations behind a py.typed marker (this page), a separate stub-only distribution named <pkg>-stubs, and the bundled typeshed fallback covered in type stubs and third-party packages. The marker is what selects the first path: it is the package saying “my own .py files are the source of truth for types.”
The file is genuinely empty for a fully inline-typed library. PEP 561 also defines a one-line partial\n content, but that is a signal for stub-only or intentionally incomplete distributions — it tells checkers “these types are partial, keep searching other sources for what I don’t cover.” An inline-typed package you consider complete leaves the file zero bytes; adding partial here would wrongly invite mypy and pyright to keep looking for stubs you never shipped.
The marker applies recursively. One py.typed in orderkit/ marks orderkit, orderkit.repo, and any orderkit.api subpackage — you do not scatter a marker into every subdirectory. The important exceptions are PEP 420 namespace packages: when several separately-published distributions contribute portions to one shared namespace (say acme.auth and acme.billing ship from different wheels into acme/), each distribution must carry its own marker in its own portion, because they are installed independently and no single top-level __init__.py owns the namespace. A src/ layout changes nothing about placement — the marker still sits beside __init__.py at src/orderkit/py.typed; only your build’s package-discovery where needs to point at src.
Including it in the build
A file on disk is not a file in the wheel. Build backends package your .py modules by default, but py.typed has no .py extension, so it counts as package data — an extra file each backend must be told (or already knows) to carry. This is the single most common place the marker is lost, and the failure is invisible until a downstream consumer runs a checker. The exact incantation depends on which build backend your pyproject.toml names under [build-system].
setuptools (pyproject.toml):
# pyproject.toml — setuptools >= 61
[tool.setuptools.package-data]
orderkit = ["py.typed"]
[tool.setuptools.packages.find]
where = ["."]
An alternative is include-package-data = true combined with a MANIFEST.in, but the two mechanisms interact confusingly, so prefer the explicit package-data rule above. Here is why they differ: MANIFEST.in controls what enters the source distribution (sdist). With include-package-data = true, setuptools also copies any package-data files it finds (via MANIFEST.in or version control) into the wheel — but only files that sit inside a discovered package. A py.typed at the project root is never in a package, so no MANIFEST.in line will place it in the wheel. Consumers pip install the wheel, so a marker that made it into the sdist but not the wheel helps nobody.
setuptools (setup.cfg) — legacy but still common:
[options.package_data]
orderkit = py.typed
[options]
zip_safe = False
Set zip_safe = False for zipped eggs: a checker cannot read a marker sealed inside a zipped egg. Modern wheels are unzipped on install, so this matters only for the older egg format.
Hatchling ships files that live inside the package directory, but if py.typed is .gitignored or you are being explicit, name it directly:
# pyproject.toml — hatchling
[tool.hatch.build.targets.wheel]
include = ["orderkit/py.typed"]
# or, for a file a VCS-based file selector would skip:
# [tool.hatch.build.targets.wheel.force-include]
# "orderkit/py.typed" = "orderkit/py.typed"
Flit packages every file inside the module/package directory by default, so a py.typed beside __init__.py ships with no extra config. Poetry (poetry-core) likewise includes py.typed automatically when it sits inside the package — but pin down which version your CI uses, because older Poetry releases dropped it, and confirm with the wheel inspection below regardless of backend. The rule of thumb: setuptools always needs the explicit line; the newer backends usually do the right thing, but never trust the config over the built artifact.
Verifying it actually shipped
Do not trust the config; inspect the built artifact. Verification is a short pipeline: build the distributions, confirm the marker is inside the wheel’s package directory, then prove a fresh consumer actually resolves your types. Skipping the last step is how teams ship a marker that is present but ineffective — for example one placed at the wrong path.
The marker must appear inside the wheel’s package directory, not just the sdist:
$ 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 and the path prefixed by your package name (orderkit/py.typed, not a bare py.typed) is what a downstream checker needs. A wheel is just a zip, so unzip -l works everywhere; python -m zipfile -l dist/*.whl is a dependency-free alternative if unzip is not installed. It is worth glancing at the sdist too (tar tzf dist/orderkit-1.2.0.tar.gz | grep py.typed), because a marker that is in the sdist but missing from the wheel is the classic include-package-data/MANIFEST.in trap from the previous section.
Path present in the archive still is not proof the types resolve — install into a clean environment and ask a checker directly:
$ python -m venv /tmp/probe && /tmp/probe/bin/pip install dist/orderkit-*.whl mypy
$ printf 'from orderkit.repo import OrderRepository\nreveal_type(OrderRepository)\n' > probe.py
$ /tmp/probe/bin/mypy probe.py
probe.py:2: note: Revealed type is "type[orderkit.repo.OrderRepository]"
A throwaway virtualenv matters: running the checker from your source tree reads the annotated .py files directly and passes whether or not the marker shipped, so it can never catch a packaging bug. Only a consumer that imports the installed wheel exercises the PEP 561 path. In application code, reveal_type confirms the real class flows 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. reveal_type is a checker-only builtin (recognized by mypy and pyright without import; available at runtime from typing since Python 3.11), so leaving it in a committed file is a mistake — use it as a one-off probe.
Beware one trap that mimics a shipped marker: an editable install (pip install -e .). Editable installs point site-packages back at your working tree, so the checker reads your annotated .py files directly and reports types whether or not py.typed is packaged — exactly the false pass a source-tree run gives. The probe venv must install the built wheel, never -e ., to exercise the real PEP 561 path.
pyright offers a purpose-built auditor that goes further than a single reveal_type: pyright --verifytypes orderkit. It walks every public symbol your package exports and reports a type completeness score — the fraction of the public API with known (non-Any, non-implicit) types — plus a list of every symbol still missing an annotation or partially unknown.
$ pyright --verifytypes orderkit --ignoreexternal
Type completeness score: 87.5%
...
error: Type of "OrderRepository.get" is partially unknown
Run it against the installed wheel in CI and fail the job below a threshold; it is the most direct measure of whether shipping the marker actually bought your consumers useful types rather than a package full of Any. A score near 100% is the real goal — a py.typed marker on top of half-annotated code advertises types you do not deliver.
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
Almost every failure below has the same symptom — a downstream [import-untyped] despite a correctly annotated source tree — because the marker is dropped somewhere on the journey from repository to installed package. The diagram traces those leak points; the list explains each and its fix.
- 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 — a root-level marker ships nowhere useful even if aMANIFEST.inpicks it up. 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. - Testing from the source tree, not an installed wheel. A green mypy run in your own repo proves nothing about downstream consumers, because the checker reads your
.pyfiles directly. Verify in a throwaway venv as shown above; a runtime test suite never exercises the marker at all. - A stale local stub shadowing your inline types. If a consumer once wrote a
types-orderkitstub orstubgenskeleton (see generating stub files with stubgen) for your then-untyped package, that stub now wins over your shipped inline types and can raise phantom[attr-defined]errors. Once you shippy.typed, downstream should delete those local stubs. - Shipping
.pyifiles when inline annotations would do. Inline types stay in lockstep with the code and version with it; a parallel.pyitree duplicates every signature and drifts. Reach for bundled.pyionly when annotations cannot live in the source (heavily dynamic modules, C extensions) — otherwise annotate in place and ship the marker.
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.