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.
“Complete annotation” means every parameter and the return type carry an annotation. A signature with an annotated return but one bare parameter is still untyped as far as this flag is concerned, and mypy reports it on the def line. 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()
It helps to hold three neighbouring flags apart, because they overlap in confusing ways:
disallow_untyped_defsrejects both fully bare and partially annotated functions — it is the strict superset.disallow_incomplete_defsrejects only partially annotated functions and still tolerates fully bare ones, which makes it a useful softer intermediate.check_untyped_defsdoes not require annotations at all; it tells mypy to type-check the bodies of unannotated functions instead of skipping them. It is orthogonal — you can enable it alongside either of the above.
Two special cases trip people up. mypy infers the return of __init__ and __new__ as -> None when at least one parameter is annotated, so you rarely annotate their return explicitly; but a fully bare def __init__(self, name): still trips [no-untyped-def]. And a function that genuinely returns nothing still needs -> None written out — mypy will not accept an omitted return annotation under this flag. Pyright has no single equivalent switch: in strict mode the same gap is reported piecewise by reportMissingParameterType and reportUnknownParameterType, so a codebase can be clean under one checker and noisy under the other. See pyright vs mypy comparison for where the two diverge.
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
A subtle point about this baseline: mypy still parses every module and still reports errors that are on by default, such as [assignment] or [return-value] type mismatches. Turning disallow_untyped_defs off does not turn mypy off — it only stops the checker from demanding annotations. That is exactly what you want mid-migration: real type bugs in already-annotated code keep failing CI, while the absence of annotations on legacy functions is tolerated. If you are running from a mypy.ini or setup.cfg instead of pyproject.toml, the same key lives under the [mypy] section, and per-module overrides use [mypy-app.core.*] headers rather than [[tool.mypy.overrides]] tables; the semantics are identical, only the syntax differs. See optimizing mypy.ini for large codebases for the INI form and cache tuning that keeps this baseline fast.
One guardrail worth adding on day one is warn_unused_ignores = true. It costs nothing on a lax baseline and it means that when you later annotate a function and delete the code that produced an error, any now-stale # type: ignore on that line is flagged rather than silently rotting. Pin your mypy version in the lockfile too (mypy==1.13.*, say): each minor release tightens inference, and an unpinned checker can turn a green baseline red on an unrelated CI run.
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]. Note the trailing .* in the glob: module = "app.core" matches only the package’s __init__.py, whereas app.core.* matches every submodule beneath it. If a package has both an __init__.py with code and submodules, list both — module = ["app.core", "app.core.*"] — because mypy treats the package module and its children as separate match targets.
Choosing the right first package matters more than picking the smallest one. A leaf package with few imports (a models or schemas layer, a pure-logic domain module) is ideal: it has little inbound coupling, so annotating it does not cascade into forcing annotations on ten callers. Avoid starting with a package that everything imports, because disallow_untyped_calls — a sibling flag often enabled next — will then complain at every call site that reaches into still-untyped code. Verify the package is genuinely clean before you lock it:
# Dry-run just this package with the flag, before committing the override
mypy --disallow-untyped-defs --disallow-incomplete-defs app/core/
If that command is silent, the override is safe to merge and can never regress. If it is not, finish the annotations first — locking a package that still emits errors just moves the flag-day problem into a single PR.
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. That lets you land a package in two hops: first require completeness (disallow_incomplete_defs), then require presence (disallow_untyped_defs).
You can collapse repeated globs by passing a list to a single override, which keeps the file readable as the enforced set grows:
# pyproject.toml — one override, many modules
[[tool.mypy.overrides]]
module = ["app.core.*", "app.api.*", "app.services.*"]
disallow_untyped_defs = true
disallow_incomplete_defs = true
Be deliberate about override ordering, because mypy does not merge overrides by specificity — when two blocks match the same module, the last matching block wins. If a broad lax block for app.* sits below a strict block for app.core.*, the lax block silently re-disables the flag for app.core. Keep the most specific globs last, or keep lax and strict globs disjoint so they can never both match. This is the single most common way a ratchet quietly stops ratcheting. The same package-by-package discipline extends to multi-repo layouts in monorepo incremental typing.
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. A sensible ordering is disallow_incomplete_defs (already close), then disallow_untyped_calls (which forces the callees of your typed code to be typed too), then warn_return_any and disallow_any_generics, saving strict_equality and warn_unused_ignores for last. Each of these has its own error code — [no-untyped-call], [no-any-return], [type-arg] — so you can grep CI output to see which flag is producing the remaining noise before you flip it globally. The full sequencing is laid out in enabling mypy strict mode incrementally.
Deleting the overrides at this stage is not just tidiness. A leftover override block that sets disallow_untyped_defs = true for app.core.* is now redundant with the global default, but if it also sets other keys it can shadow a later, stricter global — the last-match-wins rule cuts both ways. Once the global flag is on, remove every override whose only job was to enable the flag you just globalized, and keep only overrides that genuinely relax settings for still-untyped corners like tests or vendored code.
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.
A more precise burn-down uses mypy’s own coverage reporting rather than a grep. mypy --html-report and --txt-report emit per-module “imprecision” and “lines annotated” figures you can diff week over week; --any-exprs-report breaks down where Any still leaks in, which predicts where disallow_untyped_defs will bite next. Wire the count into CI as a non-blocking metric:
# Fail the nightly job only if the count went UP versus the committed baseline
COUNT=$(mypy --disallow-untyped-defs app/ 2>&1 | grep -c "\[no-untyped-def\]")
BASELINE=$(cat .untyped-def-baseline)
test "$COUNT" -le "$BASELINE" || { echo "regressed: $COUNT > $BASELINE"; exit 1; }
Dedicated tools formalize this. mypy-baseline snapshots the current error set into a file and fails CI only on new errors, so you get the ratchet effect across the whole tree at once without writing per-module overrides by hand — a good complement to the package-by-package approach when the two are combined. Whichever signal you pick, publish it somewhere the team sees it; a migration that nobody can see the finish line of is the one that stalls at 80%.
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.
The ParamSpec fix (PEP 612, Python 3.10+, or typing_extensions.ParamSpec on 3.8/3.9) lets a decorator preserve the wrapped signature instead of erasing it to Any:
# Python 3.10+; use typing_extensions on 3.8/3.9
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def timed(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return inner
@timed
def charge(order: Order) -> Receipt: # still checked; signature preserved
...
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. The implementation signature must also be compatible with every overload, or mypy reports [misc] “Overloaded function implementation does not accept all possible arguments” — a separate error you will hit the moment the implementation is annotated. Abstract and Protocol methods are checked too: a bare def save(self): ... inside a Protocol or an @abstractmethod still needs annotating, because subclasses inherit the (missing) signature. Async and generator functions follow the same rule — annotate the return as Coroutine/Awaitable, Iterator[T], or AsyncIterator[T] respectively; a bare async def is as untyped as a bare def. Finally, from __future__ import annotations (PEP 563) changes how annotations are stored (as strings, lazily) but not whether they are required — it has no effect on disallow_untyped_defs.
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.
A fourth, subtler mistake is locking a package before it is truly clean — committing the override in the same PR that adds the annotations, without a separate verifying mypy run. If a single function was missed, the override now enforces a flag the package fails, and CI is red on main. Always run the dry-run from Step 2 first and only add the override once the package is silent. And do not reach for blanket # type: ignore to force a package green: an uncoded ignore suppresses every future error on that line, including real bugs. If you must suppress, use the coded form # type: ignore[no-untyped-def] with warn_unused_ignores = true so mypy tells you when the annotation finally makes the ignore unnecessary.
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__.
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.