Ruff UP Rules vs mypy --strict: Who Checks What
Ruff’s UP (pyupgrade) rules and mypy --strict look like they overlap, but they police completely different things: UP rewrites the syntax of your annotations (turning Optional[X] into X | None), while mypy --strict verifies the meaning of your types (catching a function that returns int where str was promised). They are complementary, not redundant — you want both in CI. This guide maps concrete concerns to the right tool and gives you real rule and error codes for each.
Why these tools are not interchangeable
Ruff is a linter and formatter. It parses each file into a syntax tree and applies lint rules; many UP rules carry an autofix. It does not build a type graph, resolve imports, or reason about whether a value of one type can flow into a slot expecting another. mypy, by contrast, is a static type checker: it resolves your whole import graph, infers types, and validates assignability. --strict is a bundle of flags (disallow_untyped_defs, warn_return_any, no_implicit_optional, and more) that raise the bar on what counts as a typed program.
The practical consequence: ruff can tell you to write dict[str, int] instead of Dict[str, int], but it cannot tell you the function actually returns dict[str, str]. Only mypy can.
What the UP rules enforce
The UP category modernizes syntax to match the lowest Python version you target (target-version in config). The annotation-relevant rules:
# Python 3.9+, ruff UP006 / UP007 / UP045
from typing import Dict, List, Optional, Union
def load_config(path: str) -> Dict[str, int]: # ruff: UP006 (use dict)
...
def first(items: List[str]) -> Optional[str]: # UP006 (list) + UP045 (X | None)
...
def parse(raw: Union[int, str]) -> int: # ruff: UP007 (use X | Y)
...
After ruff check --fix, the same code becomes:
# Python 3.10+, modernized by ruff --fix
def load_config(path: str) -> dict[str, int]: # UP006 applied
...
def first(items: list[str]) -> str | None: # UP006 + UP045 applied
...
def parse(raw: int | str) -> int: # UP007 applied
...
Key codes to grep for in CI logs:
- UP006 — use
list/dict/setinstead oftyping.List/Dict/Set(PEP 585). - UP007 — use
X | Yinstead ofUnion[X, Y](PEP 604). - UP045 — use
X | Noneinstead ofOptional[X](split out from UP007 in newer ruff). - UP035 — flag deprecated
typingimports that should come fromcollections.abc. - UP037 — remove quotes from now-unnecessary forward-reference annotations.
None of these change behavior or catch type bugs. They keep your Union and Optional syntax modern and consistent.
What mypy --strict enforces
--strict is about correctness and coverage, not style. A representative slice:
# Python 3.11+, mypy 1.x --strict
def build_payload(name): # mypy error: [no-untyped-def]
return {"name": name} # "Function is missing a type annotation"
def fetch_count() -> int:
return "0" # mypy error: [return-value]
def total(values: list[int]) -> int:
return sum(values) + None # mypy error: [operator]
Codes you only get from mypy:
- [no-untyped-def] — a
defis missing annotations (fromdisallow_untyped_defs). - [return-value] — the returned value’s type does not match the declared return.
- [arg-type] — an argument’s type is incompatible with the parameter.
- [no-any-return] — returning
Anyfrom a typed function (fromwarn_return_any). - [union-attr] — accessing an attribute that may not exist on every member of a union.
Ruff cannot produce any of these, because each requires resolving and propagating types across the program.
from __future__ import annotations is evaluated at function-definition time, so on Python 3.7–3.9 it raises TypeError. Ruff's target-version guards against emitting syntax your interpreter can't run.
Mapping concerns to the right tool
| Concern | Ruff UP |
mypy --strict |
|---|---|---|
Optional[X] should be `X |
None` | UP045 (autofix) |
Union[X, Y] should be `X |
Y` | UP007 (autofix) |
List/Dict should be list/dict |
UP006 (autofix) | — |
Import from collections.abc not typing |
UP035 | — |
| Every function is annotated | — | [no-untyped-def] |
| Return value matches the declared type | — | [return-value] |
| Argument types are compatible | — | [arg-type] |
No accidental Any leaking out |
— | [no-any-return] |
| Attribute exists on all union members | — | [union-attr] |
The split is clean: anything in the “shape of the annotation text” column is ruff; anything in the “is this type actually correct” column is mypy.
Edge cases
Ruff fixes can surface new mypy errors. When UP007 collapses Union[int, str] to int | str, nothing changes semantically — but if you were previously suppressing a [union-attr] with a stale # type: ignore, mypy may now report [unused-ignore]. Run mypy again after a ruff autofix sweep.
Ruff respects target-version; mypy respects python_version. Keep them aligned. If ruff targets 3.10 (and emits X | None) but mypy is pinned to python_version = 3.9, mypy will reject the new-style union syntax as invalid at that version.
Common mistakes
- Treating UP autofix as “type checking done.” UP rewrote your syntax; it verified nothing about correctness. A fully UP-clean file can still be riddled with
[arg-type]errors. Always run mypy too. - Disabling
UPbecause “mypy already covers it.” It doesn’t — mypy is indifferent toOptional[X]vsX | None. DroppingUPjust lets legacy syntax drift back in. - Expecting ruff to flag
[no-untyped-def]. Ruff has its ownANN(flake8-annotations) rules that flag missing annotations syntactically, but they are not the same as mypy’s semanticdisallow_untyped_defs, which also validates the annotations that exist.
FAQ
Should I run ruff or mypy first in CI?
Run ruff check --fix (and ruff format) first so mypy sees the modernized, normalized source. Then run mypy on the result. Order them as separate, fast-failing steps.
Can ruff replace mypy entirely? No. Ruff has no semantic type-inference engine, so it cannot verify assignability, return types, or argument compatibility. It complements a type checker; it does not substitute for one.