Type checking with mypy and pyright in CI
Getting to --strict without breaking the team, and the difference between the two type checkers.
Why this matters
Type checkers catch a class of bugs that no runtime test would catch โ passing None where you promised User, calling .append on a frozenset, mistyped field names in a dict. They also document intent. In 2026, every production Python codebase is type-checked, usually in CI with strict settings.
Learning objectives
- Configure
mypyandpyrightwith strict mode. - Read and fix common type errors.
- Use
reveal_type,cast,# type: ignorecorrectly. - Type third-party libraries that lack stubs.
- Add types incrementally to legacy code.
1. mypy vs pyright
| mypy | pyright | |
|---|---|---|
| Author | Python core team / Dropbox | Microsoft |
| Language | Python | TypeScript |
| Speed | slower | faster (used in VS Code/Cursor) |
| Strictness defaults | per-flag | strict by default in strict mode |
| Plugin ecosystem | larger (pydantic, sqlalchemy) | smaller |
| Output | terse | richer, more hints |
Use both if you can; they catch slightly different things. Most teams pick one for CI, often pyright (faster, drives the editor's inline diagnostics).
uv add --dev mypy pyright2. mypy strict config (pyproject.toml)
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
warn_return_any = true
warn_unreachable = true
warn_redundant_casts = true
disallow_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
check_untyped_defs = true
explicit_package_bases = true
plugins = ["pydantic.mypy"]
# Per-module overrides for untyped third-party libs
[[tool.mypy.overrides]]
module = ["some_untyped_lib.*"]
ignore_missing_imports = truestrict = true is the right default. It will produce dozens of errors on legacy code โ see "incremental adoption" below.
Run:
uv run mypy src/
uv run mypy --strict src/3. pyright config
pyrightconfig.json (or [tool.pyright] in pyproject.toml):
{
"pythonVersion": "3.12",
"typeCheckingMode": "strict",
"include": ["src", "tests"],
"exclude": ["**/node_modules", "**/__pycache__"],
"reportMissingTypeStubs": "warning",
"reportUnknownMemberType": "error",
"reportImplicitOverride": "warning"
}uv run pyright4. Common errors and fixes
Optional not handled
def find_user(uid: int) -> User | None: ...
def main():
u = find_user(42)
print(u.name) # error: u may be NoneFix:
if u is None: raise ValueError("not found")
print(u.name) # narrowed to UserIncompatible types
def total(xs: list[int]) -> int: return sum(xs)
total(["a", "b"]) # errorType the inputs correctly; or genericise the function.
Untyped function
def foo(x): # error: missing types in strict mode
return x + 1Add hints:
def foo(x: int) -> int: return x + 1Any leakage
import json
data = json.loads(raw) # type is `Any`
data.banana # passes โ but is probably wrongCast or validate:
from typing import cast
data = cast(dict[str, int], json.loads(raw))Better: validate with Pydantic / msgspec.
Wrong assignment
x: int = "5" # error5. Narrowing โ how the checker tracks types
Type checkers narrow types based on:
isinstance(x, T): x narrows to T inside the branch.x is None/x is not None: narrows to None / not-None.assert isinstance(x, T): narrows after.if x:(truthiness): narrows out falsy values.match x: case Point(): ...: narrows.assert x is not None: narrows in subsequent code.
def f(x: int | None):
if x is None: return
reveal_type(x) # int (None ruled out)
x + 1TypeGuard for custom narrowing functions
from typing import TypeGuard
def is_str_list(x: list[object]) -> TypeGuard[list[str]]:
return all(isinstance(s, str) for s in x)
def process(items: list[object]):
if is_str_list(items):
", ".join(items) # checker knows items: list[str]3.13+ also has TypeIs (PEP 742) โ more precise narrowing in else branches.
6. reveal_type โ debug what the checker sees
x = some_complex_pipeline()
reveal_type(x) # mypy/pyright print the inferred typereveal_type doesn't exist at runtime โ remove before running. (pyright accepts from typing import reveal_type to make it explicit and avoid NameError.)
7. cast and # type: ignore
from typing import cast
x = cast(MyType, untyped)cast is a no-op at runtime; it tells the checker "trust me." Use sparingly โ every cast is a place a bug can hide.
result = bad_lib_call() # type: ignore[no-any-return]# type: ignore[error-code] suppresses one specific error on one line. The bracket form is mandatory under warn_unused_ignores=True. If you fix the underlying issue, the unused ignore becomes a separate error.
Better than wholesale # type: ignore. Never ignore an entire file (# mypy: ignore-errors) unless you have a written plan to fix it.
8. Stub files (.pyi)
When a third-party library has no types, add a stub file:
# stubs/legacy_lib/__init__.pyi
def fetch(url: str) -> dict[str, str]: ...
class Client:
def __init__(self, host: str) -> None: ...
def get(self, path: str) -> bytes: ...Tell the checker to look in stubs/:
[tool.mypy]
mypy_path = "stubs"For widely-used libraries, install community stubs:
uv add --dev types-requests types-redis types-PyYAMLThe types-* packages on PyPI are official typeshed stubs for popular libs.
9. Plugins
mypy plugins teach the checker about specific libraries:
pydantic.mypy: knows aboutBaseModel, field aliases, validators.sqlalchemy.ext.mypy: knows about declarative models, relationships.attrs: built-in support for@attrs.define.
pyright has fewer plugins because more is built in.
10. Incremental adoption
For a legacy codebase, start gentle:
- Add minimal config โ no strict yet.
- Run mypy with
--check-untyped-defs --ignore-missing-importsto see existing damage. - Type leaf modules first (utilities with few imports).
- Move outward; type APIs you control before internals.
- Tighten config gradually:
--disallow-untyped-defs, then--disallow-any-generics, then full--strict. - Use
# type: ignore[unused-ignore]only as a temporary plaster, with a TODO.
Don't try to type-clean a 100k-line codebase in one PR. Make CI fail on new untyped code; let the rest be typed as it's touched.
11. Practical patterns
Always annotate function signatures
def process(items: list[dict[str, Any]]) -> int: ...You don't need to annotate every local variable; the checker infers them.
Use Final for constants
from typing import Final
MAX_RETRIES: Final = 3
PI: Final = 3.14159The checker prevents reassignment.
assert for narrowing in hot paths
def f(x: int | None) -> int:
assert x is not None
return x * 2Note: assert is removed under python -O. For runtime checks, use if x is None: raise.
Generic helper functions
from typing import TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T: return xs[0]TypedDict for JSON payloads
from typing import TypedDict, NotRequired
class UserDict(TypedDict):
id: int
name: str
email: NotRequired[str]Pair with Pydantic / msgspec for runtime validation at the boundary.
12. CI integration
# .github/workflows/ci.yml
- name: Type check
run: uv run mypy src/
- name: pyright
run: uv run pyrightAdd to pre-commit-config.yaml:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies: [pydantic, sqlalchemy]
args: [--strict]Block PRs with type errors. The rule is: types are part of the contract; CI enforces them.
13. Worked example: typing a small service
# Before โ no types
def get_user(uid, default=None):
user = repo.find(uid)
return user or default
def render_user(u):
return f"{u.name} ({u.email})"
# After โ typed, narrowed
from typing import Final
DEFAULT_USER: Final = User(id=0, name="anonymous", email="")
def get_user(uid: int, default: User = DEFAULT_USER) -> User:
user = repo.find(uid)
if user is None:
return default
return user
def render_user(u: User) -> str:
return f"{u.name} ({u.email})"Both versions work. The typed version:
- Tells the reader / IDE / checker the contract.
- Catches "I passed a
None" bugs at lint time. - Makes refactors safer.
Hands-on lab (1.5 hours)
- Run
mypy --strict src/on an old script. Fix every error. - Find a
dictparameter; convert it toTypedDict. - Add a
TypeGuardfor one boolean predicate function. - Install
types-requests(ortypes-PyYAML); see the difference in your editor. - Add
pyrighttopre-commit. Verify it blocks a commit with a typing error. - Type a generic helper (
def first[T](xs: list[T]) -> T). - Bonus: use
Annotated[int, "in seconds"]to attach metadata; read withget_type_hints(include_extras=True).
Common pitfalls
Anyeverywhere โ defeats the point. Lock down withdisallow_any_generics,warn_return_any.- Silencing errors with
# type: ignorewithout fixing. - Forgetting to install stubs (
types-requests). - Pydantic v1 syntax in a v2 project (the mypy plugin will surface this).
- Wide unions (
int | str | bytes | dict | list) โ break them apart with classes/Protocols. - Treating
None | Tas ifNonerarely happens โ it always happens.
Self-check
- What does
strict = trueenable? castvsassert isinstance?- What is a stub file?
- When use
TypeGuard? - How would you adopt types incrementally on a legacy codebase?
References
- mypy docs: https://mypy.readthedocs.io/.
- pyright docs: https://microsoft.github.io/pyright/.
- typeshed (community stubs): https://github.com/python/typeshed.
- PEP 484, 526, 544, 561, 591, 612, 646, 673, 695, 705, 742.
- ลukasz Langa, "Python's Type System" (talk).
Sign in to save your progress and earn badges.