Python Type Annotations in a Large Codebase: Three Years Later
We added mypy strict mode to a 120 KLOC Python codebase in incremental passes over 18 months. What we caught, what we missed, and what the team thinks now.

We enabled mypy strict on a 120 KLOC Python 3.10 codebase over 18 months — incremental by package, not big bang. Three years later (as of Q1 2025): CI blocks merges on type errors, ~94% strict coverage, and the team is split 60/40 on whether it was worth it. This is the honest accounting.
Rollout timeline
| Phase | Duration | Scope | mypy version |
|---|---|---|---|
| Baseline | 2 mo | # type: ignore audit, py.typed vendors | 1.5 |
| Per-package strict | 12 mo | 22 packages, easiest first | 1.7 → 1.8 |
| strict + untyped defs | 4 mo | remaining glue code | 1.8 |
| CI gate | ongoing | -m strict, --warn-unused-ignores | 1.10 |
Config anchor (pyproject.toml):
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_ignores = true
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
What we caught (high value)
- Optional abuse —
.get()on dict assumed present; ~400 fixes, prevented prod KeyError class we saw monthly in logs - Pydantic v1 → v2 migration — mypy caught model field renames before runtime
- SQLAlchemy 2.0 typed ORM — wrong column types in filters at check time
- Async boundary bugs — coroutine passed where sync callable expected; 23 instances
- Dead code — unreachable branches after exhaustive
matchwithLiteraltypes
Estimated 3–5 incidents/quarter avoided — based on incident tags pre/post (imperfect metric).
What we missed (limits)
- Dynamic JSON APIs —
TypedDicthelps until vendor adds field; still need contract tests - C extension boundaries —
ctypes/cffistubs hand-maintained, drift - Runtime duck typing in Django — admin hooks, signal receivers resist strict typing without noise
- Performance — mypy strict added ~4 min to CI; acceptable; local
dmypyrequired for sanity
Incremental strategy that worked
Package boundary rule: once services/billing is strict, no new untyped public functions exported from it.
Ratchet file: mypy-baseline.txt via mypy --baseline during migration — errors can only go down.
No org-wide "typing day": each team owns 2 packages per sprint.
Ignore discipline: # type: ignore[code] with ticket link; --warn-unused-ignores purges stale.
Team sentiment (internal survey, n=18)
Pros: IDE autocomplete "actually works", onboarding faster on payment flows, refactors safer.
Cons: "typing JSON soup", "Generic hell in repositories", senior devs resist for scripts.
Compromise: scripts/ and one-off tools stay on disallow_untyped_defs = false permanently.
Tooling stack
- mypy 1.10.0 strict
- Ruff 0.3 for lint (not replacement for mypy)
- Pyright in VS Code for faster feedback — occasional disagreements; mypy wins in CI
monkeytypeexperiment abandoned — too noisy on Django ORM
Interaction with large-codebase hygiene
Type-strict packages pair with RFC/decision memo requirements for public API changes — schema + types in same PR.
Database layer typing surfaced index issues overlapping Postgres index bloat investigations — unrelated root cause, but typed queries made EXPLAIN diffs reviewable.
What I'd do differently
Start with Pydantic at API boundaries first — highest ROI before internal strict.
Adopt Protocol for test doubles earlier — reduced Mock typing pain.
Set 15% sprint capacity cap for typing debt — we bursted to 40% once; feature freeze resentment lingered.
Package-level metrics we track
| Metric | Q1 2023 | Q1 2025 |
|---|---|---|
| mypy errors (strict packages) | 4,200 | 0 |
# type: ignore count | 890 | 41 |
| Production incidents tagged Optional/KeyError | 11/qtr | 2/qtr |
| Median PR review time (typed packages) | 1.2 days | 1.4 days |
Review time slightly up — reviewers read more carefully or nitpick types; net positive on incident reduction.
Django-specific notes
Model ForeignKey nullability: null=True without Optional in return types caused the most post-strict regressions in API serializers. Standardize on from __future__ import annotations in all Django apps — delayed evaluation reduces forward ref pain.
Celery task signatures: typed delay() kwargs caught 15 wrong argument names at CI when renaming task parameters — high ROI.
Rollback story
We disabled strict mypy on one Django app for 48 hours during a fire — reintroduced 23 untyped public functions. Re-enabling strict took 6 weeks of cleanup. Do not pause the gate; use # type: ignore with ticket instead.
New hires run dmypy run -- mypackage locally before first PR — documented in onboarding README, step 6 after venv setup.
We publish typing-debt Grafana panel — count of ignores per package, trending down quarterly. Visible debt beats hidden resentment about strict CI.
TypedDict for external webhook payloads reduced production KeyErrors by half in integrations team — smallest change with highest user-visible win after Optional cleanup.
Strict typing on admin-only Django views remains optional — low traffic, high dynamism; do not force uniform strictness where ROI is negative.
Generated Pydantic models from OpenAPI for partner webhooks are next — hand-maintained TypedDicts drift within one quarter of vendor doc updates.
What I'd do next
Evaluate Pyrefly / gradual Pyright adoption for 3.12+ only subpackages.
Auto-generate OpenAPI → TypedDict for external integrations — reduce hand stubs.
Publish internal typing cookbook — 10 patterns (Result type, branded IDs, sqlalchemy session scope).
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

