mypy — Make Python’s Type Hints Actually Do Something
A complete guide to static type checking in Python: from your first annotation to strict mode, Protocols, TypedDict, mypyc, and everything…
mypy — Make Python’s Type Hints Actually Do Something
A complete guide to static type checking in Python: from your first annotation to strict mode, Protocols, TypedDict, mypyc, and everything new in mypy 1.19. 🔍
Here’s a scenario you’ve probably lived through.
You write a function. It works. You call it from somewhere else six weeks later, pass in a None by accident, and get a beautiful AttributeError: 'NoneType' object has no attribute 'strip' at 4pm on a Friday. You spend 20 minutes tracing back through the call chain wondering where the None came from.
Now here’s the thing — Python knew that could happen. You just didn’t ask it to tell you.

mypy is the tool that asks. It reads your Python type annotations and reasons about your code before you run it, catching exactly these kinds of bugs at the point where you wrote the mistake — not at 4pm on a Friday when a user hits it.
As of mypy 1.19.1 (December 2025), mypy is faster and more capable than ever. This guide walks you through everything: why type checking matters, how to read and write annotations, every major feature, the modern Python typing ecosystem, and what’s new in recent releases.
Wait — Doesn’t Python Already Have Type Hints?
Yes! Python has had type annotations since Python 3.5 (PEP 484). You can write this:
def greet(name: str) -> str:
return "Hello, " + name
And Python… completely ignores those annotations at runtime. They’re just metadata. Nothing stops you from calling greet(42) — Python will happily try to concatenate "Hello, " with 42 and crash.
That’s where mypy comes in. mypy is a static type checker — it reads your annotations and your code, and before you run anything, it tells you where types don’t match up. It’s like a spell-checker for your type logic.
The best part: it’s entirely optional and incremental. You can add it to an existing project file by file, line by line. No big rewrite needed.
Installation
pip install mypy
# or with uv
uv add mypy --dev
Check your version:
uv run mypy --version
# mypy 1.19.1 (compiled: yes)
The (compiled: yes) means you're using the mypyc-compiled binary — significantly faster than the pure Python version.
Your First Type-Checked File
# greet.py
def greet(name: str) -> str:
return "Hello, " + name
result = greet("Alice") # ✅ fine
wrong = greet(42) # ❌ mypy will catch this
Run mypy:
uv run mypy src/topic_mypy/greet.py

That’s mypy’s core value proposition — it found the bug before you ran the code. No test needed, no runtime, no user report.
Reading mypy’s Output
mypy errors follow a consistent format:
filename.py:LINE: error: MESSAGE [ERROR-CODE]
For example:
src/topic_mypy/greet.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
**greet.py:4** — exactly where the problem is**error:** — severity (could also bewarning:ornote:)**[arg-type]** — the error code, which you can use to selectively ignore or filter errors
Clean, precise, and actionable. This is one of mypy’s real strengths — the error messages are genuinely readable.
The Basic Type Annotations
Built-in types
name: str = "Alice"
age: int = 30
height: float = 5.9
active: bool = True
data: bytes = b"hello"
Collections
# Modern syntax (Python 3.9+) — no need to import from typing
names: list[str] = ["Alice", "Bob"]
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
coords: tuple[float, float] = (1.0, 2.0)
unique: set[str] = {"a", "b", "c"}
# Variable-length tuple
items: tuple[int, ...] = (1, 2, 3, 4)
Optional values (None-able)
# These two are equivalent — use | syntax in Python 3.10+
from typing import Optional
def find_user(user_id: int) -> Optional[str]: # classic
...
def find_user(user_id: int) -> str | None: # modern, preferred
...

With Naming conflict

