Generating Stub Files with stubgen

TL;DR — When a dependency ships no inline types and has no types-* package, run stubgen — it comes with mypy — to auto-generate .pyi skeleton files from the module’s structure. The skeletons are full of Any, so you refine the important signatures by hand, then point the checker at the folder with mypy_path / MYPYPATH for mypy or stubPath for pyright.

stubgen reads a package (by import or by parsing source) and writes one .pyi per module containing every public class, function, and attribute — with real names and structure but placeholder Any types. That skeleton is a starting point, not a finished stub: it makes the API visible to the checker, and each signature you refine turns an Any into a real constraint.

stubgen workflow: generate, refine, wire up An untyped package is fed to stubgen, which emits Any-filled pyi skeletons; you refine them, then register the stubs folder with mypy_path or stubPath. untyped dep legacypay/ stubgen .pyi full of Any refine by hand real signatures wire path mypy sees it generate → refine → register
stubgen bootstraps the shape of the API; you supply the real types and register the folder so the checker resolves them.

Generating the skeletons

Suppose legacypay is an installed dependency with zero annotations. Point stubgen at the package name and choose an output directory:

$ stubgen -p legacypay -o stubs
Processed 6 modules
Generated files under stubs/legacypay

stubgen accepts three different ways to name what it should process, and they behave differently. -p/--packages legacypay recurses through an installed package and every submodule; -m/--module legacypay.client targets one importable module by dotted name; and a bare positional path — stubgen legacypay/client.py or stubgen src/ — parses files or directories straight off disk without importing them. The -o/--output directory is where the mirrored .pyi tree is written, and stubgen never writes outside it.

stubgen parse mode versus inspect mode Pure-Python source is read by parsing the syntax tree, while C extensions require importing and introspecting the module. stubgen -p legacypay is the source parseable? pure-Python .py default: parse the AST C-extension / .so --inspect-mode: import it .pyi from the syntax tree .pyi from live introspection
With source on disk stubgen parses it; for compiled modules --inspect-mode imports and introspects instead.

Under the hood stubgen has two engines. By default it runs in parse mode: it feeds each .py file to mypy’s own parser and reads structure out of the abstract syntax tree without ever executing your code. That is safe and deterministic, but it only sees what is written literally in the source, so signatures assembled dynamically at import time are invisible to it. The alternative, --inspect-mode, imports the module and introspects the live objects with inspect; this is the mode you need for C extensions, Cython modules, or anything compiled to a .so/.pyd where no Python source exists to parse. Inspect mode runs the module’s import side effects, so only point it at code you trust. (--parse-only sits at the opposite extreme, suppressing even mypy’s semantic analysis for the rare file that trips it up.)

Several flags shape the output. --include-private emits _underscore members that stubgen otherwise drops as non-public; --include-docstrings (added in mypy 1.5) copies docstrings into the stub so editors can surface them; --export-less trims re-exported names to cut noise; and --search-path adds roots for parse mode when a module is not installed. Point stubgen at a package and it mirrors the layout:

$ stubgen -p legacypay -o stubs
$ find stubs -name '*.pyi'
stubs/legacypay/__init__.pyi
stubs/legacypay/client.pyi
stubs/legacypay/models.pyi

The raw output is deliberately conservative. Anything stubgen cannot infer becomes a placeholder — in current mypy that placeholder is Incomplete (an alias for Any imported from _typeshed), which makes the holes greppable; older releases emitted a bare Any:

# stubs/legacypay/client.pyi  — as emitted by stubgen (mypy 1.10)
from _typeshed import Incomplete

class PaymentClient:
    api_key: Incomplete
    def __init__(self, api_key: Incomplete, timeout: Incomplete = ...) -> None: ...
    def charge(self, amount: Incomplete, currency: Incomplete = ...) -> Incomplete: ...

pyright ships its own equivalent — pyright --createstub legacypay writes skeletons into its typings/ directory — but most teams standardize on stubgen so a single stub tree serves both checkers.

Know what stubgen cannot see, because those gaps become your manual work. In parse mode it reads only what is written literally, so attributes injected in __init__ by a loop, members synthesized through __getattr__, names re-exported via a runtime __all__, and signatures rewritten by a decorator (a functools.wraps wrapper, a @dataclass-style transform) all come out wrong or missing. stubgen also flattens @overload sets into one signature and emits decorated methods without their @property/@staticmethod markers. Inspect mode recovers some of this because it sees the live objects, but it still cannot recover a precise type — an introspected attribute is only ever Incomplete. Treat the skeleton as a faithful map of the names in the API and an unreliable guide to their types.

