Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~30 min· topic 6 of 8

Topic 11.6

Linting and Formatting with Ruff

In one line

Ruff is a very fast tool that checks Python code for likely bugs and style problems (ruff check) and rewrites its layout into one consistent style (ruff format). One tool replaces Flake8, isort, Black and several plugins.

Think of it like this

A newspaper has two helpers before printing. A proofreader reads every article and marks mistakes: a repeated word, a name spelt two ways. A layout editor makes every page look the same: same fonts, same column widths, same spacing. Neither changes what the article says. In Python, the proofreader is a linter and the layout editor is a formatter, and Ruff is both.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Linter
A tool that reads code without running it and reports likely bugs and style problems.
Formatter
A tool that rewrites code layout into one consistent style without changing behaviour.
Static analysis
Checking code by reading it, not by running it.
Rule code
The short name of a check, such as F401 (unused import).
`--fix`
A flag that lets Ruff change your files to repair the problems it knows how to fix safely.
`noqa`
A comment (# noqa: F401) that tells the linter to ignore one rule on one line.
Line length
The maximum number of characters on a line. Ruff's default is 88.
Rule set (`select`)
The families of rules you switch on in the configuration.
Flake8, Black, isort
The older separate tools (linter, formatter, import sorter) that Ruff replaces.

Step by step

01A messy file

This file runs, but a reviewer would wince. Two imports are unused, imports are not sorted, == None is the wrong way to test for None, a variable is never used, a bare except: swallows every error, and the layout is inconsistent.

messy.pywhole filepython
import os
import sys
import json

def load(path,default = None):
    if default == None:
        default = {}
    data = {'path':path, "items":[1,2,3]}
    try:
        return json.load(open(path))
    except:
        return default

02ruff check: the findings

--select E,F,I turns on pycodestyle errors, Pyflakes and import sorting. The default output is detailed; --output-format concise prints one line per finding: file, line, column, code, message. The [*] marks a rule Ruff can fix by itself.

terminal
$ ruff check messy.py --select E,F,I --output-format concise
── expected output ──
messy.py:1:1: I001 [*] Import block is un-sorted or un-formatted
messy.py:1:8: F401 [*] `os` imported but unused
messy.py:2:8: F401 [*] `sys` imported but unused
messy.py:6:19: E711 Comparison to `None` should be `cond is None`
messy.py:8:5: F841 Local variable `data` is assigned to but never used
messy.py:11:5: E722 Do not use bare `except`
Found 6 errors.
[*] 3 fixable with `--fix` (2 unsafe fixes available with `--fix --unsafe-fixes`).

03ruff check --fix: safe fixes only

The three safe fixes (remove two unused imports, sort the import block) are applied. The other three need a human decision, or an unsafe fix: for example, removing data = ... also removes whatever side effect its right-hand side had, so Ruff will not do it silently.

terminal
$ ruff check messy.py --select E,F,I --fix --output-format concise
── expected output ──
messy.py:5:19: E711 Comparison to `None` should be `cond is None`
messy.py:7:5: F841 Local variable `data` is assigned to but never used
messy.py:10:5: E722 Do not use bare `except`
Found 6 errors (3 fixed, 3 remaining).
2 unsafe fixes available with `--fix --unsafe-fixes`.

04ruff format: layout only

After the fixes, the code is correct but still ugly. ruff format --diff previews the layout change; ruff format applies it. Notice the spaces after commas, default=None without spaces (the PEP 8 style for defaults), and double quotes everywhere. The behaviour is identical.

terminal
$ ruff format --diff messy.py
── expected output ──
--- messy.py
+++ messy.py
@@ -1,10 +1,10 @@
import json
 
 
-def load(path,default = None):
+def load(path, default=None):
if default == None:
default = {}
- data = {'path':path, "items":[1,2,3]}
+ data = {"path": path, "items": [1, 2, 3]}
try:
return json.load(open(path))
except:
 
1 file would be reformatted

05Configure it once in pyproject.toml

Put the settings in the project file so every developer, the editor and CI use the same rules. select lists the rule families. E and F are the classic errors, I sorts imports, B is flake8-bugbear (likely bugs, such as mutable default arguments), and UP upgrades old syntax to what your Python version supports.

pyproject.tomlwhole filetoml
[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["E501"]            # long lines: the formatter already handles layout

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["B011"]         # allow `assert False` in tests

[tool.ruff.format]
quote-style = "double"

06How a linter works: it walks the tree

A linter does not run your code. It parses the text into a tree (the same tree Python builds, Topic 0.8), then walks the tree and applies rules. A rule is just a question about the tree: "is there an import whose name never appears again?" The runnable example builds two real rules with Python's ast module. Ruff does the same thing, in Rust, for hundreds of rules, at much higher speed.

How a linter works: it walks the treediagram
Rendering diagram…

07A rule that catches a real bug

The bugbear rule B006 flags a mutable default argument (Topic 5.2). It is a good example of why linting is more than style: the second runnable example shows the bug a linter prevents. The default list is created once, when the function is defined, and every call without an argument shares it.

08noqa, and running Ruff automatically

Silence a single line with # noqa: CODE. Run Ruff in your editor (it has a language server, ruff server) so problems appear as you type, and in CI so none reach the main branch. Topic 11.8 wires this into pre-commit and GitHub Actions.

terminal
$ ruff check shop.py --select E,F,I
ruff format --check shop.py
── expected output ──
All checks passed!
1 file already formatted

Try it yourself

  1. 1

    Add a third rule

    In the first example, add a check that reports a bare except:. In the syntax tree this is an ast.ExceptHandler whose type is None. Add a try/except: to source and make your program report it with line number.

  2. 2

    Run Ruff on your own computer

    Install Ruff (pip install ruff or uv tool install ruff), create messy.py from the walkthrough, and run ruff check, ruff check --fix and ruff format --diff. Compare your output with the one above. Tool versions differ a little, and the message text can change between releases.

  3. 3

    Turn a rule on and off

    Add select = ["E", "F", "I", "B"] to a pyproject.toml and lint the mutable-default function. Find the B006 finding. Then add ignore = ["B006"] and watch it disappear. Then remove the ignore and fix the code instead.

Code & diagrams

Build two lint rules with ast New tab

This is how a linter works: parse the text into a tree (Topic 0.8), walk the tree, report. The text between the triple quotes is the program being checked. Add `import re` at the top of `source` and see a new finding.

Sign in to run this example in your browser.

Expected output

main.py:1: F401 'os' imported but unused
main.py:3: F401 'sys' imported but unused
main.py:14: E711 comparison to None should be 'is None'
Found 3 problems.
The bug behind rule B006: a mutable default argument New tab

The first function is what a linter flags. The second uses `None` and creates a new list per call. This is the fix Ruff's rule suggests.

Sign in to run this example in your browser.

Expected output

first call:  ['apple']
second call: ['apple', 'banana']
default now: (['apple', 'banana'],)
fixed first:  ['apple']
fixed second: ['banana']
Linter and formatter in the workflowdiagram
Rendering diagram…

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Silence a rule with a blanket noqa

Add a bare # noqa (no rule code) to a line to make the warning go away.

main.pywhole filepython
import os  # noqa

def load(path, default=None):
    if default == None:  # noqa
        default = {}
    return default
terminal
$ ruff check main.py --select E,F,I
── what you'll see ──
All checks passed!

Myth vs fact

Myth

A formatter changes how my program behaves.

Fact

Formatters change only layout: whitespace, quotes, line breaks. Ruff's formatter is designed to keep the syntax tree the same. That is why it is safe to run on every save.

Myth

Style tools are about taste, not about bugs.

Fact

Many rules catch real bugs: unused or undefined names, mutable default arguments, bare except:, == against None, shadowed variables. Style and correctness checks come in one pass.

Myth

If Ruff passes, my code is correct.

Fact

Ruff does not run code and does not check types. Passing Ruff means no known risky patterns. You still need tests (Topics 11.1 to 11.5) and a type checker (Topic 10.3).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Ruff reads requires-python from pyproject.toml to guess target-version, and rule families like UP (pyupgrade) use it to decide which modern syntax is allowed. Raise your minimum Python version and ruff check --select UP --fix can modernise the codebase for you.

  • ▸

    Safe vs unsafe fixes is a deliberate line: a safe fix is guaranteed not to change behaviour, an unsafe one might (removing an assignment that had a side effect, replacing == None). CI usually runs ruff check without --fix, so the build fails and a person decides. Locally you run --fix and review the diff.

  • ▸

    A good adoption path for an old codebase is to start with the default rules, then add families one at a time, using per-file-ignores or ruff check --add-noqa to record existing violations, so new code is held to the new standard without a giant cleanup commit.

  • ▸

    Know the families you will meet: E/W (pycodestyle), F (Pyflakes: unused or undefined names), I (isort), B (bugbear: likely bugs), UP (pyupgrade), SIM (simplifications), C4 (comprehensions), N (naming), S (security checks from Bandit), PT (pytest style), RUF (Ruff's own) and D (docstrings). Without a select, Ruff's default is only E4, E7, E9 and F, a small set that avoids style noise. Use extend-select to add families on top of the defaults instead of replacing them, and ruff rule F401 prints the explanation of any rule.

  • ▸

    Order matters when you use both commands: run ruff check --fix first, then ruff format, because a lint fix (deleting an import, sorting a block) can leave code that the formatter would lay out differently. Some lint rules fight the formatter (COM812 and ISC001 can clash with it), and Ruff warns about them when both are enabled.

  • ▸

    Ruff's formatter is intentionally close to Black but not identical in every corner case. If you migrate from Black, run ruff format once, commit the (usually tiny) difference separately, and add that commit hash to .git-blame-ignore-revs so git blame stays useful.

Remember this

  1. 1

    A linter reads your code without running it (this is called static analysis) and reports things that are probably wrong or risky. Examples: an import you never use (Topic 6.1), a variable assigned but never read, a name that does not exist, a comparison like x == None, a bare except: that hides every error (Topic 7.1), a mutable default argument (Topic 5.2).

  2. 2

    Each finding has a rule code such as F401. The letter says which family of rules it comes from and the number says which rule.

  3. 3

    A formatter rewrites layout only: spaces around operators, quote style, how long lines are split, blank lines between functions. It never changes what the program does. The benefit is social: nobody argues about style in code review, and diffs show only real changes, because everyone's code has the same shape.

  4. 4

    Ruff is written in Rust (a fast systems programming language) and made by Astral, the makers of uv (Topic 6.6). It is usually fast enough to run on every save.

  5. 5

    It replaces a stack of older tools: Flake8 and its plugins (linting), isort (sorting imports), Black (formatting; Ruff's formatter is designed to match Black's style almost exactly), and pieces of pyupgrade, pydocstyle and others. One install, one config, one command.

  6. 6

    The commands you need: ruff check . lists problems. ruff check --fix . applies the safe fixes automatically (removing unused imports, sorting imports). Fixes that might change behaviour are "unsafe" and only applied with --unsafe-fixes.

  7. 7

    ruff format . rewrites files. ruff format --check . changes nothing and fails if any file would change, which is what CI (a server that runs your checks on every change, Topic 11.8) uses. ruff format --diff shows what would change without writing.

  8. 8

    Configure Ruff in pyproject.toml (Topic 6.7). [tool.ruff] holds line-length (default 88) and target-version. [tool.ruff.lint] has select (which rule families to turn on, such as ["E", "F", "I", "B", "UP"]), ignore (rules you turn off) and per-file-ignores (for example, relax some rules in tests/).

  9. 9

    Ruff ships with a default set of rules, which can change between versions. So most projects state their own select list.

  10. 10

    When a rule is wrong for one line, silence it on that line with a comment: import settings # noqa: F401. Always name the code, so you silence one rule and not everything. If you add many noqas, the rule probably does not suit your project: turn it off in the config instead, once.

  11. 11

    Ruff also fits the AI era. Code written or suggested by an AI assistant often has unused imports, old-style patterns or inconsistent layout. Running ruff check --fix and ruff format on it takes a second and makes it match your project.

  12. 12

    Ruff is not a type checker: it does not know that x is an int. That is the job of mypy or pyright (Topic 10.3), and the two are used together.

Explain it without notes

01

What is the difference between a linter and a formatter?

02

What tools does Ruff replace, and why would a team move to it?

03

What does ruff check --fix change, and what does --unsafe-fixes add?

04

How do you configure Ruff for a project, and how do you silence one warning properly?

05

Why can a linter find a bug without running the program?

Practice

01

Write a function with a mutable default argument, run it twice to show the bug, then fix it with None.

02

Using the ast approach from the example, write a check that reports every function whose name is not lower case with underscores (for example loadData). Use ast.FunctionDef and str.islower().

03

Write the [tool.ruff.lint] configuration that turns on errors, Pyflakes, import sorting and bugbear, and ignores line length.

Trade-offs

  • ↔

    More rules find more problems but add noise. Start with a focused set (E, F, I, B, UP) and add families when the team agrees on them. A rule set nobody follows is worse than a smaller one everybody does.

  • ↔

    Auto-fix saves time but needs review. Safe fixes are fine to run freely. Unsafe fixes can change behaviour, so read the diff and run the tests afterwards.

  • ↔

    One tool versus the best tool for each job. Ruff's all-in-one speed and simplicity wins for most teams. A few projects keep a specialist tool (for example, a strict docstring checker) alongside it.

Done when you can

  • Done when you can explain what a linter does and what a formatter does.

  • Done when you can run ruff check, ruff check --fix, ruff format and ruff format --check.

  • Done when you can read a rule code like F401 or E711 and know where to look it up.

  • Done when you can write a [tool.ruff.lint] section with select and ignore.

  • Done when you can silence one rule on one line with a named noqa.