skip to content

Why does a built wheel sometimes omit the py.typed marker file?

level: middleimportance: should knowfreq 35%

answer

  1. The repo is not the artefact
  2. Backends choose what to copy
  3. Non-code files need declaring
  4. Editable installs hide it
  5. List the wheel before publishing

basics

~20 s

A build backend decides which non-Python files enter the wheel, and some copy only .py files unless the rest are declared as package data. The marker then exists in your repo but not in the installed package, so consumers see an untyped dependency.

solid answer

~40 s

A wheel contains what the build backend put in it. Backends differ: several include every file that sits inside the package directory, while setuptools collects modules from package discovery and needs non-code files declared — as package data in `pyproject.toml`, or picked up through the sdist-inclusion path — before a bare `py.typed` will travel. The bug hides because local development uses an editable install pointing at the source tree, where the marker is obviously present; only the built artefact is missing it, so the failure lands on consumers. The check is mechanical: build the wheel, list the archive, and confirm the marker is at `<package>/py.typed` inside it. Better still, install the built wheel into a scratch environment and look at the installed package directory, which is exactly what a consumer's checker will read.

code

console · 1 line
console
python -m zipfile -l dist/billing-1.0.0-py3-none-any.whl

go deeper

for a junior

Understand that what you edit and what gets installed are different file sets, and that a file in the repository only reaches users if the build copies it into the wheel.

for a middle

Explain the mechanics: build backends differ on non-code files, setuptools needs package data declared, MANIFEST.in targets the sdist, and an editable install masks the whole problem. Be able to name a way to inspect the built wheel.

for a senior

Demonstrate release discipline — you assert on the artefact rather than the source tree, you install the built wheel into a clean environment before publishing, and you recognise silent-degradation bugs that no test of your own repo can catch.

for a principal

Own the standard across many libraries: a shared release check that every published wheel carries its declared payloads, and a policy for how a quietly broken release is detected and superseded rather than left for consumers to discover.

### Two different file sets It helps to keep three things apart. There is the **source tree** you edit, the **sdist** (a source archive), and the **wheel** (the installable artefact). Each is built by rules, and the rules are not the same. A file being in the source tree implies nothing about the wheel; a build backend chooses what to copy. Backends split roughly into two camps. Some treat the package directory as the unit and copy everything inside it, so dropping `py.typed` next to `__init__.py` is all you do. setuptools is the one that bites: it discovers *packages* and takes their Python modules, and non-code files inside a package are "package data" that must be declared, or must arrive by way of the sdist-inclusion machinery. The direct declaration in `pyproject.toml` looks like this: ```toml [tool.setuptools.package-data] billing = ["py.typed"] ``` The classic near-miss is `MANIFEST.in`. It governs what goes into the **sdist**; it only influences the wheel indirectly, through the setting that tells setuptools to also take the data files it found for the source archive. Teams add a `MANIFEST.in` line, watch the sdist grow the file, and assume the wheel followed. It may not have. ### Why nobody notices During development the library is installed in editable mode, which wires the environment straight to your working tree. A checker resolving `import billing` there reaches your actual directory, marker and all, so the library type-checks its own tests perfectly. Consumers install the wheel. Inside their `site-packages` the package arrives without the marker, so every one of them silently loses type information from your library — and the report they get names their import, not your build, which is why the bug is usually reported to you as "your library is untyped" long after the release. This is not specific to the marker; it is the general shape of the package-data trap. It just happens that a missing `py.typed` fails *quietly*: nothing crashes, no import error, only the gradual erosion of checking in the code that depends on you. Consider a subscription-billing library that a dozen internal teams import. The release notes announce that the library is now fully typed; the wheel ships without the marker; every consuming team still gets `Any` for `billing.total(...)`, and nobody finds out until someone passes a currency string where cents were expected and nothing complains. ### Verifying instead of hoping A wheel is a zip archive, so verification takes one command against the built file rather than the source tree: ```console $ python -m zipfile -l dist/billing-1.0.0-py3-none-any.whl ``` The listing must contain `billing/py.typed`. The stronger version of the same check installs the built wheel into a throwaway environment and inspects the installed package directory, because that reproduces exactly what a consumer's checker sees, including any renaming or layout surprise. Either check belongs in the release pipeline: build, assert the marker is present in the artefact, then publish. A one-line assertion in CI is worth more than a convention, because the failure mode is invisible to every test you run against your own source tree. ### Where layout comes into it Layout choices interact with the declaration. Under a `src/` layout the marker lives at `src/billing/py.typed` in the tree but must arrive as `billing/py.typed` in the wheel, so a package-data entry written against the on-disk path rather than the package name silently matches nothing. Subpackages inherit the top-level marker and need no entry of their own, but a distribution installing two independent top-level packages needs a declaration for each. And when a project changes build backend, declarations written for the previous one are ignored rather than reported as invalid — exactly the kind of change that reintroduces the bug months after someone fixed it. ### Related traps in the same family If you ship inline stub files (`.pyi`) alongside your modules rather than plain annotations, they are non-code files too and are lost by exactly the same mechanism, with exactly the same silence. Sdist-only inclusion is another: publishing both an sdist and a wheel where only the sdist carries the marker means consumers who install from the wheel — nearly all of them — get nothing, while the rare source install works, producing a wonderfully confusing bug report.

  • Why does an editable install hide this bug so effectively?
    An editable install points the environment at your working tree instead of copying files into site-packages, so imports resolve to the directory where you created the marker. Every check you run locally therefore sees a correctly typed package. The wheel is a separate build product, and only consumers install it — which is why the missing file never surfaces until someone outside the repo depends on you.
  • How would you stop this from regressing after you fix it once?
    Make it a build-time assertion rather than a convention: after building, open the wheel as a zip and fail the release if `<package>/py.typed` is absent. Installing the built wheel into a scratch environment and inspecting the installed package is the stronger form, since it also catches layout or renaming surprises. Both run in seconds and cover the case no test of your source tree can reach.

It is the difference between what is in your kitchen and what made it into the lunchbox: the sandwich you remember packing is only really there if you open the box and look.

saying these in an interview costs you the question

  • Assumes any file in the package directory lands in the wheel
  • Thinks MANIFEST.in controls wheel contents directly
  • Only ever tests with an editable install of the repo
  • Adds the marker at the repository root and rebuilds
  • Blames the consumer's checker setup for a missing marker
  • Verifies the sdist and assumes the wheel matches

context