Refining the stub

The skeleton compiles but constrains nothing — every Incomplete (or Any, on older mypy) is a hole that swallows checking. Refinement is the act of walking those holes and replacing each with the real type you know from the library’s docs or source. This is where the value is created: an Incomplete return type lets any downstream misuse pass, whereas -> str immediately makes client.charge(...) + 1 an [operator] error.

Refining Incomplete placeholders into concrete types Each Incomplete slot in the generated skeleton is replaced by a precise type, turning an unchecked hole into a real constraint. stubgen output (holes) refined by hand api_key: Incomplete api_key: str amount: Incomplete amount: Decimal -> Incomplete -> str (txn id) each replaced hole checks one more call site
Refinement is one-hole-at-a-time: every Incomplete you replace with a real type upgrades checking wherever that member is used.
# stubs/legacypay/client.pyi  — refined by hand
from decimal import Decimal

class PaymentClient:
    api_key: str
    def __init__(self, api_key: str, timeout: float = ...) -> None: ...
    def charge(self, amount: Decimal, currency: str = ...) -> str: ...
    # returns the transaction id

Keep ... as the default-value placeholder — in a .pyi a function body is always ..., and for a parameter default it stands in for “some default exists,” its literal value irrelevant to the checker. Do not write timeout: float = 5.0 in a stub; write timeout: float = .... You do not have to refine every member at once; each signature you tighten immediately upgrades checking at that call site, and the rest stay Incomplete — a partial stub is legitimate and useful.

Several constructs need hand-attention because stubgen cannot infer them. Overloads collapse to a single vague signature in the skeleton; restore them with @overload so a Decimal argument and a str argument can return different types:

# stubs/legacypay/client.pyi
from decimal import Decimal
from typing import overload

class PaymentClient:
    @overload
    def charge(self, amount: Decimal) -> str: ...
    @overload
    def charge(self, amount: Decimal, currency: str) -> str: ...

Properties appear as plain attributes; re-decorate them with @property (and a matching @x.setter if the attribute is writable) so read-only access is enforced. Module-level type aliases and TypeVars let you name a repeated shape once — declare Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None at module scope and reference it, rather than pasting the union into every signature. Generic classes need their Generic[T] base restored, and where the real API accepts several concrete types, a precise Decimal | int union beats widening back to Any. If you only know a value is “some object with a read method,” reach for a Protocol in a companion .pyi rather than falling back to Incomplete. The Type Stubs and Third-Party Packages overview covers how this local stub tree slots in ahead of bundled typeshed.

Imports inside a stub follow special rules worth internalizing. A name imported in a .pyi is not re-exported to consumers unless you use an explicit from x import y as y redundant-alias form (or list it in __all__); a plain from x import y is treated as private to the stub. This trips people up when a refined stub imports Decimal and downstream code that does from legacypay.client import Decimal unexpectedly fails. Because stub bodies never run, you also never need if TYPE_CHECKING: guards inside a .pyi — every import is a typing-time import already, so import whatever you need for annotations directly.

Refinement is only trustworthy if it matches the real module, and mypy ships a second tool to enforce that: stubtest. It imports the live package and compares it against your stubs, reporting signatures that disagree, members present in one but not the other, and defaults that do not line up:

$ python -m mypy.stubtest legacypay --mypy-config-file pyproject.toml
error: legacypay.client.PaymentClient.charge is inconsistent, stub
argument "currency" has a default but runtime does not

Run stubtest in CI against the pinned library version so a dependency upgrade that changes a signature fails loudly instead of letting your stub drift into a comfortable lie — the same runtime-versus-static gap the callout below warns about.

Wiring the stubs into the checker

The stub folder is useless until the checker knows to search it. Both checkers have a dedicated setting that points at exactly this kind of local stub tree, and both resolve it ahead of bundled typeshed, so your refined .pyi wins over any vendored fallback. Register stubs/ as a search root.

Config settings that register a local stub folder mypy_path or MYPYPATH for mypy and stubPath for pyright both point at the same stubs directory that resolves the import. mypy mypy_path / MYPYPATH pyright stubPath stubs/legacypay/ *.pyi import legacypay resolves, typed
Different setting names, one destination: point either checker at stubs/ and the import resolves against your refined .pyi instead of falling back to Any.

