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, so each package gets its own tier.
# 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
# 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. Legacy packages opt down explicitly, which makes the technical debt visible in one file.
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. That is what mypy_path and explicit_package_bases provide. The precedence and ordering rules for multiple matching overrides are detailed in per-package mypy overrides.
follow_imports
follow_imports decides what mypy does when a strict package imports an untyped one. The default normal analyzes the imported module and reports its errors — undesirable when you want core strict but billing ignored. Set follow_imports = "silent" (analyze but suppress the imported module’s own errors) or "skip" (treat it as Any) on the legacy tier.
# 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.
namespace packages
Monorepos frequently use implicit namespace packages (no __init__.py) 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 [import-not-found] even though the package imports fine at runtime.
Type narrowing across package boundaries
Narrowing works within a module regardless of tier, but at a package boundary the imported symbol’s declared type is what propagates. 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. 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.
Strictness tuning and ratcheting
The ratchet is the whole point: every package should be on a path from untyped → typed → strict. Two 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, baselines let you freeze existing errors and fail only on new ones, so a package can be added to 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 relaxations now unneeded
warn_unused_ignores is the ratchet’s pawl: once a package no longer triggers a suppressed code, mypy flags the now-pointless override so you can tighten it. Tools like mypy-baseline automate freezing the current error set and diffing against it on each run.
Debugging false positives
The classic monorepo false positive is [import-not-found] / [import] on a sibling package that imports fine at runtime. 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 check the resolved search paths in the verbose output before reaching for ignore_missing_imports, which would mask genuine missing-stub problems too.
Common pitfalls
- 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. ignore_missing_importsglobally. Setting it at the top level hides missing third-party stubs across every package, including strict ones. Scope it to the legacy tier only.- Forgetting
follow_importstuning. Without it, a strict package’s CI run drowns in errors from the untyped packages it imports. Usesilent/skipon the legacy tier. - No ratchet. Overrides that only ever loosen accumulate forever. Pair every relaxation with
warn_unused_ignoresand revisit it 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.