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.
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.
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.
# 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.
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.
.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.
- Leaving the skeleton un-refined. A stub that is still all
Incomplete/Anysilences mypy[import-untyped]but checks nothing — every call resolves toAny. You have hidden the warning without gaining type safety. Grep the stub tree forIncompleteto measure how much real typing is left to do. - Forgetting to register the path. Generating
stubs/legacypay/*.pyiwithout settingmypy_path/stubPathleaves mypy reporting[import-untyped](or pyrightreportMissingModuleSource) — 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.clientneedsstubs/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 (amypy_pathroot 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.