mypy: make Python's type hints actually do something
A practical guide to static type checking in Python: from your first annotation to strict mode, Protocols, TypedDict, mypyc, and what changed in mypy 1.15 to 1.19.
Here is a scenario you have probably lived through. You write a function and it works. Six weeks later you call it from somewhere else, pass in None by accident, and get AttributeError: 'NoneType' object has no attribute 'strip' at 4 pm on a Friday. Then you spend twenty minutes tracing back through the call chain to find where the None came from.
The information needed to catch that bug was already in the code. Nobody asked for it to be checked.
mypy is the tool that asks. It reads your type annotations and reasons about your code before you run it, so it reports this kind of mistake at the line where you made it, not when a user hits it.
This guide is written against mypy 1.19.1, released in December 2025 and the current release at the time of writing. It covers why type checking is worth it, how to read and write annotations, the main features of the type system, the recent mypy releases, and how to adopt all of it in an existing project.
Doesn't Python already have type hints?
It does. Python has had type annotations since Python 3.5, specified in PEP 484. You can write this:
def greet(name: str) -> str:
return "Hello, " + name
At runtime, Python ignores those annotations. They are metadata. Nothing stops you from calling greet(42): Python tries to concatenate "Hello, " with 42 and raises a TypeError.
mypy is a static type checker. It reads your annotations and your code, and before anything runs, it tells you where the types do not line up. Think of it as a spell-checker for your type logic.
It is also optional and incremental. You can add it to an existing project one file at a time, without a rewrite.
Installation
pip install mypy
# or, in a uv project
uv add --dev mypy
Check your version:
uv run mypy --version
# mypy 1.19.1 (compiled: yes)
(compiled: yes) means you are running the build of mypy that is compiled with mypyc, which is what the wheels on PyPI contain. It is much faster than the pure Python version; more on mypyc later.
Your first type-checked file
# greet.py
def greet(name: str) -> str:
return "Hello, " + name
result = greet("Alice") # fine
wrong = greet(42) # mypy catches this
Run mypy on it:
$ mypy --pretty greet.py
greet.py:5: error: Argument 1 to "greet" has incompatible type "int";
expected "str" [arg-type]
wrong = greet(42) # mypy catches this
^~
Found 1 error in 1 file (checked 1 source file)
That is the core value of mypy: it found the bug before the code ran. No test, no runtime, no user report.
Reading mypy's output
Every mypy message follows the same format:
FILE:LINE: SEVERITY: MESSAGE [ERROR-CODE]
greet.py:5is the file and line where the problem is.erroris the severity. You will also seenotelines, which add context to an error.[arg-type]is the error code. You can use it to silence or filter one kind of error without hiding the others.
Without --pretty, each message is a single line. With it, mypy wraps long messages to the width of your terminal and points at the exact expression, which is how the samples in this post are shown. The messages are precise and readable, and that is one of mypy's real strengths.
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
Since Python 3.9 (PEP 585) you can use the built-in collection types directly, with no 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"}
# A tuple of any length, all ints
items: tuple[int, ...] = (1, 2, 3, 4)
Optional values
A value that may be None can be written two ways. They mean the same thing; the | syntax needs Python 3.10 or later (PEP 604) and is the one I use.
from typing import Optional
def find_user(user_id: int) -> Optional[str]: # classic spelling
...
def find_user_by_email(email: str) -> str | None: # modern, preferred
...
The two functions have different names on purpose. If you define find_user twice in one module to compare the spellings, mypy reports the second one as a redefinition.
str | None means "either a str or None". It is one of the most useful annotations you can write, because mypy then makes you handle the None case everywhere the value is used. That is exactly the Friday afternoon bug from the introduction.
Union types
from typing import Union
def process(value: Union[str, int]) -> str: # older spelling
return str(value)
def process_new(value: str | int) -> str: # Python 3.10 and later
return str(value)
Function annotations in depth
from collections.abc import Callable, Generator, Iterator
# A function that takes another function as an argument
def apply(func: Callable[[int], str], value: int) -> str:
return func(value)
# A generator: yield type, send type, return type
def count_up(start: int) -> Generator[int, None, None]:
while True:
yield start
start += 1
# When you only yield, Iterator[int] is shorter and means the same
def count_up_simple(start: int) -> Iterator[int]:
while True:
yield start
start += 1
# *args and **kwargs: annotate the type of each item
def log(*args: str, **kwargs: int) -> None:
...
# A function that returns nothing
def setup() -> None:
...
TypedDict: typing your dictionaries
This is one of the most practical features for real code. If you pass dictionaries around as structured data, TypedDict (PEP 589, Python 3.8) gives mypy a structure to check them 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']}")
send_welcome({"name": "Alice", "age": 30, "email": "alice@example.com"})
send_welcome({"name": "Bob", "age": 25})
send_welcome(
{"name": "Carol", "age": "thirty", "email": "carol@example.com"}
)
The first call is fine. mypy reports the other two:
typeddict_demo.py:16: error: Missing key "email" for TypedDict "UserConfig"
[typeddict-item]
typeddict_demo.py:18: error: Incompatible types (expression has type "str",
TypedDict item "age" has type "int") [typeddict-item]
You can also mark individual keys as optional with NotRequired (PEP 655, in typing since Python 3.11; use typing_extensions on older versions):
from typing import NotRequired, TypedDict
class UserConfig(TypedDict):
name: str
email: str
age: NotRequired[int] # this key may be missing
ok: UserConfig = {"name": "Dana", "email": "dana@example.com"}
dataclasses and type safety
Python's dataclasses work well 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
p = Product(name="Widget", price=9.99)
print(p.price + 1) # fine: float + int
print(p.price + "1") # error: float + str
dc.py:14: error: Unsupported operand types for + ("float" and "str")
[operator]
You already write the field types when you use dataclasses, so the type checking comes almost for free.
Protocols: duck typing with type safety
This is one of the most useful and least used features of the type system. In Python, a class does not need to inherit from anything to be compatible with code that uses it: if an object has the right methods, it works. Protocols (PEP 544, Python 3.8) let you describe that with full type checking.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
def draw(self) -> None:
print("Drawing circle")
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def __init__(self, side: float) -> None:
self.side = side
def draw(self) -> None:
print("Drawing square")
def resize(self, factor: float) -> None:
self.side *= factor
def render(shape: Drawable) -> None:
shape.draw()
render(Circle(1.0)) # fine
render(Square(2.0)) # fine
render("hello") # error: str has no draw() or resize()
proto.py:37: error: Argument 1 to "render" has incompatible type "str";
expected "Drawable" [arg-type]
Circle and Square do not inherit from Drawable. They do not even know it exists. Because their methods match the protocol, mypy accepts them. This is structural subtyping: Python's usual duck typing, made checkable before the code runs.
Generics: flexible code that stays type-safe
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:
return self._items.pop()
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") # error: expected int, got str
gen.py:21: error: Argument 1 to "push" of "Stack" has incompatible type
"str"; expected "int" [arg-type]
Python 3.12 added a shorter syntax for generics (PEP 695). The type parameter is declared in brackets after the class name, with no TypeVar import:
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 most impressive abilities is type narrowing. It tracks how isinstance checks, if conditions and assert statements change which types are possible at each point in your code.
def process(value: str | int | None) -> str:
if value is None:
return "nothing"
# Here mypy knows value is str | int.
if isinstance(value, int):
return str(value * 2)
# Here mypy knows value is str.
return value.upper()
def get_user(user_id: int) -> str | None:
return None if user_id < 0 else "alice"
user = get_user(1)
assert user is not None # mypy narrows user to str after this line
print(user.upper()) # safe
This is what makes mypy feel smart rather than pedantic. It does not match types mechanically; it reasons about your control flow. If you want to see what mypy has inferred at any point, add reveal_type(user) and run mypy: it prints a note such as Revealed type is "builtins.str".
Literal types: when the value matters, not only the type
from typing import Literal
Direction = Literal["north", "south", "east", "west"]
def move(direction: Direction) -> None:
print(f"Moving {direction}")
move("north") # fine
move("up") # error: not a valid Direction
# Useful for status codes, modes and states
Status = Literal["pending", "active", "cancelled"]
def update_order(order_id: int, status: Status) -> None:
...
update_order(1, "active") # fine
update_order(1, "deleted") # error: mypy catches the typo
lit.py:11: error: Argument 1 to "move" has incompatible type
"Literal['up']"; expected "Literal['north', 'south', 'east', 'west']"
[arg-type]
Literal (PEP 586) prevents magic-string bugs: the kind where you mistype a status value and nothing fails until you notice the data is wrong.
Final and ClassVar
from typing import ClassVar, Final
MAX_RETRIES: Final = 3 # cannot be reassigned
MAX_RETRIES = 5 # error
class Config:
debug: ClassVar[bool] = False # belongs to the class
name: str # belongs to each instance
Config.debug = True # fine: set on the class
instance = Config()
instance.debug = True # error: ClassVar set through an instance
fin.py:4: error: Cannot assign to final name "MAX_RETRIES" [misc]
fin.py:14: error: Cannot assign to class variable "debug" via instance
[misc]
cast and type: ignore, the escape hatches
Sometimes you know more than mypy does. These are the ways to tell it so:
from typing import cast
# You know the return type; mypy only sees Any
result = cast(str, some_opaque_function())
# Silence one error code on one line
x: str = legacy_function() # type: ignore[assignment]
# Silence every error on one line (use sparingly)
y: str = legacy_function() # type: ignore
Prefer # type: ignore[error-code] to a bare # type: ignore. Being specific means a new, different error on the same line still shows up: mypy reports it, with a note saying its code is "not covered by" the comment. Turn on --warn-unused-ignores (it is part of --strict) and mypy also reports every ignore comment that no longer suppresses anything, with the code [unused-ignore], so the comments do not pile up after the underlying problem is fixed.
What changed in recent releases
mypy 1.18: about 40 percent faster
mypy 1.18.1, released in September 2025, is about 40 percent faster than 1.17 when type checking mypy itself, and in extreme cases the improvement is 10x or more. The gains come from many optimizations, including caching the inferred types of common expressions, plus improvements in mypyc that also speed up the compiled mypy. If mypy was the slow step in your CI, this release is worth the upgrade on its own.
The release notes add one caution: the type caching carries a small risk of regressions. If you see an unexpected inferred type, check whether --disable-expression-cache changes it, and mention that in your bug report.
mypy 1.18 and 1.19: the fixed-format cache
mypy keeps a cache so that later runs can reuse earlier results. mypy 1.18 added a new binary format for this cache, the fixed-format cache, as an experimental feature that worked only with the compiled build. The release notes say it makes incremental builds up to twice as fast. In mypy 1.19 the feature is no longer experimental, and the mypy team plans to make it the default in a future release, possibly 1.20.
Enable it on the command line:
mypy --fixed-format-cache myapp/
Or in your configuration file:
[mypy]
fixed_format_cache = true
The new format is faster and smaller than the old JSON cache, but you cannot read it directly. If you have tooling that parses the cache, mypy 1.19 ships a converter: python -m mypy.exportjson turns a .data.ff file back into JSON. mypy 1.19 also adds a runtime dependency on the librt package, which it uses to read and write this cache.
mypy 1.18: disjoint bases (PEP 800)
mypy 1.18 added support for PEP 800, which targets Python 3.15. It recognizes the @disjoint_base decorator and rejects class definitions that combine incompatible base classes. A disjoint base is a class whose instances have a memory layout that cannot be combined with another such class. Many built-in classes are like this: CPython cannot create a class that inherits from both str and Exception, and now mypy knows that too.
from typing_extensions import disjoint_base
@disjoint_base
class A: ...
@disjoint_base
class B: ...
class C(A, B): ... # error
class Bad(str, Exception): ... # error
disj.py:12: error: Class "C" has incompatible disjoint bases [misc]
disj.py:13: error: Class "Bad" has incompatible disjoint bases [misc]
On current Python versions the decorator comes from typing_extensions (4.15.0 or later). Most of its users are the authors of stub files for C extensions and the standard library; the PEP says it is not meant to be added routinely to ordinary classes. What you gain as an application developer is better checking: mypy rejects class hierarchies that would fail at runtime, and it uses the same knowledge when it decides whether an isinstance branch can ever be reached.
mypy 1.15: the --strict-bytes flag
For historical reasons, mypy treats bytearray and memoryview values as assignable to bytes. PEP 688 removed that special case, and mypy 1.15 added --strict-bytes to turn it off:
def checksum(data: bytes) -> int:
return sum(data)
checksum(bytearray(b"abc")) # accepted unless --strict-bytes is on
$ mypy --strict-bytes sb.py
sb.py:5: error: Argument 1 to "checksum" has incompatible type "bytearray";
expected "bytes" [arg-type]
Since mypy 1.16, --strict includes --strict-bytes, and the flag will be on by default in mypy 2.0. If you work with binary data and do not use --strict, enable it now, and annotate parameters that accept any bytes-like object as bytes | bytearray | memoryview or collections.abc.Buffer. That way the change in 2.0 does not arrive as a surprise.
Configuration in pyproject.toml
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
pretty = true
# Per-module overrides help with gradual adoption
[[tool.mypy.overrides]]
module = "legacy_module.*"
ignore_errors = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
strict = true is shorthand for a set of checks. In mypy 1.19 it enables warn_unused_configs, disallow_any_generics, disallow_subclassing_any, disallow_untyped_calls, disallow_untyped_defs, disallow_incomplete_defs, check_untyped_defs, disallow_untyped_decorators, warn_redundant_casts, warn_unused_ignores, warn_return_any, no_implicit_reexport, strict_equality, strict_bytes and extra_checks. You do not need to list those again once strict is on. The most important ones:
| Flag enabled by strict | What it catches |
|---|---|
disallow_untyped_defs | Functions with missing annotations |
check_untyped_defs | Errors inside the bodies of functions that have no annotations |
disallow_any_generics | Generic types used without parameters, such as list instead of list[str] |
warn_return_any | Returning Any from a function declared to return a specific type |
strict_equality | Equality and containment checks between types that can never be equal |
strict_bytes | bytearray or memoryview passed where bytes is expected |
Two settings you may see in older configurations are no longer needed. Error codes are shown by default, so show_error_codes = true changes nothing. Implicit Optional has been off by default since mypy 0.990: a parameter with a None default must be annotated T | None explicitly, whether or not you use strict.
For an existing codebase, start without strict and turn on individual flags. Move to strict = true once the code is fully annotated. For a new project, start strict from the first commit.
Gradual typing: the practical adoption path
mypy is designed for incremental adoption. You do not have to type-check everything or nothing.
Step 1: quiet the missing imports. Some third-party libraries ship no type information. --ignore-missing-imports stops mypy from reporting them. Once things settle, prefer a per-module override with ignore_missing_imports = true for the specific libraries, so a typo in one of your own imports is still reported.
mypy myapp/ --ignore-missing-imports
Step 2: check one file or package at a time.
mypy myapp/models.py # one file
mypy myapp/services/ # one directory
Step 3: mark known problems. Put a # type: ignore[code] on the lines you cannot fix yet and move on. You can come back to them later.
Step 4: turn on stricter flags one at a time.
mypy myapp/ --disallow-untyped-defs # annotate every function first
mypy myapp/ --warn-return-any # then this
mypy myapp/ --strict # the goal
Step 5: track how much is still untyped.
mypy myapp/ --any-exprs-report mypy-any/
This writes any-exprs.txt, which lists for each module how many expressions have type Any and the percentage that do not, and types-of-anys.txt, which breaks the Any types down by cause. The coverage percentage is a useful number to track over time.
Stub files: when libraries do not have types
If a library does not ship type information, you have three options.
1. Install a stub package. This is the most common case.
pip install pandas-stubs
pip install types-requests
pip install types-PyYAML
Stubs for many popular libraries live in the typeshed project and are published to PyPI as types-* packages. Others, such as pandas-stubs, are maintained separately and use the -stubs suffix from PEP 561.
2. Write your own stub file. A .pyi file holds only signatures. Put it in a directory on MYPYPATH (or the mypy_path setting):
# stubs/legacy_http.pyi
from typing import Any
class Response:
status_code: int
text: str
def json(self) -> Any: ...
def get(url: str, **kwargs: Any) -> Response: ...
def post(url: str, **kwargs: Any) -> Response: ...
3. Ship a py.typed marker. If you are a library author, add an empty py.typed file to your package (PEP 561). It tells mypy and other checkers that the package's inline annotations are meant to be used:
mypackage/
__init__.py
py.typed # empty file: the package ships its own types
core.py
Running mypy in CI
# GitHub Actions, in a uv project
- name: Type check
run: uv run mypy myapp/ --strict
# Or with uvx, without adding mypy to the project
- name: Type check
run: uvx mypy myapp/ --strict
uvx runs mypy in its own isolated environment, so it cannot see your project's dependencies. It suits a package with no third-party imports; otherwise use uv run.
The same isolation applies to the pre-commit hook, which is why you list stub packages and plugins there as additional_dependencies:
# .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 a real bonus. mypy comes with mypyc, a compiler that uses your type annotations to compile Python modules into C extensions. According to the mypyc documentation, existing annotated code is often 1.5x to 5x faster when compiled, and code tuned for mypyc can be 5x to 10x faster. Existing code often needs only minor changes to compile.
pip install "mypy[mypyc]"
# Compile a module (you need a C compiler)
mypyc myapp/hot_path.py
# The extension is written next to the source, and Python imports
# it in place of the .py file
python -c "import myapp.hot_path"
mypy itself is compiled with mypyc, which the mypyc documentation credits with a 4x speedup over interpreted Python. That is why (compiled: yes) in the version output matters. If you have CPU-bound Python code with complete type annotations, mypyc is worth a benchmark.
mypy and the alternatives
Pyright and Pylance. Pyright, from Microsoft, powers Pylance in VS Code and is mypy's strongest competitor. It is fast on large codebases and has the better editor integration out of the box. mypy has been around longer, has a larger plugin ecosystem, and comes with mypyc. Pyright has stricter defaults in some areas; mypy is more configurable for gradual adoption. Most teams pick one and stay with it.
Pytype, from Google. Pytype infers more on its own and can check unannotated code, but Google has announced that Python 3.12 is the last version it will support. I would not start a new project on it.
Pyre, from Meta. Pyre was built for very large monorepos. Meta has since replaced it with Pyrefly, its next-generation type checker and language server.
Ruff. Ruff has lint rules about type annotations, but it is not a type checker. mypy and Ruff complement each other; use both.
My summary: mypy or Pyright for most teams. If you are on VS Code and want the best editor experience, Pyright and Pylance are excellent. If you want CI integration, framework plugins (Django, Pydantic) and mypyc, mypy has the richer ecosystem.
mypy plugins: framework-aware type checking
mypy supports plugins that teach it how a framework behaves at runtime:
[tool.mypy]
plugins = [
"mypy_django_plugin.main",
"pydantic.mypy",
]
[tool.django-stubs]
django_settings_module = "myproject.settings"
- django-stubs with its
mypy_django_pluginunderstands model fields,QuerySettypes,request.userand more. It needs to know your settings module, either from the[tool.django-stubs]section above or fromDJANGO_SETTINGS_MODULE. Without it, much of the Django ORM looks likeAnyto mypy. pydantic.mypyships inside Pydantic and makes mypy understand model fields, validators and the generated__init__signatures more precisely.- SQLAlchemy has a plugin,
sqlalchemy.ext.mypy.plugin, but it is deprecated and will be removed in SQLAlchemy 2.1. On SQLAlchemy 2.0, use the nativeMapped[]annotations withmapped_column()instead; they need no plugin.
A real-world workflow
Here is a small FastAPI project, fully typed and checked with the strict configuration above plus the Pydantic plugin.
# myapp/models.py
from datetime import datetime
from pydantic import BaseModel
class UserCreate(BaseModel):
name: str
email: str
age: int | None = None
class UserResponse(BaseModel):
id: int
name: str
email: str
created_at: datetime
The database layer is a stand-in here; in a real project it wraps your ORM session. What matters to mypy is that add_user and get_db are annotated.
# myapp/database.py
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class UserRow:
id: int
name: str
email: str
age: int | None
created_at: datetime
@dataclass
class Session:
rows: list[UserRow] = field(default_factory=list)
def add_user(
self, *, name: str, email: str, age: int | None
) -> UserRow:
row = UserRow(
id=len(self.rows) + 1,
name=name,
email=email,
age=age,
created_at=datetime.now(timezone.utc),
)
self.rows.append(row)
return row
def get_db() -> Iterator[Session]:
yield Session()
# myapp/services.py
from .database import Session
from .models import UserCreate, UserResponse
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,
)
# myapp/app.py
from typing import Annotated
from fastapi import Depends, FastAPI
from .database import Session, get_db
from .models import UserCreate, UserResponse
from .services import create_user
app = FastAPI()
@app.post("/users", response_model=UserResponse)
def register(
data: UserCreate,
db: Annotated[Session, Depends(get_db)],
) -> UserResponse:
return create_user(db, data)
$ mypy myapp
Success: no issues found in 5 source files
The dependency is written as Annotated[Session, Depends(get_db)]. The older form, db=Depends(get_db) with no annotation, fails under --strict with "Function is missing a type annotation for one or more arguments", and inside the function mypy would not know what db is.
The whole request and response path is now checked. If you change a field of UserCreate, for example make name optional, mypy tells you every place in the codebase that assumed it was always a str.
The commands you will actually use
# Basic check
mypy myapp/
# Check a single file
mypy myapp/services.py
# Strict mode (recommended for new projects)
mypy myapp/ --strict
# Readable output with source lines and markers
mypy myapp/ --pretty
# Check against a specific Python version
mypy myapp/ --python-version 3.12
# Use the binary cache for faster incremental runs
mypy myapp/ --fixed-format-cache
# Show column numbers in messages
mypy myapp/ --show-column-numbers
# HTML report of type coverage (needs: pip install "mypy[reports]")
mypy myapp/ --html-report mypy-report/
# then open mypy-report/index.html in a browser
Where to go from here
- The mypy documentation. The type system reference section is excellent, and the page on using mypy with an existing codebase expands on the adoption path above.
- PEP 484, the original type hints PEP. It is readable and still the foundation.
- The mypy blog, where each release is announced, and the changelog with the full release notes.
- Robust Python by Patrick Viafore (O'Reilly), a good book on type safety in everyday Python.
- typeshed, the shared repository of stubs for the standard library and many third-party packages.
- Adam Johnson's blog, with many practical mypy tips, especially for Django.
Wrapping up
Type checking looks like overhead until it saves you from a bad production bug. After that, you do not want to work without it.
mypy does not change how Python feels to write. You still write Python, just Python that is more honest about what it expects and what it returns, and mypy rewards that by catching a whole class of bugs before they reach your users.
With the speedups in 1.18, the fixed-format cache, and support for new typing PEPs, mypy is in good shape. The gradual adoption path means you can start today, even on an existing codebase. Start with one file: run mypy yourfile.py and see what it says.
Sources
- mypy, changelog (release notes for 1.15, 1.16, 1.18 and 1.19) and mypy 1.19.1 on PyPI
- mypy, command line and configuration file reference
- mypy, using installed packages (stub packages and
py.typed) - mypyc, introduction
- PEPs 484, 544, 561, 585, 586, 589, 604, 655, 688, 695 and 800
- Google, pytype README; Meta, pyre-check README
- SQLAlchemy, Mypy / Pep-484 support for ORM mappings; django-stubs