Type checking with mypy and pyright in CI

Getting to --strict without breaking the team, and the difference between the two type checkers.

๐Ÿงช Module 6 9 min read Not started

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

  1. Configure mypy and pyright with strict mode.
  2. Read and fix common type errors.
  3. Use reveal_type, cast, # type: ignore correctly.
  4. Type third-party libraries that lack stubs.
  5. Add types incrementally to legacy code.

1. mypy vs pyright

mypypyright
AuthorPython core team / DropboxMicrosoft
LanguagePythonTypeScript
Speedslowerfaster (used in VS Code/Cursor)
Strictness defaultsper-flagstrict by default in strict mode
Plugin ecosystemlarger (pydantic, sqlalchemy)smaller
Outputtersericher, 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).

powershell
uv add --dev mypy pyright

2. mypy strict config (pyproject.toml)

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 = true

strict = true is the right default. It will produce dozens of errors on legacy code โ€” see "incremental adoption" below.

Run:

powershell
uv run mypy src/
uv run mypy --strict src/

3. pyright config

pyrightconfig.json (or [tool.pyright] in pyproject.toml):

json
{
  "pythonVersion": "3.12",
  "typeCheckingMode": "strict",
  "include": ["src", "tests"],
  "exclude": ["**/node_modules", "**/__pycache__"],
  "reportMissingTypeStubs": "warning",
  "reportUnknownMemberType": "error",
  "reportImplicitOverride": "warning"
}
powershell
uv run pyright

4. Common errors and fixes

Optional not handled

python
def find_user(uid: int) -> User | None: ...

def main():
    u = find_user(42)
    print(u.name)              # error: u may be None

Fix:

python
if u is None: raise ValueError("not found")
print(u.name)                  # narrowed to User

Incompatible types

python
def total(xs: list[int]) -> int: return sum(xs)

total(["a", "b"])              # error

Type the inputs correctly; or genericise the function.

Untyped function

python
def foo(x):                    # error: missing types in strict mode
    return x + 1

Add hints:

python
def foo(x: int) -> int: return x + 1

Any leakage

python
import json
data = json.loads(raw)         # type is `Any`
data.banana                    # passes โ€” but is probably wrong

Cast or validate:

python
from typing import cast
data = cast(dict[str, int], json.loads(raw))

Better: validate with Pydantic / msgspec.

Wrong assignment

python
x: int = "5"                   # error

5. 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.
python
def f(x: int | None):
    if x is None: return
    reveal_type(x)              # int (None ruled out)
    x + 1

TypeGuard for custom narrowing functions

python
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

python
x = some_complex_pipeline()
reveal_type(x)                  # mypy/pyright print the inferred type

reveal_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

python
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.

python
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:

python
# 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/:

toml
[tool.mypy]
mypy_path = "stubs"

For widely-used libraries, install community stubs:

powershell
uv add --dev types-requests types-redis types-PyYAML

The types-* packages on PyPI are official typeshed stubs for popular libs.


9. Plugins

mypy plugins teach the checker about specific libraries:

  • pydantic.mypy: knows about BaseModel, 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:

  1. Add minimal config โ€” no strict yet.
  2. Run mypy with --check-untyped-defs --ignore-missing-imports to see existing damage.
  3. Type leaf modules first (utilities with few imports).
  4. Move outward; type APIs you control before internals.
  5. Tighten config gradually: --disallow-untyped-defs, then --disallow-any-generics, then full --strict.
  6. 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

python
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

python
from typing import Final
MAX_RETRIES: Final = 3
PI: Final = 3.14159

The checker prevents reassignment.

assert for narrowing in hot paths

python
def f(x: int | None) -> int:
    assert x is not None
    return x * 2

Note: assert is removed under python -O. For runtime checks, use if x is None: raise.

Generic helper functions

python
from typing import TypeVar
T = TypeVar("T")

def first(xs: list[T]) -> T: return xs[0]

TypedDict for JSON payloads

python
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

yaml
# .github/workflows/ci.yml
- name: Type check
  run: uv run mypy src/

- name: pyright
  run: uv run pyright

Add to pre-commit-config.yaml:

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

python
# 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)

  1. Run mypy --strict src/ on an old script. Fix every error.
  2. Find a dict parameter; convert it to TypedDict.
  3. Add a TypeGuard for one boolean predicate function.
  4. Install types-requests (or types-PyYAML); see the difference in your editor.
  5. Add pyright to pre-commit. Verify it blocks a commit with a typing error.
  6. Type a generic helper (def first[T](xs: list[T]) -> T).
  7. Bonus: use Annotated[int, "in seconds"] to attach metadata; read with get_type_hints(include_extras=True).

Common pitfalls

  1. Any everywhere โ€” defeats the point. Lock down with disallow_any_generics, warn_return_any.
  2. Silencing errors with # type: ignore without fixing.
  3. Forgetting to install stubs (types-requests).
  4. Pydantic v1 syntax in a v2 project (the mypy plugin will surface this).
  5. Wide unions (int | str | bytes | dict | list) โ€” break them apart with classes/Protocols.
  6. Treating None | T as if None rarely happens โ€” it always happens.

Self-check

  1. What does strict = true enable?
  2. cast vs assert isinstance?
  3. What is a stub file?
  4. When use TypeGuard?
  5. How would you adopt types incrementally on a legacy codebase?

References

Sign in to save your progress and earn badges.