Ruff: The Python Linter and Formatter That Made Everything Else Obsolete
Ruff: The Python Linter and Formatter That Made Everything Else Obsolete
Ruff is a Python linter and formatter written in Rust. It replaces Flake8, Black, isort, pyupgrade, pydocstyle, and several other tools with a single binary that runs 10-100x faster than any of them individually. In 2026, there is almost no reason to use anything else for Python linting and formatting.
This guide covers practical configuration, rule selection, migration from legacy tools, and editor integration.
Why Ruff Won
The Python linting ecosystem was fragmented for years. A typical project used:
- Flake8 for linting (plus plugins: flake8-bugbear, flake8-comprehensions, flake8-import-conventions)
- Black for formatting
- isort for import sorting
- pyupgrade for modernizing syntax
- pydocstyle for docstring conventions
- bandit for security checks
Each tool had its own configuration format, its own CLI flags, and its own performance characteristics. Running all of them on a large codebase took minutes.
Ruff reimplements the rules from all of these tools in Rust, runs them in a single pass, and finishes in seconds. It is not an incremental improvement -- it is a generational leap in speed that changes how you use linting.
When linting takes 30 seconds, you run it before commits. When linting takes 200 milliseconds, you run it on every keystroke in your editor.
Installation
# Via pip/uv (recommended for project-local installs)
uv add --dev ruff
pip install ruff
# Via Homebrew
brew install ruff
# Via mise
mise use ruff@latest
# Standalone binary
curl -LsSf https://astral.sh/ruff/install.sh | sh
Basic Usage
# Check for lint violations
ruff check .
# Fix auto-fixable violations
ruff check --fix .
# Format code (replaces Black)
ruff format .
# Check formatting without modifying
ruff format --check .
# Check and fix everything in one pass
ruff check --fix . && ruff format .
Configuration
Ruff reads configuration from pyproject.toml, ruff.toml, or .ruff.toml. Using pyproject.toml keeps your project to a single config file:
[tool.ruff]
# Target Python version (affects which rules apply)
target-version = "py312"
# Line length (default 88, matching Black)
line-length = 88
# Directories to exclude
exclude = [
".venv",
"migrations",
"__pycache__",
"*.pyi",
]
[tool.ruff.lint]
# Enable specific rule sets
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # Ruff-specific rules
]
# Rules to ignore
ignore = [
"E501", # line too long (handled by formatter)
"B008", # function call in default argument (common in FastAPI)
]
# Allow autofix for all enabled rules
fixable = ["ALL"]
[tool.ruff.lint.isort]
known-first-party = ["myapp"]
force-single-line = false
combine-as-imports = true
[tool.ruff.format]
# Use double quotes (matching Black default)
quote-style = "double"
# Use Unix line endings
line-ending = "lf"
# Format docstrings
docstring-code-format = true
Rule Selection Strategy
Ruff implements over 800 rules from 50+ rule sets. Do not enable all of them. Here is a practical tiered approach:
Tier 1 -- Essential (enable on every project):
select = ["E", "W", "F", "I"]
These catch real errors (undefined variables, unused imports, syntax issues) and sort imports. Zero controversy.
Tier 2 -- Recommended (enable on new projects):
select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM", "RUF"]
Adds bugbear (common gotchas), comprehension simplification, automatic Python version upgrades, and general simplification rules. These occasionally produce false positives but the signal-to-noise ratio is excellent.
Tier 3 -- Strict (enable on teams that value consistency):
select = [
"E", "W", "F", "I", "B", "C4", "UP", "SIM", "RUF",
"TCH", # type-checking imports
"PTH", # pathlib over os.path
"PERF", # performance anti-patterns
"S", # bandit security checks
"N", # pep8-naming
]
These are opinionated. PTH forces pathlib usage over os.path. N enforces naming conventions. Enable these rules consciously, not by default.
Per-File Overrides
Different rules for different parts of your codebase:
[tool.ruff.lint.per-file-ignores]
# Tests can use assert, magic values, etc.
"tests/**/*.py" = ["S101", "PLR2004", "B011"]
# __init__.py can have unused imports (re-exports)
"__init__.py" = ["F401"]
# Scripts are allowed to use print
"scripts/**/*.py" = ["T201"]
# Migration files are auto-generated
"migrations/**/*.py" = ["ALL"]
Migrating from Legacy Tools
From Flake8
Ruff maps Flake8 rule codes directly:
# Convert your Flake8 config
# .flake8:
# max-line-length = 88
# extend-ignore = E203, E501
# extend-select = B950
# Equivalent ruff config in pyproject.toml:
[tool.ruff]
line-length = 88
[tool.ruff.lint]
select = ["E", "W", "F", "B"]
ignore = ["E203", "E501"]
Flake8 plugins map to Ruff rule prefixes:
| Flake8 Plugin | Ruff Prefix |
|---|---|
| flake8-bugbear | B |
| flake8-comprehensions | C4 |
| flake8-import-conventions | ICN |
| flake8-simplify | SIM |
| pep8-naming | N |
| flake8-bandit | S |
| flake8-print | T20 |
From Black
Ruff's formatter is designed to produce identical output to Black in most cases:
# Remove Black from your dependencies
uv remove black
# Ruff format is the direct replacement
ruff format .
# If you relied on Black's magic trailing comma behavior,
# Ruff supports it by default
The rare cases where Ruff and Black disagree are documented in Ruff's changelog. For practical purposes, the output is interchangeable.
From isort
# Your isort config:
# [tool.isort]
# profile = "black"
# known_first_party = ["myapp"]
# Equivalent Ruff config:
[tool.ruff.lint]
select = ["I"]
[tool.ruff.lint.isort]
known-first-party = ["myapp"]
Enable the I rule prefix and configure the isort section. Remove isort from your dependencies.
Editor Integration
VS Code
Install the official "Ruff" extension by Astral. Configure in VS Code settings:
{
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
}
}
Disable the old Python linting extensions (Pylint, Flake8, Black formatter) to avoid conflicts.
Neovim
Using nvim-lspconfig:
require('lspconfig').ruff.setup({
init_options = {
settings = {
lineLength = 88,
lint = {
select = { "E", "W", "F", "I", "B" },
},
},
},
})
Ruff runs as an LSP server, providing diagnostics and code actions directly in the editor.
Zed
Zed supports Ruff through language settings:
{
"languages": {
"Python": {
"formatter": {
"external": {
"command": "ruff",
"arguments": ["format", "--stdin-filename", "{buffer_path}", "-"]
}
}
}
}
}
CI Integration
# GitHub Actions
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Lint with Ruff
run: |
uv run ruff check .
uv run ruff format --check .
For pre-commit hooks:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.6
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
Performance Numbers
On a medium-sized Python project (~50,000 lines of code):
| Tool | Time |
|---|---|
| Flake8 (with plugins) | 12.4s |
| Black | 3.2s |
| isort | 1.8s |
| All three combined | ~17s |
| Ruff (check + format) | 0.15s |
That is not a typo. Ruff is two orders of magnitude faster. This speed makes it practical to run Ruff on every file save, on every keystroke in an editor, and as a pre-commit hook without developers bypassing it.
Common Gotchas
Line length conflicts: Set the same line-length in both [tool.ruff] (affects the formatter) and [tool.ruff.lint] (affects lint rules). Or just set it at the top level and it applies to both.
E501 with formatter: If you are using Ruff's formatter, ignore E501 (line too long). The formatter handles line wrapping. Having the lint rule enabled too creates noise.
Type-checking imports (TCH): The TCH rules move imports used only for type annotations into if TYPE_CHECKING: blocks. This speeds up runtime imports but can break if you use those types at runtime. Enable carefully.
Autofix caution: ruff check --fix can modify code automatically. Most fixes are safe, but review the changes before committing, especially for unfamiliar rule sets. Use --fix --unsafe-fixes only when you understand what each fix does.
The Bottom Line
If you are starting a new Python project, use Ruff. There is no reason to install Flake8, Black, and isort separately. If you are maintaining an existing project, the migration is straightforward and the speed improvement alone justifies the effort. Ruff has become the Python ecosystem's answer to the question "why can't linting just be fast?" -- and the answer turns out to be "it can, if you write it in Rust."