Tests catch bugs at runtime. Static analysis catches them before the code runs at all — at editor-save time, ideally. There are two separate questions a static-analysis tool can answer:
- Does this code make sense given the type hints? (the type checker)
- Does this code follow style and avoid common mistakes? (the linter / formatter)
In 2026 the answer to the first is pyright or mypy, and the answer to the second is overwhelmingly ruff. This lesson is how to set up all three, what their configs look like, and the failures you’ll actually see.
Why type checking pays off
We covered type hints in lesson 2: def add(a: int, b: int) -> int. Hints are runtime no-ops — Python ignores them. They only matter when a tool reads them and tells you something doesn’t add up.
What does “doesn’t add up” look like? Real examples from real codebases:
- A function annotated
-> UserreturnsNoneon the error path. Tests didn’t catch it because the error path was rare. The type checker did. - A refactor renamed a parameter from
user_idtouidin three places out of four. The fourth caller still passesuser_id=. Without types, tests at runtime in CI catch it. With types, the editor squiggle catches it the moment you save. - A function takes
list[str]and somebody passestuple[str, str]. It “works” until someone calls.append.
Type checkers find this class of bug instantly, on every file, on every save. They cost you the time it takes to add hints and not much else.
pyright vs mypy
Two main type checkers in 2026:
pyright — Microsoft’s, written in TypeScript. Fast (it can re-check a large file in tens of milliseconds), powers VS Code’s Pylance extension by default, has the most aggressive inference. The default for new projects.
mypy — the reference implementation, written in Python by the team that drove PEP 484. Slower than pyright but more configurable, with a long tail of edge-case knobs and a huge installed base. Still the standard at many large companies.
You can run both. They occasionally disagree on edge cases, and that disagreement is sometimes useful as a signal that the code is doing something unusual. For a new project, pick pyright; for an existing codebase that already runs mypy, keep running mypy.
Setting up pyright
Install:
uv add --dev pyright
Configure in pyproject.toml:
[tool.pyright]
include = ["src", "tests"]
exclude = ["**/__pycache__", "**/.venv"]
pythonVersion = "3.13"
typeCheckingMode = "standard"
reportMissingImports = "error"
reportMissingTypeStubs = "warning"
The typeCheckingMode is the big knob:
off— only syntactic checks, no real type inference.basic— light, catches the obvious stuff.standard— the new default as of 2025; reasonable strictness for most projects.strict— flags every untyped function, everyAny, every implicitOptional. Fantastic for new code; painful to retrofit.
Run it:
pyright
Output looks like:
src/my_app/users.py:42:5 - error: Argument of type "str | None" cannot be assigned to parameter "name" of type "str" in function "create_user"
Type "str | None" is not assignable to type "str"
"None" is not assignable to "str"
That’s the type checker telling you a value that might be None is being passed where None isn’t allowed. The fix is either narrow before calling, or change the parameter to str | None.
Setting up mypy
Install:
uv add --dev mypy
Configure:
[tool.mypy]
python_version = "3.13"
files = ["src", "tests"]
strict = true
warn_return_any = true
warn_unused_ignores = true
# Per-module overrides — third-party libraries without type stubs
[[tool.mypy.overrides]]
module = ["legacy_lib.*", "old_internal.*"]
ignore_missing_imports = true
strict = true is shorthand for ten or so individual flags (disallow_untyped_defs, disallow_any_generics, warn_redundant_casts, …). The granular flags exist if you need to opt out of one for a specific module.
Run it:
mypy src/
The output format is similar to pyright’s: file, line, column, error code, message.
Common errors and fixes
A handful you’ll see constantly.
Optional not narrowed:
def greet(name: str | None) -> str:
return f"Hello {name.upper()}" # error: name might be None
Fix: narrow first.
def greet(name: str | None) -> str:
if name is None:
return "Hello stranger"
return f"Hello {name.upper()}"
Return type mismatch:
def find_user(uid: int) -> User:
user = db.get(uid)
return user # error: db.get returns User | None, declared User
Fix: change the signature to -> User | None, or raise on the missing case.
Signature drift after rename:
def create_user(name: str, email: str) -> User: ...
create_user(name="Ada", mail="ada@example.com")
# error: unexpected keyword argument "mail"; did you mean "email"?
This is the bug type that pays for type checking on its own. Pure runtime testing catches it only if a test exercises that exact call site. The type checker catches it instantly.
Any poisoning:
import json
config: dict = json.load(open("config.json")) # config inferred as dict[Any, Any]
port: str = config["port"] # no error, but config["port"] is actually int
Any matches everything, which means it disables type checking. The strict flags warn when Any enters your code unannotated. Fix by giving the load a real type, or using a parser like Pydantic that returns a typed model.
CI integration
Both tools exit non-zero on errors, which is all you need:
# .github/workflows/ci.yml
- run: uv run pyright
- run: uv run pytest
For mypy:
- run: uv run mypy src/
In a large codebase, set up mypy --install-types or pre-commit caching to keep it fast. Pyright is fast enough out of the box that you can run it on every push without thinking.
Enter ruff: the formatter and linter
Until 2023, the Python style toolchain looked like this: black for formatting, isort for import sorting, flake8 for linting (with five plugins), pylint if you wanted deeper checks. Four tools, four configs, four invocations. They were all written in Python and they were all slow.
ruff replaced almost all of it. Written in Rust by the Astral team (the same people behind uv), it runs the equivalent of black + isort + flake8 + most of pylint in milliseconds on a typical project. As of 2026 it’s the de facto default for new Python work.
Install:
uv add --dev ruff
Two commands:
ruff check . # lint
ruff format . # format (the black replacement)
That’s it. No more remembering which tool does what.
Configuring ruff
All in pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src", "tests"]
[tool.ruff.lint]
# Rule families to enable
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import order)
"N", # pep8-naming
"UP", # pyupgrade (modernize syntax)
"B", # flake8-bugbear (likely bugs)
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RUF", # ruff's own rules
]
ignore = [
"E501", # line too long (formatter handles this)
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["N802", "N803"] # pytest test/fixture names
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
The rule selection language is the killer feature. Each two-or-three-letter prefix is a family of rules — E is pycodestyle, F is pyflakes, B is bugbear, UP modernizes old syntax. You select families wholesale ("B"), sub-rules specifically ("B008"), or everything in one tool ("ALL", then ignore what you don’t want).
A practical starting set is ["E", "W", "F", "I", "B", "UP"]. Add "SIM" and "C4" once you’re used to them.
What ruff actually catches
Examples from real ruff check output:
def make_widgets(items=[]): # B006: mutable default argument
items.append(...)
return items
import os, sys # E401: multiple imports on one line
x = lambda: 5 # E731: do not assign a lambda; def is clearer
if x == None: # E711: use `is None`
for i in range(len(things)): # B007 / SIM-style: enumerate is cleaner
print(i, things[i])
import json
import json # F811: redefinition of unused import
Most of these have automatic fixes:
ruff check --fix .
Ruff will rewrite the file. The --unsafe-fixes flag enables fixes that change behavior; review the diff before committing.
ruff format — the black replacement
ruff format is byte-compatible with black’s output for >99% of inputs (the Astral team explicitly tracks divergences). If your project uses black, you can swap it for ruff format and your diff will be empty.
ruff format . # reformat in place
ruff format --check . # CI mode: exit 1 if anything would change
Pre-commit hook integration
The standard pattern is to run ruff (and your type checker) before each commit. With pre-commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.380
hooks:
- id: pyright
Then:
uv run pre-commit install
Now git commit runs ruff and pyright on changed files and refuses commits that don’t pass. Combine with pytest in CI, and you have three layers of protection: editor squiggles, pre-commit gate, full CI run.
Putting it all together
A complete pyproject.toml [tool.*] section for a modern Python project, in 2026:
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.13"
typeCheckingMode = "standard"
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = ["E", "W", "F", "I", "N", "B", "UP", "C4", "SIM", "RUF"]
ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --strict-markers"
Three tools, one config file, one virtual environment via uv. The whole loop — ruff format, ruff check, pyright, pytest — runs on a typical small project in a couple of seconds total. That’s the difference between catching bugs at the keyboard and catching them in production.
The static-analysis stack and the test stack are complementary: static analysis tells you the code makes sense to itself, tests tell you it does what you intended. Module 4 has covered both. Next module: AI-augmented workflows, which is where 2026 looks genuinely different from 2022.
Citations: pyright documentation, mypy documentation, ruff documentation, pytest documentation. Retrieval 2026-05-01.