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, disallow_any_generics, 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.
The two tools also differ in scope of analysis, which is why one is instant and the other is not. Ruff, written in Rust, lints each file in isolation: it never opens the modules you import, so it has no idea what SomeClass.method returns. That single-file model is exactly what lets it check thousands of files in tens of milliseconds. mypy does the opposite — it walks the import graph, loads .pyi stubs for third-party packages, and propagates inferred types across module boundaries, which is why a cold run on a large codebase takes seconds to minutes (see Pyright vs mypy speed for the caching techniques that tame it).
Because their inputs differ, their outputs can never coincide. Consider one function:
# Python 3.9, targeted by both tools
from typing import Optional
def head(items: Optional[list]) -> Optional[str]:
return items[0]
Ruff sees two Optional[...] spellings and raises UP045 twice, offering to rewrite them to list | None and str | None. It also raises UP006/type-arg-adjacent noise on the bare list only if you enable those rules — but it says nothing about the body. mypy ignores the spelling entirely — Optional[str] and str | None are the same type to it — yet flags items[0] with [index] because items may be None, and under --strict warns that the bare list is list[Any] via [type-arg]. Same three lines, two disjoint diagnostics, zero overlap. That is the whole argument for running both: each tool is blind to the other’s category of defect.
A useful mental model: Ruff answers “does this annotation follow our house style and the modern PEP spellings?” while mypy answers “if I actually trace the types, does this program hold together?” Neither question implies the other. A file can be 100% UP-clean and still be a minefield of [arg-type] errors; a file mypy accepts as fully sound can still be littered with Dict[str, int] that UP006 wants modernized.
What the UP rules enforce
The UP category modernizes syntax to match the lowest Python version you target (target-version in config). Ruff only emits a given UP fix when the rewrite is safe to run on that floor — either the interpreter is new enough, or the annotation is stringized. 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
...
Each rewrite is anchored to a specific PEP, and each has a distinct runtime floor that Ruff enforces through target-version:
Key codes to grep for in CI logs:
- UP006 — use
list/dict/setinstead oftyping.List/Dict/Set(PEP 585). Runtime-subscriptable since Python 3.9; on 3.8 the rewrite is only safe underfrom __future__ import annotations, and Ruff respects that. - UP007 — use
X | Yinstead ofUnion[X, Y](PEP 604). The|operator on types is a runtime construct available from 3.10; earlier interpreters need the annotation stringized. - UP045 — use
X | Noneinstead ofOptional[X]. Split out ofUP007in newer Ruff releases so you can adopt or suppress theOptionalrewrite independently of the generalUnionrewrite. - UP035 — flag deprecated
typingimports whose canonical home is nowcollections.abc(Mapping,Sequence,Iterable) or builtins; also flagstyping.Textand other removed aliases. - UP037 — remove quotes from forward-reference annotations that no longer need them, e.g. once
from __future__ import annotationsmakes every annotation lazy.
None of these change behavior or catch type bugs. They keep your Union and Optional syntax modern and consistent, and they normalize a codebase so that reviewers and downstream tools see one spelling instead of five. Because UP006, UP007, and UP045 all carry safe autofixes, a single ruff check --fix sweep can retire the entire typing.List/Union/Optional vocabulary from a repository in one commit — which is why teams often pair the UP family with a one-time flake8-to-Ruff migration.
What mypy --strict enforces
--strict is about correctness and coverage, not style. It is not a single check but a curated bundle of stricter flags, and each flag maps to a family of error codes. A representative slice of what those flags catch:
# 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]
--strict expands into roughly a dozen individual settings; the ones with the highest signal are shown below alongside the error code each one turns on:
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.
- [type-arg] — a generic used without its type parameters, e.g. bare
listwherelist[int]is required (fromdisallow_any_generics). - [unused-ignore] — a
# type: ignorethat no longer suppresses anything (fromwarn_unused_ignores).
Ruff cannot produce any of these, because each requires resolving and propagating types across the program. A subtle point on strictness levels: mypy made no_implicit_optional the default in 0.990, so an implicit-None default like def f(x: int = None) is now an error even without --strict. The --strict umbrella is therefore additive on top of a baseline that has already tightened over the 0.9x → 1.x series, and recent releases have folded extra_checks into the bundle as well. If turning on the full bundle at once floods CI, adopt it incrementally — enable one flag at a time in [tool.mypy], as covered in optimizing mypy for large codebases — rather than gating on --strict before the code is ready.
Mapping concerns to the right tool
Once you internalize the syntax-versus-semantics split, routing a concern to the right tool is mechanical. Anything about the shape of the annotation text belongs to Ruff; anything about whether the types actually line up belongs to mypy. The table below is the canonical routing sheet, and the diagram sorts the same concerns into their owning 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. There is exactly one region where the columns brush against each other — annotation presence. Ruff’s ANN family (from flake8-annotations) flags a missing annotation syntactically, and mypy’s disallow_untyped_defs flags the same missing annotation semantically. They overlap in when they fire but not in what else they do: mypy additionally validates the annotation you did write, while ANN never looks at correctness. Most teams resolve the overlap by letting mypy own def-annotation coverage and disabling ANN under strict mypy — the exact wiring is in integrating ruff check with mypy in CI. Everything outside that one cell routes to a single owner with no ambiguity, and ruff format (whitespace, quotes, line wrapping) is a third bin neither type checker touches at all.
Edge cases
The tools are cleanly separated in theory, but their interaction has sharp corners worth knowing before you wire both into the same pipeline. Most bite at the seam where a Ruff autofix changes text that mypy then re-reads.
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’s warn_unused_ignores (part of --strict) may now report [unused-ignore]. Sequence the pipeline so ruff check --fix runs before mypy, and re-run mypy after any 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 — a [valid-type] error on code ruff just wrote. Set both from your true minimum supported interpreter, and verify it in a version matrix.
PEP 604 syntax has runtime weight the linter cannot see. X | None written outside from __future__ import annotations is evaluated at function-definition time, so on Python 3.7–3.9 it raises TypeError: unsupported operand type(s) for |. Ruff’s target-version gate is what stops it emitting UP007/UP045 where the interpreter can’t run the result — but if you hand-write the union yourself, neither tool warns you at edit time. The future import stringizes every annotation, which both removes the runtime cost and lets UP037 de-quote forward references safely.
UP038 is a trap Ruff itself retracted. UP038 once rewrote isinstance(x, (int, float)) to isinstance(x, int | float). That form is legal from 3.10 but slower at runtime and was later deprecated in Ruff, so if an old config still selects it, expect churn with no upside — drop it from select.
TYPE_CHECKING-only names interact with both tools. When you move imports under if TYPE_CHECKING: (Ruff’s TCH family), those names exist only during type checking. mypy resolves them fine, but the annotations that reference them must be stringized or lazy (from __future__ import annotations), or you get a runtime NameError. Ruff moves the import; only mypy confirms the annotation still resolves.
Common mistakes
The recurring errors all stem from one root misconception — treating a clean run from one tool as evidence about the other. Each mistake below pairs the wrong belief with what is actually true.
- 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, and gate CI on both exit codes rather than assuming a green Ruff run implies a sound program. - Disabling
UPbecause “mypy already covers it.” It doesn’t — mypy is indifferent toOptional[X]vsX | None; the two are the same type to it. DroppingUPjust lets legacytyping.List/Unionsyntax drift back in, undoing the normalization and re-fragmenting the codebase’s annotation style. - 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.ANNsees an absent annotation; only mypy sees a wrong one. - Running the two tools in the wrong order. Because a
ruff check --fixsweep rewrites text mypy then reads, running mypy first wastes the pass — it checks code that is about to change. Fix with Ruff, then type-check the result, so mypy never reports on syntax that is one commit from being modernized.
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.