Monorepo Incremental Typing
Adopting type hints across a Python monorepo is rarely a flag flip — it is a ratchet. Dozens of packages sit at different maturity levels: a new core library may be ready for --strict, while a decade-old billing package can barely survive ignore_missing_imports. This guide covers how to run one coherent typing policy over a multi-package repository without blocking the whole tree on its weakest module: per-package strictness with [[tool.mypy.overrides]], follow_imports tuning, namespace packages and mypy_path, baselines, and ratcheting strictness upward over time. It sits under Static Analysis Tools & CI Integration and pairs with the GitHub Actions workflows that enforce it.
Syntax spec: one config, per-package strictness
The cleanest monorepo setup is a single pyproject.toml at the repo root with a strict global baseline, relaxed by targeted module overrides. mypy’s module patterns match the dotted import path, not the filesystem path, so each package gets its own tier. Overrides live in an array of tables — each [[tool.mypy.overrides]] block requires a module key holding one glob string or a list of them, where * matches a single component and .* matches any suffix (billing.* covers billing, billing.db, and billing.db.models). Only per-module options are legal inside an override; global-only settings such as python_version, mypy_path, namespace_packages, and plugins raise Setting "…" not supported in per-module sections if you nest them there, so they must stay in the top-level [tool.mypy] table.
# pyproject.toml — single root config, mypy 1.x
[tool.mypy]
python_version = "3.10"
strict = true # the aspirational default for the whole repo
mypy_path = "packages" # so package roots resolve as top-level imports
namespace_packages = true
explicit_package_bases = true
warn_unused_configs = true # flag override sections that match nothing
# Tier 2: "typed" — relaxed from strict, still enforces annotations
[[tool.mypy.overrides]]
module = ["api.*", "scheduler.*"]
disallow_untyped_defs = true
warn_return_any = false
# Tier 3: "untyped" — legacy packages held at a floor
[[tool.mypy.overrides]]
module = ["billing.*", "legacy_etl.*"]
ignore_missing_imports = true
check_untyped_defs = false
disallow_untyped_defs = false
The global strict = true means any new package is checked strictly by default — the right bias, since strict is a meta-flag that expands to roughly a dozen sub-flags (disallow_untyped_defs, disallow_incomplete_defs, check_untyped_defs, disallow_untyped_decorators, no_implicit_optional, warn_redundant_casts, warn_unused_ignores, warn_return_any, strict_equality, and extra_checks, among others). Overriding one of those sub-flags in a tier composes cleanly with the rest, so warn_return_any = false in the typed tier leaves every other strict check intact. Legacy packages opt down explicitly, which makes the technical debt visible in one file. When two patterns both match a module, mypy applies the more specific one — the pattern with more non-wildcard components wins, and a tie breaks toward the section defined later in the file — so a narrow billing.db.legacy override beats a broad billing.* one. Set warn_unused_configs = true so any override whose pattern stops matching real modules (a package renamed or deleted) is reported instead of silently rotting.
Analyzer behaviour
mypy
mypy resolves overrides by module glob against the import path, not the filesystem path, so billing.* matches packages/billing/... only if billing is importable as a top-level package. Internally mypy turns each source file into a fully qualified module name before matching any override: it takes the file path, finds the applicable package base, strips that prefix, and joins the remaining directories with dots. That is exactly what mypy_path and explicit_package_bases provide — mypy_path = "packages" makes packages/ a base so packages/billing/db.py becomes billing.db, and only then does the billing.* override fire. The precedence and ordering rules for multiple matching overrides are detailed in per-package mypy overrides.
Run mypy -v to watch this resolution: the verbose log prints the search paths it built from mypy_path/MYPYPATH and lines like LOG: Found source: BuildSource(path='packages/billing/db.py', module='billing.db', …). If that module= value is wrong — e.g. db instead of billing.db — your override patterns will never match, and the fix is a base directory, not a wildcard.
follow_imports
follow_imports decides what mypy does when a strict package imports an untyped one. It takes four values. The default normal analyzes the imported module and reports its errors — undesirable when you want core strict but billing ignored. silent still analyzes the module (so cross-module types stay accurate) but suppresses that module’s own diagnostics. skip does not analyze the module at all and treats every symbol imported from it as Any. error behaves like skip but additionally emits an error on the import line, useful when you want to forbid a dependency edge entirely.
# pyproject.toml — keep legacy imports from polluting strict packages, mypy 1.x
[[tool.mypy.overrides]]
module = "legacy_etl.*"
follow_imports = "skip" # imported symbols become Any, no errors leak out
The trade-off: skip hides real type information, so a strict package calling into a skipped one loses checking at that boundary — surfacing as silent Any propagation rather than an error code. Note the override targets the imported module’s name (legacy_etl.*), not the importer’s; a frequent mistake is setting follow_imports = "skip" on core.* and then wondering why core stopped being checked. A related follow_imports_for_stubs extends the same handling to .pyi files. silent is the safer default for the legacy tier because it preserves the inferred types that strict callers depend on while still muting the noise.
namespace packages
Monorepos frequently use implicit namespace packages (no __init__.py, per PEP 420) so multiple distributions share a prefix like acme.core, acme.billing. Enable namespace_packages = true and explicit_package_bases = true, and set mypy_path to the directory that contains the namespace roots, or mypy reports error: Cannot find implementation or library stub for module named "acme.billing" [import-not-found] even though the package imports fine at runtime. explicit_package_bases is what disambiguates: without it, mypy infers a package root by walking up while __init__.py files exist, which fails for PEP 420 packages that have none; with it, the roots are exactly the entries on mypy_path (plus the current directory), so the dotted name is computed relative to packages/ rather than to the first directory that happens to contain an __init__.py. For installed first-party packages the mechanism is different — PEP 561 requires a py.typed marker file inside the distribution before mypy will read its inline annotations, so a first-party wheel without py.typed is treated as untyped even if the source was fully annotated.
Type narrowing across package boundaries
Narrowing works within a module regardless of tier — isinstance(), x is None, assert, and user-defined TypeGuard/TypeIs predicates all refine a value’s type inside the function that uses them. But at a package boundary the imported symbol’s declared type is what propagates, and narrowing can only ever refine what it is handed. If billing is follow_imports = "skip", a value crossing from billing into strict core arrives as Any, and strict-mode narrowing on it is a no-op: reveal_type(invoice) prints Revealed type is "Any", an isinstance check on it yields Any again, and warn_return_any cannot fire because the value never had a non-Any type to lose.
Add a typed facade — a small annotated module that re-exports the legacy API with real signatures — so the boundary carries types even while the legacy internals stay untyped. The facade lives at the strict tier, imports from the skipped package, and re-declares each entry point with a concrete signature, using typing.cast() or an explicit annotation to assert the type mypy could not infer:
# packages/billing/facade.py — typed boundary over an untyped package
from typing import cast
from billing._legacy import load_invoice as _load_invoice # Any at this import
from core.models import Invoice
def load_invoice(invoice_id: str) -> Invoice:
return cast(Invoice, _load_invoice(invoice_id))
Now core imports billing.facade.load_invoice and receives a real Invoice, so isinstance/is None narrowing and reveal_type behave as expected. An equivalent alternative is to ship a hand-written _legacy.pyi stub next to the module — mypy reads the stub’s signatures and ignores the untyped .py body — which keeps the annotations out of runtime code entirely. Either way, cast() is a compile-time assertion with no runtime check, so a facade is only as trustworthy as the signatures you hand-write into it.
Strictness tuning and ratcheting
The ratchet is the whole point: every package should be on a path from untyped → typed → strict, and CI should make backsliding impossible. Three mechanics drive it. First, remove relaxations from an override as a package improves — drop check_untyped_defs = false, then add disallow_untyped_defs = true, then delete the override entirely so the global strict applies. Second, use enable_error_code to opt a package into stricter-than-strict checks incrementally (redundant-expr, truthy-bool, possibly-undefined, ignore-without-code) so tightening happens one code at a time rather than as a wall of new failures. Third, baselines let you freeze existing errors and fail only on new ones, so a package can enter checking before it is clean.
# pyproject.toml — a baseline-style floor for a package mid-migration, mypy 1.x
[[tool.mypy.overrides]]
module = "billing.*"
disable_error_code = ["no-untyped-def", "no-untyped-call"] # tolerate, don't ignore
warn_unused_ignores = true # flag ignores now unneeded
disable_error_code tolerates specific codes without hiding everything the way ignore_missing_imports does, so billing can be checked for real bugs while its missing annotations are deferred. Two mypy flags act as the ratchet’s pawls, each catching a different kind of stale suppression: warn_unused_ignores reports inline # type: ignore[code] comments that no longer suppress anything (so once code is fixed, the now-pointless ignore is flagged for removal), and warn_unused_configs reports whole override sections whose module pattern matched no files — the signal that a tier is empty and can be deleted. Together they stop the config from only ever loosening. For error-set baselines specifically, mypy-baseline wraps mypy: mypy … | mypy-baseline sync writes the current errors to a baseline file, and mypy … | mypy-baseline filter in CI passes as long as no new errors appear, failing the build the moment a change introduces one. That converts a package’s existing debt into a frozen floor and makes every future edit strictly better, which is exactly the ratchet behaviour you want across a large tree. See also mypy configuration & strictness for the full flag catalogue.
Debugging false positives
The classic monorepo false positive is [import-not-found] on a sibling package that imports fine at runtime. mypy distinguishes two closely related codes here: [import-not-found] means it could not locate the module or a stub for it at all, while [import-untyped] means it found the module but the package ships no py.typed marker, so it is treated as untyped and mypy suggests installing a types-* stub package. Confusing the two wastes time — import-untyped is a PEP 561 packaging gap, not a search-path problem, and ignore_missing_imports would mask both indiscriminately.
For a true [import-not-found], work down the search path before reaching for a blanket ignore. It almost always means mypy_path doesn’t include the namespace root, or explicit_package_bases is off. Confirm with mypy --namespace-packages -v and read the resolved search paths and Found source lines in the verbose output. A common cause specific to monorepos is editable installs: pip install -e writes a .pth or __editable__ shim that Python honours at runtime but mypy does not, so a package that imports fine in the REPL still fails type checking — the fix is to add its source directory to mypy_path, not ignore_missing_imports, which would also mask genuine missing-stub problems everywhere else. If the module resolves but arrives as Any, check py.typed: a first-party installed package needs the marker before mypy will honour its inline types.
Common pitfalls
The recurring failures below all come from treating strictness as a single global switch instead of a per-package dial. Each pairs the mistake with the scoped fix that keeps the migration moving.
- One config that’s strict everywhere, blocking the migration. A single
strict = truewith no overrides means the weakest package fails CI for the whole repo. Tier with overrides instead, keeping strict as the default that packages opt out of rather than a gate they must all clear at once. ignore_missing_importsglobally. Setting it at the top level hides missing third-party stubs across every package, including strict ones, so a genuinely broken import incorepasses silently. Scope it to the legacy tier only, and prefer installingtypes-*stub packages for real dependencies.- Forgetting
follow_importstuning. Without it, a strict package’s CI run drowns in errors from the untyped packages it imports, because the defaultnormalreports the imported module’s own diagnostics. Usesilent(keeps types, mutes noise) orskip(drops toAny) on the legacy tier. - No ratchet. Overrides that only ever loosen accumulate forever and the repo never gets stricter. Pair every relaxation with
warn_unused_configs,warn_unused_ignores, and amypy-baselinefloor, and revisit it each release so packages actually climb tiers.
FAQ
One shared config or a config per package? A single root config is easier to ratchet and audit — every package’s tier is visible in one file. Per-package configs suit repos where teams own packages independently and tolerate drift. The trade-offs are weighed in per-package mypy overrides.
How do I stop legacy packages from breaking strict ones in CI?
Set follow_imports = "skip" or "silent" on the legacy tier so their errors don’t leak, and add typed facades at the boundaries that strict packages actually call.
Does this apply to pyright too?
Yes, with different mechanics — pyright uses include lists and executionEnvironments rather than module overrides. See enabling pyright strict mode incrementally.
Where does this run in CI?
The same GitHub Actions workflow — one mypy job over the whole tree, with the tiers expressed entirely in pyproject.toml.