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.

Type resolution order for third-party imports A checker tries inline py.typed types first, then an installed stub package, then bundled typeshed, and finally treats the module as untyped Any. import orderlib → where do the types come from? 1. Inline types (py.typed) annotations shipped in the wheel 2. Stub package types-orderlib on PyPI (.pyi) 3. Bundled typeshed stdlib + vendored third-party 4. Nothing found module becomes Any mypy: [import-untyped] first hit wins →
A checker walks the chain top to bottom and stops at the first source that provides types; if all four miss, the module resolves to Any.

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.

How inline types travel from source to a downstream checker Annotated source plus a py.typed marker is packaged into a wheel, which a downstream checker reads to recover real types. annotated .py + py.typed marker in the package dir build backend package-data rule wheel py.typed inside downstream real types annotate → mark → package → consumed
The marker rides along with the annotations into the wheel; drop the marker from the build and the downstream half of this pipeline collapses to Any.

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.

How a stub distribution maps back onto the runtime package Each runtime module name maps to a types-prefixed distribution that installs a parallel name-stubs directory the checker prefers. runtime package stub distribution → installs requests types-requests requests-stubs/ yaml (PyYAML) types-PyYAML yaml-stubs/ dateutil types-python-dateutil dateutil-stubs/ distribution name ≠ import name ≠ installed -stubs dir
The PyPI distribution name, the import name, and the installed -stubs directory frequently differ — pip install types-PyYAML to type import yaml.

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 two compartments of bundled typeshed Bundled typeshed holds authoritative standard-library stubs and a fallback set of third-party stubs that installed packages override. typeshed vendored inside mypy / pyright stdlib/ os · json · pathlib · typing authoritative — never install stubs/ (third-party) fallback snapshot overridden by installed types-* pinned to the checker version — upgrading mypy can surface new stdlib errors
One half is the ground truth for the standard library; the other is a convenience fallback that any installed types-* or inline-typed package supersedes.

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.

Runtime vs static analysis Stub files and 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 which types-* package to install when one exists.
  • mypy [import-not-found] — the module itself could not be imported at all (wrong virtualenv, missing dependency, bad MYPYPATH). 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].
Import diagnostics by checker and by whether the module is on disk A grid pairing found-but-untyped versus not-found against the diagnostic mypy and pyright each emit. situation mypy pyright on disk, no types imports fine [import-untyped] reportMissingModuleSource (warning) not resolvable import fails [import-not-found] reportMissingImports
The row you are in — module present but untyped, versus module unresolvable — dictates the fix; the column just tells you which checker's wording to read.

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 as the last rung of the fallback timeline Once inline types, a stub package, and typeshed have all been ruled out, stubgen is the final option for producing types. no py.typed no types-* not in typeshed stubgen last resort check 1 check 2 check 3 generate .pyi exhaust maintained sources before generating your own
Reach for stubgen only after the three maintained sources miss; hand-written stubs are yours to keep in sync forever.

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

Common third-party typing mistakes and their fixes Three frequent mistakes are shown on the left with the corrective action on the right. mistake fix annotate but omit py.typed downstream still Any ship the marker in the wheel global ignore_missing_imports hides real import failures scope override to named modules install unpinned types-* phantom [attr-defined] pin stub near library version
Each failure has a precise, scoped remedy — none of them is a global suppression, which trades a warning for a blind spot.
  • 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’s package-data/include rules, covered in shipping inline types with py.typed.
  • Silencing [import-not-found] with ignore_missing_imports. That flag hides a real import failure. The module is genuinely unresolvable; fix the environment instead of masking it, or you will ship Any-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 os or json is a sign of a misconfigured environment. If import json reports [import-not-found], the problem is your interpreter or sys.path, not a missing stub.
  • Refining a stubgen skeleton to nothing. Leaving every signature as Any clears 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.

Back to Static Analysis Tools & CI Integration