Skip to content
← All articles

Ruff: a configuration you can defend in review

Lint config is where a team encodes its standards, and a bad one costs more than none — too permissive catches nothing, too aggressive teaches everyone to write # noqa.

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.