Ruff Is All You Need
I used to have four different Python linting tools. Now I have one. Life is better.
Ruff is a Python linter and formatter written in Rust. It's fast, it's comprehensive, and it's replaced my entire Python toolchain. Let me explain why I'm evangelical about it.
The old days
My pre-commit config used to look like this:
repos:
- repo: https://github.com/psf/black
hooks:
- id: black
- repo: https://github.com/pycqa/isort
hooks:
- id: isort
- repo: https://github.com/pycqa/flake8
hooks:
- id: flake8
- repo: https://github.com/PyCQA/bandit
hooks:
- id: bandit
Four tools. Four separate configs. They'd sometimes disagree with each other - black would format something, isort would reformat the imports, and flake8 would complain about the result.
And they were slow. Like, go-get-coffee slow on big codebases. Running pre-commit on our work repo took 45 seconds. You'd push a commit, wait for hooks, realize you had a typo, fix it, wait again...
But the worst part wasn't the speed. It was the mental overhead. Each tool had its own config file, its own quirks, its own way of doing things. Want to ignore a rule? That's .flake8 for flake8, pyproject.toml for black, and a separate section for isort. Want to add a new check? Better hope the plugins are compatible and don't conflict with each other.
I remember spending an entire afternoon trying to get black and flake8 to agree on line length. Black would format a line one way, flake8 would complain it was too long, I'd adjust the config, and then black would format it differently. It was like refereeing a fight between robots.
Enter ruff
Ruff does all of that. One tool. One config in pyproject.toml. And it's so fast it feels broken.
I'm not exaggerating. The first time I ran it, I thought it crashed because it finished instantly. On a codebase where the old toolchain took 45 seconds, ruff takes about 300 milliseconds. That's not a typo. It's 100x faster.
The speed difference is almost comical. I've had teammates refuse to believe me until they see it themselves. "It can't be that fast," they say. Then they run it and just stare at their terminal like it betrayed them. The cognitive dissonance is real - we're so used to linters being slow that fast linters feel wrong.
But speed isn't the only win. Ruff implements over 800 linting rules from dozens of different tools. It's not just replacing your old stack - it's expanding it. Rules from pylint, pycodestyle, pyflakes, flake8 and all its plugins, pydocstyle, pep8-naming, and more. All in one place, all with consistent behavior, all configured in one file.
What ruff replaces
- black →
ruff format - isort → ruff's
Irules - flake8 → ruff's
E,F,Wrules - pyupgrade → ruff's
UPrules - bandit → ruff's
Srules - pydocstyle → ruff's
Drules
It implements rules from dozens of other tools too. Check the docs for the full list - it's extensive.
My config
Here's what I use for most projects:
[tool.ruff]
line-length = 100
target-version = 'py312'
[tool.ruff.lint]
select = [
'E', # pycodestyle errors
'F', # pyflakes
'I', # isort
'B', # flake8-bugbear
'C4', # flake8-comprehensions
'UP', # pyupgrade
'SIM', # flake8-simplify
]
ignore = ['E501'] # line too long - let the formatter handle it
[tool.ruff.format]
quote-style = 'double'
That's it. One file, one tool.
The rules I like
B (bugbear) catches common bugs:
# B006: mutable default argument
def bad(items=[]): # ruff catches this
items.append(1)
C4 (comprehensions) improves list/dict/set operations:
# C401: unnecessary generator - use set comprehension
set(x for x in items) # ruff suggests: {x for x in items}
UP (pyupgrade) modernizes your code:
# UP035: deprecated import
from typing import List # ruff suggests: list (Python 3.9+)
SIM (simplify) suggests simpler alternatives:
# SIM102: nested if statements can be collapsed
if a:
if b:
pass # ruff suggests: if a and b:
Real-world examples that saved my bacon
Let me show you some actual bugs ruff caught in production code that our old toolchain missed.
Mutable default arguments
This is Python's most famous footgun:
class Cache:
def __init__(self, items=[]): # B006: Do not use mutable data structures for argument defaults
self.items = items
def add(self, item):
self.items.append(item)
# This looks innocent, but...
cache1 = Cache()
cache1.add("foo")
cache2 = Cache()
print(cache2.items) # ['foo'] - wat?
Ruff catches this immediately. The fix is simple:
class Cache:
def __init__(self, items=None):
self.items = items if items is not None else []
I've seen this bug in production code more times than I'd like to admit. It's subtle, it's sneaky, and it causes weird state-sharing bugs that are hard to track down.
Except-pass sins
# B110: try-except-pass detected, consider logging the exception
try:
result = risky_operation()
except Exception:
pass # ruff says: "Hey, maybe log this?"
Silent failures are debugging nightmares. Ruff doesn't just catch these - it nudges you toward better practices:
import logging
logger = logging.getLogger(__name__)
try:
result = risky_operation()
except Exception as e:
logger.exception("risky_operation failed: %s", e)
raise # or handle appropriately
Unused imports and variables
import os
import sys
import json # F401: imported but unused
from typing import List, Dict, Optional # F401: imported but unused
def process_data(data: list): # UP006: Use `list` instead of `List` for type annotations
items = []
for item in data:
x = item.get('value') # F841: local variable 'x' is assigned to but never used
items.append(item.get('id'))
return items
Ruff catches all of this and can auto-fix most of it. The unused import check is particularly nice because imports accumulate like dust during refactoring.
Path manipulation gotchas
# PTH123: `open()` should be replaced by `Path.open()`
with open('data.json') as f: # works, but...
data = json.load(f)
# Better:
from pathlib import Path
with Path('data.json').open() as f:
data = json.load(f)
The pathlib rules (PTH) encourage modern path handling. It's more readable, cross-platform, and harder to mess up.
Auto-fixing
Most issues can be fixed automatically:
ruff check --fix .
ruff format .
Or in one command:
ruff check --fix . && ruff format .
I have this aliased in my shell and run it constantly.
Pre-commit integration
The new pre-commit config is much simpler:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
One repo, two hooks, instant execution.
Advanced configuration tips
Here are some config patterns I've found useful across different projects.
Strict mode for new projects
[tool.ruff.lint]
select = [
'ALL' # Enable everything, then opt out
]
ignore = [
'D', # pydocstyle - too strict for most projects
'ANN', # type annotations - use mypy instead
'COM812', # trailing comma - conflicts with formatter
'ISC001', # implicit string concat - conflicts with formatter
]
Starting with ALL and ignoring what you don't want is great for new projects. You get maximum coverage without spending hours reading docs.
Per-file ignores
[tool.ruff.lint.per-file-ignores]
'tests/**/*.py' = [
'S101', # Use of assert detected - duh, it's a test
'PLR2004', # Magic value used in comparison - tests are full of these
'D', # Missing docstrings - tests are self-documenting
]
'migrations/*.py' = [
'E501', # Line too long - generated code
]
'__init__.py' = [
'F401', # Imported but unused - that's what __init__ does
]
Different files have different needs. Tests shouldn't require docstrings. Init files exist to re-export things. Migrations are generated code. Per-file ignores make this easy.
Project-specific line length
[tool.ruff]
line-length = 88 # Black's default
[tool.ruff.format]
line-length = 88
[tool.ruff.lint.pycodestyle]
max-line-length = 100 # Allow slightly longer for complex expressions
You can have different line length limits for formatting vs linting. This gives you flexibility for complex lines while keeping formatted code consistent.
Editor integration
Ruff has LSP support, so it works in VS Code, Neovim, etc. Errors show inline as you type. Format on save just works. The experience is smooth.
VS Code setup
// settings.json
{
'[python]': {
'editor.defaultFormatter': 'charliermarsh.ruff',
'editor.formatOnSave': true,
'editor.codeActionsOnSave': {
'source.fixAll': 'explicit',
'source.organizeImports': 'explicit'
}
},
'ruff.lint.args': ['--select=ALL', '--ignore=D,ANN'],
}
The codeActionsOnSave config is key - it auto-fixes issues and organizes imports on every save. You barely think about formatting anymore.
Neovim setup (using nvim-lspconfig)
require('lspconfig').ruff_lsp.setup {
init_options = {
settings = {
args = {'--select=E,F,I,B,C4,UP,SIM'},
}
}
}
Ruff integrates beautifully with Neovim's LSP ecosystem. Diagnostics appear inline, and you can trigger fixes with standard LSP keybindings.
Why is it so fast?
It's written in Rust. Same reason uv is fast. Same reason pydantic v2 is fast. Python is great for writing applications but slow for writing tools that process Python code. Rust is fast for both.
The technical reasons are interesting. Ruff uses a hand-written parser that's optimized for error recovery (so it can lint broken code). It processes files in parallel across all your CPU cores. It caches aggressively. And Rust's zero-cost abstractions mean there's no performance penalty for clean code structure.
The Astral team (who make both ruff and uv) seems to have a mission: make Python tooling not suck. They're succeeding.
CI/CD integration
Ruff shines in CI pipelines. Here's how I use it in GitHub Actions:
name: Lint
on: [push, pull_request]
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: chartboost/ruff-action@v1
with:
args: 'check --output-format=github'
The --output-format=github flag makes errors show up as inline annotations in pull requests. Super helpful for code review.
For GitLab CI:
ruff:
image: python:3.12
before_script:
- pip install ruff
script:
- ruff check .
- ruff format --check .
only:
- merge_requests
- main
The --check flag on format makes it fail if code isn't formatted, without actually changing files.
Migration guide
Switching from the old stack to ruff is straightforward. Here's how I do it:
Step 1: Install ruff
pip install ruff
# or
uv add ruff --dev
Step 2: Create basic config
[tool.ruff]
line-length = 100
target-version = 'py312'
[tool.ruff.lint]
select = ['E', 'F', 'I', 'B', 'C4', 'UP']
Step 3: Run it and see what breaks
ruff check . > ruff_output.txt
You'll get a lot of errors. Don't panic. Most are auto-fixable:
ruff check --fix .
ruff format .
Step 4: Deal with the rest
Review the remaining errors. Some will be real issues. Some you'll want to ignore. Add them to your config:
[tool.ruff.lint]
ignore = [
'E501', # line too long - common first ignore
]
Step 5: Update pre-commit
Replace all the old hooks with ruff:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
Step 6: Remove old dependencies
pip uninstall black isort flake8 bandit
And you're done. Total time: maybe 30 minutes for a medium-sized codebase.
Common gotchas
Formatter conflicts
Some rules conflict with the formatter. Ruff warns you about these:
warning: `ISC001` is incompatible with the formatter
Just add them to your ignore list:
[tool.ruff.lint]
ignore = ['ISC001', 'COM812']
Too many rules
Starting with select = ['ALL'] can be overwhelming. If you get hundreds of errors, start smaller:
[tool.ruff.lint]
select = ['E', 'F'] # Just the basics
Then gradually add more rule categories as you clean up the codebase.
Import sorting differences
Ruff's import sorting (the I rules) is similar to isort but not identical. If you have a huge codebase, you might see a lot of import reordering. Run ruff check --select I --fix . once to fix them all, then commit that separately.
When NOT to use ruff
I'm pretty evangelical about ruff, but there are edge cases where the old tools might still be better:
- Custom flake8 plugins - If you have niche plugins that ruff doesn't support yet, you might need to keep flake8 around
- Black formatting quirks - Ruff's formatter is 99% compatible with black, but not 100%. If you're in a huge codebase and can't tolerate any formatting changes, stick with black
- Team resistance - If your team is firmly in the "if it ain't broke" camp, forcing a migration might not be worth the political capital
But honestly? These are rare. For 95% of Python projects, ruff is the right choice.
The only downside
I have to find something else to do while my linter runs. Checking Twitter is no longer an option when it finishes in under a second.
Seriously though, there's no downside. If you're using black, isort, and flake8, switch to ruff. It's better in every way. It's faster, it's more comprehensive, it's easier to configure, and it's actively maintained by a team that clearly cares about developer experience.
The Python tooling ecosystem is going through a Rust renaissance right now, and ruff is leading the charge. We went from "Python tooling is slow and fragmented" to "Python tooling is world-class" in the span of a couple years.
If you haven't tried ruff yet, give it 15 minutes. Install it, run it on your codebase, and watch it find bugs you didn't know existed. I bet you'll be hooked.