Skip to content

How to ship type annotations with a Python library

Annotating a library’s public API does nothing for users until the built wheelA prebuilt Python package file (.whl) that installs without compiling anything. The standard distribution format for Python packages. Learn more → carries py.typed, the empty marker file defined by PEP 561, the standard for distributing type information.

Without it, mypy discards every annotation in the package and hands users Any. The fix is one empty file plus a check that it survived the build.

Prerequisites

  • uv installed
  • A library project with a src/ layout and a [build-system] table in pyproject.toml

Projects created with uv init --lib already have the marker; skip to building the wheel.

Annotate the public API

Annotate the parameters, return types, and attributes users touch. Internal helpers can stay unannotated without weakening what a user’s type checker sees.

src/greeter/__init__.py
from dataclasses import dataclass

__all__ = ["Greeting", "greet"]


@dataclass
class Greeting:
    text: str
    formal: bool = False


def greet(name: str, *, formal: bool = False) -> Greeting:
    prefix = "Good day" if formal else "Hello"
    return Greeting(text=f"{prefix}, {name}!", formal=formal)

Add the py.typed marker

The marker is empty; its location is what goes wrong. It belongs inside the import package, next to __init__.py, not at the repository root and not in src/.

touch src/greeter/py.typed

Each top-level import package your distribution installs needs its own marker.

Choose inline annotations or stub files

Inline annotations are the default: they cannot drift from the implementation, and they keep docstrings and default values visible to editors.

Reach for a .pyi stub when the implementation is not Python. A Rust or C extension module has no annotatable source, so the stub carries the types:

src/greeter/_speedups.pyi
def fast_greet(name: str) -> str: ...

A stub takes priority over the module it shadows, so type checkers read _speedups.pyi and ignore _speedups.py. That priority makes stubs useful for extension modules and a maintenance cost everywhere else, because nothing catches a stub that no longer matches the code. Ship stubs alongside py.typed, not instead of it.

Build the wheel and confirm the marker shipped

uv build --wheel

List the wheel’s contents rather than trusting the build configuration:

$ uv run --no-project python -m zipfile -l dist/greeter-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
greeter/                                       1980-01-01 00:00:00            0
greeter/__init__.py                            1980-01-01 00:00:00          305
greeter/py.typed                               1980-01-01 00:00:00            0
greeter-0.1.0.dist-info/WHEEL                  1980-01-01 00:00:00           80
greeter-0.1.0.dist-info/METADATA               1980-01-01 00:00:00          214
greeter-0.1.0.dist-info/RECORD                 1980-01-01 00:00:00          350

greeter/py.typed is the line that matters. uv run --no-project skips syncing the project, so the listing is the only output.

No build backend needs extra pyproject.toml configuration. uv_build and hatchling ship every file inside the package directory, and setuptools treats py.typed and *.pyi as implicit package data, including them even when include-package-data is off. A misplaced marker, not a missing config table, produces a wheel with no py.typed line and no error message.

Prove a consumer sees the types

Install the wheel into a throwaway environment and check a script that misuses the API:

check.py
from greeter import greet

greeting = greet(42)
print(greeting.text.upper())
$ uv run --no-project --with dist/greeter-0.1.0-py3-none-any.whl --with mypy mypy check.py
check.py:3: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

An [arg-type] error means the annotations reached the consumer. The failure to watch for says mypy found the package but not the marker:

check.py:1: error: Skipping analyzing "greeter": module is installed, but missing library stubs or py.typed marker  [import-untyped]

Run this check with mypy specifically. It is the only one of the four common checkers that gates on the marker: Pyright, Pyrefly, and ty all read library annotations without it, so a package missing the marker looks correctly typed in an editor and still fails in a user’s mypy run.

Once the wheel ships types, test the public API against several type checkers so users of mypy, Pyright, and Pyrefly all get the same answers.

Learn More

Last updated on