Per-package mypy Overrides in a Monorepo

TL;DR

Use [[tool.mypy.overrides]] blocks to set strictness per package: disable specific error codes for legacy packages with disable_error_code, require annotations per module with disallow_untyped_defs, and scope ignore_missing_imports to just the package that needs it. When two overrides match the same module, the more specific module glob wins — so order from general to specific and keep the global section as your strict default.

A [[tool.mypy.overrides]] block is mypy’s per-module escape hatch: it applies a setting only to modules whose import path matches its module glob, layered on top of the global [tool.mypy] section. In a monorepo this is how you keep a strict global baseline while holding legacy packages at a tolerable floor — without a separate config file per package. This page covers disabling error codes for legacy code, per-module annotation requirements, scoping ignore_missing_imports, and the precedence rules that decide which override actually applies.

Step 1: a strict global default

Set the aspirational policy globally so any package without an override is checked strictly. Overrides then subtract from this, making every relaxation explicit and auditable.

# pyproject.toml — strict baseline, mypy 1.x
[tool.mypy]
python_version = "3.10"
strict = true
warn_unused_ignores = true          # flags suppressions that are no longer needed
show_error_codes = true

What the analyzer sees: every module is strict unless an override below changes a specific setting for it. warn_unused_ignores is what lets the strictness ratchet upward — it reports [unused-ignore] when a relaxation has become unnecessary.

Step 2: disable specific error codes for a legacy package

Rather than turning off checking wholesale, silence only the codes a legacy package can’t yet satisfy. This keeps every other diagnostic live.

# pyproject.toml — tolerate specific codes in legacy code, mypy 1.x
[[tool.mypy.overrides]]
module = "billing.*"
disable_error_code = ["no-untyped-def", "no-untyped-call", "var-annotated"]

billing.* matches billing and every submodule. The package still reports [arg-type], [return-value], and [union-attr] — you’ve only forgiven the annotation-coverage codes, so real type bugs are still caught. As billing gains annotations, delete codes from this list one at a time.

Step 3: require annotations per module

The inverse: turn on a strictness setting for a mid-tier package that the global config (if it were looser) wouldn’t enforce. Under a strict global this is more often used to partially tighten a package you previously relaxed.

# pyproject.toml — enforce annotated defs in a promoted package, mypy 1.x
[[tool.mypy.overrides]]
module = "scheduler.*"
disallow_untyped_defs = true        # every def must be annotated
disallow_incomplete_defs = true     # no half-annotated signatures
check_untyped_defs = true

A def lacking annotations now raises [no-untyped-def] in scheduler specifically, even if a broader override above had relaxed it.

Step 4: scope ignore_missing_imports

A common monorepo mistake is a global ignore_missing_imports = true, which hides missing stubs everywhere — including strict packages where you want the [import-untyped]/[import-not-found] signal. Scope it to the one package importing the stubless dependency.

# pyproject.toml — narrow stub-ignore to the package that needs it, mypy 1.x
[[tool.mypy.overrides]]
module = "legacy_etl.vendored_client.*"
ignore_missing_imports = true       # only this subtree tolerates missing stubs

Now core still reports [import-untyped] if it imports a stubless library, while legacy_etl.vendored_client quietly treats its untyped dependency as Any.

Override precedence: specificity, not file order When several [[tool.mypy.overrides]] blocks match the same module, mypy does not simply take the last one. It prefers the most specific module pattern — a pattern with no wildcard beats one with .*, and a longer dotted prefix beats a shorter one. So module = "billing.reports" overrides module = "billing.*" for that submodule, regardless of which block appears first.
# pyproject.toml — specific block wins over the wildcard, mypy 1.x
[[tool.mypy.overrides]]
module = "billing.*"                 # tier: untyped
disable_error_code = ["no-untyped-def"]

[[tool.mypy.overrides]]
module = "billing.reports"           # this submodule is ready — fully strict again
disallow_untyped_defs = true

Here billing.reports is held to annotated defs while the rest of billing is forgiven — the specific module path takes precedence over the wildcard. This is the per-submodule ratchet in action.

Runtime vs static analysis Overrides change only what mypy reports — they never touch imports or execution. ignore_missing_imports makes mypy treat a module as Any; at runtime the import still happens (or still fails) exactly as before. An override can silence [import-not-found] while the program still raises ModuleNotFoundError when run.

Edge cases

  • List vs single module. module = ["api.*", "scheduler.*"] applies one block to several packages. Mixing tiers in one list is fine, but you lose the ability to tune them independently later — split them when their tiers diverge.
  • Matching a single module vs its subtree. module = "billing" matches only billing/__init__.py; you almost always want billing.* (subpackages) or both. Forgetting the .* silently leaves submodules on the global default.
  • disable_error_code vs # type: ignore. A block-level disable_error_code is repo-wide policy for that package; an inline # type: ignore[code] is a one-line exception. With warn_unused_ignores on, stale inline ignores get flagged as [unused-ignore] — block-level ones don’t, so audit them manually.

Common mistakes

  • Global ignore_missing_imports = true. Hides missing stubs across the whole repo, including strict packages. Scope it to the importing subtree only.
  • Relying on file order for precedence. mypy resolves by specificity, not position, so reordering blocks to “fix” a conflict does nothing. Make the winning block’s module pattern more specific instead.
  • Over-broad disable_error_code. Disabling [arg-type] or [return-value] to quiet a package suppresses genuine bugs. Limit disabled codes to annotation-coverage ones like [no-untyped-def], and keep correctness codes live.

FAQ

Why is my override being ignored? Almost always a module glob that doesn’t match the import path. Run mypy --show-traceback -v and check the module name mypy resolves — overrides match the dotted import name, not the file path, so mypy_path/explicit_package_bases must make the package importable first (see monorepo incremental typing).

Should I disable error codes or use a baseline file? disable_error_code permanently forgives a code for a package; a baseline (e.g. mypy-baseline) freezes the current set of errors and fails on new ones, which ratchets better. Use disable_error_code for codes you’ll never enforce there, baselines for codes you’re actively burning down.

Back to Monorepo Incremental Typing