Skip to main content

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 (.pyi files) for untyped libraries
  • The py.typed marker 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

OptionWhat It DoesRecommended
python_versionTarget Python version for type checkingSet to your minimum supported version
disallow_untyped_defsError on functions without type annotationsYes for new code
check_untyped_defsType-check bodies of unannotated functionsYes always
no_implicit_optionaldef f(x: str = None) is an error (must be str | None)Yes
warn_return_anyWarn when returning Any from a typed functionYes
strict_equalityFlag comparisons between incompatible typesYes
warn_unused_ignoresFlag # type: ignore comments that are no longer neededYes
ignore_missing_importsSuppress errors for untyped third-party importsPer-module only
strictEnable ALL strict options at onceFor mature projects

Progressive Configuration Strategy

tip

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:

Featuremypypyright
LanguagePythonTypeScript (Node.js)
SpeedModerate (daemon mode helps)Fast (incremental, parallel)
IDE integrationGood (plugins)Excellent (Pylance)
Error messagesClear but sometimes verboseDetailed with suggestions
StrictnessConfigurable via flags4 preset modes
Plugin systemYes (mypy plugins)No
Type inferenceGoodStronger 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

ModeDescription
offNo type checking
basicBasic checks, few errors on untyped code
standardGood balance -- recommended starting point
strictAll 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/
note

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
danger

Every # type: ignore is technical debt. Track them. Each should have:

  1. A specific error code ([return-value], [arg-type], etc.)
  2. A comment explaining WHY it is needed
  3. 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:

  1. Add an empty py.typed file in the package root
  2. Include it in your distribution (via package_data or MANIFEST.in)
  3. Use inline type annotations (preferred) or ship .pyi stubs alongside .py files
  4. Test with mypy --strict in your CI
  5. 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
tip

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:

  1. API route handlers
  2. Data models (dataclasses, Pydantic, TypedDicts)
  3. Public function signatures in core modules
  4. 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

MetricHow to MeasureTarget
Type coverage% of functions with annotations>90%
Type errorsCount from mypy output0
type: ignore countgrep countDecreasing weekly
CI pass rateType check step100%
Bug detectionBugs found by mypy pre-mergeTrack and celebrate

Key Takeaways

  • Configure mypy progressively: start with check_untyped_defs, advance to disallow_untyped_defs, end at strict
  • 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 (.pyi files) provide types for untyped libraries; install from PyPI or write your own
  • Include py.typed in 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: ignore should 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:

  1. [union-attr]: Item None of dict[str, str] | None has no attribute __getitem__. mypy sees that user might be None, and you cannot index None.
  2. 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:

  1. Create the mypy configuration (pyproject.toml) with appropriate phases
  2. Design the CI pipeline that enforces typing without blocking all PRs
  3. Determine the order of modules to type
  4. 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.

© 2026 EngineersOfAI. All rights reserved.