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
Four plugins cover most annotation-relevant linting. Their Ruff equivalents:
flake8-annotations→ANN— missing-annotation checks.ANN001(missing function argument annotation),ANN201(missing return annotation),ANN401(dynamically-typedAnyin a signature).pyupgrade→UP— modernizers.UP006(List[int]→list[int]),UP007(Union[X, Y]→X | Y),UP035(deprecatedtypingimports),UP045(Optional[X]→X | None).flake8-type-checking→TCH— move import-only-for-typing names into anif TYPE_CHECKING:block.TCH001(first-party),TCH002(third-party),TCH003(stdlib).flake8-bugbear→B— likely-bug patterns.B006(mutable default argument),B008(function call in a default).
Translate the config
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.
Turn on autofix
Much of the value is that UP and TCH rules are auto-fixable. Ruff rewrites the code for you:
# 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 .
Before you rely on UP007/UP045 rewrites, confirm your lowest supported interpreter accepts the new union operator — the matrix-testing guide shows how. 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.
Wire it into CI and pre-commit
Replace the flake8 hook with Ruff’s, and add both the linter and formatter.
# .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
# .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. See pre-commit hooks setup for the local-vs-CI split.
Common mistakes
- Expecting Ruff to type-check.
ANNrules only detect missing annotations; a wrong annotation still needs mypy’s[return-value]/[arg-type]. Ruff and a type checker are complementary, not substitutes. - Using
selectwhen you meantextend-select.selectoverwrites the default set, so listing only["ANN"]silently disablesE/F. Useextend-selectto add on top of the defaults. - Porting
ANN101/ANN102. They were removed from Ruff; keeping them inignoreis harmless clutter, and putting them inselecterrors out. Just delete them. - Leaving
.flake8behind. A stray.flake8orsetup.cfg [flake8]section confuses contributors and CI about which linter is authoritative. Delete it 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.