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. strict = true is not a single check — in mypy 1.x it expands to a bundle of flags: disallow_untyped_defs, disallow_incomplete_defs, disallow_untyped_calls, disallow_untyped_decorators, check_untyped_defs, disallow_any_generics, disallow_subclassing_any, warn_redundant_casts, warn_unused_ignores, warn_return_any, no_implicit_reexport, strict_equality, and extra_checks. Setting it once at the top means each of those is on for every module you have not explicitly relaxed.
# 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. A subtlety that trips people up: strict itself is not a per-module option — you cannot write strict = true inside a [[tool.mypy.overrides]] block. Overrides accept only the individual boolean flags, so to re-tighten a relaxed package (Step 3) you re-list the specific flags you want back. A handful of settings are global-only and can never appear in an override at all: python_version, platform, plugins, mypy_path, namespace_packages, explicit_package_bases, and exclude. Everything that governs how strictly a module is judged — the disallow_*, check_untyped_defs, warn_*, ignore_*, follow_imports, disable_error_code, and enable_error_code settings — is per-module and therefore overridable.
warn_unused_ignores is what lets the strictness ratchet upward: it reports [unused-ignore] — error: unused "type: ignore" comment — when a suppression no longer matches any real error, so as you fix a package its stale ignores surface as failures and get deleted. show_error_codes (on by default since mypy 0.990) prints the [code] in brackets after each message, which you need in order to know exactly which string to pass to disable_error_code.
The [[tool.mypy.overrides]] array-of-tables syntax is specific to pyproject.toml; the equivalent in a legacy mypy.ini or setup.cfg is a [mypy-billing.*] section header, and mypy merges both if present. Note too that this per-module model is a mypy concept with no direct pyright analogue — pyright drives per-directory strictness through executionEnvironments and # pyright: strict file comments instead, so a monorepo that runs both checkers keeps two parallel policy surfaces (see enabling pyright strict mode incrementally).
Step 2: disable specific error codes for a legacy package
Rather than turning off checking wholesale, silence only the codes a legacy package cannot yet satisfy. This keeps every other diagnostic live. disable_error_code takes a list of error-code strings — the bare names, without the surrounding brackets that appear in mypy’s output — and filters those messages out of the report for matching modules.
# 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 three codes here are the annotation-coverage family: [no-untyped-def] fires on a def with missing parameter or return annotations (Function is missing a type annotation, or the narrower Function is missing a return type annotation / Function is missing a type annotation for one or more arguments); [no-untyped-call] fires when typed code calls such an unannotated function (Call to untyped function "load" in typed context); and [var-annotated] fires when mypy cannot infer a variable’s type and needs help (Need type annotation for "items" (hint: "items: list[<type>] = ...")). The package still reports [arg-type], [return-value], and [union-attr] — you have only forgiven annotation coverage, so real type bugs are still caught. As billing gains annotations, delete codes from this list one at a time.
There are two ways to quiet [no-untyped-def] and they are not equivalent. disable_error_code = ["no-untyped-def"] leaves disallow_untyped_defs on but drops the message; disallow_untyped_defs = false changes the analysis so the code is never generated in the first place. Prefer disable_error_code — it is surgical (one code at a time) and reversible without re-reasoning about which strict flag produced which message. Its opposite, enable_error_code, turns on codes that are off by default ([redundant-expr], [truthy-bool], [ignore-without-code], [possibly-undefined]), and it is equally overridable per module, so a promoted package can gain checks the rest of the repo does not run.
Step 3: require annotations per module
The inverse: turn on a strictness setting for a mid-tier package that a looser global would not enforce. Under a strict global this is more often used to partially re-tighten a package you previously relaxed with a broader override. Three related flags do different jobs and are worth separating: disallow_untyped_defs rejects any def with no annotations at all; disallow_incomplete_defs rejects a def that annotates some parameters but not all (the half-typed signature that slips past the first flag); and check_untyped_defs actually type-checks the bodies of unannotated functions, which mypy otherwise skips entirely.
# 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 — because the more specific module pattern wins (see the precedence callout below). One consequence people miss: without check_untyped_defs, mypy does not merely skip the signature of an unannotated function, it skips the whole body, so bugs inside legacy functions are invisible until you annotate the signature. Turning check_untyped_defs on for a package is often the cheapest first ratchet step: you get body-level checking without yet demanding annotations on every signature, and it frequently uncovers latent [arg-type] and [attr-defined] errors. Note also disallow_untyped_calls, which is about the caller side — it flags typed code that calls into still-untyped code, and is usually the last flag you enable because it depends on the callee packages being annotated first.
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 instead to the single dependency that lacks stubs. The crucial subtlety: in an override, the module pattern names the module being imported, not the module doing the importing. So you match the import path of the stubless library itself, and the exemption then applies wherever that library is imported.
# pyproject.toml — narrow stub-ignore to the imported dependency, mypy 1.x
[[tool.mypy.overrides]]
module = "legacy_etl.vendored_client.*"
ignore_missing_imports = true # only this dependency tolerates missing stubs
The two codes it suppresses are distinct. [import-not-found] means mypy cannot locate the module at all — Cannot find implementation or library stub for module named "vendored_client". [import-untyped] means mypy found the module but it ships no type information — Skipping analyzing "vendored_client": module is installed, but missing library stubs or py.typed marker. Both cause the imported names to be treated as Any. ignore_missing_imports silences both; if you only want to tolerate the untyped case (a library that exists but lacks a py.typed marker) and still be told about genuinely missing modules, prefer the narrower disable_error_code = ["import-untyped"], or, on mypy 1.14+, follow_untyped_imports = true, which follows into the untyped module and infers structure rather than blanking it to Any.
If what you really want is “ignore errors raised inside package X”, that is a different option — ignore_errors = true, whose pattern matches the module being checked. Confusing the two is exactly why core in the example still correctly reports [import-untyped] when it imports some other stubless library: the exemption is keyed to vendored_client’s import path, not to whoever imports it. For managing this across an incremental rollout, see monorepo incremental typing and the broader mypy configuration and strictness options.
[[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. Themodulekey accepts either a string or a list of strings:module = ["api.*", "scheduler.*"]applies one block to several packages. Patterns are matched against the dotted import name withfnmatch-style globbing, so*and?work, but the idiomatic form is a trailing.*. 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. This is the highest-frequency bug.
module = "billing"matches only thebillingpackage object itself (its__init__.py);module = "billing.*"matches every submodule —billing.reports,billing.tax— but notbillingitself. Neither pattern covers both. To apply a setting to a package and everything under it, list both:module = ["billing", "billing.*"]. Forgetting the.*silently leaves submodules on the global default, and forgetting the bare name silently leaves the package__init__on it.
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 at a single source location. The interaction withwarn_unused_ignoresdiffers sharply: stale inline ignores get flagged as[unused-ignore]and can be pruned, but adisable_error_codeentry that is no longer needed is never reported as unused — nothing tells you the package outgrew it, so you must audit those lists by hand. Prefer specific inline ignores (# type: ignore[arg-type], not a bare# type: ignore) sowarn_unused_ignoresand the[ignore-without-code]check can keep them honest.
Common mistakes
- Global
ignore_missing_imports = true. Hides missing stubs across the whole repo, including strict packages, and masks real[import-not-found]errors that indicate a genuinely broken import or a missing dependency. Scope it to the specific dependency’s import path, or install stubs (types-requests,pandas-stubs, and friends) and drop the flag entirely.
- Relying on file order for precedence. mypy resolves overlapping overrides by specificity, not position in the file, so reordering blocks to “fix” a conflict does nothing. A pattern with no wildcard beats one containing
.*; among wildcard patterns the one with the longer literal dotted prefix wins. Make the intended winner’smodulepattern more specific instead of moving it. - Over-broad
disable_error_code. Disabling correctness codes like[arg-type],[return-value], or[union-attr]to quiet a noisy package suppresses genuine bugs, not just annotation gaps. Limit disabled codes to the annotation-coverage family ([no-untyped-def],[no-untyped-call],[var-annotated]) and keep correctness codes live. When you need to freeze existing errors of a correctness code while blocking new ones, reach for a baseline tool such asmypy-baselinerather than a blanketdisable_error_code(see the FAQ).
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.