Type something to search...
Linting and Formatting Python Code with Ruff

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:

PrefixOriginWhat it catches
FPyflakesUnused imports and variables, undefined names, real bugs
E, WpycodestylePEP 8 errors and warnings
IisortImport ordering
UPpyupgradeSyntax that can be modernized for your Python version
Bflake8-bugbearLikely bugs and design problems
SIMflake8-simplifyCode that can be written more simply
Sflake8-banditSecurity issues
Npep8-namingNaming conventions
DpydocstyleDocstring conventions
PLPylintA large port of Pylint checks
RUFRuff itselfRuff-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-length is shared by the linter and the formatter. 88 matches Black's default.
  • Target version. Ruff reads requires-python from [project] to decide which syntax upgrades are safe. Here UP rules will happily suggest anything valid on 3.13. If you don't have a [project] table, set target-version = "py313" under [tool.ruff].
  • extend-exclude adds paths to Ruff's built-in excludes (.venv, build, .git, and so on). Use exclude only if you want to replace the defaults entirely.
  • select replaces the default rule set with exactly the families listed. Use extend-select instead if you want to keep the defaults and add more.
  • ignore removes 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-ignores relaxes rules for specific paths. S101 flags assert, which is how pytest tests are written, so it's ignored under tests/. Re-exports in __init__.py look like unused imports, so F401 is ignored there.
  • [tool.ruff.lint.isort] configures import sorting. known-first-party makes 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:

  1. Start with E, F, W, I, and UP. These are uncontroversial and mostly auto-fixable.
  2. Add B (bugbear) and SIM once the first set is clean. Bugbear catches real bugs like mutable default arguments.
  3. Consider S for anything that handles untrusted input, N for naming, and D if 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: F401 at the end of a line suppresses that rule on that line. Always list the code; a bare # noqa suppresses everything and hides future problems (the PGH004 rule flags blanket noqa comments 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: F401 anywhere 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 format is designed to match Black's style. Set the same line-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 to C4, and so on) and move your extend-ignore list into ignore. Ruff respects existing # noqa comments, 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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading