Static Analysis in Practice
Here is a real mypy run on a small project:
$ mypy src/
src/api/routes.py:42: error: Incompatible return value type (got "Optional[User]", expected "User") [return-value]
src/api/routes.py:67: error: Argument 1 to "process" has incompatible type "str | None"; expected "str" [arg-type]
src/db/models.py:15: error: Need type annotation for "cache" (hint: "cache: dict[str, Any] = ...") [var-annotated]
src/utils/helpers.py:8: error: Function is missing a return type annotation [no-untyped-def]
src/utils/helpers.py:23: error: Unsupported operand types for + ("int" and "str") [operator]
Found 5 errors in 3 files (checked 12 source files)
One of these errors is a genuine bug (int + str). Three are annotation gaps. One is a real logic error (returning Optional[User] where User is expected -- a missing None check). This is the power of static analysis: it finds real bugs buried in annotation noise, but only if you know how to configure it and read its output.
This lesson is about making static analysis work in real projects, not toy examples.
What You Will Learn
- How to configure mypy for projects of different sizes and maturity
- pyright as an alternative and how it differs from mypy
- Strict mode vs gradual typing -- when to use each
- Type stubs (
.pyifiles) for untyped libraries - The
py.typedmarker for distributing typed packages - Integrating type checking into CI pipelines
- The most common mypy errors, what they mean, and how to fix them
- A practical strategy for migrating untyped codebases to full type coverage
Prerequisites
- All previous lessons in this module
- Experience with Python packaging (
pyproject.toml,setup.py) - Basic CI/CD concepts (GitHub Actions, GitLab CI, or similar)
- A codebase you would like to type-check (even a small one)
Part 1 -- mypy Configuration
mypy.ini vs pyproject.toml
mypy reads configuration from several files. The two most common:
pyproject.toml (recommended for modern projects):
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
warn_redundant_casts = true
warn_unused_ignores = true
# Per-module overrides:
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "third_party_lib.*"
ignore_missing_imports = true
mypy.ini (standalone file):
[mypy]
python_version = 3.11
warn_return_any = True
disallow_untyped_defs = True
check_untyped_defs = True
[mypy-tests.*]
disallow_untyped_defs = False
[mypy-third_party_lib.*]
ignore_missing_imports = True
Key Configuration Options Explained
| Option | What It Does | Recommended |
|---|---|---|
python_version | Target Python version for type checking | Set to your minimum supported version |
disallow_untyped_defs | Error on functions without type annotations | Yes for new code |
check_untyped_defs | Type-check bodies of unannotated functions | Yes always |
no_implicit_optional | def f(x: str = None) is an error (must be str | None) | Yes |
warn_return_any | Warn when returning Any from a typed function | Yes |
strict_equality | Flag comparisons between incompatible types | Yes |
warn_unused_ignores | Flag # type: ignore comments that are no longer needed | Yes |
ignore_missing_imports | Suppress errors for untyped third-party imports | Per-module only |
strict | Enable ALL strict options at once | For mature projects |
Progressive Configuration Strategy
Start with Phase 1 on existing projects. Fix all errors. Then advance to Phase 2. Only move to Phase 3 (strict = true) when the entire codebase has type annotations. Jumping straight to strict mode on an untyped codebase produces thousands of errors and is demoralizing.
Part 2 -- pyright
pyright vs mypy
pyright (by Microsoft, powers Pylance in VS Code) is a faster alternative with some differences:
| Feature | mypy | pyright |
|---|---|---|
| Language | Python | TypeScript (Node.js) |
| Speed | Moderate (daemon mode helps) | Fast (incremental, parallel) |
| IDE integration | Good (plugins) | Excellent (Pylance) |
| Error messages | Clear but sometimes verbose | Detailed with suggestions |
| Strictness | Configurable via flags | 4 preset modes |
| Plugin system | Yes (mypy plugins) | No |
| Type inference | Good | Stronger in some cases |
pyright Configuration
pyright uses pyrightconfig.json or pyproject.toml:
{
"pythonVersion": "3.11",
"typeCheckingMode": "standard",
"reportMissingImports": true,
"reportMissingTypeStubs": false,
"reportUnusedImport": true,
"reportUnusedVariable": true,
"exclude": [
"**/node_modules",
"**/__pycache__",
"tests/"
]
}
Or in pyproject.toml:
[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "standard"
reportMissingImports = true
reportMissingTypeStubs = false
pyright Strictness Modes
| Mode | Description |
|---|---|
off | No type checking |
basic | Basic checks, few errors on untyped code |
standard | Good balance -- recommended starting point |
strict | All checks enabled -- very strict |
Using Both
Many teams run both mypy and pyright. They catch different issues:
# In CI, run both:
- name: Type check (mypy)
run: mypy src/
- name: Type check (pyright)
run: pyright src/
mypy and pyright can disagree on edge cases. When they do, investigate which is correct. Common differences involve:
- Inference of generic return types
- Protocol compatibility rules
- TypeVar bound resolution
- ParamSpec handling
If you must pick one, pyright is generally stricter and catches more issues. mypy has better plugin support (SQLAlchemy, Django).
Part 3 -- Strict Mode vs Gradual Typing
What Strict Mode Enables
mypy's strict = true turns on all strict flags simultaneously:
[tool.mypy]
strict = true
# Equivalent to enabling ALL of these:
# disallow_untyped_defs = true
# disallow_any_generics = true
# disallow_untyped_calls = true
# disallow_incomplete_defs = true
# disallow_untyped_decorators = true
# no_implicit_optional = true
# no_implicit_reexport = true
# warn_return_any = true
# warn_unused_configs = true
# warn_redundant_casts = true
# warn_unused_ignores = true
# strict_equality = true
# extra_checks = true
Gradual Typing: The Practical Approach
In a real codebase, going from zero types to strict mode is a multi-month project. Here is a gradual approach:
# Step 1: Start loose, type-check what exists
[tool.mypy]
python_version = "3.11"
check_untyped_defs = true
# Everything else default (permissive)
# Step 2: Require types on new code (enforce in PR review)
[[tool.mypy.overrides]]
module = "myapp.new_module.*"
disallow_untyped_defs = true
# Step 3: Expand to more modules as they get typed
[[tool.mypy.overrides]]
module = [
"myapp.core.*",
"myapp.api.*",
"myapp.models.*",
]
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
# Step 4: Eventually go strict
[tool.mypy]
strict = true
[[tool.mypy.overrides]]
module = "myapp.legacy.*"
disallow_untyped_defs = false # Legacy code still exempt
The # type: ignore Escape Hatch
When you cannot fix a type error immediately:
# Acceptable: specific error code, with explanation
result = some_dynamic_call() # type: ignore[no-any-return] # SDK returns Any
# BAD: blanket ignore, no explanation
result = some_dynamic_call() # type: ignore
# Track your ignores:
# $ grep -r "type: ignore" src/ | wc -l
# Goal: reduce this number over time
Every # type: ignore is technical debt. Track them. Each should have:
- A specific error code (
[return-value],[arg-type], etc.) - A comment explaining WHY it is needed
- An associated ticket/issue for eventual resolution
Some teams enforce this with a warn_unused_ignores = true setting and CI checks that count ignore comments.
Part 4 -- Type Stubs
What Type Stubs Are
Type stubs (.pyi files) provide type information for modules that do not have inline annotations:
# mylib.pyi -- type stub for mylib.py
def connect(host: str, port: int, timeout: float = 30.0) -> Connection: ...
def query(conn: Connection, sql: str, params: dict[str, object] | None = None) -> list[dict[str, object]]: ...
class Connection:
host: str
port: int
def close(self) -> None: ...
def execute(self, sql: str) -> int: ...
Where Type Stubs Live
myproject/
├── src/
│ └── mylib/
│ ├── __init__.py
│ └── core.py
├── stubs/
│ └── third_party_lib/
│ ├── __init__.pyi # Stubs for untyped third-party lib
│ └── utils.pyi
└── pyproject.toml
Configure mypy to find stubs:
[tool.mypy]
mypy_path = "stubs"
typeshed: The Standard Library Stubs
typeshed is a repository of type stubs for the Python standard library and popular third-party packages. mypy and pyright bundle it automatically:
# You get types for stdlib without doing anything:
import os
import json
import re
path: str = os.path.join("dir", "file.txt") # mypy knows this returns str
data: dict = json.loads('{"key": "val"}') # mypy knows this returns Any
match: re.Match[str] | None = re.search(r"\d+", "abc123") # Typed!
Installing Third-Party Stubs
# Many popular libraries have stubs on PyPI:
pip install types-requests # Stubs for requests
pip install types-PyYAML # Stubs for PyYAML
pip install types-redis # Stubs for redis
pip install types-Pillow # Stubs for Pillow
# mypy can suggest missing stubs:
# $ mypy src/
# error: Library stubs not installed for "requests"
# (or run "mypy --install-types" to install all missing stub packages)
mypy --install-types # Auto-install all needed stubs
Writing Your Own Stubs
For internal untyped libraries:
# stubs/legacy_lib/__init__.pyi
from typing import Any, Sequence
def init(config: dict[str, Any]) -> None: ...
def process(data: bytes, format: str = "json") -> dict[str, Any]: ...
def batch_process(items: Sequence[bytes]) -> list[dict[str, Any]]: ...
class Client:
def __init__(self, api_key: str, base_url: str = "https://api.example.com") -> None: ...
def get(self, endpoint: str, params: dict[str, str] | None = None) -> dict[str, Any]: ...
def post(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]: ...
Part 5 -- py.typed and Distributing Typed Packages
What py.typed Does
If you distribute a Python package and want users to benefit from your type annotations, include a py.typed marker file:
mypackage/
├── __init__.py
├── core.py
├── utils.py
└── py.typed # Empty file! Just its presence matters.
This tells type checkers: "This package ships its own type information. Do not look for stubs."
Package Setup
# pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "mypackage"
version = "1.0.0"
[tool.setuptools.package-data]
mypackage = ["py.typed"]
PEP 561 Compliance Checklist
To make your package fully PEP 561 compliant:
- Add an empty
py.typedfile in the package root - Include it in your distribution (via
package_dataorMANIFEST.in) - Use inline type annotations (preferred) or ship
.pyistubs alongside.pyfiles - Test with
mypy --strictin your CI - Export public types in
__init__.py
# mypackage/__init__.py
from mypackage.core import Client, Config
from mypackage.utils import Result, Error
# These are available to users:
# from mypackage import Client, Config, Result, Error
# All with full type information
If you publish a library, include py.typed. It is zero effort (an empty file) and dramatically improves the experience for users who use type checkers. Without it, their type checker treats every import from your library as Any.
Part 6 -- CI Integration
GitHub Actions
name: Type Check
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -e ".[dev]"
pip install mypy types-requests types-PyYAML
- name: Run mypy
run: mypy src/ --strict
- name: Run pyright
uses: jakebailey/pyright-action@v2
with:
version: "latest"
GitLab CI
typecheck:
stage: test
image: python:3.11-slim
script:
- pip install -e ".[dev]" mypy
- mypy src/ --strict
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
Pre-commit Hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies:
- types-requests
- types-PyYAML
args: [--strict, --ignore-missing-imports]
Tracking Coverage Over Time
mypy does not have a built-in coverage metric, but you can track typing progress:
# Count untyped definitions:
mypy src/ --disallow-untyped-defs 2>&1 | grep "Function is missing" | wc -l
# Count type: ignore comments:
grep -r "type: ignore" src/ | wc -l
# Track these numbers in CI and trend them downward
Part 7 -- Common mypy Errors and Fixes
error: Incompatible return value type [return-value]
# ERROR:
def get_user(user_id: str) -> User:
return db.find(user_id) # Returns User | None
# FIX (option 1: change return type):
def get_user(user_id: str) -> User | None:
return db.find(user_id)
# FIX (option 2: handle None):
def get_user(user_id: str) -> User:
user = db.find(user_id)
if user is None:
raise ValueError(f"User {user_id} not found")
return user
error: Argument has incompatible type [arg-type]
# ERROR:
def process(name: str) -> None: ...
value: str | None = get_value()
process(value) # str | None is not str
# FIX:
if value is not None:
process(value) # Narrowed to str
error: Need type annotation for variable [var-annotated]
# ERROR:
cache = {}
# FIX:
cache: dict[str, int] = {}
error: Missing return statement [return]
# ERROR:
def classify(x: int) -> str:
if x > 0:
return "positive"
elif x < 0:
return "negative"
# Missing return for x == 0!
# FIX:
def classify(x: int) -> str:
if x > 0:
return "positive"
elif x < 0:
return "negative"
return "zero"
error: Item "None" of "Optional[X]" has no attribute "Y" [union-attr]
# ERROR:
user: User | None = find_user(id)
print(user.name) # user might be None
# FIX:
user: User | None = find_user(id)
if user is not None:
print(user.name)
error: Incompatible types in assignment [assignment]
# ERROR:
x: int = "hello"
# More subtle ERROR:
items: list[int] = [1, 2, 3]
items = ["a", "b", "c"] # list[str] is not list[int]
error: Library stubs not installed [import-untyped]
# ERROR:
import requests # Library stubs not installed for "requests"
# FIX:
pip install types-requests
# Or suppress for this module:
# pyproject.toml:
# [[tool.mypy.overrides]]
# module = "requests.*"
# ignore_missing_imports = true
The type: ignore Best Practices
# GOOD: Specific code, with justification
x = dynamic_lib.call() # type: ignore[no-any-return] # lib returns Any, safe here
# GOOD: Suppress a known mypy limitation
match value: # type: ignore[misc] # mypy does not fully support pattern matching
# BAD: No code, no explanation
x = foo() # type: ignore
# WORSE: Hiding a real bug
x: int = "hello" # type: ignore
Part 8 -- Migration Strategy for Untyped Codebases
The 4-Phase Approach
Phase 1: Baseline (Week 1)
# Start with the absolute minimum:
[tool.mypy]
python_version = "3.11"
check_untyped_defs = true
Run mypy. Fix the errors it finds -- these are likely real bugs (wrong types, missing attributes, unreachable code). Do not add any annotations yet.
Phase 2: Boundaries (Weeks 2-4)
Type the entry points of your application first:
# Before:
def create_user(data):
name = data["name"]
email = data["email"]
return {"id": generate_id(), "name": name, "email": email}
# After:
from typing import TypedDict
class CreateUserInput(TypedDict):
name: str
email: str
class UserResponse(TypedDict):
id: str
name: str
email: str
def create_user(data: CreateUserInput) -> UserResponse:
name = data["name"]
email = data["email"]
return {"id": generate_id(), "name": name, "email": email}
Priority order for typing:
- API route handlers
- Data models (dataclasses, Pydantic, TypedDicts)
- Public function signatures in core modules
- Configuration loading
Phase 3: Core (Weeks 4-8)
Enable disallow_untyped_defs module by module:
[[tool.mypy.overrides]]
module = [
"myapp.api.*",
"myapp.models.*",
"myapp.services.*",
]
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
Track progress:
# Script to count typing progress
echo "Typed functions:"
mypy src/ --disallow-untyped-defs 2>&1 | grep -c "Function is missing"
echo "Type ignores:"
grep -rc "type: ignore" src/ | awk -F: '{sum += $2} END {print sum}'
Phase 4: Strict (Ongoing)
[tool.mypy]
strict = true
# Only legacy code gets exceptions:
[[tool.mypy.overrides]]
module = "myapp.legacy_module"
disallow_untyped_defs = false
disallow_any_generics = false
Handling Third-Party Libraries
# For typed libraries -- nothing to do:
import fastapi # Has py.typed, works out of the box
# For libraries with stubs:
import requests # pip install types-requests
# For completely untyped libraries:
import some_old_lib # type: ignore[import-untyped]
# Better: write minimal stubs
# stubs/some_old_lib/__init__.pyi
def main_function(arg: str) -> dict[str, object]: ...
Measuring Success
| Metric | How to Measure | Target |
|---|---|---|
| Type coverage | % of functions with annotations | >90% |
| Type errors | Count from mypy output | 0 |
| type: ignore count | grep count | Decreasing weekly |
| CI pass rate | Type check step | 100% |
| Bug detection | Bugs found by mypy pre-merge | Track and celebrate |
Key Takeaways
- Configure mypy progressively: start with
check_untyped_defs, advance todisallow_untyped_defs, end atstrict - pyright is faster and stricter than mypy; consider running both in CI
- Use per-module overrides to apply different strictness levels across your codebase
- Type stubs (
.pyifiles) provide types for untyped libraries; install from PyPI or write your own - Include
py.typedin any package you distribute to enable type checking for your users - CI integration is non-negotiable: type checking catches bugs before code review
- Every
# type: ignoreshould have a specific error code and justification - Migrate gradually: baseline, boundaries, core, strict -- do not try to type everything at once
- Track typing metrics (coverage, error count, ignore count) and trend them in the right direction
- The goal is not perfect types -- it is catching bugs before they reach production
Graded Practice Challenges
Level 1 -- Predict the Type Checker Output
Question 1: Which mypy error code does this produce?
def get_name(user: dict[str, str] | None) -> str:
return user["name"]
Answer
Two errors:
[union-attr]: ItemNoneofdict[str, str] | Nonehas no attribute__getitem__. mypy sees thatusermight beNone, and you cannot indexNone.- Alternatively, depending on mypy version, it may report
[index]for indexing into a possibly-None value.
Fix: Add a None check:
def get_name(user: dict[str, str] | None) -> str:
if user is None:
return "Anonymous"
return user["name"]
Question 2: Does this pass under strict = true?
def double(x):
return x * 2
result = double(21)
Answer
No. Under strict mode, disallow_untyped_defs = true is enabled. The function double has no type annotations, so mypy reports: Function is missing a type annotation [no-untyped-def].
Fix:
def double(x: int) -> int:
return x * 2
Question 3: You see this mypy output. What is the root cause?
error: Incompatible types in assignment (expression has type "list[str]", variable has type "list[int]") [assignment]
def process(mode: str) -> list[int]:
if mode == "names":
return ["alice", "bob"] # Line with error
return [1, 2, 3]
Answer
The function declares -> list[int] but the "names" branch returns list[str]. This is a genuine bug -- the function's return type is wrong or the branch logic is wrong.
Fix (if the function should handle both):
def process(mode: str) -> list[int] | list[str]:
if mode == "names":
return ["alice", "bob"]
return [1, 2, 3]
Or use @overload with Literal for precise typing (see Lesson 5).
Level 2 -- Debug and Fix
This mypy configuration produces unexpected behavior. The team reports that mypy is not catching obvious bugs in the utils module. Diagnose and fix the configuration:
[tool.mypy]
python_version = "3.11"
strict = true
[[tool.mypy.overrides]]
module = "myapp.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "myapp.api.*"
disallow_untyped_defs = true
# myapp/utils/helpers.py
def add(a, b): # No type annotations
return a + b # Could be int + str -- a real bug
# myapp/api/routes.py
def get_user(user_id: str) -> dict:
return {"id": user_id, "name": None}
Answer
The problem is the first override: ignore_errors = true for myapp.*. This tells mypy to suppress all errors for the entire myapp package. The second override for myapp.api.* enables disallow_untyped_defs, but ignore_errors = true from the parent pattern still suppresses the actual errors.
Fix:
[tool.mypy]
python_version = "3.11"
strict = true
# Remove the blanket ignore_errors override!
# Only suppress specific issues where needed:
[[tool.mypy.overrides]]
module = "myapp.legacy_module"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = "third_party.*"
ignore_missing_imports = true
Key lesson: ignore_errors = true silences ALL type errors, making mypy useless for that module. Never use it on broad patterns. Use specific suppressions (disallow_untyped_defs = false, ignore_missing_imports = true) instead.
Level 3 -- Design Challenge
You have been tasked with adding type checking to an existing 50,000-line Python project with zero type annotations. The project has:
- A FastAPI API layer (15 modules)
- A SQLAlchemy ORM layer (10 models)
- A business logic layer (20 modules)
- Utility functions (10 modules)
- Tests (30 modules)
Design a complete migration plan:
- Create the mypy configuration (pyproject.toml) with appropriate phases
- Design the CI pipeline that enforces typing without blocking all PRs
- Determine the order of modules to type
- Estimate the timeline
Hint
# Phase 1 -- Week 1-2: Baseline
[tool.mypy]
python_version = "3.11"
check_untyped_defs = true
warn_return_any = true
warn_unused_configs = true
# Ignore all third-party imports initially:
[[tool.mypy.overrides]]
module = [
"sqlalchemy.*",
"fastapi.*",
# ... other third-party
]
ignore_missing_imports = true
# Phase 2 -- Week 3-6: API and Models
# Add to each module as it gets typed:
[[tool.mypy.overrides]]
module = [
"myapp.api.*",
"myapp.models.*",
]
disallow_untyped_defs = true
no_implicit_optional = true
CI strategy:
# Run mypy but don't block on existing errors:
# Week 1-2: mypy runs, report only (allow_failure: true)
# Week 3-6: mypy blocks on typed modules only
# Week 7+: mypy blocks on everything
typecheck:
script:
- mypy src/ --no-error-summary 2>&1 | tee mypy-report.txt
- python scripts/check_mypy_regression.py mypy-report.txt
# check_mypy_regression.py: fail if error count increased from last run
Order: Models first (data definitions), then API routes (boundaries), then services (business logic), then utils, then tests last.
Timeline: 8-12 weeks for a team of 3-4 developers, working on typing alongside normal feature work.
What's Next
This concludes Module 2: Advanced Type System. You now have the tools to write fully typed Python code that catches bugs at development time, validates data at system boundaries, and scales across large codebases.
In the next module, we move to Advanced Async and Concurrency, where you will master async generators, structured concurrency, custom awaitables, and the patterns that make concurrent Python code both correct and performant.
