PYTHON / TESTING AND TOOLING
Linting and formatting with ruff and black
Configure ruff and black in pyproject.toml, and know which problems a formatter can fix, which ones only the linter reports, and in what order to run them.
What you will learn
- Run ruff check --fix before the formatter so layout is decided last
- Set one line-length in pyproject.toml and stop selecting E501 alongside black
- Tell formatting fixes (AST-preserving) apart from lint fixes (AST-changing)
- Gate CI on ruff check . and black --check . and read their exit codes
Understanding Linting and formatting with ruff and black
A formatter and a linter answer different questions about the same file. black parses your code into a syntax tree, throws away every choice you made about whitespace, line breaks and quote characters, and prints the tree back out in one canonical shape. Because the tree is what Python executes, a black diff cannot change what the program does, which is also why black has almost no options: line length is nearly the only knob, and arguing about it is the point it removes.
ruff covers both halves of the job with two separate commands. `ruff check` walks that same tree asking questions about meaning: is this import ever referenced (F401), is this function default a shared mutable list (B006), does this except clause swallow everything (E722), are these imports in the right groups (I001). `ruff format` is a black-compatible reprinter, so a project picks either black or ruff format, never both, and when black owns formatting you drop ruff's overlapping stylistic rules. The classic mistake is leaving E501 selected: black refuses to split a long string literal or a URL in a comment, so that line stays over the limit forever and the linter complains about something no tool will fix.
Order matters because `ruff check --fix` edits code. Deleting an unused import shortens the file, and rewriting `"{}".format(x)` into an f-string changes a line's width, so the formatter must have the last word or you get a second diff immediately. ruff only applies fixes it marks safe; ones that could change behaviour sit behind `--unsafe-fixes` and deserve a human read. In CI, use the check-only modes (`ruff check .`, `black --check .`) so the job fails with a nonzero exit code instead of quietly rewriting the checkout.
import ast
before = "d = { 'a':1,'b':2 }\nif d[ 'a' ]==1 : print( 'yes' )\n"
after = 'd = {"a": 1, "b": 2}\nif d["a"] == 1:\n print("yes")\n'
print("same AST after reformatting:", ast.dump(ast.parse(before)) == ast.dump(ast.parse(after)))
src = "import os\nimport sys\nprint(sys.argv)\n"
tree = ast.parse(src)
imported = {alias.name for node in ast.walk(tree)
if isinstance(node, ast.Import) for alias in node.names}
used = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
print("unused imports (what ruff F401 reports):", sorted(imported - used))A formatter rewrites layout while preserving the parse tree; a linter reads that tree and reports meaning, so neither can do the other's job.
Worked examples
The bug black will never fix
B006 flags a mutable default argument, a defect that survives any amount of reformatting.
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("pear"))
def add_item_fixed(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item_fixed("apple"))
print(add_item_fixed("pear"))Example explained
Line 1`basket=[]` is evaluated once when the `def` statement runs, so every call shares that one list.
Line 2The second call therefore returns ['apple', 'pear'] instead of a fresh list, which is what B006 warns about.
Line 3black would happily reformat that signature and leave the shared list in place, because layout is all it touches.
Line 4The `basket=None` sentinel builds a new list per call, which is why ruff's fix for this rule changes behaviour and is not applied by default.
Lint fixes change the tree, formatting does not
Shows that ruff's UP032 rewrite produces a different AST node while formatting keeps the node identical.
import ast
name = "ada"
old = "hello {}".format(name)
new = f"hello {name}"
print(old, "|", new, "|", old == new)
print(type(ast.parse('"hi {}".format(x)').body[0].value).__name__)
print(type(ast.parse('f"hi {x}"').body[0].value).__name__)Example explained
Line 1`old == new` is True, so the rewrite UP032 suggests keeps the runtime result the same.
Line 2The `.format()` version parses to a `Call` node, the f-string to a `JoinedStr`: different trees, so this is a lint fix, not a formatting change.
Line 3That is the dividing line: `ruff format` only ever reprints an unchanged tree, `ruff check --fix` replaces nodes.
Line 4Because the replacement shortens the expression, the formatter should run after the fix to re-decide line breaks.
Important notes
black and ruff format aim to agree but still differ on edge cases such as docstring indentation and some comment placement, so pick one formatter per repository and expect one large diff if you switch.
Both tools parse before they act: if black reports it cannot format a file, that file has a syntax error rather than a style problem.
Common mistakes
Selecting E501 while black formats the file: black will not break a long URL in a comment or a long string literal, so the same line is reported on every run and the only fix is to ignore E501 and let the formatter own line width.
Running the formatter first and `ruff check --fix` second, then being surprised the tree is dirty again, because removing an import or rewriting a `.format()` call changes line lengths the formatter had already settled.
Silencing a warning with a bare `# noqa` instead of `# noqa: F401`: it suppresses every rule on that line, so a genuine undefined-name error there is hidden from then on.
Try it yourself
Change, predict, then run
In a browser editor, write one badly spaced and one cleanly formatted version of the same three-line function as strings and assert that `ast.dump(ast.parse(...))` matches for both. Then rename a parameter in only one version and confirm the assertion fails, proving that renaming is not something a formatter is allowed to do.
Open the Python workspaceCheck your understanding
A CI job runs `black --check .` (exit 0) and `ruff check .` (fails with F841, local variable assigned but never used). Why can the formatter pass while the linter fails on the same file?
- black only inspects files touched by the latest commit, so it skipped the file ruff flagged
- Formatting only rewrites layout and preserves the parse tree, so an unused variable is still unused after formatting
- black and ruff disagree about style here, so one of the two configurations must be wrong
- `black --check` always exits 0; only `black --diff` can report a problem
Show answer
black reprints the syntax tree without changing it, and F841 is a statement about that tree's meaning, so correct formatting and an unused variable coexist happily. Option 3 is tempting because black and ruff genuinely can conflict over stylistic rules like E501, but F841 is not a style rule and no formatter setting would silence it.