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.

Syntactic linting vs semantic type checking Ruff UP rules operate on annotation syntax and autofix it; mypy strict checks whether the types are actually sound. Two passes over the same code ruff (UP rules) syntactic · per-file · no imports Optional[int] → int | None List[str] → list[str] autofix with --fix UP006 · UP007 · UP045 mypy --strict semantic · whole-program · resolves types is this assignment sound? is every def annotated? no fix — it reports [no-untyped-def] · [return-value]
Ruff rewrites how annotations look; mypy decides whether they are correct.

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.

Choosing Ruff or mypy by the kind of question you are asking A decision tree: the root question splits into a how-it-is-written branch leading to Ruff UP rules and a whether-it-is-correct branch leading to mypy strict, each with example codes. What do you need checked? one concern about one line how it is written whether it is correct Ruff UP rules syntactic · autofixable mypy --strict semantic · report-only Optional[X] → X | None List[str] → list[str] UP006 · UP007 · UP045 is the return type sound? is every def annotated? [return-value] · [no-untyped-def]
Route each concern by its kind: annotation spelling goes to Ruff's autofixable UP rules; type soundness goes to mypy's report-only strict mode.

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:

What each UP rule rewrites Five rows show a legacy annotation spelling on the left rewritten to its modern equivalent on the right, each tagged with its UP rule code. legacy spelling modern equivalent rule List[int] list[int] UP006 Union[int, str] int | str UP007 Optional[int] int | None UP045 typing.Mapping collections.abc.Mapping UP035 "Node" (quoted ref) Node UP037
The annotation-relevant UP rewrites: PEP 585 generics (UP006), PEP 604 unions (UP007/UP045), stdlib re-homing (UP035), and forward-reference de-quoting (UP037).

Key codes to grep for in CI logs:

  • UP006 — use list/dict/set instead of typing.List/Dict/Set (PEP 585). Runtime-subscriptable since Python 3.9; on 3.8 the rewrite is only safe under from __future__ import annotations, and Ruff respects that.
  • UP007 — use X | Y instead of Union[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 | None instead of Optional[X]. Split out of UP007 in newer Ruff releases so you can adopt or suppress the Optional rewrite independently of the general Union rewrite.
  • UP035 — flag deprecated typing imports whose canonical home is now collections.abc (Mapping, Sequence, Iterable) or builtins; also flags typing.Text and other removed aliases.
  • UP037 — remove quotes from forward-reference annotations that no longer need them, e.g. once from __future__ import annotations makes 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:

What the --strict bundle expands into mypy --strict is a stack of individual flags; each flag in the stack emits a specific error code shown on the right. mypy --strict expands to… …these error codes disallow_untyped_defs [no-untyped-def] warn_return_any [no-any-return] disallow_any_generics [type-arg] disallow_untyped_calls [no-untyped-call] strict_equality [comparison-overlap]
A slice of the --strict stack: each internal flag is what actually emits a given error code, so you can dial strictness one flag at a time instead of all-or-nothing.

Codes you only get from mypy:

  • [no-untyped-def] — a def is missing annotations (from disallow_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 Any from a typed function (from warn_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 list where list[int] is required (from disallow_any_generics).
  • [unused-ignore] — a # type: ignore that no longer suppresses anything (from warn_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.

Sorting concerns into the tool that owns them Five concern chips on the left are each routed by an arrow into either the Ruff bin, for syntax, or the mypy bin, for correctness. a concern owning tool Optional[X] should be X | None List/Dict should be list/dict every def is annotated return matches declared type no Any leaking out Ruff UP bin syntax · autofix mypy --strict bin correctness · coverage report-only
Two concerns sort into Ruff (they are about spelling); three sort into mypy (they are about whether the types hold). No chip lands in both.
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.

How a ruff autofix can surface a fresh mypy error Sequence: ruff check --fix modernizes source, the source is normalized, mypy re-runs, and a previously masked type ignore now reports unused-ignore. 1 ruff check --fix UP007 rewrites 2 source normalized Union → X | Y 3 mypy re-runs re-checks types 4 [unused-ignore] stale suppression
A ruff --fix sweep changes the source mypy reads next, so a stale type: ignore can flip to [unused-ignore] — always re-run mypy after autofixing.

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.

Three UP-versus-strict misconceptions and their corrections Three cards, each with a crossed-out misconception on top and the checked correcting reality below it. "UP autofix passed, so the types are checked." UP only rewrote spelling — run mypy for correctness. "mypy covers it, so I can drop the UP rules." mypy ignores spelling — legacy syntax drifts back in. "Ruff ANN equals mypy disallow_untyped_defs." ANN checks presence; mypy also validates the annotation.
Every common mistake is a version of assuming one tool's clean run vouches for the other's domain — it never does.
  • 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 UP because “mypy already covers it.” It doesn’t — mypy is indifferent to Optional[X] vs X | None; the two are the same type to it. Dropping UP just lets legacy typing.List/Union syntax drift back in, undoing the normalization and re-fragmenting the codebase’s annotation style.
  • Expecting ruff to flag [no-untyped-def]. Ruff has its own ANN (flake8-annotations) rules that flag missing annotations syntactically, but they are not the same as mypy’s semantic disallow_untyped_defs, which also validates the annotations that exist. ANN sees an absent annotation; only mypy sees a wrong one.
  • Running the two tools in the wrong order. Because a ruff check --fix sweep 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.

Back to Ruff Linter Integration