Skip to content
← All articles

py.typed and PEP 561: how a checker finds your types

You annotated the library exhaustively, published it, and every consumer still sees Any. The marker file, the naming rules, and the resolution order that silently overrides your real types.

You spend a week annotating a library. mypy --strict is clean. You publish it. A consumer installs it, runs their own checker, and every value crossing your API surface is Any.

Nothing is wrong with your annotations. The wheel is missing a zero-byte file.

The rule

A package shipping inline annotations MUST include a py.typed marker file in the package directory, and that file must be present in the built distribution. Without it, PEP 561 says checkers must ignore the package’s inline types entirely and treat it as untyped.

The file is normally empty. It is a flag, not a payload.

Getting it into the wheel is where it actually goes wrong. It is not a .py file, so nothing includes it by default:

# pyproject.toml, setuptools
[tool.setuptools.package-data]
mypkg = ["py.typed"]

Hatchling and PDM pick up everything inside the package directory, so they usually do the right thing without configuration — which is its own hazard, because it means the failure mode differs by build backend and you will not notice when you migrate.

💡Your CI runs mypy --strict on the source tree and it passes. What test would actually have caught the missing marker? click to reveal

Build the wheel, install it into a clean virtualenv, and type-check a tiny consumer file against the installed package — not against the source tree.

Checking the source tree tests the wrong thing entirely. mypy is reading your .py files directly, as first-party code, where inline annotations are always honoured and py.typed is irrelevant. The marker only governs how a third party sees a package resolved from site-packages.

The cheap version of this test is three lines in CI:

python -m build
pip install dist/*.whl
python -c "import mypkg, pathlib, sys; sys.exit(0 if (pathlib.Path(mypkg.__file__).parent / 'py.typed').exists() else 1)"

The thorough version is a consumer file with a deliberate type error, and an assertion that the checker reports it. If the marker is missing, everything is Any, the deliberate error is not reported, and the test fails — which is the negative-assertion technique from the rest of this track applied to packaging.

Naming rules for stub distributions

Three MUSTs from PEP 561, all of which are enforced by tooling and none of which are guessable:

  • A stub-only distribution MUST be named foopkg-stubs — that is the distribution name and the directory name. This is why you install types-requests and get a requests-stubs directory.
  • An incomplete stub distribution MUST include the literal line partial (with a trailing newline) in its py.typed. That tells the checker to fall back to the runtime package for anything the stubs do not cover, rather than treating the omission as “this attribute does not exist”.
  • Stub packages MUST NOT be imported at runtime and MUST NOT be listed as a runtime dependency of the package they describe.
💡A stub package covers 80% of a library. What is the difference in a consumer's experience between shipping it with and without partial in py.typed? click to reveal

Without partial, the stubs are authoritative and complete. Every symbol the stubs do not mention does not exist. A consumer calling a real, working function gets Module has no attribute "frobnicate" and has no recourse but a # type: ignore — for a function that is right there in the source.

With partial, the stubs are a partial overlay. Symbols the stubs cover get the stub types; symbols they do not get resolved from the runtime package, which usually means Any, which is what an unstubbed library gives you anyway. Nothing regresses.

So partial converts “80% typed, 20% broken” into “80% typed, 20% as before”. If you are publishing incremental stubs for something large, it is not optional — and it is one line.

Resolution order

When a checker resolves import foo, it consults, in order:

  1. Manual path entriesMYPYPATH, mypy_path, and equivalents
  2. Your own code — first-party modules
  3. typeshed’s bundled stdlib stubs
  4. Stub packagesfoo-stubs
  5. Inline typesfoo with a py.typed
  6. Vendored third-party stubs — typeshed’s bundled third-party stubs

Two consequences you will meet in practice.

A local stub directory silently overrides a library’s real, better types. Entry 1 beats everything. Somebody added a stubs/ directory to mypy_path two years ago with a three-line .pyi for a library that has since shipped complete inline annotations. The three-line stub still wins, the rest of the library is invisible, and nothing warns you. When a well-typed dependency suddenly seems to have lost most of its API, look at mypy_path first.

Stub packages beat inline types. That ordering is deliberate — it lets a consumer patch a bad stub locally without vendoring the library — but it also means an abandoned types-foo left in your lockfile continues to shadow the library’s own annotations long after the library started shipping better ones. types-* packages that exist only as historical artefacts are a real source of stale types; prune them when a dependency gains py.typed.

💡--ignore-missing-imports makes the error go away. When, if ever, is it the right answer? click to reveal

Per-module, for a dependency you have decided not to type, as a recorded decision — never globally.

The mypy docs describe the global form as “equivalent to adding a # type: ignore to all unresolved imports”, which is exactly what it is: a blanket suppression whose scope grows every time you add a dependency. The new untyped dependency someone adds next quarter is silently covered by it, and nobody ever finds out.

The per-module form is different in kind, because it names the module:

[[tool.mypy.overrides]]
module = ["legacy_vendor_sdk.*"]
ignore_missing_imports = true

That entry is greppable, reviewable, and deletable. It is a to-do list. When the vendor ships stubs, someone removes a line.

The full ladder, cheapest first: check for types-* on typeshed, try mypy --install-types, write a minimal .pyi covering only the surface you use, try follow_untyped_imports, and only then a scoped ignore_missing_imports.

The checklist

Publishing a typed library, in full:

  • py.typed in the package directory
  • the marker included in the built wheel, verified from a clean install
  • __all__ declared in every public module, and accurate
  • deliberate re-exports spelled from .x import Y as Y
  • mypy --strict clean on the source, and a consumer smoke test against the installed wheel

Five items. The first one is the one that gets forgotten, and it is the one that makes the other four invisible.