mypy, via pyproject.toml:

# pyproject.toml — mypy 1.10+
[tool.mypy]
mypy_path = "stubs"

Or per-invocation with the MYPYPATH environment variable:

$ MYPYPATH=stubs mypy app.py

pyright, via pyrightconfig.json (or the [tool.pyright] table):

{
  "stubPath": "stubs"
}

With the path registered, a previously untyped call now type-checks against your refined signatures:

# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from decimal import Decimal
from legacypay.client import PaymentClient

client = PaymentClient(api_key="sk_live_…")
txn_id: str = client.charge(Decimal("19.99"), currency="usd")

client.charge(19.99)   # mypy: error [arg-type] — float is not Decimal
                       # pyright: reportArgumentType

A few resolution details matter. mypy_path accepts multiple roots (a list in pyproject.toml, or an OS-path-separator-joined string in MYPYPATH), and an explicit setting merges with — it does not replace — the MYPYPATH environment variable. The folder you register is a search root, so the tree beneath it must mirror the import path exactly: for stubs to satisfy import legacypay.client, the file must live at stubs/legacypay/client.pyi. pyright’s stubPath defaults to ./typings, which is also where pyright --createstub writes, so if you standardize on stubgen’s stubs/ you must set stubPath explicitly or pyright will look in the wrong place. Commit the stub tree to version control and treat it like source — the whole point is that every developer and CI job resolves the same refined types. When you have several such roots, the Mypy Configuration & Strictness guide shows where mypy_path sits among the other resolution settings.

Runtime vs static analysis Your .pyi files never run and are never imported by Python. If a refined stub claims charge takes a Decimal but the real function silently accepts a float, mypy will flag the float call even though the program executes fine — and conversely a stub that promises a return type the code does not deliver hides a genuine bug. Stubs assert; they do not verify. Keep them honest against the real implementation.

Common mistakes

Each stubgen failure mode produces a specific checker symptom, and reading the symptom backwards usually points straight at the cause. The mapping below pairs the four most common mistakes with the error they surface as.

Stubgen mistakes mapped to their checker symptoms Un-refined stubs check nothing, an unregistered path re-triggers import-untyped, a mismatched layout yields import-not-found, and stale stubs shadow upstream types. mistake symptom you see skeleton left un-refined passes, but everything is Any path not registered [import-untyped] again layout does not mirror import [import-not-found] stale stub shadows new py.typed phantom [attr-defined] errors
Four failure modes, four distinct symptoms — the error code tells you which mistake you made.
  • Leaving the skeleton un-refined. A stub that is still all Incomplete/Any silences mypy [import-untyped] but checks nothing — every call resolves to Any. You have hidden the warning without gaining type safety. Grep the stub tree for Incomplete to measure how much real typing is left to do.
  • Forgetting to register the path. Generating stubs/legacypay/*.pyi without setting mypy_path/stubPath leaves mypy reporting [import-untyped] (or pyright reportMissingModuleSource) — the files exist but the checker never looks there.
  • Mismatched package layout. The folder under your stub root must mirror the import path exactly: legacypay.client needs stubs/legacypay/client.pyi. A flattened or misnamed folder yields [import-not-found], since the checker searches for the stub at the dotted path and finds nothing.
  • Refining against the wrong version. A stub written from the current docs but checked against an older installed release produces phantom [attr-defined] errors for methods that version lacks. Pin the stub to the library version you actually ship.
  • Committing stubs for a package that later ships py.typed. Once the upstream library adds inline types, your stale local stubs shadow them (a mypy_path root outranks the installed package) and can introduce phantom [attr-defined] errors. Delete local stubs when upstream types arrive; see Shipping Inline Types with py.typed for how that upstream marker takes over.

FAQ

Do I have to refine every signature stubgen produces? No. Refine the parts of the API you actually call; leave the rest as Any. Partial stubs still improve checking at every site you have annotated, and you can tighten more over time.

stubgen vs a published types-* package — which should I use? Always prefer an existing types-* stub package or upstream py.typed types; they are maintained and versioned for you. Reach for stubgen only when no maintained stubs exist, treating your local stubs as a temporary bridge.

Back to Type Stubs and Third-Party Packages