Non Naming conflict
Optional[str] means "either a str or None." This is one of the most important annotations — it forces you to handle the None case explicitly everywhere you use the return value.
Union types
from typing import Union
# Old style
def process(value: Union[str, int]) -> str: ...
# Modern style (Python 3.10+)
def process_(value: str | int) -> str: ...
Function Annotations in Depth
from collections.abc import Callable, Iterator, Generator
# A function that takes another function as an argument
def apply(func: Callable[[int], str], value: int) -> str:
return func(value)
# A generator function
def count_up(start: int) -> Generator[int, None, None]:
while True:
yield start
start += 1
# *args and **kwargs
def log(*args: str, **kwargs: int) -> None:
...
# Return nothing
def setup() -> None:
...
TypedDict: Typing Your Dictionaries 📖
One of the most practically useful features for real-world code. If you pass dictionaries around as structured data, TypedDict gives mypy the structure to check against:
from typing import TypedDict
class UserConfig(TypedDict):
name: str
age: int
email: str
def send_welcome(config: UserConfig) -> None:
print(f"Welcome, {config['name']}!")
print(f"Sending to {config['email']}")
# ✅ Fine
send_welcome({"name": "Alice", "age": 30, "email": "alice@example.com"})
# ❌ mypy error: missing key 'email'
send_welcome({"name": "Bob", "age": 25})
# ❌ mypy error: value of 'age' has wrong type (str instead of int)
send_welcome({"name": "Carol", "age": "thirty", "email": "carol@example.com"})
You can also mark some keys as optional:
from typing import TypedDict, NotRequired
class UserConfig(TypedDict):
name: str
email: str
age: NotRequired[int] # this key doesn't have to be present

dataclasses and Type Safety
Python’s dataclasses work beautifully with mypy:
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
tags: list[str] = field(default_factory=list)
in_stock: bool = True
# mypy knows the types of all fields
p = Product(name="Widget", price=9.99)
print(p.price + 1) # ✅ float + int, fine
print(p.price + "1") # ❌ mypy error: can't add float and str
Since you’re writing the field types anyway with dataclasses, mypy coverage comes almost for free.
Protocols: Duck Typing With Type Safety 🦆
This is one of mypy’s most powerful — and underused — features. In Python, you don’t need to inherit from a class to be “compatible” with it. If an object has the right methods, it works. Protocols let you express this pattern with full type safety.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def draw(self) -> None:
print("Drawing square")
def resize(self, factor: float) -> None:
self.side *= factor
# Works with any object that has draw() and resize()
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # ✅
render(Square()) # ✅
render("hello") # ❌ mypy error: str doesn't have draw/resize
Circle and Square don't inherit from Drawable. They don't even know about it. As long as they structurally match the Protocol, mypy considers them compatible. This is structural subtyping — Python's natural duck-typing, made statically verifiable.

Generics: Writing Flexible, Type-Safe Code
from typing import TypeVar, Generic
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:
return self._items.pop()
if __name__ == '__main__':
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
value: int = int_stack.pop() # mypy knows this is int
int_stack.push("hello") # ❌ mypy error: expected int, got str
Python 3.12 introduced the new, cleaner generic syntax:
# Python 3.12+ syntax — no TypeVar import needed
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
Narrowing: mypy Follows Your Logic
One of mypy’s genuinely impressive capabilities is type narrowing — it tracks how isinstance checks, if conditions, and assert statements change what types are possible at each point in your code.
def process(value: str | int | None) -> str:
if value is None:
return "nothing"
# mypy knows: value is str | int here
if isinstance(value, int):
return str(value * 2)
# mypy knows: value is str here
return value.upper() # ✅ mypy is certain this is str
# assert also narrows
def get_user(user_id: int) -> str | None:
...
if __name__ == '__main__':
user = get_user(1)
assert user is not None # mypy narrows to str after this line
print(user.upper()) # ✅ safe
This is what makes mypy feel smart rather than just pedantic. It doesn’t just match types mechanically — it reasons about your code’s control flow.

