Per-package mypy Overrides in a Monorepo
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.
[[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.
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 onlybilling/__init__.py; you almost always wantbilling.*(subpackages) or both. Forgetting the.*silently leaves submodules on the global default. disable_error_codevs# type: ignore. A block-leveldisable_error_codeis repo-wide policy for that package; an inline# type: ignore[code]is a one-line exception. Withwarn_unused_ignoreson, 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
modulepattern 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.