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.

Ratcheting disallow_untyped_defs per module The global setting stays off while per-module overrides flip on one package at a time until the whole codebase is covered. Global default: off — overrides flip modules on Week 1 app.core ✓ app.api … app.legacy … 1 / 3 covered Week 4 app.core ✓ app.api ✓ app.legacy … 2 / 3 covered Week 8 app.core ✓ app.api ✓ app.legacy ✓ flip global on
Each package gets annotated and locked behind an override; when all are covered, the global flag flips on.

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.

Big-bang flip versus incremental rollout Flipping the flag globally turns the build red with thousands of errors; the incremental per-module path keeps the build green while coverage climbs. Global flip on day one 4,312 errors every PR blocked [no-untyped-def] everywhere build: red ✗ Incremental per module 0 new errors merges keep flowing coverage climbs each PR build: green ✓
The same end state, reached two ways: a red build nobody can land versus a green build that ratchets coverage upward.

“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_defs rejects both fully bare and partially annotated functions — it is the strict superset.
  • disallow_incomplete_defs rejects only partially annotated functions and still tolerates fully bare ones, which makes it a useful softer intermediate.
  • check_untyped_defs does 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.

The global default is inherited by every module One global setting of disallow_untyped_defs equals false sits at the top and every module below inherits it until a per-module override changes it. [tool.mypy] disallow_untyped_defs = false app.core inherits: allowed app.api inherits: allowed app.legacy inherits: allowed
With the global default off, every module inherits "untyped allowed" — the green starting point you ratchet away from.
# 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.

The override acts as a regression gate for one package Annotated functions in app.core pass through the override gate while a newly added untyped function is rejected with a no-untyped-def error. app.core typed def ✓ typed def ✓ new bare def ? override gate annotated → pass merge ✓ bare → fail [no-untyped-def]
Once locked, the override is a one-way gate: annotated code merges, any newly introduced untyped def in the package fails.
# 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.

The per-package ratchet loop A cycle of annotate the next package, add its override, verify clean, and merge, which repeats and expands enforced coverage every iteration. 1. Annotate package next leaf module 2. Add override disallow_untyped_defs 3. Verify clean mypy is silent 4. Merge coverage += 1
The same four steps repeat every iteration; because overrides stack, the enforced set is monotonic and can only grow.
# 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.

Flipping the global flag, then stacking the rest of strict disallow_untyped_defs becomes the global base layer, then warn_return_any, disallow_any_generics, disallow_untyped_calls and finally strict equals true stack on top. disallow_untyped_defs = true (global base) + warn_return_any + disallow_any_generics + disallow_untyped_calls strict = true
The flag becomes the foundation; the rest of the strict bundle stacks on top, each layer added with the same per-module ratchet.
# 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:

Remaining [no-untyped-def] errors trending to zero A bar chart showing the count of remaining no-untyped-def errors falling from 312 in week one to zero by week eight as coverage grows. 312 Week 1 180 Week 3 64 Week 5 0 Week 8
Charting the remaining error count turns an abstract migration into a burn-down that makes stalls obvious.
# 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.

Why did an untyped def slip past the flag? A decision tree branching from an unflagged untyped function into an untyped decorator collapsing it to Any or an unannotated overload implementation. Untyped def not flagged? why did mypy stay silent Untyped decorator wraps → collapses to Any @overload impl bare stubs typed, impl is not Fix: type decorator / ParamSpec Fix: annotate the impl
Two common reasons a bare function passes silently — an Any-typed decorator or an unannotated overload implementation — and the fix for each.

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_defs tells mypy to type-check inside unannotated bodies; disallow_untyped_defs requires the signature to be annotated at all. They are independent flags.
Three ratchet mistakes and their fixes A two-column matrix pairing over-broad globs, wrong override order, and confusing check_untyped_defs with the correct fix for each. Mistake Fix glob "app.*" too early match finished subpackage only lax block after strict block keep specific globs last confuse check_untyped_defs it checks bodies, not signatures
The three failure modes that quietly stall a ratchet, each paired with the one-line correction.

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.

Runtime vs static analysis 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.

Back to mypy Configuration & Strictness