Type Stubs and Third-Party Packages
When you import a library, your type checker has to find annotations for it from somewhere. There are four places it looks, in a fixed order, and knowing that order is the difference between fixing a [import-untyped] error in thirty seconds and disabling type checking for a whole subtree by accident.
A dependency can ship its own annotations inline (marked with a py.typed file per PEP 561), rely on a separate stub-only distribution such as types-requests, be covered by the typeshed collection that mypy bundles, or ship nothing at all. This section explains that resolution chain, the exact error codes each checker raises when the chain comes up empty, and how to plug the gaps yourself.
Inline types shipped with the package
The modern, preferred source is annotations that live inside the library itself. A maintainer annotates their .py files and drops an empty marker file named py.typed into the package directory, then declares it as package data so it lands in the wheel. That marker is the PEP 561 signal that says “trust the inline annotations here.” Without it, checkers ignore the annotations even if they are present, because an un-marked package is assumed to be accidentally-annotated rather than deliberately typed.
PEP 561, accepted in 2017 and supported by every current checker, defines three flavors of typed distribution: inline-typed packages (annotations in the .py files plus a py.typed marker), stub-only packages (.pyi files in a <name>-stubs directory), and packages that bundle .pyi files next to their .py sources. The marker file is genuinely empty for a fully inline-typed library; a single line of partial\n is reserved for partially-typed stub distributions and does not apply here. Both mypy and pyright honor the marker identically — there is no checker-specific handshake beyond “is py.typed present in the installed package directory.”
Discovery is purely filesystem-based: the checker looks up the package in site-packages, sees orderlib/py.typed beside orderlib/__init__.py, and only then reads annotations from the adjacent modules. This is why an editable install (pip install -e .) that points at your source tree usually “just works” for your own code, while a built wheel that forgot to include the marker fails — the annotations exist in both cases, but the marker is what authorizes reading them from an installed third party.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
from httpx import Client # httpx ships py.typed — inline types
client = Client()
reveal_type(client.get) # note: Revealed type is a real overloaded method,
# not Any — because the marker is present
Libraries such as attrs, pydantic, and httpx all ship a py.typed marker today. For a namespace package or a project with several distributed top-level packages, each one that carries types needs its own marker — a single marker at the repo root does nothing. When you author your own distributable package, this is the route you want — the types version with the code and never drift. The mechanics of adding the marker and wiring it into your build are covered in shipping inline types with py.typed.
Separate stub (.pyi) packages
Many older or C-extension libraries have no inline types. For these, the community publishes stub-only distributions: packages containing just .pyi files that describe the API. The naming convention is types-<name>, so requests is typed by types-requests, PyYAML by types-PyYAML, and so on. These live mostly in the typeshed stubs/ tree and are auto-published to PyPI.
Under PEP 561 a stub-only distribution installs into a sibling directory named <import_name>-stubs, so types-requests drops a requests-stubs/ tree into site-packages, and the checker searches that alongside the real requests/. This is also why the PyPI name, the import name, and the stubs directory can all differ: types-PyYAML on PyPI provides yaml-stubs/ for import yaml, and types-python-dateutil provides dateutil-stubs/. mypy’s error notes tell you the exact PyPI name to install, which spares you guessing.
$ pip install types-requests
$ mypy app.py
Success: no issues found in 1 source file
A stub package installs alongside the real library and the checker prefers it over the bundled copy. Because stubs and library ship separately, they can fall out of sync — pin the stub version close to the library version in your lockfile. Note that not every stub package lives in typeshed: some projects maintain their own out-of-tree stubs (for example pandas-stubs), which are versioned by the library authors and often track releases more tightly than the community types-* line.
# Python 3.12+, checked with mypy 1.10 / pyright 1.1.370
import requests # types-requests supplies the .pyi stubs
def fetch_status(url: str) -> int:
resp = requests.get(url, timeout=5)
return resp.status_code # resp: requests.Response — fully typed
If a stub package drifts ahead of the installed library, you get phantom errors: mypy reports [attr-defined] for a method the stub promises but your older runtime lacks, even though the code would run once you upgraded. When that happens, align the two versions rather than suppressing the error. To silence a stub you cannot fix, scope an override to that module — see fixing import-untyped and missing imports.
Bundled typeshed
Both mypy and pyright vendor a snapshot of typeshed, the central repository of stubs for the standard library and thousands of third-party packages. Standard-library types (os, json, pathlib) always come from here — you never install a stub for the stdlib. For third-party modules, the bundled copy is a fallback that a freshly-published types-* package or newer inline types will override.
The vendored snapshot is pinned to the checker release: a given mypy version ships one frozen typeshed commit, and pyright ships its own. This has a practical consequence — upgrading mypy can surface new errors on unchanged code, because the newer typeshed tightened a stdlib signature (a classic case is subprocess or os.environ gaining more precise overloads). It is expected, not a regression in your code. typeshed encodes version-specific behavior with sys.version_info guards inside the stubs and per-module VERSIONS metadata, so asyncio.TaskGroup is only visible when you target Python 3.11+ and tomllib only on 3.11+.
# checked with --python-version 3.10 vs 3.11
import tomllib # 3.11+: resolves from bundled typeshed
# 3.10: error: Cannot find implementation or library
# stub for module "tomllib" [import-not-found]
If you ever need to test against an unreleased stdlib fix, mypy accepts --custom-typeshed-dir to point at a checkout of typeshed rather than the bundled copy. For everyday work you never touch this — the bundled snapshot is the reason import json needs no setup at all. Because third-party entries here are only a fallback, an installed types-* package or an inline-typed release always wins over the bundled version, keeping you on the fresher stubs.
py.typed markers are invisible at runtime. Python never reads a .pyi file when it executes; only the checker does. A stub can therefore describe a signature the real module does not actually honor — if a stub is wrong, mypy passes but the program still raises TypeError. Treat mismatched stubs as bugs to report upstream, not as ground truth.
When nothing is found: the error codes
If none of the four sources supplies types, each checker reacts differently, and the two mypy codes mean genuinely different things:
- mypy
[import-untyped]— the module was found on disk but ships no types and has no stub package. mypy tells you exactly whichtypes-*package to install when one exists. - mypy
[import-not-found]— the module itself could not be imported at all (wrong virtualenv, missing dependency, badMYPYPATH). This is an environment problem, not a typing gap. - pyright
reportMissingModuleSource— pyright found a stub but not the runtime module (or vice versa); usually harmless, often a warning. - pyright
reportMissingImports— pyright could not resolve the import at all, the analogue of mypy’s[import-not-found].
The distinction matters because the fixes are opposite. [import-untyped] is safe to close with a stub package or a scoped ignore; [import-not-found] means the code would not even run, so suppressing it hides a real defect. mypy emits [import-untyped] only for a module it could actually locate on sys.path, which is your fastest signal that the import itself is sound.
# Python 3.12+, checked with mypy 1.10
import orderlib # error: Skipping analyzing "orderlib": module is
# installed, but missing library stubs [import-untyped]
import notinstalled # error: Cannot find implementation or library
# stub for module "notinstalled" [import-not-found]
pyright’s split is severity-based rather than two hard errors: reportMissingModuleSource defaults to a warning (the module resolved via a stub but pyright could not find the runtime source), while reportMissingImports is an error. In basic mode both are visible; you can tune either through pyrightconfig.json. One subtlety: mypy only reports [import-untyped] when follow_imports is at its default, and a global ignore_missing_imports = true collapses both mypy codes into silence — which is exactly why you scope ignores per module. Telling these apart is the whole battle — fixing import-untyped and missing imports walks through the resolution for each.
Generating your own stubs
When a dependency has no inline types, no types-* package, and no typeshed entry, you can synthesize stubs. mypy ships stubgen, which introspects a module and emits .pyi skeletons full of Any that you then refine by hand. You point the checker at the result with MYPYPATH / mypy_path (mypy) or stubPath (pyright).
stubgen -p legacypay -o stubs writes one .pyi per module — real names and structure, but every type is Any, so the skeleton makes the API visible without constraining it. You refine the signatures you actually call, leave the rest as Any, and register the folder so the checker searches it. For C extensions where source parsing fails, stubgen can import and introspect the module at runtime instead.
$ stubgen -p legacypay -o stubs
Processed 6 modules
Generated files under stubs/legacypay
# pyproject.toml — mypy 1.10+
[tool.mypy]
mypy_path = "stubs"
The trap is a skeleton left un-refined: an all-Any stub silences [import-untyped] but checks nothing, so you have hidden the warning without gaining safety. Delete your local stubs the moment upstream ships py.typed, or the stale copies will shadow the real types and manufacture phantom [attr-defined] errors. See generating stub files with stubgen for the full workflow, including refining signatures and wiring stubPath for pyright.
Common mistakes
- Adding annotations but forgetting
py.typed. Downstream checkers still report mypy[import-untyped]because the PEP 561 marker is what flips the switch — the annotations alone do nothing. The fix lives in the wheel’spackage-data/includerules, covered in shipping inline types with py.typed. - Silencing
[import-not-found]withignore_missing_imports. That flag hides a real import failure. The module is genuinely unresolvable; fix the environment instead of masking it, or you will shipAny-typed code. Scope any legitimate ignore to named modules with a[[tool.mypy.overrides]]block rather than the global switch. - Installing a
types-*package but not pinning it. A stub package that drifts ahead of the library produces phantom[attr-defined]errors for methods your installed version does not have. Pin the stub version in the same lockfile entry region as the library so they upgrade together. - Assuming the stdlib needs stubs. Standard-library types come from bundled typeshed automatically; installing anything for
osorjsonis a sign of a misconfigured environment. Ifimport jsonreports[import-not-found], the problem is your interpreter orsys.path, not a missing stub. - Refining a
stubgenskeleton to nothing. Leaving every signature asAnyclears the warning while checking nothing — tighten the members you call, and delete local stubs once upstream ships inline types so they cannot shadow the real thing.
FAQ
What is the difference between [import-untyped] and [import-not-found]?
[import-untyped] means mypy imported the module but it has no type information — a typing gap you close with a stub package or a scoped ignore. [import-not-found] means mypy could not import the module at all — an environment or path problem you fix by installing the dependency or correcting MYPYPATH.
Do I need to install stubs for the standard library?
No. Both mypy and pyright bundle typeshed, which includes complete stubs for every standard-library module. You only install types-* packages for third-party libraries that lack inline types.
Which source wins when a library ships py.typed and a types-* package also exists?
Inline py.typed types take precedence over an installed stub package, which in turn takes precedence over bundled typeshed. The first source in the chain that supplies types is the one used.