skip to content

When should a library ship a stub-only distribution instead of py.typed?

level: seniorimportance: should knowfreq 28%

answer

  1. Who owns the source decides
  2. Two copies of one public surface
  3. The -stubs directory suffix
  4. Stubs outrank inline annotations
  5. Delete them when upstream marks itself

basics

~20 s

Ship stubs when annotations cannot live in the code: a library you do not own, a compiled extension with no Python source, or a codebase that cannot carry them. If you own the source, inline annotations plus the marker win, because stubs drift.

solid answer

~40 s

A stub-only distribution ships `.pyi` files in a `<name>-stubs` directory and nothing importable at run time. It is right in three situations: the library is someone else's and will not annotate itself; the module is a compiled extension whose surface exists only at run time, so there is no source to annotate; or the annotations must move on a different release cadence than the implementation. Everywhere else inline annotations plus a `py.typed` marker win, because the types sit beside the code and cannot silently disagree with it. The cost of stubs is drift: two artefacts, two releases, and a checker that **prefers stubs over inline annotations** when both are installed, so stale stubs quietly shadow correct types. Deliberately incomplete stubs say so with a `py.typed` containing the word `partial`.

code

python · 8 lines
python
import pathlib, tempfile

root = pathlib.Path(tempfile.mkdtemp())
stubs = root / "billing-stubs"
stubs.mkdir()
(stubs / "__init__.pyi").write_text("def total(cents: int) -> int: ...\n")
(stubs / "py.typed").write_text("partial\n")
print(sorted(p.name for p in stubs.iterdir()))

go deeper

for a junior

Know that a stub file carries signatures without bodies, that stub-only distributions exist for libraries which ship no types of their own, and that nothing imports them at run time.

for a middle

Explain the layout and the tradeoff: a <name>-stubs directory of .pyi files, duplication of the public surface, and the drift that follows. Be able to say why inline annotations plus the marker are preferred for code you own.

for a senior

Demonstrate the judgement: pick stubs only when the source cannot carry annotations, know that stubs take precedence over inline types and that stale ones shadow good ones, and treat removing them as part of upgrading a dependency that becomes typed.

for a principal

Own the strategy — whether the organisation maintains stubs for unmaintained dependencies at all, how that debt is tracked and retired, and how internal libraries are pushed toward shipping their own types so the stub inventory shrinks rather than grows.

### What a stub-only distribution is A stub file (`.pyi`) contains signatures and no bodies. A stub-only distribution packages those files in a directory named after the package it describes plus a `-stubs` suffix — `billing-stubs` for `billing` — and installs nothing that is imported at run time. Nobody's program executes it; only checkers read it. ### The three cases where stubs are right **You do not own the library.** This is the common one, and the reason a whole ecosystem of community-maintained stub distributions exists. If a dependency you rely on ships no marker and shows no sign of adding one, the only way to get types is to describe its surface from outside. **There is no Python source to annotate.** A compiled extension module presents functions and classes that exist only once the shared object is loaded; there is no `def` to hang an annotation on. Stubs are the standard answer, and a `.pyi` sitting next to the extension inside a marked package is the inline form of the same idea. **The annotations need a separate cadence.** Occasionally the implementation must stay conservative — supporting an old syntax baseline, or keeping the runtime free of typing constructs — while the types want to move faster. Splitting them lets each release on its own schedule. This is the weakest of the three reasons and worth resisting when the constraint is soft. ### Why inline plus the marker wins otherwise Stubs duplicate your public surface. Every signature exists twice, and the copy that checkers trust is the one *not* validated by your test suite. Over a few releases a stub set drifts: a parameter is renamed, a return type widens, a function is removed, and the stubs still describe last quarter's library. Consumers then get confident, wrong answers, which is worse than the honest `Any` they had before the stubs existed. The resolution order sharpens this. PEP 561 has checkers prefer a `<name>-stubs` package over the inline annotations of the package it describes. That is deliberate — it lets a user override a library's own types — but it means a stub distribution left installed after the library starts shipping its own marker will keep shadowing the accurate types with stale ones, with no warning anywhere. When a dependency becomes properly typed, removing the stubs distribution is part of the upgrade. Concretely: a subscription-billing library used by a dozen internal teams once needed community stubs. It later annotated itself and shipped the marker. Teams that dropped the stubs got types that match the code they call; teams that left them installed kept a two-year-old signature for the function that computes a proration, and their checker cheerfully approved calls the current library rejects at run time. ### Partial stubs Stubs are allowed to be incomplete. A stub-only package that covers part of its target adds a `py.typed` whose contents are the single word `partial`, which tells a checker that names the stubs do not define should be looked for elsewhere rather than treated as errors. It is the honest option while a stub set is being grown, and it is the one place where the marker's *contents* matter at all. ### The three names to keep straight Stub-only distributions are the sharpest case of Python's package-naming ambiguity, and an interviewer will notice if you blur it. The **distribution** is what an installer is asked for and what appears in a requirements file, commonly a `types-`-prefixed name. The **installed directory** must be the target package's name plus a `-stubs` suffix, because that suffix is what the resolution rule matches on — a hyphen, not an underscore, and deliberately not a legal identifier, since nothing is ever meant to import it. The **importable package** the stubs describe keeps its own ordinary name and is untouched. Get the directory wrong and the distribution installs cleanly, imports nothing, breaks nothing, and provides no types at all — the quietest failure in this whole area. ### Running the decision Ask who owns the source. If it is you and the source can carry annotations, annotate the public surface, check the library against itself in your build, ship `py.typed`, and treat the annotations as part of the API from that release on. If it is not you, write stubs, mark them partial while they are incomplete, pin them against the library version they describe, and plan to delete them the day upstream ships its own marker. Maintaining stubs for code you control is a standing tax paid in drift, and the answer an interviewer is listening for is that you noticed the difference.

  • If both a billing-stubs distribution and an inline-typed billing package are installed, which one does a checker use?
    The stubs win. PEP 561's resolution order puts a `<name>-stubs` package ahead of the inline annotations of the package it describes, so that a user can override a library's own types. The practical consequence is that an obsolete stubs distribution silently shadows accurate inline annotations, so uninstalling it belongs in the upgrade step whenever a dependency starts shipping its own marker.
  • What does a py.typed containing the word `partial` mean inside a stub-only package?
    It declares the stubs incomplete. For names the stubs do define, the checker uses them; for names they do not, it keeps searching instead of concluding the attribute does not exist. That makes it safe to publish a growing stub set without breaking consumers who use parts of the library you have not described yet. For an inline-typed package the marker's contents are ignored — this is the one place they matter.
  • How do you keep a stub set from drifting away from the implementation?
    Pin the stub distribution's version to the library release it describes so a mismatch is visible in the lockfile, generate a fresh stub skeleton from the installed library in CI and diff it against what you ship, and check a small set of real call sites against the stubs so a renamed parameter fails a build rather than a user. None of it removes the tax — it only makes the drift loud.

Stubs are a translated manual printed separately from the appliance: indispensable when the manufacturer shipped none, but a liability once the manufacturer starts including one, because the old translation is still the copy people reach for.

saying these in an interview costs you the question

  • Writes stubs for a library they own rather than annotating it
  • Assumes inline annotations beat an installed stubs package
  • Names the stub directory after the package with no -stubs suffix
  • Forgets stubs drift from the implementation across releases
  • Thinks stub files are imported or executed at run time
  • Leaves obsolete stubs installed after upstream ships a marker

context