Real performance numbers, migration paths, and honest tradeoffs.
Step 1: The Problem With Your Current Setup
Most Python projects reach a point where the CI lint step takes longer than the tests. You run black ., wait. Then pylint src/, wait longer. On a 50k-line codebase, that combo regularly costs 30–60 seconds per run.
$ time black . && pylint src/
reformatted src/utils.py
reformatted src/models/user.py
All done! ✨ 🍰 ✨
2 files reformatted, 43 files left unchanged.
--------------------------------------------------------------------
Your code has been rated at 8.94/10 (previous run: 8.91/10)
real 0m47.312s
That 47-second wait is the exact friction that makes developers skip linting locally and let CI catch it instead — slowing the whole team down.
Step 2: What Ruff Actually Does
Ruff is a Python linter and formatter written in Rust. It implements rules from Pyflakes, pycodestyle, isort, pydocstyle, flake8, and a large subset of Pylint — all in one binary. It also ships a formatter that is intentionally Black-compatible.
The same 50k-line project:
$ time ruff check . && ruff format .
All checks passed!
2 files reformatted, 43 files left unchanged.
real 0m0.631s
Ruff avoids Python interpreter startup cost, runs checks in parallel, and caches results between runs. The speedup typically lands between 10x and 100x.
Step 3: Installing Ruff
# Global install — use pipx to keep it isolated
$ pipx install ruff
# Or as a dev dependency
$ pip install ruff
# Verify
$ ruff --version
ruff 0.4.4
No compiler, no build step. Pre-built binaries ship for macOS (arm64 and x86_64), Linux, and Windows.
Step 4: Running Your First Check
# Lint the entire project
$ ruff check .
# Auto-fix safe issues
$ ruff check --fix .
# Format (replaces Black)
$ ruff format .
# Check formatting without writing (useful in CI)
$ ruff format --check .
Step 5: Configuring Ruff
Ruff reads from pyproject.toml, ruff.toml, or .ruff.toml.
# pyproject.toml
[tool.ruff]
target-version = "py311"
line-length = 88 # matches Black's default
exclude = [".git", "__pycache__", ".venv", "dist"]
[tool.ruff.lint]
# E/W = pycodestyle, F = Pyflakes, I = isort,
# N = pep8-naming, PL = Pylint, UP = pyupgrade, B = bugbear
select = ["E", "W", "F", "I", "N", "PL", "UP", "B"]
ignore = ["E501"] # line-too-long (let the formatter handle it)
fixable = ["ALL"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
The PL prefix maps to Pylint rules. Run ruff rule PLR0913 to see any rule's explanation inline.
Step 6: What Ruff Catches (Concrete Example)
# bad_code.py — intentionally broken
import os
import sys
import json # unused import
def process_data(data,x,y,z,a,b,c,d,e,f): # too many args
if data == None: # should use `is None`
return
result = []
for i in range(len(data)): # unnecessary index loop
result.append(data[i])
return result
$ ruff check bad_code.py
bad_code.py:3:8: F401 [*] `json` imported but unused
bad_code.py:5:1: E231 Missing whitespace after ','
bad_code.py:5:1: PLR0913 Too many arguments in function definition (10 > 5)
bad_code.py:6:8: E711 [*] Comparison to `None` (use `is` or `is not`)
bad_code.py:9:14: B007 [*] Loop control variable `i` not used in loop body
Found 5 errors.
[*] 3 fixable with the `--fix` option.
After ruff check --fix and a quick manual cleanup:
# clean_code.py
import os
import sys
def process_data(data: list) -> list:
if data is None:
return []
return list(data)
Step 7: Migrating From Black + Pylint
Remove Black:
$ pip uninstall black
# Remove [tool.black] from pyproject.toml
$ ruff format . # Black-compatible output
Replace pre-commit hooks:
# BEFORE
repos:
- repo: https://github.com/psf/black
rev: 24.3.0
hooks:
- id: black
- repo: https://github.com/pylint-dev/pylint
rev: v3.1.0
hooks:
- id: pylint
# AFTER
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
GitHub Actions:
- name: Lint with Ruff
run: |
pip install ruff
ruff check .
ruff format --check .
Step 8: Honest Comparison
| Feature | Pylint + Black | Ruff |
|---|---|---|
| Speed (50k-line project) | ~40–60s | ~0.5–1s |
| Rule count | ~500 (Pylint) | ~700+ (multi-tool) |
| Black formatter parity | Exact | Near-exact (99%+) |
| Type-aware checks | Yes (Pylint) | No — use mypy separately |
| Auto-fix support | Limited | Extensive |
| Import sorting | Separate tool | Built-in (I rules) |
| Incremental cache | No | Yes |
| Editor LSP | Mature | Mature (ruff-lsp) |
The one real gap: Ruff does not perform type-aware analysis. Keep mypy or pyright alongside it for that coverage.
Additional Tips
noqa comments still work — existing inline suppressions carry over with no changes.
result = some_long_function() # noqa: E501
Per-file ignores:
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["PLR2004", "S101"]
"scripts/migrate_*.py" = ["BLE001"]
Preview formatter changes before switching:
$ ruff format --diff .
VSCode integration — install the Ruff extension (charliermarsh.ruff), then:
{
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
}
For most Python projects with active CI or pre-commit hooks, Ruff is the right move. You lose type-aware lint and a handful of obscure Pylint rules, but you get a workflow fast enough that developers will actually run it consistently.