What is a `.pyi` stub file in Python, and does it override inline annotations?
answer
- A file the interpreter never reads
- Signatures only, bodies are ellipsis
- Two files, one wins for the checker
- Replacement, not a merge
- Stale stub hides the real signature
basics
~20 sA .pyi stub is a Python-syntax file carrying only signatures, with ... for every body. A static type checker reads the stub instead of the matching .py module, so it fully overrides that module's inline annotations.
solid answer
~50 sA stub file is a parallel `.pyi` file that declares what a module looks like to a type checker: imports, class and function signatures, module-level variable annotations, and `...` for each body. It is never imported at runtime — CPython still executes the `.py`. When a checker finds both `foo.py` and `foo.pyi` on its search path, it uses the stub **exclusively**: the annotations written inline in the `.py` are not merged in, they are ignored, so a stale stub silently hides the real signatures. Stubs exist because some code cannot carry annotations — a compiled extension module, a package you do not own, or code that must keep running on an older syntax level — and because the standard library itself is typed this way, through the community-maintained typeshed collection rather than through annotations in CPython's own sources.
code
python · 13 linesimport pathlib
import sys
import tempfile
pkg = pathlib.Path(tempfile.mkdtemp())
(pkg / "flightdiff.py").write_text("def gate(cases):\n return cases\n")
(pkg / "flightdiff.pyi").write_text("def gate(cases: int) -> str: ...\n")
sys.path.insert(0, str(pkg))
import flightdiff
print(flightdiff.gate(340))
print(flightdiff.__file__.endswith(".py"))go deeper
Recall the shape: a .pyi file lists signatures with ... bodies and exists for type checkers only. Be able to say that running the program is unaffected by the presence or absence of a stub.
Explain the precedence mechanically: with both files present the checker uses the stub and ignores the module's inline annotations entirely, and nothing re-checks the stub against the code. Name the stub-only syntax conveniences too.
Show judgement about when a stub is warranted — compiled extensions, packages you do not own, code frozen on an old syntax level — and how you keep stubs from drifting once they are in your repository.
Own the policy: which parts of the estate are typed inline versus by stub, who maintains the stubs, and how a wrong stub's blast radius across downstream teams is contained when nothing at runtime disagrees with it.
### What a stub file is A stub file is a file with the `.pyi` extension whose only job is to describe a module's interface to a static type checker. It is written in Python syntax, so a checker can parse it with the same grammar it uses for real code, but it contains no implementation. Function and method bodies are the literal ellipsis `...`; class bodies hold attribute annotations and method signatures; module-level names get bare annotations with no assignment. ```python # flightdiff/core.pyi from typing import Final DEFAULT_WINDOW: Final[int] class Schedule: legs: list[str] def compare(self, other: Schedule, *, strict: bool = ...) -> list[str]: ... ``` Stubs were introduced alongside annotations themselves in PEP 484. Nothing in the runtime knows about them: `import flightdiff.core` loads `core.py` (or the compiled extension), and the `.pyi` sitting next to it is inert. That single fact answers most stub questions — stubs are a *checker* artefact, not a *runtime* artefact. ### Precedence over inline annotations When a checker resolves a module and finds both a `.py` and a `.pyi` for it, the stub wins outright. This is replacement, not merging. If `core.py` annotates `compare` as returning `list[str]` and `core.pyi` declares it returns `None`, the checker believes the stub, every caller is checked against `None`, and the inline annotation has no effect at all. There is also no cross-check: nothing in a checker's normal run verifies that a stub still matches the module it describes. That is the central hazard of stubs, and it is what interviewers are probing when they ask "what happens if both exist". The practical consequence is that you keep a stub only for code that genuinely cannot be annotated inline; for ordinary Python source, an inline annotation is checked *against the implementation*, and a stub is not. More broadly, a checker resolves types for a third-party import in a fixed order: stubs on a path you configured explicitly, then a stub-only distribution named for the package, then the installed package's own inline annotations — but only if it ships the `py.typed` marker — then bundled typeshed stubs. The rule of thumb worth carrying is that a stub always outranks the inline types of the thing it stubs. ### Stub-specific syntax rules Because a stub is never executed, it plays by slightly different rules than real code: * **Newer syntax is fine.** A stub may write `int | None` or PEP 695 type-parameter syntax even when the package it describes still supports an older Python, because the checker — not the interpreter that installs the package — parses it. * **`...` is the universal body**, including for `__init__`, properties and abstract methods. Default argument values are also written as `...` when the real default is uninteresting or unrepresentable. * **Overloads are cheap.** `typing.overload` is far more usable in a stub than inline, since there is no implementation to append. * **Re-exports must be explicit.** A name merely imported into a stub is not considered part of that module's public interface; you make it visible with `from x import y as y` or by listing it in `__all__`. * **No runtime code.** Conditionals on `sys.version_info` are allowed and are understood by checkers, but arbitrary logic, docstring-driven behaviour and side effects have nowhere to run. ### Where stubs come from Three sources matter. The standard library is typed entirely by typeshed, the community collection that checkers bundle — which is why annotations you see in a checker's hover text for a stdlib function are often more precise than anything in CPython's source. Third-party packages that do not ship their own types are covered by *stub-only distributions*: a separate installable whose importable directory is named `<package>-stubs` and holds nothing but `.pyi` files. And a package you own can ship stubs inside itself, which is the standard answer for a compiled extension module whose binary has no Python source to annotate. ### What to say in an interview Define the file, state the precedence rule crisply ("the stub replaces the module's annotations for the checker; the runtime ignores the stub"), then name the drift risk and the cases where a stub is still the right tool. A candidate who claims the two are merged, or that a stub changes anything at runtime, has not used one.
- If a stub and the module it describes disagree, what catches the mistake?Nothing in a normal checking run — the checker trusts the stub and never compares it to the implementation. You catch drift with a dedicated stub-consistency tool that imports the module and diffs the runtime signatures against the `.pyi`, or by running the checker over the package's own source with the stub temporarily out of the way. It is the main argument for annotating inline whenever the source can carry annotations.
- Why can a stub use syntax the package's supported Python versions cannot run?Because the stub is only ever parsed by the checker, which uses the newest grammar it knows, not by the interpreter that installed the package. So a library still supporting 3.9 can write `int | None` unions and PEP 695 type parameters in its stubs. Inline annotations do not get that freedom unless they are strings or guarded, since the module must actually import on the oldest supported version.
- How does a name imported inside a `.pyi` become part of that stub's public interface?It has to be re-exported explicitly. A plain `from x import y` inside a stub is treated as private to the stub, so consumers importing `y` from that module are flagged. Make it public with the redundant-alias form `from x import y as y`, or by naming it in `__all__`. The same implicit-re-export rule is why a package's `__init__.pyi` often looks repetitive.
A stub is a header file for Python: it declares the shape of a module for a tool that reads it, while the interpreter compiles and runs only the source.
saying these in an interview costs you the question
- Says the interpreter imports the .pyi at runtime
- Claims stub and inline annotations are merged together
- Thinks a stub can hold real executable code
- Believes the checker verifies a stub against its module
- Confuses a .pyi stub with a test double or mock
- Assumes stubs are only for the standard library