Fixing import-untyped and Missing Imports
TL;DR — mypy [import-untyped] means the module was found but ships no type information — install a types-* stub package or scope an ignore to that module. mypy [import-not-found] means the module could not be resolved at all — fix the virtualenv, dependency, or path. They look similar in CI logs but demand opposite fixes, and treating one as the other either hides a real bug or wastes an afternoon.
The two errors sit next to each other in mypy output and both mention an import, so it is tempting to reach for ignore_missing_imports and move on. Resist that: one is a typing gap you can safely paper over, the other is a broken environment that ignore_missing_imports will silently mask, leaving you shipping Any-typed code that no checker guards.
Confirming which error you have
The single most reliable diagnostic is to reproduce the import in the same interpreter mypy analyses against. mypy does not execute your code, but it does resolve modules against a Python environment: by default the one whose site-packages it can see, or the one named by python_executable in config. If python -c "import legacypay" succeeds, the module is present on that path, so any remaining mypy complaint is a types problem — [import-untyped]. If the same command raises ModuleNotFoundError, mypy cannot resolve it either, and you are looking at [import-not-found]. No stub package will fix the second case until the import itself works.
$ python -c "import legacypay" && echo OK
OK # → so mypy's complaint is [import-untyped]
$ python -c "import notinstalled"
ModuleNotFoundError: No module named 'notinstalled' # → [import-not-found]
The catch is making sure you probe the right interpreter. A globally-installed mypy analyses whatever environment its python_executable points at, which may not be the venv you have activated. Pin it explicitly so the probe and the checker agree:
$ which python; python -c "import sys; print(sys.executable)"
$ python -m mypy app.py # runs mypy from *this* interpreter
Setting python_executable (or letting mypy default to sys.executable when invoked as python -m mypy) removes the ambiguity:
# pyproject.toml — mypy 1.10+
[tool.mypy]
python_executable = ".venv/bin/python"
Add mypy -v app.py (verbose) when you want to see where mypy looked: it prints each candidate path and whether a py.typed marker, a stub package, or nothing was found — the same four-source chain described in type stubs and third-party packages. One subtlety: follow_imports = skip or silent can suppress the very error you are chasing, so if mypy stays quiet on a module you expected to flag, check that setting before assuming the types are fine.
Fixing [import-untyped]
The module runs; it just has no types. There is a clear ladder of remedies, and you want the highest rung that applies. The top rung is a maintained stub package: when one exists, mypy names it for you in a note: right under the error, so you rarely have to guess.
$ mypy app.py
app.py:3: error: Library stubs not installed for "requests" [import-untyped]
app.py:3: note: Hint: "python3 -m pip install types-requests"
app.py:3: note: (or run "mypy --install-types" to install all missing stub packages)
$ pip install types-requests
mypy can install the suggested stubs for you in CI — non-interactively, so it never blocks a pipeline. Since mypy needs to have seen the errors before it knows what to fetch, --install-types alone runs mypy twice; passing the flags together is the CI-safe form:
$ mypy --install-types --non-interactive app.py
Pin whatever it installs in your lockfile so runs stay reproducible — stub packages version independently of the library and a drifting types-* produces phantom [attr-defined] errors for methods your pinned library does not actually have.
When no types-* package exists (an internal library, a niche C extension), the second rung is to generate your own skeletons with stubgen and refine them. If even that is not worth the effort, scope the suppression to just the offending module with a per-module override — never globally, which would blind mypy to every third-party gap:
# pyproject.toml — mypy 1.10+
[[tool.mypy.overrides]]
module = ["legacypay.*", "internal_billing.*"]
ignore_missing_imports = true
That tells mypy “treat these specific modules as Any on purpose.” The .* suffix matches the package and every submodule; without it, legacypay.client would still error. Note the direction of travel over recent versions: mypy used to be silent about untyped third-party imports and only began emitting [import-untyped] as a distinct, actionable code in 1.0 (previously these were folded into a generic note). Under --strict the related disallow_untyped_calls will additionally flag calls into an Any-typed module, so a scoped ignore_missing_imports alone may not fully silence a strict run.
One flag deserves special attention when you accept an untyped import: disallow_any_unimported. It is not part of --strict, so you must enable it deliberately, and it catches a subtle leak — when an ignored module’s Any bleeds into your public signatures. If legacypay is treated as Any and you write def process(client: legacypay.Client) -> None, the parameter silently becomes Any, and every caller loses checking without any warning. With disallow_any_unimported = true mypy reports Argument 1 to "process" becomes "Any" due to an unfollowed import, forcing you to wrap the boundary in a Protocol or a local stub instead of quietly propagating Any through your own API. The mypy configuration and strictness guide covers where these overrides fit into a broader config.
Fixing [import-not-found]
Here the module genuinely cannot be found, and the cause is almost always environmental. Each root cause maps to a specific, different fix — reaching for the wrong one wastes the afternoon the TL;DR warned about:
- Dependency not installed in the environment mypy runs against —
pip installit, and make sure your CI job installs the project’s real dependencies (not just the dev/lint extras) before the type-check step. - mypy running against the wrong interpreter — a global mypy checking a project whose deps live in a virtualenv. Run
python -m mypyfrom inside the venv so it sharessys.path, or setpython_executablein config. - First-party package not on the path — your own
src/layout isn’t discoverable. Setmypy_pathor install the package into the environment withpip install -e ..
# Python 3.12+, checked with mypy 1.10
from internal_billing.ledger import Ledger # error: Cannot find
# implementation or library stub for module "internal_billing.ledger"
# [import-not-found] → the package is not installed in this env
For a src/ layout, tell mypy where the roots live rather than relying on discovery. Both mypy_path (config) and MYPYPATH (environment) prepend search roots:
# pyproject.toml — mypy 1.10+
[tool.mypy]
mypy_path = "src"
explicit_package_bases = true
explicit_package_bases matters for src/ layouts: it stops mypy inferring a package name from the wrong directory and mislabelling src/internal_billing as a top-level internal_billing. The most robust option, though, is an editable install (pip install -e .) so the package lands in site-packages exactly as consumers will import it — that keeps the runtime interpreter and mypy in agreement and sidesteps path juggling entirely. If you own the package, the proper long-term fix is to ship it as a real typed distribution with a py.typed marker rather than papering over it.
The wrong fix here is ignore_missing_imports = true: it silences the message but leaves Ledger typed as Any, so every downstream [attr-defined] and [arg-type] check on it evaporates — you convert a loud, correct error into silent, unchecked code.
ModuleNotFoundError at runtime and one that imports fine but lacks stubs are completely different failures — yet suppressing mypy with ignore_missing_imports makes both go quiet. The static tool then reports success while the program may still crash on import. Always reproduce the import in the interpreter before deciding a mypy import error is "just a stubs problem."
The pyright equivalents
pyright splits the same distinction across two diagnostics, but with one important twist: the two do not share mypy’s default severity, which changes how they show up in CI.
reportMissingModuleSource— pyright found a stub (or types) but not the runtime source module, or the module resolves without type info. This is pyright’s rough analogue of[import-untyped]; it is a warning by default, not an error, so a default pyright run stays green while mypy fails the build.reportMissingImports— pyright cannot resolve the import at all, the counterpart of mypy’s[import-not-found]. This is an error by default and will fail the run.
Configure the two independently in pyrightconfig.json (or the [tool.pyright] table in pyproject.toml). Each diagnostic accepts "none", "warning", or "error":
{
"reportMissingModuleSource": "none",
"reportMissingImports": "error",
"extraPaths": ["src"]
}
Set reportMissingModuleSource to "none" to accept a known-untyped module, and use extraPaths (pyright’s mypy_path equivalent) to make a first-party src/ layout resolvable so reportMissingImports clears. Two behaviours differ from mypy and catch people out: pyright resolves against venvPath/venv (or the active environment) rather than a single interpreter, and its basic versus strict mode changes whether a bare untyped import is even reported. Because the severities differ out of the box, teams that run both checkers should raise reportMissingModuleSource to "error" to match mypy, or lower mypy’s strictness — otherwise the two tools disagree on whether the same untyped import fails CI.
There is a deeper reason the two disagree so often: useLibraryCodeForTypes, which pyright enables by default. When a package lacks a py.typed marker, pyright will still infer types by reading the library’s installed source code, whereas mypy refuses to look at un-marked annotations and reports [import-untyped]. So a dependency with annotations but no marker frequently type-checks cleanly under pyright and fails under mypy — not a bug in either tool, but a policy difference. Setting "useLibraryCodeForTypes": false makes pyright strict like mypy (it then raises reportMissingModuleSource instead of silently inferring), which is the setting to reach for when you want both checkers to agree that a missing marker is a real gap. The full mypy-vs-pyright picture is in pyright vs mypy comparison.
Common mistakes
Nearly every wrong turn here is the same shape: reaching for a broad Any-producing suppression when a narrow, correct fix exists. The contrast below pairs each anti-pattern with the fix it should have been.
- Global
ignore_missing_imports = true. It silences[import-untyped]and[import-not-found]everywhere, so a genuinely broken import never surfaces. Scope every ignore to named modules with[[tool.mypy.overrides]]. - Installing a
types-*package for a module that still fails[import-not-found]. Stubs cannot resolve a module that does not import; you fixed the wrong error. Get the runtime import working first. - Checking with a different interpreter than the app uses. A global mypy hits
[import-not-found]on deps installed only in the project venv. Runpython -m mypyinside that venv, or setpython_executable. - Reaching for
# type: ignoreon the import line. A bare# type: ignorehides whichever code fired, so an[import-not-found]masquerades as a handled[import-untyped]. If you must inline-suppress, name the code —# type: ignore[import-untyped]— so a later[import-not-found]on the same line still surfaces. - Marking a first-party package untyped instead of adding a
py.typedmarker. For code you own, ship the marker described in shipping inline types with py.typed rather than suppressing it as an untyped third party.
FAQ
Is --install-types --non-interactive safe for CI?
Yes. It makes mypy fetch the types-* stubs it recommends without prompting, which unblocks pipelines. Pin the resulting stub versions in your lockfile so CI stays reproducible across runs.
Why does ignore_missing_imports feel like it “fixes everything”?
Because it converts the offending module to Any, which makes both [import-untyped] and [import-not-found] disappear. That is exactly why it is dangerous globally — it can hide a real unresolved import as easily as a missing stub. Always scope it to specific modules.