Python Ruff Catches 900 Rules — Lint Setup That Scales
Flake8 plus Black plus isort is slow and split.
20+ years shipping production Python across data and backend systems. Everything here is grounded in real deployments.
- ✓Basic Python syntax knowledge
- ✓A Python project with pyproject.toml
- ✓Git plus pre-commit installed
- Ruff is Astral's Rust linter and formatter for Python — one binary replacing Flake8, Black, isort, pyupgrade, and autoflake
- Two subcommands: ruff check finds violations across 900+ rules, ruff format enforces Black-compatible style; run both in pre-commit and CI
- Performance insight: Ruff runs 10-100x faster than Flake8/Black with built-in caching — warm runs on unchanged files finish in milliseconds, not minutes
- Production rule: roll out rule families incrementally (E,F first, then B, UP, I, SIM) and run CI as bare ruff check so violations fail instead of being silently rewritten
- Set target-version to your real minimum Python or UP rules will suggest syntax production cannot parse
- Biggest mistake: select ALL on day one plus --fix --unsafe-fixes in CI — that buries the team and can rewrite semantics nobody reviewed
Picture an editor who reads your entire novel in three seconds and flags every typo, repeated phrase, and outdated expression — then fixes the safe ones for you. That's Ruff for Python code. Before it, you needed four different editors (one for spelling, one for margins, one for chapter order, one for modern word usage), each slow and each with its own rulebook. Ruff merges them into a single speed-reader with 900+ checks built in. It learns your preferences from one short config file, ignores the chapters you tell it to skip, and finishes before you've lifted your fingers off the keyboard. The catch is the same as with any strict editor: turn on every rule on day one and you'll get 12,000 red marks and stop reading them.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Python linting used to mean assembling a toolchain. Flake8 for errors, Black for formatting, isort for imports, pyupgrade for modernization, plus a plugin for every extra opinion. Each tool parsed your code separately, so a pre-commit run took minutes and configs lived in three files.
Slow feedback kills adoption. When lint takes longer than the test suite, developers skip the hook with --no-verify. You'll recognize the result: PRs full of style nits and real bugs hiding behind them.
Ruff ends the assembly job. One Rust binary runs 900+ rules and a Black-compatible formatter in milliseconds. It reads a single config section and plugs into pre-commit, CI, and VS Code cleanly.
Speed alone won't save you though. Enabling every rule at once buries the team, and mixing two formatters creates phantom diffs. This guide shows the rollout that sticks.
Why Ruff Replaces Four Tools Instead of Joining Them
The classic Python toolchain grew by accretion. Flake8 checks errors, Black formats, isort sorts imports, pyupgrade modernizes syntax — each parses your code independently, each has its own config, and a pre-commit run pays the startup cost four times. On a large repo that means minutes per commit.
Ruff reimplements all of it in Rust behind one interface. Its 900+ rules are native ports of Flake8 plugins (bugbear, pyupgrade, isort, pydocstyle, and more), and its formatter is a Black-compatible fork of the Rome formatter. One parse feeds every check, and a global cache skips unchanged files.
The practical effect is a different development rhythm. Lint-on-save in VS Code answers instantly, pre-commit hooks finish before you context-switch, and CI lint stages stop dominating pipeline time. Speed is not vanity here — it is what makes the checks actually run.
Install Ruff and Baseline Before Changing Anything
Install Ruff with pip, uv, or the standalone installer, then take a baseline before changing anything. ruff check --statistics ranks your current violations by rule so you know what you are adopting. ruff format --check shows how far your style is from the formatter without touching files.
Do not fix anything yet. The baseline tells you which rule families are cheap to enable (usually E and F) and which will need a campaign (often SIM or D). That ordering drives the whole rollout.
Configure Once — pyproject.toml That Survives Review
All Ruff configuration lives in one place: pyproject.toml (or ruff.toml). Set line-length and target-version first — target-version gates every modernization suggestion, so it must match your oldest production interpreter.
The lint section selects rule families: E (errors), F (pyflakes), B (bugbear), UP (pyupgrade), I (isort). Start with that set. Per-file ignores exempt tests from assert rules and migrations from everything. Keep unsafe-fixes off globally and enable it only for deliberate one-shot cleanups.
The format section mirrors Black options. One decision matters most: Ruff format or Black, never both. The example above is a sane production starting point for a Python 3.12 fleet.
check vs format vs fix — The Daily Commands
ruff check reports violations; --fix applies safe autofixes like removing unused imports. Unsafe fixes — things that can change semantics — require explicit opt-in and a human reading the diff. That two-tier design is the whole safety model.
Local workflow: ruff check --fix . then ruff format ., review the diff, commit. CI workflow: bare ruff check . and ruff format --check . with no fix flags, so violations fail the build. Fixing in CI means CI authors code nobody reviews.
--statistics stays useful forever: run it monthly to find the next rule family worth enabling. Each new family is a small PR with an owner, not a big bang.
Pre-commit, VS Code, and CI Wired Together
Two hooks cover everything: ruff-check with --fix for lint autofixes, ruff-format for style. Pin rev to an exact version and bump it monthly with pre-commit autoupdate — unpinned hooks are how phantom diffs sneak in.
Pair the hooks with the first-party VS Code extension for instant feedback while typing. The extension uses the same binary and config, so editor and CI never disagree.
For GitHub Actions, the ruff-action runs the same checks on the whole repo. Keep the local and remote versions identical; version skew between hook and action produces works-here-fails-there confusion.
Rolling Out to Legacy Code Without a Revolt
Legacy codebases need a campaign, not a flag day. Week one: E and F with autofix. Week two: B (bugbear catches real bugs). Week three: UP (modernize syntax). Then I (import sorting) and SIM (simplifications), each as a reviewed PR.
Preview rules stay off in production config — they are the unstable frontier where the incident above happened. Revisit them quarterly, never on a Friday.
Track the burn-down with --statistics in a visible place. Watching SIM violations drop from 800 to zero across four weeks keeps the team motivated in a way a single giant PR never does.
The Friday Linter Swap That Rewrote Exception Handling
select = ["ALL"] on a 400k-line legacy codebase surfaced a decade of tolerated patterns at once. Second, a preview rule for exception-tuple parentheses (aimed at Python 3.14 semantics) rewrote except (ValueError,) handling in a payments module whose production interpreter was 3.11. The rewrite was syntactically valid but changed which exceptions were caught, and it shipped because CI ran --fix instead of failing.ruff check --fix plus ruff format; CI runs bare ruff check and ruff format --check to fail loudly. Six weeks later the full set was on with zero reverts.- Linter migrations are behavior changes disguised as tooling changes. Roll out rule families like feature flags: incrementally, with owners and a revert path.
- CI should fail on violations, never silently fix them. Autofix in CI rewrites code nobody reviewed.
ruff check --show-settings and ruff check --explain <CODE> to see which config file wins and what the rule means. Fix: consolidate to one pyproject.toml section and delete stale .flake8/setup.cfg files that shadow it.ruff format --check --diff on one file to see the contested lines, then check pre-commit config for both black and ruff-format hooks. Fix: keep exactly one formatter and reformat the repo in a single isolated commit.ruff check --statistics to rank violations, then scope the noisy family with per-file ignores in pyproject.toml. Fix: enable families incrementally instead of ALL, and exempt generated files and migrations explicitly.python3 --version on the deploy image). Fix: set target-version to the true minimum and re-run; UP suggestions that cannot parse there must stay disabled.| File | Command / Code | Purpose |
|---|---|---|
| pip install "ruff==0.16.6" | Install Ruff and Baseline Before Changing Anything | |
| pyproject.toml | [tool.ruff] | Configure Once |
| .pre-commit-config.yaml | repos: | Pre-commit, VS Code, and CI Wired Together |
Key takeaways
Common mistakes to avoid
4 patternsEnabling ALL rules on a legacy codebase at once
ruff check --select E,F --fix for safe fixes, then enable one family at a time (B, UP, I, SIM) with team review. Track progress with ruff check --statistics.Running Black and Ruff format together
ruff format once over the repo in a single isolated commit.Leaving target-version unset while enabling pyupgrade rules
Auto-fixing everything with --fix --unsafe-fixes in CI
ruff check --fix for safe autofixes and review unsafe ones individually. In CI, run ruff check without --fix so violations fail the build instead of being silently rewritten.Interview Questions on This Topic
What is Ruff and how does it replace the classic Python toolchain?
ruff check finds violations, ruff format enforces Black-compatible style, and both read one pyproject.toml section. Built-in caching means unchanged files are skipped, so warm runs finish in milliseconds.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Everything here is grounded in real deployments.
That's Tooling. Mark it forged?
3 min read · try the examples if you haven't