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.

Effect of the py.typed marker on downstream type checking Two identical annotated packages; the one containing a py.typed file exposes real types downstream, the one without resolves to Any. with py.typed without py.typed orderkit/ __init__.py (annotated) py.typed ✓ marker present repo.py (annotated) orderkit/ __init__.py (annotated) — no marker — repo.py (annotated) downstream: real types downstream: Any [import-untyped]
Identical source; the only difference is the marker. The un-marked package's annotations are discarded downstream.

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.

Runtime vs static analysis The 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.typed exists in your source tree but no package-data/include rule ships it, so consumers still hit mypy [import-untyped]. Always unzip -l the built wheel.
  • Marker at the project root instead of inside the package. A py.typed next to pyproject.toml is never imported. It must sit beside __init__.py, inside the orderkit/ directory.
  • MANIFEST.in includes it in the sdist but not the wheel. MANIFEST.in governs the source distribution; wheels are built from package-data/include rules. Consumers install the wheel, so a MANIFEST.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.

Back to Type Stubs and Third-Party Packages