Strict Type Checking in Python with mypy
How to configure mypy strict mode for Python projects, handle common type errors, use Protocol and TypeGuard, and integrate with CI/CD.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
mypy is a static type checker for Python. In strict mode, it enforces type annotations on all functions, catches None where int was expected, prevents unsafe Any usage, and flags missing return statements. Strict type checking catches bugs at development time that would otherwise surface in production — AttributeError on None, wrong argument types, unhandled Optional values.
When to Use
- Production Python codebases where runtime bugs are costly
- Libraries and APIs consumed by other teams
- Codebases with complex data flows where types help navigation
- When onboarding new developers — types serve as documentation
- CI/CD pipelines to enforce type safety before merge
When NOT to Use
- Quick scripts or prototypes — the annotation overhead isn’t worth it
- Legacy codebases with no existing annotations — start gradual, not strict
- Jupyter notebooks — mypy doesn’t integrate well with notebook workflows
- When the team has no TypeScript/mypy experience — start with basic mode first
Solution
Install mypy
pip install mypy
# With useful extensions
pip install mypy types-requests types-pyyaml
# Using poetry
poetry add --group dev mypy
Basic configuration
# mypy.ini
[mypy]
python_version = 3.12
strict = True
warn_return_any = True
warn_unused_ignores = True
warn_redundant_casts = True
warn_unreachable = True
disallow_untyped_defs = True
disallow_untyped_decorators = True
disallow_any_generics = True
no_implicit_optional = True
check_untyped_defs = True
show_error_codes = True
show_column_numbers = True
pretty = True
Configuration in pyproject.toml
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_unreachable = true
show_error_codes = true
show_column_numbers = true
pretty = true
# Per-module overrides
[[tool.mypy.overrides]]
module = "legacy.*"
strict = false
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
Annotating functions
from typing import Optional
# BAD — no annotations, mypy strict will error
def process_data(data):
return data["key"]
# GOOD — full annotations
def process_data(data: dict[str, str]) -> str:
return data["key"]
# GOOD — handle Optional explicitly
def find_user(user_id: int) -> Optional[dict[str, str]]:
user = db.get(user_id)
if user is None:
return None
return user
# GOOD — caller handles Optional
user = find_user(42)
if user is not None:
print(user["name"])
Common mypy errors and fixes
error: Missing return statement
# BAD — mypy: Missing return statement
def get_status(code: int) -> str:
if code == 200:
return "OK"
elif code == 404:
return "Not Found"
# What about other codes?
# GOOD — exhaustive return
def get_status(code: int) -> str:
if code == 200:
return "OK"
elif code == 404:
return "Not Found"
return "Unknown"
# GOOD — use assert_never for exhaustive checking
from typing_extensions import assert_never
def get_status(code: int) -> str:
if code == 200:
return "OK"
elif code == 404:
return "Not Found"
else:
assert_never(f"Unexpected code: {code}")
error: Argument has incompatible type
# BAD — mypy: Argument 1 has incompatible type "None"
def greet(name: str) -> str:
return f"Hello, {name}"
user_input: str | None = get_input()
greet(user_input) # Error: None not compatible with str
# GOOD — check for None first
if user_input is not None:
greet(user_input)
error: Item “None” of “Optional[X]” has no attribute
# BAD — mypy: Item "None" of "Optional[User]" has no attribute "name"
user: User | None = get_user(42)
print(user.name) # Error
# GOOD — narrow the type
if user is not None:
print(user.name)
# GOOD — use assert (mypy narrows after assert)
assert user is not None
print(user.name)
error: Returning Any from function
# BAD — mypy: Returning Any from function declared to return str
import json
def get_config() -> str:
return json.loads('{"key": "value"}') # json.loads returns Any
# GOOD — cast or validate
from typing import cast
import json
def get_config() -> dict[str, str]:
data = json.loads('{"key": "value"}')
return cast(dict[str, str], data)
# BETTER — use TypedDict
from typing import TypedDict
class Config(TypedDict):
key: str
def get_config() -> Config:
data = json.loads('{"key": "value"}')
return Config(**data)
Using Protocol for structural typing
from typing import Protocol
class Readable(Protocol):
def read(self, size: int = -1) -> bytes: ...
def process_stream(stream: Readable) -> str:
data = stream.read(1024)
return data.decode("utf-8")
# Any object with a read() method works — no inheritance needed
class MyReader:
def read(self, size: int = -1) -> bytes:
return b"hello"
process_stream(MyReader()) # OK — structurally compatible
process_stream(open("file.txt", "rb")) # OK — file objects have read()
Using TypeGuard for custom type narrowing
from typing import TypeGuard, Any
def is_str_list(value: list[Any]) -> TypeGuard[list[str]]:
return all(isinstance(item, str) for item in value)
def process_items(items: list[Any]) -> None:
if is_str_list(items):
# mypy knows items is list[str] here
for item in items:
print(item.upper()) # OK — str method
Using @overload for multiple signatures
from typing import overload, Literal
@overload
def get_value(key: str, default: str) -> str: ...
@overload
def get_value(key: str, default: int) -> int: ...
@overload
def get_value(key: str, default: None) -> str | None: ...
def get_value(key: str, default: str | int | None) -> str | int | None:
val = db.get(key)
if val is None:
return default
return val
# mypy knows the return type based on the argument
name: str = get_value("name", "anonymous") # OK
count: int = get_value("count", 0) # OK
maybe: str | None = get_value("key", None) # OK
Generic classes
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
# Type inference from usage
stack: Stack[int] = Stack()
stack.push(42)
value = stack.pop() # mypy knows: int | None
Inline type: ignore
# Suppress a specific error code
result = some_untyped_function() # type: ignore[no-untyped-call]
# Suppress all errors on a line (use sparingly)
data = legacy_parse(input_data) # type: ignore
CI/CD integration
# .github/workflows/mypy.yml
name: mypy Type Check
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install mypy
pip install -r requirements.txt
- name: Run mypy
run: mypy src/ --strict --show-error-codes --junit-xml mypy-report.xml
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: mypy-report
path: mypy-report.xml
Pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
args: [--strict, --show-error-codes]
additional_dependencies: [types-requests, types-pyyaml]
exclude: ^(tests/|migrations/)
Variants
Gradual typing for legacy code
# pyproject.toml — start lenient, tighten over time
[tool.mypy]
python_version = "3.12"
disallow_untyped_defs = false # Start here
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
# Tighten specific modules that are already annotated
[[tool.mypy.overrides]]
module = "src.models.*"
strict = true
[[tool.mypy.overrides]]
module = "src.api.*"
strict = true
mypy with Pydantic
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str | None = None
# mypy understands Pydantic models
user = User(id=1, name="Alice")
user_id: int = user.id # OK — mypy knows id is int
user_name: str = user.name # OK
mypy with SQLAlchemy
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# mypy knows the types from Mapped
user = session.get(User, 1)
if user:
print(user.name) # str
print(user.email) # str | None
Best Practices
-
For a deeper guide, see Find Security Issues in Python Code with Bandit.
-
Start with
strict = Truefor new projects — catching issues early is easier than retrofitting -
Use
show_error_codes = True— error codes make# type: ignore[code]precise -
Use
pretty = True— readable output with context lines -
Per-module overrides for legacy code — don’t block the whole codebase on one module
-
Install
types-*stub packages — provides types for third-party libraries -
Use
assert_neverfor exhaustive enum/union checking — catches missing cases -
Run in pre-commit and CI/CD — enforce types before merge
-
Review
# type: ignorecomments periodically — some become unnecessary as types improve
Common Mistakes
- Starting with strict on a legacy codebase: hundreds of errors will overwhelm the team. Start lenient and tighten per-module.
- Using
# type: ignorewithout error codes:# type: ignoresuppresses everything. Use# type: ignore[no-untyped-call]to suppress specific errors. - Not installing type stubs: third-party libraries like
requestsneedtypes-requestsfor mypy to understand them. - Ignoring
warn_unused_ignores: if a# type: ignorebecomes unnecessary, this flag catches it. - Not using
Optionalexplicitly:def f(x: int = None)is not valid in strict mode. Usedef f(x: int | None = None).
FAQ
What is mypy strict mode?
A configuration that enables all type-checking flags: disallow_untyped_defs, warn_return_any, disallow_any_generics, no_implicit_optional, and more. It catches the most type-related bugs but requires full annotations.
How do I handle third-party libraries without type stubs?
Install types-* packages (e.g., types-requests). If no stubs exist, use [[tool.mypy.overrides]] with ignore_missing_imports = true for that module.
What is the difference between mypy and pyright?
mypy is the original Python type checker, slower but stable. pyright (used by Pylance) is faster and more aggressive. Both follow PEP 484. Use mypy for CI/CD, pyright for IDE feedback.
Should I use Optional[X] or X | None?
Both are equivalent. X | None is the modern syntax (Python 3.10+). Optional[X] is the older syntax. Use X | None for new code.
How do I type a dictionary with specific keys?
Use TypedDict:
class UserDict(TypedDict):
id: int
name: str
email: str | None
This gives you type checking on dict keys and values.
Related Resources
Find Security Issues in Python Code with Bandit
How to use Bandit to scan Python code for common security vulnerabilities, configure ignore lists, integrate with CI/CD, and interpret results.
RecipeScan Python Packages for Known CVEs with pip-audit
How to use pip-audit to scan Python dependencies for known vulnerabilities, configure ignore lists, integrate with CI/CD, and remediate findings.
RecipeShare Workflow Logic with GitHub Actions Reusable Workflows
How to create and consume reusable workflows in GitHub Actions, covering inputs, secrets, conditional jobs, matrix strategy, and organization-wide sharing.
RecipeDetect Bugs in Java with SpotBugs Static Analysis
How to configure SpotBugs for Maven and Gradle, interpret bug patterns, suppress false positives, and integrate with CI/CD pipelines.
RecipeStrict TypeScript ESLint Configuration for Production
How to configure typescript-eslint with strict rules for production TypeScript projects, handle type-aware linting, and integrate with CI/CD.