Integrating Mypy into CI for Python Static Type Checking

Integrating Mypy into CI for Python Static Type Checking

Mypy is Python's most widely used static type checker. It catches type errors before runtime — wrong argument types, missing attributes, incorrect return types — the kind of bugs that slipped through Python's dynamic typing for decades. Integrating Mypy into CI transforms it from a developer convenience into an enforced quality gate.

Why Mypy in CI Matters

Running Mypy locally is optional. Running it in CI makes it mandatory. Every pull request gets checked, every team member's code gets validated, and type regressions can't quietly sneak into main.

A CI Mypy check typically catches:

  • Missing return types on new functions
  • Arguments passed in wrong order when signatures change
  • None dereferenced without a null check
  • Wrong dict/list element types passed to typed functions
  • API changes in dependencies that break callers

Installing and Configuring Mypy

pip install mypy

# Or with extras for common libraries
pip install <span class="hljs-string">"mypy[reports]"

The recommended configuration lives in pyproject.toml:

[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
show_error_codes = true
ignore_missing_imports = false

# Per-module overrides for gradual adoption
[[tool.mypy.overrides]]
module = "legacy_module.*"
ignore_errors = true

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false

Or in mypy.ini:

[mypy]
python_version = 3.12
strict = True
warn_return_any = True
show_error_codes = True

[mypy-legacy_module.*]
ignore_errors = True

Running Mypy

# Check a single file
mypy src/mymodule.py

<span class="hljs-comment"># Check entire source directory
mypy src/

<span class="hljs-comment"># Check with explicit config
mypy --config-file mypy.ini src/

<span class="hljs-comment"># Incremental check (cache-aware, faster on re-runs)
mypy --incremental src/

Basic Type Annotations

Mypy works with Python's built-in type hints:

# Before — no types, Mypy can't check
def process_order(order_id, quantity, discount):
    if discount < 0:
        raise ValueError("Discount can't be negative")
    return order_id, quantity * (1 - discount)

# After — typed, Mypy validates callers
def process_order(
    order_id: str,
    quantity: int,
    discount: float = 0.0
) -> tuple[str, float]:
    if discount < 0:
        raise ValueError("Discount can't be negative")
    return order_id, quantity * (1 - discount)

Mypy now catches:

process_order(123, 5)  # error: Argument 1 has type "int"; expected "str"
process_order("ord-1", 3.5)  # error: Argument 2 has type "float"; expected "int"

Handling Optional and None

The most common Mypy errors involve None:

from typing import Optional

def find_user(user_id: str) -> Optional[User]:
    return db.get(user_id)

user = find_user("u-123")
print(user.name)  # error: Item "None" of "Optional[User]" has no attribute "name"

# Fix: check before use
if user is not None:
    print(user.name)  # OK

# Or use the walrus operator
if user := find_user("u-123"):
    print(user.name)

Setting Up Stub Files for Third-Party Libraries

Many libraries don't ship type annotations. Install stubs from typeshed:

pip install types-requests types-PyYAML types-redis types-boto3

# Check what stubs are available
pip install types-

For libraries without stubs, create a .pyi stub or configure Mypy to ignore them:

[[tool.mypy.overrides]]
module = "unstubbed_library"
ignore_missing_imports = true

GitHub Actions Integration

# .github/workflows/type-check.yml
name: Type Check
on: [push, pull_request]

jobs:
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install mypy types-requests types-PyYAML
      
      - name: Run Mypy
        run: mypy src/ --strict --show-error-codes

Speeding Up CI with Caching

Mypy's incremental mode caches results in .mypy_cache/. Cache this in CI:

- name: Cache Mypy
  uses: actions/cache@v4
  with:
    path: .mypy_cache
    key: mypy-${{ hashFiles('requirements*.txt', 'pyproject.toml') }}
    restore-keys: mypy-

- name: Run Mypy
  run: mypy --incremental src/

With a warm cache, Mypy only re-checks changed files — cutting check time from minutes to seconds on large codebases.

Gradual Adoption Strategy

Enabling strict mode on a large existing codebase produces thousands of errors. Use a phased approach:

Phase 1 — Baseline: ignore all errors in existing code, check only new files:

[tool.mypy]
# Minimum settings
check_untyped_defs = false
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = "myapp.legacy.*"
ignore_errors = true

Phase 2 — Tighten gradually: enable one rule at a time:

[tool.mypy]
warn_return_any = true
disallow_any_generics = true
# Add one at a time

Phase 3 — Full strict mode when the codebase is clean.

Track error counts by module in CI to prevent regression:

# Fail if error count increases (no new type errors allowed)
ERRORS=$(mypy src/ 2>&1 <span class="hljs-pipe">| <span class="hljs-built_in">tail -1 <span class="hljs-pipe">| grep -oP <span class="hljs-string">'\d+ error' <span class="hljs-pipe">| <span class="hljs-built_in">head -1)
<span class="hljs-built_in">echo <span class="hljs-string">"Type errors: $ERRORS"

Mypy in Pre-commit Hooks

Catch type errors before they reach CI:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.9.0
    hooks:
      - id: mypy
        args: [--strict, --show-error-codes]
        additional_dependencies: [types-requests, types-PyYAML]

Common Mypy Patterns

TypedDict for dictionary type safety:

from typing import TypedDict

class UserData(TypedDict):
    id: str
    name: str
    email: str

def create_user(data: UserData) -> User:
    return User(**data)

Protocol for structural subtyping:

from typing import Protocol

class Serializable(Protocol):
    def to_dict(self) -> dict[str, object]: ...

def serialize(obj: Serializable) -> str:
    return json.dumps(obj.to_dict())

Overload for functions with multiple signatures:

from typing import overload

@overload
def get_value(key: str, default: None = None) -> str | None: ...
@overload
def get_value(key: str, default: str) -> str: ...

def get_value(key: str, default: str | None = None) -> str | None:
    return cache.get(key, default)

Combining Mypy with Runtime Validation

Mypy checks types statically at write time. For validating types at runtime boundaries (HTTP requests, file parsing), combine with Pydantic or beartype:

from pydantic import BaseModel

class CreateOrderRequest(BaseModel):
    product_id: str
    quantity: int
    discount: float = 0.0

# Mypy validates the model definition
# Pydantic validates incoming data at runtime

Connecting Type Checking to End-to-End Quality

Mypy gates type correctness at the code level. For verifying that your typed Python services actually behave correctly in production — that API endpoints accept the right payloads, that integrations work end-to-end — HelpMeTest provides behavioral testing on top of your type-safe foundation.

Summary

  • Configure Mypy in pyproject.toml — keep configuration checked into version control
  • Start with check_untyped_defs = false on existing codebases; tighten over time
  • Cache .mypy_cache/ in CI for incremental mode — major speed improvement on re-runs
  • Install types-* stubs for common libraries before enabling ignore_missing_imports = false
  • Pre-commit hooks catch type errors locally; CI enforces them for everyone
  • Optional[T] and T | None require explicit null checks before attribute access — Mypy enforces this

Read more