Rolling Out disallow_untyped_defs Incrementally
disallow_untyped_defs is the mypy flag that turns an unannotated function into a hard error, and switching it on globally for a large untyped codebase produces thousands of [no-untyped-def] failures at once. The fix is to enable it per module with [[tool.mypy.overrides]], leaving the global default off, then ratchet it package by package until every module is covered. This guide walks the rollout, the exact config, and how to track progress toward an eventual --strict.
Why incremental, and what the flag does
disallow_untyped_defs makes mypy reject any function definition that lacks a complete annotation. On a mature project that started without type hints, enabling it globally floods CI with [no-untyped-def] errors and blocks every merge until the entire codebase is annotated — an all-or-nothing migration nobody can land. The incremental approach keeps the build green while you make steady, reviewable progress.
The flag is part of the mypy strict bundle, but it can be controlled independently. The error it produces:
# Python 3.11+, mypy 1.x with disallow_untyped_defs = true
def serialize_order(order): # mypy error: [no-untyped-def]
return order.to_dict() # "Function is missing a type annotation"
def serialize_order(order: Order) -> dict[str, object]: # passes
return order.to_dict()
Step 1: keep the global default off
Start with the flag globally disabled so the existing build stays green. This is the baseline every other module inherits.
# pyproject.toml — Python 3.11, mypy 1.x
[tool.mypy]
python_version = "3.11"
disallow_untyped_defs = false # global baseline: untyped defs allowed
Step 2: turn it on for one fully-annotated package
Pick a small, well-understood package, annotate every function in it, then lock it behind an override so it can never regress. The override section matches by module glob.
# pyproject.toml — enforce on app.core only
[[tool.mypy.overrides]]
module = "app.core.*"
disallow_untyped_defs = true # this package must stay fully annotated
Now mypy enforces annotations inside app.core while leaving the rest of the tree untouched. Any new untyped function added to app.core fails CI with [no-untyped-def].
Step 3: ratchet package by package
Each iteration is the same loop: annotate the next package, add its module glob to the overrides, merge. Overrides stack, so the enforced set only grows.
# pyproject.toml — two packages now locked in
[[tool.mypy.overrides]]
module = "app.core.*"
disallow_untyped_defs = true
[[tool.mypy.overrides]]
module = "app.api.*"
disallow_untyped_defs = true
For functions you cannot annotate yet (third-party callbacks, generated code), disallow_untyped_defs still requires some annotation. A pragmatic intermediate step is disallow_incomplete_defs = true instead, which only fails partially-annotated defs and tolerates fully-bare ones while you finish the package.
Step 4: flip the global flag and layer in --strict
Once every package is behind an enforcing override, invert the defaults: set the global flag on and delete the now-redundant overrides.
# pyproject.toml — global enforcement; overrides no longer needed
[tool.mypy]
python_version = "3.11"
disallow_untyped_defs = true # now the whole codebase is covered
From here, adopt the rest of --strict the same way — enable warn_return_any, disallow_any_generics, and the others package by package using the identical override pattern, then flip each globally.
disallow_untyped_defs changes only what mypy reports — it never alters runtime behavior. An unannotated function runs identically before and after you annotate it. Adding annotations to satisfy the flag has zero runtime cost unless you also evaluate them (e.g. via typing.get_type_hints); the annotations are otherwise just metadata stored on __annotations__.
Tracking progress
Make the ratchet visible so the migration doesn’t stall. Two cheap signals:
# Count remaining untyped-def errors across the whole tree (overrides off)
mypy --disallow-untyped-defs app/ 2>&1 | grep -c "\[no-untyped-def\]"
Run this on a schedule and chart the number trending to zero. Second, keep the list of enforced modules in pyproject.toml itself — the count of [[tool.mypy.overrides]] blocks with disallow_untyped_defs = true is a self-documenting progress bar that lives in code review. For monorepos, scope the ratchet per package as described in monorepo incremental typing.
Edge cases
Decorated functions can mask the error. If a decorator is untyped, mypy may infer the wrapped function as Any and skip the [no-untyped-def] check. Annotate the decorator (or use ParamSpec) so the flag applies to the functions it wraps.
Overloads need every variant annotated. A @overload stub plus the implementation must all be annotated; an unannotated implementation still triggers [no-untyped-def] even when the overloads above it are typed.
Common mistakes
- Globbing too broadly too early.
module = "app.*"in one override re-creates the all-or-nothing problem. Match the specific subpackage you’ve actually finished. - Forgetting that overrides override, not merge by precedence rank. When two override blocks match the same module, the last matching block wins. Order matters — keep the most specific globs last.
- Confusing it with
check_untyped_defs.check_untyped_defstells mypy to type-check inside unannotated bodies;disallow_untyped_defsrequires the signature to be annotated at all. They are independent flags.
FAQ
Can I enforce on new code only?
Not directly with this flag — mypy checks files, not diffs. Approximate it by enforcing on the packages where new code lands and reviewing that PRs add annotations; tools like a pre-commit mypy hook on changed files help.
Why does mypy still pass an untyped function in an unlisted module?
Because the global default is false and no override covers that module. That’s the intended state mid-migration — add the module to overrides once it’s annotated.