Here is a pre-commit hook I used to have:
black .
isort .
flake8 .
pylint src/
Four tools. Four configs. Four things that could break. And on a mid-sized project, about 18 seconds of waiting every time I committed.
Now I have this:
ruff check . --fix
ruff format .
Two commands. One tool. 0.3 seconds. Same coverage, fewer headaches.
That tool is Ruff, and if you're still running the old stack, this article will convince you to switch.
What Is Ruff?
Ruff is a Python linter and formatter written in Rust. It was built by Astral, the same team behind uv. The pitch is simple: replace Flake8, Black, isort, pyupgrade, and autoflake with one binary that runs 10 to 100 times faster than any of them individually.
It's not a wrapper around those tools. Ruff re-implements their rules directly in Rust, which is why it's so fast and why it has no Python dependencies at all.
As of 2025, Ruff ships with over 900 lint rules. It's used in production by NumPy, Pandas, FastAPI, Airflow, and thousands of other projects.
Installing Ruff
# Via pip
pip install ruff
# Via uv (recommended)
uv add --dev ruff
# Standalone binary (no Python required)
curl -LsSf https://astral.sh/ruff/install.sh | sh
Check it works:
ruff --version
# ruff 0.x.x
The Two Commands You'll Use Every Day
Ruff has two main modes: check (linting) and format (formatting).
# Lint your code
ruff check .
# Lint and auto-fix what can be fixed
ruff check . --fix
# Format your code (Black-compatible)
ruff format .
# Check formatting without changing files
ruff format . --check
That's the whole surface area for day-to-day use. Everything else is configuration.
What Does It Actually Catch?
Let's take a messy file:
import os
import sys
import json
def greet(name):
x = 42
if name == None:
return "nobody"
msg = "Hello, %s" % name
return msg
Run ruff check messy.py:
messy.py:3:1: F401 `json` imported but unused
messy.py:6:5: F841 Local variable `x` is assigned to but never used
messy.py:7:12: E711 Comparison to `None` (use `is None`)
messy.py:8:11: UP031 Use f-string instead of `%`-formatting
Found 4 errors.
Now run ruff check messy.py --fix and look at the result:
import os
import sys
def greet(name):
if name is None:
return "nobody"
msg = f"Hello, {name}"
return msg
Unused import removed. == None fixed to is None. %-formatting upgraded to an f-string. All automatically.
The x = 42 unused variable is flagged but not auto-fixed because removing a variable assignment could theoretically change behavior in edge cases. Ruff is conservative about what it touches automatically.
Replacing isort
Import sorting is built in. No separate tool, no separate config.
# Before
import sys
import os
from collections import defaultdict
import json
from typing import Optional
# After ruff check . --fix
import json
import os
import sys
from collections import defaultdict
from typing import Optional
Standard library, third-party, and local imports get grouped and sorted correctly. Same behavior as isort, no extra setup.
Enable it explicitly in your config if it's not running by default:
[tool.ruff.lint]
select = ["I"] # isort rules
Configuration in pyproject.toml
Ruff reads from pyproject.toml, which means no separate .flake8, .isort.cfg, or setup.cfg. One file rules everything.
A solid starting config:
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"RUF", # ruff-specific rules
]
ignore = [
"E501", # line too long (handled by formatter)
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
The select field is where you pick which rule categories to enable. By default Ruff only runs E and F rules, which is already solid coverage. Adding B (bugbear) and UP (pyupgrade) gets you a lot more value with very little noise.
Rule Categories Worth Knowing
Ruff organizes its 900+ rules into prefixed categories. Here are the ones worth enabling on most projects:
| Prefix | Origin | What It Catches |
|---|---|---|
F |
Pyflakes | Unused imports, undefined names, redefined vars |
E / W
|
pycodestyle | PEP 8 style issues |
I |
isort | Import ordering |
B |
flake8-bugbear | Common bugs and design issues |
UP |
pyupgrade | Outdated syntax (pre-f-strings, old union types) |
N |
pep8-naming | Naming conventions |
RUF |
Ruff-native | Rules with no upstream equivalent |
SIM |
flake8-simplify | Code that can be simplified |
ANN |
flake8-annotations | Missing type annotations |
You can look up any rule at docs.astral.sh/ruff/rules. Each one has an explanation and an example.
Ignoring Rules Inline
Sometimes you genuinely need to break a rule. The syntax is the same as Flake8:
import os # noqa: F401
Or disable for an entire block:
# ruff: noqa: E501
some_very_long_string = "this line is intentionally long because it has to be"
Or skip formatting for a section:
# fmt: off
matrix = [
1, 0, 0,
0, 1, 0,
0, 0, 1,
]
# fmt: on
Editor Integration
VS Code
Install the Ruff extension and add this to your settings:
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff",
"[python]": {
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
}
}
Format on save, import sort on save, lint feedback in the gutter. Done.
Neovim
Via conform.nvim and nvim-lint, or through the LSP with nvim-lspconfig. The Ruff LSP server (ruff server) ships with the Ruff binary.
Pre-commit Integration
This is where Ruff's speed makes the biggest practical difference. Replace your old hooks with:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
Two hooks, no Python dependencies, sub-second feedback. Your teammates will stop disabling pre-commit.
Migrating From the Old Stack
If you're coming from Black + isort + Flake8, the migration path is low-friction.
Ruff's formatter is designed to be Black-compatible, so your diffs should be minimal after switching. Start by running both and comparing output:
black --check .
ruff format --check .
For linting, start conservative: just enable E, F, and I. Fix the violations, then expand select to add more rule categories as your team is ready.
You can also silence existing violations in bulk without fixing them using # noqa comments or per-file-ignores, which lets you enforce Ruff on new code while leaving legacy files alone.
One Honest Caveat
Ruff does not do type checking. It does not replace mypy or pyright. It can catch some type-annotation issues (via the ANN rules) and enforce annotation style, but it does not perform type inference.
If you want static type checking, you still need mypy or pyright alongside Ruff. They solve different problems.
The Bottom Line
Ruff is not an incremental improvement on Python linting. It's a full reset: one tool, one config file, no dependency conflicts, no wrapper scripts, and feedback fast enough that you stop dreading the pre-commit hook.
If you're starting a new project, add it as a dev dependency on day one and configure it in pyproject.toml. If you're on an existing project, swap in the formatter first since it's Black-compatible and then layer in linting rules from there.
The Python ecosystem moved slowly on tooling for a long time. Ruff is what it looks like when someone decides to stop accepting that.
Enjoyed this? I wrote a similar piece on uv, the package manager from the same team, if you want to go further down the Astral rabbit hole. Drop a reaction or share it with someone still waiting 18 seconds for their pre-commit to finish.
Top comments (0)