
Linting and Formatting Python Code with Ruff
A few years ago, a "properly set up" Python project ran four or five tools on every commit: Flake8 with a handful of plugins for linting, isort for imports, Black for formatting, pyupgrade for modern syntax, and maybe Bandit for security checks. Each had its own config section and its own opinion about line length. Ruff replaced most of that stack with a single binary written in Rust that runs fast enough that you stop noticing it.
This guide covers how to use Ruff for both jobs it does: the linter (ruff check) and the formatter (ruff format). You'll see how rule selection works, the difference between safe and unsafe fixes, how to silence a rule when you really mean it, a sensible pyproject.toml configuration, and how to wire Ruff into your editor, pre-commit, and CI. The examples were run with Ruff 0.16, which changed the default rule set significantly, so I'll point out where older versions behave differently.
Installing Ruff
Ruff is a single executable distributed on PyPI. Install it as a development dependency of your project:
python -m pip install ruff
Or with uv:
uv add --dev ruff
If you just want to try it without adding it to a project, uvx ruff check or pipx run ruff check runs it in a throwaway environment. Check the version with ruff --version, since the default rules depend on it.
Your First Lint
Here's a small module with the kinds of problems that pile up in real code:
# app.py
import os
import sys
import json
from typing import List
def load_users(path, active_only = False):
with open(path) as f:
data = json.load(f)
users: List[dict] = []
for user in data:
if active_only == True:
if user["active"]:
users.append(user)
else:
users.append(user)
return users
def greet(name):
try:
print("Hello, %s" % name)
except:
pass
Run the linter on it:
ruff check app.py --output-format concise
app.py:1:1: I001 [*] Import block is un-sorted or un-formatted
app.py:1:8: F401 [*] `os` imported but unused
app.py:2:8: F401 [*] `sys` imported but unused
app.py:4:1: UP035 `typing.List` is deprecated, use `list` instead
app.py:10:12: UP006 [*] Use `list` instead of `List` for type annotation
app.py:21:15: UP031 Use format specifiers instead of percent format
app.py:22:5: E722 Do not use bare `except`
app.py:22:5: S110 `try`-`except`-`pass` detected, consider logging the exception
Found 8 errors.
[*] 4 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).
Each line is a diagnostic: the location, a rule code, and a message. Codes marked [*] have an automatic fix. Without --output-format concise, Ruff prints a richer view for each diagnostic, with the surrounding source lines, a caret under the problem, and a diff of the proposed fix:
UP006 [*] Use `list` instead of `List` for type annotation
--> app.py:10:12
|
8 | with open(path) as f:
9 | data = json.load(f)
10 | users: List[dict] = []
| ^^^^
11 | for user in data:
12 | if active_only == True:
|
help: Replace with `list`
The concise format is handy for scanning; the full format is better when you're trying to understand a rule you haven't seen before.
Understanding Rule Codes
Ruff reimplements rules from dozens of older tools, and each rule keeps a prefix that tells you where it came from:
| Prefix | Origin | What it catches |
|---|---|---|
F | Pyflakes | Unused imports and variables, undefined names, real bugs |
E, W | pycodestyle | PEP 8 errors and warnings |
I | isort | Import ordering |
UP | pyupgrade | Syntax that can be modernized for your Python version |
B | flake8-bugbear | Likely bugs and design problems |
SIM | flake8-simplify | Code that can be written more simply |
S | flake8-bandit | Security issues |
N | pep8-naming | Naming conventions |
D | pydocstyle | Docstring conventions |
PL | Pylint | A large port of Pylint checks |
RUF | Ruff itself | Ruff-specific rules |
When a code is unfamiliar, ask Ruff to explain it:
ruff rule F401
# unused-import (F401)
Derived from the **Pyflakes** linter.
Fix is sometimes available.
## What it does
Checks for unused imports.
...
The full rule list, with an explanation and examples for each one, is in the Ruff rules reference.
What's Enabled by Default
This is where versions matter. Up to Ruff 0.15, the default was a deliberately small set: E4, E7, E9, and F, roughly "things that are almost certainly wrong". Ruff 0.16 expanded the default to over 400 rules drawn from many of the families above, including pyupgrade, isort, bugbear, and some security checks. It also dropped a few of the more opinionated pycodestyle and Pyflakes rules from the default (for example E402, module-level import not at top of file, and E731, assigning a lambda).
That's why the output above includes UP, I, and S codes with no configuration at all. On an older Ruff you would only have seen the F401 and E722 lines.
The practical advice is the same either way: set select explicitly in your config. Then upgrading Ruff can't silently change which rules your project enforces, and everyone on the team gets the same results.
Fixing Problems Automatically
Add --fix and Ruff applies every safe fix it can:
ruff check --fix app.py
With the configuration shown later in this post, that sorts the imports, removes os and sys, drops the now-unused typing import, and rewrites List[dict] as list[dict]. What's left are problems that need a decision from you:
app.py:9:12: E712 Avoid equality comparisons to `True`; use `active_only:` for truth checks
app.py:17:5: SIM105 Use `contextlib.suppress(BaseException)` instead of `try`-`except`-`pass`
app.py:18:15: UP031 Use format specifiers instead of percent format
app.py:19:5: E722 Do not use bare `except`
Found 8 errors (4 fixed, 4 remaining).
No fixes available (3 hidden fixes can be enabled with the `--unsafe-fixes` option).
Safe vs Unsafe Fixes
Ruff classifies every fix as either safe (it preserves your program's behavior) or unsafe (it could change behavior, or remove comments). --fix only applies safe ones. To see what the unsafe ones would do, combine --unsafe-fixes with --diff:
ruff check --unsafe-fixes --diff app.py
It's worth looking before applying, because "unsafe" is meant literally. In this example, the unsafe fix for SIM105 rewrites the bare except: pass like this:
def greet(name):
with contextlib.suppress(BaseException):
print(f"Hello, {name}")
That's behavior-preserving, but it also faithfully preserves the original bug: swallowing BaseException includes KeyboardInterrupt and SystemExit. The real fix is to catch the specific exception you expect, which only a human can decide. Treat unsafe fixes as suggestions, not cleanup.
Seeing the Big Picture
On a large codebase you'll want a summary rather than thousands of lines. Here's the original app.py again, checked with the configuration from later in this post:
ruff check . --statistics
2 F401 [*] unused-import
1 E722 [ ] bare-except
1 UP035 [ ] deprecated-import
1 UP006 [*] non-pep585-annotation
1 UP031 [ ] printf-string-formatting
1 SIM105 [ ] suppressible-exception
1 E712 [ ] true-false-comparison
1 I001 [*] unsorted-imports
Found 9 errors.
This is the best way to decide which rules to adopt when you're introducing Ruff to an existing project: enable a family, look at the counts, and either fix them in bulk or ignore the rule.
Formatting with ruff format
The formatter is a separate command. It's designed as a drop-in replacement for Black, and on Black-formatted code it produces nearly identical output.
ruff format app.py
1 file reformatted
For our module it removed the spaces around the = in active_only = False and added the second blank line PEP 8 expects between top-level functions. The formatter never changes what your code does; it only rewrites whitespace, quotes, parentheses, and line breaks.
Two flags are useful in scripts and CI:
ruff format --check . # exit code 1 if anything would change
ruff format --diff . # show what would change, don't write
The Magic Trailing Comma
Like Black, Ruff respects a trailing comma as an instruction to keep a collection exploded one item per line:
# Stays on one line after formatting
point = (1, 2)
# Stays multi-line because of the trailing comma after "pytest"
dev_tools = [
"ruff",
"pytest",
]
If you remove the trailing comma and the list fits within the line length, the formatter collapses it. This gives you control over layout without any configuration.
Formatting Code in Docstrings and Markdown
Ruff can also format code examples inside docstrings when you enable docstring-code-format, and since 0.16 ruff format formats Python code blocks in Markdown files too. If you run ruff format . over a repository, your README's fenced python blocks will get the same treatment as your source files. Exclude Markdown files if you don't want that.
Configuring Ruff in pyproject.toml
Ruff reads [tool.ruff] from pyproject.toml (or a standalone ruff.toml / .ruff.toml, which uses the same keys without the tool.ruff prefix). Here's a configuration that works well for most applications:
# pyproject.toml
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.13"
[tool.ruff]
line-length = 88
extend-exclude = ["migrations"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "S"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
"__init__.py" = ["F401"]
[tool.ruff.lint.isort]
known-first-party = ["myapp"]
[tool.ruff.format]
quote-style = "double"
docstring-code-format = true
Here's what each part does:
line-lengthis shared by the linter and the formatter. 88 matches Black's default.- Target version. Ruff reads
requires-pythonfrom[project]to decide which syntax upgrades are safe. HereUPrules will happily suggest anything valid on 3.13. If you don't have a[project]table, settarget-version = "py313"under[tool.ruff]. extend-excludeadds paths to Ruff's built-in excludes (.venv,build,.git, and so on). Useexcludeonly if you want to replace the defaults entirely.selectreplaces the default rule set with exactly the families listed. Useextend-selectinstead if you want to keep the defaults and add more.ignoreremoves individual rules.E501(line too long) is commonly ignored when the formatter is in charge of line length, since the formatter can't always split long strings and comments.per-file-ignoresrelaxes rules for specific paths.S101flagsassert, which is how pytest tests are written, so it's ignored undertests/. Re-exports in__init__.pylook like unused imports, soF401is ignored there.[tool.ruff.lint.isort]configures import sorting.known-first-partymakes sure your own package's imports are grouped separately from third-party ones.
Choosing Rules
There's no single correct select list, but a good progression is:
- Start with
E,F,W,I, andUP. These are uncontroversial and mostly auto-fixable. - Add
B(bugbear) andSIMonce the first set is clean. Bugbear catches real bugs like mutable default arguments. - Consider
Sfor anything that handles untrusted input,Nfor naming, andDif you enforce docstrings.
You can select ALL to turn on every rule, but expect conflicts (some pydocstyle rules are mutually exclusive, and Ruff warns about them) and a lot of noise. It's better for exploring than for daily use.
Suppressing Diagnostics
Sometimes the linter is right in general and wrong about this line. Ruff gives you several levels of suppression:
import os # noqa: F401
# ruff: ignore[F401]
import json
value = eval("1 + 1") # noqa
# noqa: F401at the end of a line suppresses that rule on that line. Always list the code; a bare# noqasuppresses everything and hides future problems (thePGH004rule flags blanketnoqacomments for this reason).# ruff: ignore[F401]on its own line, new in Ruff 0.16, suppresses the rule for the statement that follows. It's easier to read than a long trailing comment and works on multi-line statements like function definitions.# ruff: noqa: F401anywhere in a file suppresses the rule for the whole file.
Per-file ignores in config are usually better than file-level comments, because they keep policy in one place.
When you adopt a new rule on a big codebase, ruff check --add-noqa inserts noqa comments for every existing violation. That lets you enforce the rule on new code immediately and clean up the old violations gradually.
Editor Integration
Ruff includes a language server (ruff server), and the official extensions for VS Code and other editors use it. With it you get diagnostics as you type, quick fixes, organize-imports, and format-on-save. In VS Code, install the Ruff extension from Astral and set it as the default formatter for Python:
{
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
}
}
}
The extension uses the ruff from your project environment when it finds one, so the editor and the command line agree.
Running Ruff in pre-commit and CI
With pre-commit, add the official hooks:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.10
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
Order matters: run the linter with --fix first, then the formatter, because some lint fixes produce code the formatter will want to tidy.
In CI you don't want fixes, you want a failing build. These two commands are all you need:
ruff check .
ruff format --check .
Both exit with a non-zero status when there's something to fix. For GitHub Actions, --output-format github turns diagnostics into inline annotations on the pull request.
Migrating from Black, isort, and Flake8
If you're replacing an existing setup:
- Black:
ruff formatis designed to match Black's style. Set the sameline-length, run it once, and commit the (usually tiny) diff on its own. - isort: select
I. Most isort settings have equivalents under[tool.ruff.lint.isort]. If you used the Black profile, the defaults already match. - Flake8: map each plugin to its Ruff prefix (bugbear to
B, comprehensions toC4, and so on) and move yourextend-ignorelist intoignore. Ruff respects existing# noqacomments, so they keep working.
Then delete the old tools from your dev dependencies and config files. Leaving them half-configured is how you end up with two tools fighting over import order.
Conclusion
Ruff gives you a linter and a formatter that are fast enough to run on every save and every commit, configured from one section of pyproject.toml. The workflow is simple: ruff check --fix for the problems it can fix safely, read the rest, and ruff format to settle layout.
The two habits that make it work well on a team are choosing your rules explicitly with select, so a Ruff upgrade never changes your standards without you noticing, and treating unsafe fixes and blanket noqa comments with suspicion. Pair it with a type checker (see static type checking with mypy and Pyright) and a test suite, and you've covered most of what tooling can catch before code review.


