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. 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.

How overrides layer on the strict global default The global strict section is the base layer, and each matching per-module override is a plate stacked on top that changes individual settings. effective config for a module = strict base + matching override settings global [tool.mypy] strict = true module = "billing.*" → disable [no-untyped-def] module = "billing.reports" → strict again specificity increases
The strict global section is the base; each matching override stacks on top and the most specific pattern wins per setting.
# 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.

disable_error_code silences annotation codes but keeps correctness codes Before the override every code fires; after listing three annotation codes in disable_error_code those go silent while the three correctness codes keep reporting. before — billing.* strict after — disable_error_code = [ ... ] [no-untyped-def] [no-untyped-call] [var-annotated] [arg-type] [return-value] [union-attr] [no-untyped-def] [no-untyped-call] [var-annotated] [arg-type] [return-value] [union-attr] silence 3
disable_error_code drops the annotation-coverage codes for the package while correctness codes keep firing.
# 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.

Which annotation flag catches which def Each of the three annotation-related flags targets a different def state and emits a specific error code. flag targets a def that is… emits disallow_untyped_defs not annotated at all [no-untyped-def] disallow_incomplete_defs annotates some args, not all [no-untyped-def] check_untyped_defs unannotated — checks its body [arg-type], …
Each flag targets a distinct def state; the first two share the [no-untyped-def] code, while check_untyped_defs surfaces body-level bugs.
# 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.

How mypy resolves an import and where ignore_missing_imports intervenes An import is resolved by checking whether the module is found and whether it ships type information, yielding import-not-found, import-untyped, or a typed result, with ignore_missing_imports suppressing the two error branches. import vendored_client module found on path? ships py.typed / stubs? [import-not-found] [import-untyped] typed: real signatures no yes no yes ignore_missing_imports on the imported target ⇒ both codes suppressed ⇒ names become Any
Import resolution branches into import-not-found or import-untyped; ignore_missing_imports on the imported target suppresses both and yields Any.
# 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.

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. The module key 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 with fnmatch-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 the billing package object itself (its __init__.py); module = "billing.*" matches every submodulebilling.reports, billing.tax — but not billing itself. 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.
What each module pattern actually matches The bare name matches only the package init, the dotted-star matches submodules but not the package, and the two-entry list matches both. pattern modules it matches module = "billing" module = "billing.*" ["billing", "billing.*"] billing only (its __init__) billing.reports, billing.tax — not billing itself billing + every submodule
The bare name, the dotted-star, and the two-entry list each match a different set of modules — only the list covers a package and its subtree.
  • 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 at a single source location. The interaction with warn_unused_ignores differs sharply: stale inline ignores get flagged as [unused-ignore] and can be pruned, but a disable_error_code entry 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) so warn_unused_ignores and 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.
Override precedence is decided by pattern specificity, not file order Patterns are ranked by specificity: the no-wildcard exact path sits at the top and is applied, above longer and then shorter wildcard patterns and the global default. most specific pattern wins — file order is ignored module = "billing.reports" ← applied to billing.reports module = "billing.reports.*" module = "billing.*" global [tool.mypy] — no module key specificity increases
mypy ranks matching overrides by specificity; the exact, wildcard-free path applies to billing.reports no matter where it appears in the file.
  • 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’s module pattern 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 as mypy-baseline rather than a blanket disable_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.

Back to Monorepo Incremental Typing