There are two ways to make a linter useless.
Too permissive. select = ["E", "F"] catches syntax errors and unused
imports. It has never once caught a defect that a test would not also have
caught, so the team correctly concludes that linting is theatre.
Too aggressive. Turn on everything, and within a week the codebase is
speckled with # noqa because some of the rules are wrong for this project
and there was no time to argue about which. Now the suppression comment is
normal, and the next # noqa — the one hiding a real bug — is invisible.
A defensible configuration sits between those, and the defence is per-rule: every rule you enable should be one you would be willing to explain in a review comment.
The settings that make it work at all
[tool.ruff]
src = ["src"]
line-length = 100
target-version = "py312"
src = ["src"] is not cosmetic. It is how ruff knows which imports are
first-party. Without it, in a src layout, your own package looks like a
third-party dependency, import sorting puts it in the wrong block, and every
file churns on the first ruff format run. This is the single most commonly
missing setting in src-layout projects.
target-version should match the lowest Python you support, not the
one you develop on. It controls which modernisation rewrites UP will apply;
set it too high and ruff cheerfully rewrites your code into syntax your
oldest supported interpreter cannot parse.
Rules that encode defects, not taste
[tool.ruff.lint]
select = [
"F", # pyflakes: undefined names, unused imports — real defects
"E4","E7","E9", # the pycodestyle subset that is not formatting
"B", # bugbear: mutable defaults, B008, except-in-loop
"SIM", # simplifications that are also usually clearer
"C4", # comprehension misuse
"RUF", # ruff's own, including RUF012 mutable class defaults
"I", # import sorting
"UP", # modernisation — the job mypy explicitly does not do
"TC", # type-checking blocks
"PTH", # os.path -> pathlib
"DTZ", # naive datetimes
"T20", # stray print()
"LOG","G",# logging misuse
"S", # security: eval, subprocess shell=True, bare asserts in prod
]
Two of those deserve a note.
UP is load-bearing, because mypy --strict does not flag
from typing import List, Dict, Optional — not even with
--enable-error-code deprecated. Modernisation is entirely ruff’s job. If
you skip UP, nothing in your toolchain will ever tell you that half the
codebase is written for Python 3.8.
TC is what catches the TYPE_CHECKING traps. TC004 in particular:
an import inside if TYPE_CHECKING: that is used as a runtime value passes
mypy cleanly and raises NameError in production.
Per-file ignores, which are the honest part
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "PLR2004", "ANN"] # asserts, magic numbers, annotations
"__init__.py" = ["F401"] # re-exports look unused
"conftest.py" = ["ANN"]
A rule that is right for src/ and wrong for tests/ is extremely common:
assert is a security finding in production code and the entire point of a
test. Encoding that difference in configuration is better than either
disabling the rule globally or teaching everyone to write # noqa: S101
three hundred times.
The setting people miss, and it costs them a day
[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel", "attrs.AttrsInstance"]
Without this, ruff sees an import used only in annotations and moves it into
if TYPE_CHECKING:. That is correct in general and wrong for pydantic,
which evaluates annotations at runtime to build its validators. The result is
a model that fails at class-definition time with a name error, produced by an
autofix the developer did not write. If you use pydantic or attrs, this
setting is not optional.
💡A rule fires on 300 existing lines. What do you do? click to reveal
Not # noqa, and not “disable it and move on”. Three options, in order:
Autofix it. ruff check --fix handles most of UP, C4, SIM and I
mechanically. Land the codemod as its own commit — no behaviour change, easy
to review by diffstat, easy to revert.
Scope it. Enable the rule with a per-file-ignores entry for the legacy
package, so all new code is held to it and the old code is a visible, dated
exception rather than an invisible global downgrade.
Reject it. If, having looked at twenty of the 300, you conclude the rule
is wrong for this codebase — leave it out of select and write one line in
the config saying why. A rule that is deliberately absent with a reason is
fine. A rule that is present and suppressed 300 times is not.
The thing to avoid is the fourth option, which is to enable it, add
# noqa where it fires, and tell people to be careful. That trains the team
to read # noqa as punctuation, and the cost lands on the next real finding.
ruff format is not black
It is a separate implementation with its own settings
(quote-style, indent-style, docstring-code-format) and its own edge
cases. It is close to black and deliberately compatible in most code, but
“we use black” and “we use ruff format” are not the same statement — expect
a one-time diff when you switch, and land it as its own commit.
In CI, run ruff format --check --diff rather than ruff format. The check
form fails with a readable diff instead of silently rewriting files on a
machine nobody is looking at.
💡Your gate runs ruff check and ruff format --check and both pass. What class of problem have you learned nothing about?
click to reveal
Almost everything that matters about the code’s design.
Ruff is a syntactic and local-semantic tool. It cannot tell you that a
function has four responsibilities, that a Protocol is too wide, that the
domain layer imports the database three modules away, that an abstraction is
leaky, or that a test asserts on the implementation rather than the
behaviour. It also cannot tell you the code is wrong — F catches undefined
names, not incorrect ones.
Two of those gaps have automated answers worth adding: an import-graph
contract for the layering question, and mypy --strict for the type-level
ones. The rest is review, and knowing that ruff does not cover it is what
stops “the linter passed” from being used as though it were an argument.