# How to ship type annotations with a Python library


Annotating a library's public API does nothing for users until the built {{< term "wheel" >}} carries `py.typed`, the empty marker file defined by [PEP 561, the standard for distributing type information](https://pydevtools.com/handbook/explanation/what-is-pep-561.md).

Without it, [mypy](https://pydevtools.com/handbook/reference/mypy.md) 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](https://pydevtools.com/handbook/reference/uv.md) 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.

```python {filename="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](https://pydevtools.com/handbook/explanation/distribution-package-vs-import-package.md), next to `__init__.py`, not at the repository root and not in `src/`.

```bash
touch src/greeter/py.typed
```
```powershell
New-Item -ItemType File 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:

```python {filename="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

```bash
uv build --wheel
```

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

```console
$ 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](https://pydevtools.com/handbook/explanation/what-is-a-build-backend.md) needs extra `pyproject.toml` configuration. `uv_build` and [hatchling](https://pydevtools.com/handbook/reference/hatch.md) ship every file inside the package directory, and [setuptools](https://pydevtools.com/handbook/reference/setuptools.md) 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:

```python {filename="check.py"}
from greeter import greet

greeting = greet(42)
print(greeting.text.upper())
```

```console
$ 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:

```console
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](https://pydevtools.com/handbook/reference/pyright.md), [Pyrefly](https://pydevtools.com/handbook/reference/pyrefly.md), and [ty](https://pydevtools.com/handbook/reference/ty.md) 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](https://pydevtools.com/handbook/how-to/how-to-test-a-python-library-against-multiple-type-checkers.md) so users of mypy, Pyright, and Pyrefly all get the same answers.

## Learn More

- [Build and publish a Python package](https://pydevtools.com/handbook/tutorial/build-and-publish-a-python-package.md) covers the rest of the path to PyPI
- [Typing Python Libraries](https://typing.python.org/en/latest/guides/libraries.html) in the Python typing documentation
- [Typed libraries](https://github.com/microsoft/pyright/blob/main/docs/typed-libraries.md) in the Pyright docs covers type completeness for public APIs
- [Using installed packages](https://mypy.readthedocs.io/en/stable/installed_packages.html) in the mypy documentation
