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.

Per-package strictness tiers in a monorepo Packages in a monorepo sit at three tiers — strict, typed, and untyped — and the goal is to ratchet each package upward toward strict over time. packages/ — each package at its own tier untyped billing/ legacy_etl/ ignore_missing_imports check_untyped_defs = false typed api/ scheduler/ disallow_untyped_defs no_implicit_optional strict core/ sdk/ strict = true warn_return_any ratchet each package rightward over time →
Each package declares its own strictness tier; CI ratchets packages from untyped toward strict one override at a time.

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 = true with no overrides means the weakest package fails CI for the whole repo. Tier with overrides instead.
  • ignore_missing_imports globally. 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_imports tuning. Without it, a strict package’s CI run drowns in errors from the untyped packages it imports. Use silent/skip on the legacy tier.
  • No ratchet. Overrides that only ever loosen accumulate forever. Pair every relaxation with warn_unused_ignores and 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.

Back to Static Analysis Tools & CI Integration