The Type Hints lesson established that annotations are inert without a checker actually reading them. This lesson is that checker, in practice: running mypy, understanding its output, and the --strict mode decision that determines how much verification you're actually getting.
1What Actually Happens When You Run mypy
The Type Hints lesson established, correctly but somewhat abstractly, that type hints are only meaningful if 'a static checker like mypy' actually reads and verifies them. This lesson makes that concrete: mypy script.py genuinely analyzes the code ā tracing how values flow through function calls, assignments, and expressions ā without executing any of it, and reports every place where an annotated type doesn't match how a value is actually used. greet(42), where greet is annotated to accept a str, produces a specific, clear error: Argument 1 to "greet" has incompatible type "int"; expected "str" ā caught before the code ever runs, let alone reaches production.
This is genuinely different from a runtime error: mypy's analysis happens entirely statically, examining the source code's structure and the declared types, with no actual execution involved ā which is precisely why it can catch this specific mismatch even in a code path that might rarely execute in practice (an error-handling branch, an edge case), places where a runtime-only testing strategy might never happen to exercise that exact combination of values.
Running mypy regularly ā ideally integrated into your editor for immediate feedback, and definitely as a required CI check (the Pre-commit lesson covers exactly this integration) ā turns the type hints this curriculum has written throughout into a genuine, continuously-enforced correctness guarantee, rather than documentation that happens to also be readable by a checker nobody actually runs.
def greet(name: str) -> str:
return f"Hello, {name}!"
greet(42) # a plain int, where a str was declared
$ mypy script.py
script.py:3: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)error: incompatible type "int"; expected "str" ā caught before any code runs
2Default Mode's Deliberate Leniency: Supporting Gradual Adoption
mypy's default (non---strict) behavior is genuinely, deliberately lenient in a way worth understanding precisely, not just knowing about abstractly: an entirely unannotated function, like process(data) with no type hints at all, receives very little checking by default ā mypy largely leaves it alone, meaning a genuine type error inside it (calling .upper() on a value that turns out to be an int) goes entirely undetected, the exact runtime AttributeError that type hints exist to catch statically slips through unchecked.
This leniency isn't an oversight; it's a deliberate design choice supporting mypy's core adoption story ā the Type Hints lesson's point about *gradual* typing, where annotated and unannotated code coexist freely, requires a checker that doesn't produce an overwhelming wall of errors the moment it's pointed at a large, partially-typed (or entirely untyped) existing codebase. Default mode lets a team introduce mypy incrementally, annotating and gaining verification for one module at a time, without needing to annotate the entire codebase in one disruptive effort just to get mypy running without errors everywhere.
The cost of this leniency, worth being explicit about: default mode's actual verification coverage is only as comprehensive as your codebase's actual annotation coverage. A codebase with type hints on 30% of its functions gets genuine, meaningful checking on that 30%, and essentially none on the remaining 70% ā a fact easy to lose sight of if 'we run mypy in CI' is treated as a blanket assurance of type safety without checking what fraction of the codebase that CI check is actually verifying.
# WITHOUT --strict: this function has NO annotations, and mypy says NOTHING about it
def process(data):
return data.upper()
process(42) # would crash at runtime -- but mypy, by default, doesn't check this AT ALLLargely unchecked by default ā genuine errors inside it can slip through silently
3--strict: Closing the Gap, Deliberately
mypy --strict (or strict = true under [tool.mypy] in pyproject.toml) removes exactly the leniency covered above: it requires every function to carry type annotations (flagging no-untyped-def for any that don't), and it enables a bundle of additional, individually-stricter checks (disallowing implicit Any types in various contexts, requiring more precise generic type usage, and several other specific rules) that default mode leaves relaxed. The practical effect is closing the gap between 'mypy runs successfully' and 'mypy has genuinely, comprehensively verified this codebase' ā under --strict, those two statements become far closer to actually meaning the same thing.
The trade-off, and the reason --strict isn't simply always the obvious right choice from day one: adopting it on an existing, substantially unannotated codebase produces an immediate flood of no-untyped-def errors for every function lacking annotations ā a real, potentially disruptive amount of upfront work to reach a clean --strict pass, which is precisely the disruption default mode's leniency was designed to avoid for teams gradually adopting typing.
The practical, common professional pattern: start with default (lenient) mode to introduce mypy without a disruptive big-bang annotation effort, then deliberately, incrementally tighten toward --strict ā either enabling it codebase-wide once annotation coverage is genuinely comprehensive, or enabling it selectively per-module (mypy supports per-module strictness configuration) so newly-written or already-fully-annotated modules get full --strict rigor immediately, while older, not-yet-annotated modules are addressed on their own timeline rather than blocking the whole team's CI on day one.
[tool.mypy]
strict = true
$ mypy .
script.py:5: error: Function is missing a type annotation [no-untyped-def]
# --strict FORCES annotation coverage -- it won't silently skip unannotated code anymoreCloses the gap ā 'mypy passes' starts to genuinely mean 'comprehensively verified'
4Step-by-Step Breakdown
Every type hint you've written throughout this curriculum has been verified by exactly one thing so far: your own eyes. mypy is what actually checks them.
mypy reads your type hints and verifies they're used CONSISTENTLY throughout your codebase -- catching mismatches before runtime.
Without --strict, mypy is genuinely LENIENT by default -- unannotated functions are mostly ignored entirely, not flagged as a problem.
Checkpoint: Without --strict, what does mypy do with an entirely unannotated function like process(data)?
- āIt mostly skips checking it entirely -- an unannotated function receives very little verification by default
- āIt flags the missing annotations as an error, the same way --strict would
mypy --strict requires EVERY function to be annotated, and enables the full set of stricter checks -- genuinely comprehensive verification, not just 'checks whatever happens to be annotated'.
Checkpoint: What does mypy --strict fundamentally change about how much of a codebase actually gets verified?
- āIt requires every function to be annotated and enables stricter checks throughout, closing the gaps default mode leaves unchecked
- āIt primarily makes mypy run faster, with a similar level of verification
MyPy verifies types statically; Pre-commit is the next lesson, for making sure mypy (and every other check covered in this section) actually runs before code ever reaches a shared branch.
Detect a Real Type Mismatch. Finish would_mypy_flag(): mypy statically compares the declared type against the actual argument type.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Understand exactly how much of your codebase mypy is actually verifying, not just whether it's configured
In default (non-strict) mode, an unannotated function is barely checked at all -- 'mypy runs in CI' is not the same guarantee as 'mypy comprehensively verifies this codebase' unless annotation coverage is genuinely high or --strict is enabled.
Adopt --strict incrementally (per-module, or after achieving broad annotation coverage) rather than avoiding it indefinitely
Default mode's leniency is meant as a gradual-adoption starting point, not a permanent destination -- tightening toward --strict over time closes real gaps default mode leaves unchecked.
Frequent Bugs
Assuming 'mypy passes in CI' means comprehensive type verification, without realizing default (non-strict) mode leaves entirely unannotated functions largely unchecked, letting genuine type errors inside them go undetected indefinitely.
Check actual annotation coverage across the codebase, and adopt --strict (codebase-wide or per-module) to close the gap between 'mypy runs without errors' and 'mypy has genuinely verified this code'.
Real-World Examples
Incrementally Adopting --strict Per-Module
A team wants new code to be held to full --strict standards immediately, while gradually bringing older, not-yet-fully-annotated modules up to the same standard over time.
[tool.mypy]
# Lenient baseline for the whole codebase
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "myapp.new_feature.*"
strict = true # new code: full strict verification from day one
[[tool.mypy.overrides]]
module = "myapp.legacy.*"
disallow_untyped_defs = false # legacy code: not yet fully annotated, addressed over time