Literal Types: When a Value Matters, Not Just the Type
from typing import Literal
Direction = Literal["north", "south", "east", "west"]
def move(direction: Direction) -> None:
print(f"Moving {direction}")
move("north") # ✅
move("up") # ❌ mypy error: "up" is not a valid Direction
# Great for status codes, modes, states
Status = Literal["pending", "active", "cancelled"]
def update_order(order_id: int, status: Status) -> None:
...
update_order(1, "active") # ✅
update_order(1, "deleted") # ❌ mypy catches the typo
Literal is excellent for preventing magic string bugs — the kind where you typo a status value and it silently does nothing until you notice the data is wrong.
Final and ClassVar
from typing import Final, ClassVar
MAX_RETRIES: Final = 3 # cannot be reassigned
MAX_RETRIES = 5 # ❌ mypy error: cannot assign to Final
class Config:
debug: ClassVar[bool] = False # class-level, not instance-level
name: str # instance-level
Config.debug = True # ✅ ClassVar, fine
instance = Config()
instance.debug = True # ❌ mypy error: cannot assign to ClassVar via instance
cast and # type: ignore: Escape Hatches
Sometimes you know more than mypy does. These are your escape valves:
from typing import cast
# You know the return type, mypy doesn't
result = cast(str, some_opaque_function())
# Suppress a specific error on one line
x = legacy_function() # type: ignore[return-value]
# Suppress all errors on one line (use sparingly)
x = legacy_function() # type: ignore
# type: ignore[error-code] is preferred over bare # type: ignore — being specific means you'll notice if the underlying issue changes. mypy 1.15+ warns about unused # type: ignore comments when you pass --warn-unused-ignores, which helps you clean them up over time.
🆕 mypy 1.18: ~40% Performance Boost
mypy 1.18 delivered roughly a 40% speedup compared to 1.17 when type checking mypy itself, with some extreme cases showing improvements of 10× or higher. This came from a combination of type caching optimizations and mypyc-level improvements.
For large codebases where mypy was previously slow enough to feel like a CI bottleneck, this is a meaningful change.
🆕 mypy 1.18/1.19: Binary Cache Format
mypy 1.18 introduced a new binary fixed-format cache as an experimental feature. In mypy 1.19, this feature graduated from experimental — the team is planning to enable it by default in mypy 1.20.
Enable it now:
mypy --fixed-format-cache myapp/
Or in config:
[mypy]
fixed_format_cache = true
The binary cache makes incremental builds up to twice as fast and uses less space than the original JSON-based format. For large monorepos or projects with long incremental check times, this is worth enabling today.
🆕 mypy 1.19: Disjoint Bases (PEP 800)
mypy 1.19 added support for disjoint bases via PEP 800 — it recognizes the @disjoint_base decorator and rejects class definitions that combine mutually incompatible base classes.
from typing import disjoint_base
@disjoint_base
class A: ...
@disjoint_base
class B: ...
class C(A, B): ... # ❌ mypy error: A and B are disjoint, cannot combine
This is useful for modeling mutually exclusive taxonomies — database backends, serialization formats, platform-specific implementations — where combining base classes is semantically meaningless.
🆕 mypy 1.15: --strict-bytes Flag
By default, mypy treats bytearray and memoryview as assignable to the bytes type for historical reasons. mypy 1.15 introduced --strict-bytes to disable this behavior, aligned with PEP 688 — and this flag will become the default in mypy 2.0.
mypy --strict-bytes myapp/
If you work with binary data and want accurate type checking for bytes-like types, enable this now rather than waiting for it to be a breaking change in 2.0.
Configuration: pyproject.toml
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true
warn_redundant_casts = true
no_implicit_reexport = true
show_error_codes = true
pretty = true
# Per-module overrides (great for gradual adoption)
[[tool.mypy.overrides]]
module = "legacy_module.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
The strict flag is a shorthand that enables a bundle of strict checks:
Flag enabled by strict What it catches disallow_untyped_defs Functions with missing annotations disallow_any_generics Generic types used without parameters (e.g., list not list[str]) warn_return_any Returning Any when a specific type is expected no_implicit_optional Parameters defaulting to None must be Optional explicitly strict_equality Nonsensical equality comparisons between incompatible types
Start with strict = false and individual flags. Graduate to strict = true when your codebase is fully annotated.
Gradual Typing: The Practical Adoption Path
mypy is designed for incremental adoption. You don’t type-check all or nothing.
Step 1: Start with --ignore-missing-imports Many third-party libraries don't ship type stubs yet. This flag stops mypy from complaining about them.
mypy myapp/ --ignore-missing-imports
Step 2: Run mypy per-file or per-module
mypy myapp/models.py # just one file
mypy myapp/services/ # just one directory
Step 3: Use # type: ignore to silence legacy problem spots Mark the known-bad lines and move on. You can come back and fix them later.
Step 4: Enable stricter flags one at a time
mypy myapp/ --disallow-untyped-defs # annotate all functions first
mypy myapp/ --warn-return-any # next step
mypy myapp/ --strict # eventual goal
Step 5: Track coverage with mypy’s stats
mypy myapp/ --any-exprs-report .
This produces a report showing what percentage of your codebase is Any (untyped) — a useful metric to track over time.
Stub Files: When Libraries Don’t Have Types
If a library doesn’t ship types, you have a few options:
1. Install a stubs package (most common)
pip install pandas-stubs
pip install types-requests
pip install types-PyYAML
Many popular libraries have community-maintained stubs in the typeshed project or separately on PyPI as types-* packages.
2. Write your own stub file
# requests.pyi (stub for a module)
from typing import Any
def get(url: str, **kwargs: Any) -> Response: ...
def post(url: str, **kwargs: Any) -> Response: ...
class Response:
status_code: int
text: str
def json(self) -> Any: ...
3. Use py.typed marker If you're a library author, add an empty py.typed file to your package to tell mypy and other checkers that your library ships inline types:
mypackage/
├── __init__.py
├── py.typed ← empty file, signals type-checker support
└── core.py
Running mypy in CI
# GitHub Actions
- name: Type check
run: mypy myapp/ --strict
# Or with uvx for zero-install
- name: Type check
run: uvx mypy myapp/ --strict
# Pre-commit hook (.pre-commit-config.yaml)
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.19.1
hooks:
- id: mypy
additional_dependencies: [types-requests, pandas-stubs]
mypyc: Compile Python to C Extensions
This one is genuinely cool. mypy ships with mypyc, a compiler that uses your type annotations to compile Python modules into C extensions — often achieving 2–5× speedups with zero code changes.
pip install mypy[mypyc]
# Compile a module
mypyc myapp/hot_path.py
# Run — Python automatically loads the compiled .so file
python -c "import myapp.hot_path"
mypy itself is compiled with mypyc, which is why (compiled: yes) matters in the version output. If you have CPU-bound Python code with complete type annotations, mypyc is worth benchmarking.
mypy vs. The Alternatives
vs. Pyright / Pylance: Pyright (Microsoft, powers Pylance in VS Code) is mypy’s strongest competitor. It’s faster for large codebases and has better IDE integration by default. Where mypy wins: it’s been around longer, has broader community adoption, more mature plugin support, and mypyc for compilation. Most teams pick one and stick with it. Pyright has stricter defaults in some areas; mypy is more configurable for gradual adoption.
vs. Pytype (Google): Google’s type inference tool. Does more automatic inference (can type-check unannotated code) but slower and less widely adopted. Good for legacy codebases with no annotations.
vs. Pyre (Meta): Meta’s type checker, written in OCaml. Very fast, designed for massive monorepos. Harder to set up outside Meta’s toolchain.
vs. Ruff’s type linting: Ruff checks some type-annotation-related patterns (as lint rules) but is not a type checker. mypy and Ruff are complementary — use both.
The honest summary: mypy or Pyright for most teams. If you’re on VS Code and want the best IDE experience right now, Pyright/Pylance is excellent. If you want CI integration, plugin support (Django, Pydantic, SQLAlchemy), and mypyc, mypy is the richer ecosystem.
mypy Plugins: Framework-Aware Type Checking
mypy supports plugins that teach it the type semantics of specific frameworks:
[tool.mypy]
plugins = [
"mypy_django_plugin.main",
"pydantic.mypy",
]
django-stubs + mypy-django-plugin — understands QuerySet types, model fields, request.user, and more. Without the plugin, mypy treats Django ORM operations as returning Any.
pydantic.mypy — understands Pydantic model fields, validators, and model_config. Pydantic v2 ships with first-class mypy support built in.
SQLAlchemy — sqlalchemy[mypy] adds a plugin that understands mapped columns and relationships.
Real-World Workflow
Here’s what a fully typed FastAPI project looks like with mypy:
# models.py
from pydantic import BaseModel
from typing import Annotated
from datetime import datetime
class UserCreate(BaseModel):
name: str
email: str
age: int | None = None
class UserResponse(BaseModel):
id: int
name: str
email: str
created_at: datetime
# services.py
from .models import UserCreate, UserResponse
from .database import Session
def create_user(db: Session, data: UserCreate) -> UserResponse:
user = db.add_user(
name=data.name,
email=data.email,
age=data.age,
)
return UserResponse(
id=user.id,
name=user.name,
email=user.email,
created_at=user.created_at,
)
# app.py
from fastapi import FastAPI, Depends
from .services import create_user
from .models import UserCreate, UserResponse
app = FastAPI()
@app.post("/users", response_model=UserResponse)
def register(data: UserCreate, db=Depends(get_db)) -> UserResponse:
return create_user(db, data)
mypy . --strict
# Success: no issues found in 8 source files
The entire request/response cycle is type-safe. If you change UserCreate to make a field optional, mypy immediately tells you everywhere in your codebase that assumed the field was always present.
The Commands You’ll Actually Use
# Basic check
mypy myapp/
# Check a single file
mypy myapp/services.py
# Strict mode (recommended for greenfield projects)
mypy myapp/ --strict
# Show error codes (helps with targeted ignores)
mypy myapp/ --show-error-codes
# Pretty output (more readable in terminal)
mypy myapp/ --pretty
# Check what Python version you're targeting
mypy myapp/ --python-version 3.12
# Enable binary cache (faster incremental runs)
mypy myapp/ --fixed-format-cache
# Show column numbers in errors
mypy myapp/ --show-column-numbers
# Generate an HTML type coverage report
mypy myapp/ --html-report mypy-report/
open mypy-report/index.html
Where to Go From Here 📚
- 📖 Official mypy docs — the “Type system reference” section is excellent
- 🐍 PEP 484 — the original type hints PEP, readable and foundational
- 📰 mypy blog — release posts explain every new feature clearly
- 📕 Robust Python by Patrick Viafore — the best book on Python type safety in practice
- 🔌 typeshed — the community stub repository for standard library and popular packages
- 💬 mypy GitHub Discussions — active and helpful
- 🎙️ Adam Johnson’s adamj.eu — great mypy tips for Django specifically
Wrapping Up
Type checking is one of those things that seems like overhead until you’ve had it save you from a nasty production bug. Then it becomes one of those things you don’t want to work without.
mypy doesn’t require you to change how Python feels to write. You’re still writing Python. You’re just writing Python that’s a little more honest about what it expects and what it returns — and mypy rewards that honesty by catching a whole category of bugs before they ever reach your users.
With mypy 1.18 and 1.19 bringing major performance improvements, a faster binary cache format, and PEP 800 support, the experience has genuinely never been better. The gradual adoption path means there’s no excuse not to start today, even on an existing codebase.
Start with one file. Run mypy yourfile.py. See what it says. That's all it takes to begin. 🚀
Last updated March 2026 — mypy 1.19.1. Questions or thoughts? Drop them in the comments below!
메타데이터
- post_id
- b131c3f4d8cf
- slug
- mypy-make-pythons-type-hints-actually-do-something-b131c3f4d8cf
- url
- https://medium.com/@kapildagur/mypy-make-pythons-type-hints-actually-do-something-b131c3f4d8cf
- canonical_url
- https://medium.com/@kapildagur/mypy-make-pythons-type-hints-actually-do-something-b131c3f4d8cf
- author_url
- https://medium.com/@kapildagur
- status
- ok
- fetched_at
- 2026-06-20 20:29:01