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-annotationsANN, pyupgradeUP, flake8-type-checkingTCH, flake8-bugbearB, 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.

Mapping flake8 plugins to Ruff rule prefixes Four flake8 plugins on the left map to their Ruff rule-prefix equivalents on the right, all handled by one Ruff binary. flake8 plugins Ruff prefixes flake8-annotations pyupgrade flake8-type-checking flake8-bugbear ANN (ANN001, ANN401) UP (UP006, UP007) TCH (TCH001–003) B (B006, B008) one binary, config in [tool.ruff.lint]
Each flake8 plugin becomes a Ruff rule prefix you list in select; the whole stack collapses into one config block.

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-annotationsANN — 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-typed Any in a signature).
  • pyupgradeUP — modernizers. UP006 (List[int]list[int], PEP 585), UP007 (Union[X, Y]X | Y, PEP 604), UP045 (Optional[X]X | None), UP035 (deprecated typing imports that moved to collections.abc), UP037 (strip quotes from now-unneeded forward references).
  • flake8-type-checkingTCH — move import-only-for-typing names into an if TYPE_CHECKING: block. TCH001 (first-party), TCH002 (third-party), TCH003 (stdlib). Newer Ruff aliased this prefix to TC, but TCH still resolves for backward compatibility.
  • flake8-bugbearB — likely-bug patterns. B006 (mutable default argument), B008 (function call in a default), B904 (missing raise ... from inside an except).

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.

Ruff prefix coverage grid A table pairing each migrated flake8 plugin's Ruff prefix with representative rule codes and whether Ruff can autofix that family. prefix example codes autofix? ANN ANN001 · ANN201 · ANN401 no — reports only UP UP006 · UP007 · UP035 · UP045 yes — safe TCH TCH001 · TCH002 · TCH003 yes — moves imports B B006 · B008 · B904 mostly no
Each migrated prefix carries the same codes flake8 used; only UP and TCH are broadly autofixable, while ANN and B mainly report.

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,Fselect = ["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.

Translating .flake8 INI into pyproject.toml TOML The left panel is the old flake8 INI config; the right panel is the equivalent Ruff TOML, with three lines marked for the format changes they require. .flake8 (INI) — before max-line-length = 100 select = E,F,W,ANN,B ignore = ANN101,ANN102 per-file-ignores = tests/*:ANN201 pyproject.toml — after line-length = 100 select = ["E","F","W","ANN","B"] ignore = ["ANN401"] # drop 101/102 [...per-file-ignores] "tests/*" = ["ANN201"]
The codes are identical; only the container changes — INI keys and comma lists become TOML tables and arrays.

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.

Autofix transformation pipeline Legacy typing syntax enters ruff check fix, which applies UP006, UP007, and UP045, and emerges as modern union and builtin-generic annotations. legacy annotation Optional[List[Union[..]]] ruff check --fix UP006 · UP007 · UP045 safe fixes only modern syntax list[int | str] | None unused typing imports removed in the same pass
One ruff check --fix pass rewrites legacy annotations to modern syntax and prunes the now-unused typing imports.
# 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.

Runtime vs static analysis Ruff and flake8 are linters: they read your annotations as text and never verify them. 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.

Local fix lane vs CI verify lane The pre-commit lane runs ruff with fix and format to repair code before commit; the CI lane runs check and format-check to verify the pushed source without editing it. local — pre-commit (fixes) edit code ruff check --fix ruff format CI — pull request (verifies, never mutates) ruff check (report) ruff format --check pass / fail gate
Local hooks auto-fix before commit; CI verifies the pushed source with report-only check and format --check, never rewriting files.
# .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.

flake8-to-Ruff cutover checklist Four rows each contrast the correct migration action with the mistake it prevents. cutover checklist delete .flake8 and setup.cfg [flake8] once [tool.ruff.lint] exists use extend-select to keep Ruff's E/F defaults plus your prefixes drop ANN101 / ANN102 — removed from Ruff, not ported keep mypy for correctness — Ruff checks presence, not truth ✗ overwriting select · ✗ leaving .flake8 · ✗ porting dead codes · ✗ expecting type checks
Run the cutover as a checklist: remove the old config, preserve defaults with extend-select, drop removed codes, and keep a type checker for correctness.
  • Expecting Ruff to type-check. ANN rules only detect missing annotations; a wrong annotation still needs mypy’s [return-value] / [arg-type]. Ruff will accept def f(x: int) -> str: return x without 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 select when you meant extend-select. select overwrites the default set, so listing only ["ANN"] silently disables the E/F checks that catch syntax errors and undefined names. Use extend-select to add on top of the defaults, and confirm with ruff check --show-settings.
  • Porting ANN101/ANN102. They were removed from Ruff (the self/cls annotation rules were always redundant); keeping them in ignore is harmless clutter, but putting them in select errors out with an “unknown rule” message. Just delete them.
  • Leaving .flake8 behind. A stray .flake8, setup.cfg [flake8], or tox.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.

Back to Ruff Linter Integration