Migrating from flake8 to Ruff for Type Lints
TL;DR — Ruff reimplements most flake8 plugins as built-in rule prefixes, so migrating is mostly a config translation, not a behavior change. Map flake8-annotations→ANN, pyupgrade→UP, flake8-type-checking→TCH, flake8-bugbear→B, list those prefixes in select, port your ignores, and delete .flake8. Ruff is fast and covers the annotation-hygiene lints, but it is not a type checker — keep mypy for that.
A typical typed project runs flake8 with a stack of plugins bolted on: annotation checks, an import-time-only enforcer, an upgrader, and bugbear. Ruff folds all of those into one Rust binary keyed by rule prefix, so the migration is finding which prefix replaces each plugin and moving your select/ignore lists across.
The plugin-to-prefix map
flake8 gains capabilities by installing plugins, and each plugin registers its own error-code prefix — ANN, B, TCH — into one flat namespace. Ruff keeps those identical prefixes but ships the rules built in: there is nothing to pip install per plugin, and a single ruff binary owns the whole set. Migrating annotation-relevant lints is therefore a matter of knowing which prefix Ruff uses for each plugin you rely on, then listing those prefixes in select. Run ruff linter to print every implemented linter with its prefix, and ruff rule ANN201 to read the rationale, a bad/good example, and the autofix availability for any single code.
Four plugins cover most annotation-relevant linting. Their Ruff equivalents:
flake8-annotations→ANN— missing-annotation checks.ANN001(missing function-argument annotation),ANN002/ANN003(missing*args/**kwargs),ANN201/ANN202(missing return annotation on a public/private function),ANN204(missing return on a special method such as__init__),ANN401(dynamically-typedAnyin a signature).pyupgrade→UP— modernizers.UP006(List[int]→list[int], PEP 585),UP007(Union[X, Y]→X | Y, PEP 604),UP045(Optional[X]→X | None),UP035(deprecatedtypingimports that moved tocollections.abc),UP037(strip quotes from now-unneeded forward references).flake8-type-checking→TCH— move import-only-for-typing names into anif TYPE_CHECKING:block.TCH001(first-party),TCH002(third-party),TCH003(stdlib). Newer Ruff aliased this prefix toTC, butTCHstill resolves for backward compatibility.flake8-bugbear→B— likely-bug patterns.B006(mutable default argument),B008(function call in a default),B904(missingraise ... frominside anexcept).
Ruff reimplements far more than these four: E/W (pycodestyle), F (pyflakes), I (isort), C4 (flake8-comprehensions), SIM (flake8-simplify), N (pep8-naming), PT (flake8-pytest-style), D (pydocstyle), and dozens more. A handful of niche flake8 plugins have no Ruff port yet — consult the official Rules index before assuming full coverage, and keep flake8 running only for the specific codes Ruff still lacks. Because the prefixes are stable, the entire select/ignore vocabulary from your .flake8 transfers verbatim; only the file it lives in changes.
Translate the config
The migration is almost entirely a syntax change from flake8’s INI dialect to Ruff’s TOML tables — the vocabulary of codes is identical, only the container changes. Three mechanical differences trip people up: comma-separated string lists become TOML arrays (select = E,F → select = ["E", "F"]); the key = value per-file-ignores block becomes a [tool.ruff.lint.per-file-ignores] table with quoted glob keys; and max-line-length becomes line-length under [tool.ruff] (not under lint, because line length governs the formatter too). Everything else — the actual rule codes — copies across letter-for-letter.
Here is a representative .flake8 and its [tool.ruff.lint] equivalent in pyproject.toml.
# .flake8 — before
[flake8]
max-line-length = 100
select = E,F,W,ANN,B
extend-select = TCH
ignore = ANN101,ANN102
per-file-ignores =
tests/*:ANN201
__init__.py:F401
# pyproject.toml — after, Ruff 0.6.x
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "ANN", "B", "UP", "TCH"]
ignore = ["ANN401"] # ANN101/ANN102 were removed from Ruff; no self/cls rule
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["ANN201"] # tests need not annotate returns
"__init__.py" = ["F401"] # re-exports
Two migration notes. select replaces the checked set wholesale; extend-select adds to whatever Ruff already selects, so use it when you want to keep the defaults and bolt on TCH. And ANN101/ANN102 (missing annotation on self/cls) no longer exist in Ruff — those were always redundant, so just drop them rather than porting them into ignore. A subtle third gotcha: flake8’s extend-ignore and extend-select have Ruff equivalents (extend-ignore, extend-select) that stack on the resolved set, whereas plain ignore/select replace it — mixing the two in a monorepo with nested configs is the usual cause of “a rule I selected isn’t firing.” When in doubt, run ruff check --show-settings path/to/file.py to print the fully-resolved rule set Ruff will actually apply.
Most flake8 projects also carried a separate isort configuration (a [isort] section in setup.cfg or an .isort.cfg), because flake8 itself never sorted imports. Ruff absorbs that too: enable the I prefix and move the isort options under [tool.ruff.lint.isort]. The common keys map directly — known_first_party becomes known-first-party, known_third_party becomes known-third-party, and combine_as_imports/force_sort_within_sections keep the same meaning with hyphens. Delete the old isort config in the same PR as .flake8; leaving it behind means editor-integrated isort and Ruff can disagree on import order and fight each other on every save.
[tool.ruff.lint.isort]
known-first-party = ["myapp"]
combine-as-imports = true
One compatibility win eases the cutover: Ruff honors the same # noqa: <code> comments flake8 used, so your existing inline suppressions keep working unchanged the moment you switch tools — no bulk find-and-replace required. Ruff also reads a bare # noqa (suppress everything on the line), but treat those as debt: run ruff check --add-noqa to freeze a legacy baseline with specific codes, then enable RUF100 so Ruff flags any suppression that no longer applies.
Turn on autofix
Much of the value flake8 never offered is that UP and TCH rules carry an autofix — Ruff doesn’t just flag legacy annotations, it rewrites them. flake8-annotations and flake8-bugbear could only report; pyupgrade had a fixer but you ran it as a separate tool. Ruff collapses lint-and-fix into one pass: ruff check --fix applies every safe fix in place, and reports whatever remains. This is what makes a one-shot codebase modernization realistic — a legacy file with Optional, List, and Union imports comes out the other side using PEP 585/604 syntax with the now-dead typing imports removed.
# Python 3.12, before ruff check --fix
from typing import List, Optional, Union
def load_ids(raw: Optional[List[Union[int, str]]]) -> List[int]:
...
# after ruff check --fix — UP006, UP007, UP045 applied
def load_ids(raw: list[int | str] | None) -> list[int]:
...
# Ruff 0.6.x — apply safe fixes, then report what remains
ruff check --fix .
Ruff splits fixes into safe (guaranteed behavior-preserving, applied by --fix) and unsafe (usually correct but capable of changing meaning, gated behind --unsafe-fixes). Most UP annotation rewrites are safe; some TCH moves and any fix that deletes code lean unsafe. Preview before committing with ruff check --diff (prints the unified diff without touching files), and stage the modernization as its own commit so the review is a pure syntax diff, not tangled with logic changes:
# Ruff 0.6.x — preview, then apply, then see what needs a human
ruff check --diff . # dry run: show the rewrite
ruff check --fix . # apply safe fixes in place
ruff check --fix --unsafe-fixes . # opt into the riskier rewrites, then review
Before you rely on UP007/UP045 rewrites, confirm your lowest supported interpreter accepts the new union operator — the matrix-testing guide shows how. If target-version is set below 3.10, Ruff withholds the bare X | Y rewrite because it would be a runtime TypeError there — unless from __future__ import annotations is present, which stringizes the annotation and makes the new syntax safe at any version. The concept pages for the resulting forms are Union and Optional types and typing collections.
ANN001 fires because an annotation is absent, not because it is wrong — Ruff will happily accept def f(x: int) -> str: return x. Only a type checker like mypy catches the [return-value] mismatch. And UP007's int | str rewrite is a runtime TypeError on Python < 3.10 unless the annotation is a string, which the linter does not check for you.
The TCH fixes deserve special care because they change where imports live, not just how annotations look. TCH002 moves a third-party import used only in annotations into an if TYPE_CHECKING: block, which trims runtime import cost — but the moved name is now undefined at runtime, so any annotation referencing it must be a string (either quoted, or made lazy by from __future__ import annotations). Without future annotations, a moved import that a library evaluates at runtime — Pydantic and FastAPI inspect annotations via typing.get_type_hints(), for instance — raises NameError. Add from __future__ import annotations (PEP 563) before turning TCH autofix loose on a codebase that does runtime annotation introspection, or exclude those modules with per-file-ignores. This is the one migration step where a purely “syntactic” linter can produce a runtime regression, so stage the TCH sweep separately and run your test suite against it.
Wire it into CI and pre-commit
Ruff replaces two flake8-era hooks at once: the linter and (via ruff-format) Black. The healthy split is that pre-commit fixes locally while CI verifies without mutating — the local hook runs ruff check --fix and ruff format so code is clean before it leaves the developer’s machine, and the CI job runs ruff check (report-only) and ruff format --check (fails if anything is unformatted) so a bypassed hook can’t sneak past. Never run --fix in CI: a job that rewrites files either discards the changes or, worse, pushes them, and either way the gate stops reflecting the committed source.
# .pre-commit-config.yaml — Ruff 0.6.x
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff # lint; add --fix to auto-repair
args: ["--fix"]
- id: ruff-format # replaces black
Pin rev to an exact tag rather than a moving branch — pre-commit caches the hook environment by rev, so a floating reference both breaks reproducibility and defeats the cache. Keep the rev here in lockstep with the ruff== pin in CI and pyproject.toml so every surface runs the identical ruleset; a version skew between the local hook and the CI check is the classic “passes locally, fails in CI” trap.
# .github/workflows/lint.yml — GitHub Actions
- run: pip install ruff==0.6.9
- run: ruff check --output-format=github . # inline PR annotations
- run: ruff format --check .
--output-format=github emits ::error workflow commands so each ANN201 or B006 lands as an inline annotation on the offending line of the diff — the same actionable feedback flake8 needed a separate problem-matcher to produce. The astral-sh/ruff-action is an alternative to pip install ruff that pins the version and wires up the annotations for you; either way, pin the exact version rather than tracking latest, because Ruff stabilizes new rules on a roughly biweekly cadence and a floating version can turn a green pipeline red overnight when a newly-stable rule fires on existing code. On GitLab CI the same binary works — run ruff check --output-format=gitlab to emit a Code Quality report artifact. See pre-commit hooks setup for the local-vs-CI split, and keep ruff format --check as its own step so a missing local format run fails loudly instead of silently drifting.
Common mistakes
Most migration regressions are config-hygiene issues, not rule differences — a leftover file, an overwritten default set, or an expectation that a linter does a type checker’s job. Treat the cutover as a checklist and each disappears.
- Expecting Ruff to type-check.
ANNrules only detect missing annotations; a wrong annotation still needs mypy’s[return-value]/[arg-type]. Ruff will acceptdef f(x: int) -> str: return xwithout complaint — only a type checker resolves the mismatch. Ruff and a type checker are complementary, not substitutes; see Ruff UP rules vs mypy --strict for the exact division of labor. - Using
selectwhen you meantextend-select.selectoverwrites the default set, so listing only["ANN"]silently disables theE/Fchecks that catch syntax errors and undefined names. Useextend-selectto add on top of the defaults, and confirm withruff check --show-settings. - Porting
ANN101/ANN102. They were removed from Ruff (theself/clsannotation rules were always redundant); keeping them inignoreis harmless clutter, but putting them inselecterrors out with an “unknown rule” message. Just delete them. - Leaving
.flake8behind. A stray.flake8,setup.cfg [flake8], ortox.ini [flake8]section confuses contributors and CI about which linter is authoritative, and some editors will still run flake8 against it. Delete every flake8 config surface once[tool.ruff.lint]is in place.
FAQ
Does Ruff replace mypy?
No. Ruff is a linter and formatter; it checks annotation presence and style (ANN, UP, TCH), not annotation correctness. Pair it with mypy or pyright, which do the actual type inference and catch [arg-type] / [return-value].
How do I keep flake8 and Ruff running side by side during the transition?
You can, but disable overlapping rules on one side to avoid double reports — e.g. drop B and ANN from .flake8 once Ruff owns them. Most teams cut over in a single PR since the rule coverage maps cleanly.