Intermediate
You’ve probably been there: a CI pipeline fails because of a missing blank line, a wrong import order, or an unused variable that snuck in during a late-night refactor. You run flake8, then black, then isort — three separate tools, three separate configs, three separate slow passes over your codebase. It works, but it’s a friction tax you pay on every commit. Ruff eliminates that friction entirely by replacing all three (and more) in a single, shockingly fast tool.
Ruff is an open-source Python linter and formatter written in Rust. It’s compatible with the rule sets from flake8, isort, pyupgrade, and dozens of other tools — and it runs 10-100x faster than any of them. You install it with pip install ruff and it’s ready to use immediately, no complex setup required. It works in your editor, in CI, and as a pre-commit hook.
In this article we’ll cover how to install Ruff, run it for linting and formatting, configure it via pyproject.toml, integrate it into pre-commit hooks, and understand the most common rule codes. By the end you’ll have a complete code quality workflow that runs in milliseconds instead of seconds.
Ruff Quick Example
The fastest way to see what Ruff does is to run it on a messy Python file and watch it clean up. Here’s a small script with intentional problems — unused imports, inconsistent quotes, and a line that’s too long:
# messy_script.py
import os
import sys
import json # unused
def greet(name):
message = "Hello, " + name + "! Welcome to the program. This line is intentionally very long and exceeds the 88-character limit set by most formatters."
print(message)
greet('Alice')
Run Ruff’s linter on it:
# In your terminal
ruff check messy_script.py
Output:
messy_script.py:3:8: F401 [*] `json` imported but unused
messy_script.py:8:89: E501 Line too long (148 > 88 characters)
Found 2 errors.
[*] 2 fixable with the `--fix` option.
Now run the formatter:
ruff format messy_script.py
Output:
1 file reformatted
Ruff flagged the unused import, the long line, and reformatted the file to use consistent double quotes — all in under 100 milliseconds. The [*] markers in the linter output mean the issue can be auto-fixed with --fix. We’ll dig into each of these capabilities in detail below.
What Is Ruff and Why Use It?
Ruff is a Python static analysis tool that combines linting and formatting into one binary. It was created by Charlie Marsh and is maintained by Astral (the same team behind uv). The key insight behind Ruff is that Python’s existing linting ecosystem — flake8, pylint, isort, pyupgrade, pydocstyle — grew organically over decades as separate tools. Ruff re-implements all of them in Rust, which gives it two major advantages: speed and simplicity.
Here’s how Ruff compares to the traditional Python linting stack:
| Tool | Function | Speed | Config file | Ruff equivalent |
|---|---|---|---|---|
| flake8 | Linting (PEP 8, logic) | Slow | .flake8 | ruff check |
| black | Formatting | Medium | pyproject.toml | ruff format |
| isort | Import sorting | Slow | .isort.cfg | ruff check --select I |
| pyupgrade | Modernize syntax | Slow | N/A | ruff check --select UP |
| pydocstyle | Docstring style | Slow | setup.cfg | ruff check --select D |
The most important thing to understand is that Ruff’s rule set is organized by prefix codes. F rules come from pyflakes (logic errors), E/W from pycodestyle (style), I from isort (imports), UP from pyupgrade (modern syntax). Once you know the prefix system, you can pick exactly which rules your project enforces.
Installing Ruff
Ruff can be installed several ways depending on your workflow. The simplest approach is pip, but using uv is even faster if you already have it.
Install with pip
Install Ruff into your current Python environment or virtual environment:
# install_ruff.sh
pip install ruff
Output:
Successfully installed ruff-0.4.0
Install with uv (recommended)
If you’re using uv for project management, add Ruff as a development dependency:
# install_ruff_uv.sh
uv add --dev ruff
Output:
Resolved 1 package in 0.42s
Added ruff==0.4.0
After installing, verify it works by checking the version. Ruff moves fast — new versions ship frequently with rule improvements and bug fixes:
# verify_ruff.sh
ruff --version
Output:
ruff 0.4.0
Linting with ruff check
The ruff check command scans your code for problems. By default it runs a curated set of rules from pyflakes and pycodestyle that catches the most impactful issues without overwhelming you with warnings.
Running a Basic Check
Point ruff check at a file or directory. For most projects you’ll run it against the current directory:
# run_check.sh
# Check a single file
ruff check mymodule.py
# Check the entire project
ruff check .
# Check and auto-fix fixable issues
ruff check --fix .
Example output for a project with issues:
src/utils.py:5:1: F401 [*] `pathlib.Path` imported but unused
src/utils.py:22:80: E501 Line too long (92 > 88 characters)
src/api.py:14:5: F841 Local variable `response` is assigned to but never used
src/api.py:31:1: I001 [*] Import block is unsorted
Found 4 errors.
[*] 3 fixable with the `--fix` option.
The output format is file:line:col: CODE message. The [*] marker means Ruff can fix it automatically. Running ruff check --fix . will remove the unused import, sort the imports, and leave the non-fixable issues (like the long line and the assigned-but-unused variable) for you to address manually.
Selecting Specific Rule Sets
You can extend or restrict which rules Ruff applies using --select and --ignore. This is where the prefix system becomes powerful:
# rule_selection.sh
# Run only import-related rules (isort equivalent)
ruff check --select I .
# Run pyflakes + import rules
ruff check --select F,I .
# Run all rules but ignore line-length
ruff check --select ALL --ignore E501 .
Output for import-only check:
src/models.py:3:1: I001 [*] Import block is unsorted
Found 1 error.
[*] 1 fixable with the `--fix` option.
The --select ALL flag enables every rule Ruff knows about — this is very strict and usually requires several --ignore flags to tune. Most teams start with a reasonable subset and expand from there.
Formatting with ruff format
The ruff format command is Ruff’s black-compatible formatter. It enforces consistent style: double quotes, trailing commas, line length, and whitespace. It was designed to be a drop-in replacement for black — which means if your team already uses black, switching to ruff format produces identical (or near-identical) output.
Formatting Files
Run the formatter on a file or directory. Use --check in CI to verify formatting without modifying files:
# format_files.sh
# Format all Python files in the project
ruff format .
# Check if files need formatting (exit code 1 if they do)
ruff format --check .
# Preview what would change without writing
ruff format --diff .
Output of ruff format –diff mymodule.py:
--- mymodule.py
+++ mymodule.py
@@ -1,5 +1,5 @@
def greet(name):
- message = 'Hello, ' + name
+ message = "Hello, " + name
return message
The diff shows exactly what would change — single quotes converted to double, consistent with black’s rules. The --check flag is what you use in CI: it returns exit code 0 if everything is formatted correctly, or exit code 1 if any file needs changes. Your CI pipeline can fail fast on unformatted code without needing to know anything else about the project.
Configuring Ruff
Ruff reads configuration from pyproject.toml, ruff.toml, or .ruff.toml. The pyproject.toml approach is most common because it keeps all your project config in one file alongside your build system, pytest settings, and mypy config.
pyproject.toml Configuration
Here is a complete, production-ready Ruff configuration that covers most projects. Each section is commented to explain the intent:
# pyproject.toml
[tool.ruff]
# Target Python version -- affects which syntax is valid
target-version = "py311"
# Maximum line length (88 matches black's default)
line-length = 88
[tool.ruff.lint]
# Enable pyflakes (F), pycodestyle errors (E), isort (I),
# pyupgrade (UP), and flake8-bugbear (B)
select = ["E", "F", "I", "UP", "B"]
# Ignore line-too-long (E501) since the formatter handles it,
# and ignore shadowing builtins warning which is too noisy
ignore = ["E501", "A001", "A002"]
# Allow auto-fix for all enabled rules
fixable = ["ALL"]
# Don't fix these -- they need human review
unfixable = ["F841"] # unused variables -- delete or use them
[tool.ruff.lint.isort]
# Group imports: stdlib, third-party, first-party
known-first-party = ["mypackage"]
[tool.ruff.format]
# Use double quotes everywhere (black-compatible)
quote-style = "double"
# Use spaces, not tabs
indent-style = "space"
With this in pyproject.toml, running ruff check . and ruff format . from the project root will use these settings automatically — no flags needed. The select list here enables five rule families: pyflakes catches logic errors, pycodestyle catches style violations, isort keeps imports sorted, pyupgrade modernizes syntax (like Union[X, Y] to X | Y), and bugbear catches subtle bugs that flake8 misses.
Pre-Commit Hook Integration
The most effective way to use Ruff is as a pre-commit hook. This runs Ruff automatically before every commit, catching issues before they enter version control. You’ll never see a CI failure for a missing import sort again.
Setting Up the Hook
First install the pre-commit framework if you haven’t already, then add Ruff to your .pre-commit-config.yaml:
# setup_precommit.sh
pip install pre-commit
pre-commit install
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
# Run the linter and auto-fix fixable issues
- id: ruff
args: [--fix]
# Run the formatter
- id: ruff-format
Now every time you run git commit, Ruff will lint and format your staged files first. If Ruff makes any changes (like sorting imports), the commit is blocked and you see what changed. You add the changed files and commit again — a two-second feedback loop that trains good habits faster than any code review comment.
# test_precommit.sh (simulated output when a commit has issues)
git commit -m "add new feature"
Output:
ruff.....................................................................Failed
- hook id: ruff
- exit code: 1
- files were modified by this hook
src/newfeature.py
Ruff modified the file (fixed the imports). Stage the change and commit again. This takes under a second.
Understanding Common Rule Codes
Once Ruff starts flagging issues, you’ll want to understand what the codes mean so you can decide whether to fix or ignore them. Here are the most common ones you’ll encounter:
| Code | Category | Meaning | Auto-fixable? |
|---|---|---|---|
| F401 | pyflakes | Imported but unused module | Yes |
| F841 | pyflakes | Local variable assigned but never used | No |
| E501 | pycodestyle | Line too long | No (formatter handles it) |
| E711 | pycodestyle | Comparison to None (use is None) | Yes |
| I001 | isort | Import block is unsorted | Yes |
| UP007 | pyupgrade | Use X | Y instead of Union[X, Y] | Yes |
| B006 | bugbear | Mutable default argument (dangerous pattern) | No |
| B007 | bugbear | Loop control variable not used in loop body | Yes |
The B006 rule is worth highlighting — mutable default arguments are one of Python’s most common gotchas. A function like def add_item(lst=[]): shares the list across all calls, which leads to baffling bugs. Ruff flags it but won’t auto-fix it because the correct fix depends on your intent.
Real-Life Example: Full Project Lint and Format Workflow
Let’s put this all together with a realistic workflow for a small Python project. We have a package with a few modules that haven’t been linted in a while and need a cleanup pass before merging to main.
# project_cleanup.py
"""Simulate a realistic Ruff cleanup workflow using subprocess."""
import subprocess
import sys
from pathlib import Path
def run_ruff_check(project_path: str) -> dict:
"""Run ruff check and return structured results."""
result = subprocess.run(
["ruff", "check", "--output-format=json", project_path],
capture_output=True,
text=True,
)
issues = []
if result.stdout.strip():
import json
issues = json.loads(result.stdout)
return {
"exit_code": result.returncode,
"issue_count": len(issues),
"issues": issues[:5], # first 5 for display
}
def run_ruff_format_check(project_path: str) -> dict:
"""Check if files need formatting (no changes made)."""
result = subprocess.run(
["ruff", "format", "--check", project_path],
capture_output=True,
text=True,
)
return {
"needs_formatting": result.returncode != 0,
"message": result.stderr.strip() or "All files formatted correctly.",
}
def run_ruff_fix(project_path: str) -> str:
"""Auto-fix all fixable issues and format."""
subprocess.run(["ruff", "check", "--fix", project_path], capture_output=True)
subprocess.run(["ruff", "format", project_path], capture_output=True)
return "Auto-fix and format complete."
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "."
print(f"Checking project: {path}")
print()
fmt = run_ruff_format_check(path)
print(f"Formatting: {'NEEDS CHANGES' if fmt['needs_formatting'] else 'OK'}")
print(f" {fmt['message']}")
print()
lint = run_ruff_check(path)
print(f"Linting: {lint['issue_count']} issue(s) found")
for issue in lint["issues"]:
loc = f"{issue['filename']}:{issue['location']['row']}"
print(f" {loc}: {issue['code']} {issue['message']}")
if lint["issue_count"] > 0 or fmt["needs_formatting"]:
print()
print(run_ruff_fix(path))
if __name__ == "__main__":
main()
Output (example run on a messy project):
Checking project: src/
Formatting: NEEDS CHANGES
1 file would be reformatted
Linting: 3 issue(s) found
src/utils.py:4: F401 `json` imported but unused
src/api.py:17: I001 Import block is unsorted
src/api.py:44: B006 Do not use mutable data structures for argument defaults
Auto-fix and format complete.
This script wraps Ruff in a structured workflow you can call from other tools — a Makefile target, a CI step, or a custom dev script. The --output-format=json flag makes the output machine-readable so you can parse, filter, or report on it programmatically. You could extend this to only fail on specific rule categories, or to post a summary to Slack when the lint score degrades.
Frequently Asked Questions
Can Ruff fully replace black?
Yes, for almost all projects. ruff format was designed to be black-compatible and produces identical output for the vast majority of code. The Ruff team maintains a compatibility document listing the small number of edge-case differences. If you’re starting a new project, use ruff format from day one. If you’re migrating an existing project that uses black, run both tools on your codebase and diff the output to verify there are no surprises before removing black from your pipeline.
How much faster is Ruff than flake8?
The commonly cited figure is 10-100x faster, and in practice you’ll feel it. On a codebase of 50,000 lines, flake8 might take 8-10 seconds; Ruff takes under 200 milliseconds. For pre-commit hooks this makes the difference between a hook that feels instant and one that makes you regret enabling it. The speed comes from Ruff being written in Rust and processing all files in parallel across CPU cores.
How do I ignore a specific line?
Add a # noqa: CODE comment at the end of the line. For example, to ignore an unused import on line 5: import json # noqa: F401. You can also use # noqa (no code) to suppress all rules on that line, but specifying the code is better practice because it documents why you’re suppressing and prevents accidentally hiding unrelated new issues.
Can I ignore rules for specific files?
Yes. Use [tool.ruff.lint.per-file-ignores] in pyproject.toml. A common pattern is to ignore F401 (unused imports) in __init__.py files, since those files often import symbols to make them available in the package namespace: "__init__.py" = ["F401"]. You can also ignore S (security) rules in test files where you’d legitimately use assert statements and hardcoded passwords in fixtures.
How do I use Ruff in VS Code?
Install the official Ruff extension from the VS Code marketplace (search for “Ruff” by Astral). Once installed, it shows lint violations inline as you type and can auto-fix on save. Add these settings to your settings.json: "editor.formatOnSave": true and "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }. This replaces the Pylance formatter and black extension with a single, faster tool.
Conclusion
Ruff replaces your entire Python linting and formatting stack — flake8, black, isort, pyupgrade — with a single tool that runs in milliseconds. We covered installation with pip and uv, running ruff check for linting with --fix for auto-remediation, ruff format for black-compatible formatting, and configuring everything in pyproject.toml. The pre-commit hook integration is the most impactful single change you can make to your dev workflow today.
The natural next step is to add Ruff to your CI pipeline alongside the pre-commit hook. In GitHub Actions, adding ruff check . && ruff format --check . to your test job costs almost nothing in CI time and prevents style and logic issues from reaching main. From there, experiment with stricter rule sets — enable the B (bugbear) rules and see what they catch in your codebase. You might be surprised.
For the full rule reference, see the official Ruff rules documentation. For migration guides from flake8 and black, check the migration guide.