Skip to content
← All articles

The Ruff Ruleset — And The Rules You Should Not Gate On

A linter with a bad false-positive rate does not teach discipline, it teaches `# noqa`. The Bronze and Silver rule selections used in this course, what each prefix actually is, and seven checks worth displaying but never blocking a merge on.

Every linter configuration is a bet about people — specifically, about what a tired engineer will do at 6pm when CI is red and the fix is not obvious. If the rule that failed is one they believe in, they fix the code. If it is one they think is stupid, they write # noqa and move on, and from that moment the suppression comment is a normal part of the codebase’s vocabulary. The next person uses it for a rule that was worth listening to.

So the question “which rules should we enable?” is the wrong first question. The right one is “which rules are we willing to block a merge on?” — and those are different sets, because a check can be genuinely informative and still be a terrible gate.

What the prefixes are

Ruff is a reimplementation of several dozen separate Flake8-era tools, each of which kept its own code prefix. The prefix, not the number, is what you select on. Here is the Bronze set used in this course, decoded:

Prefix Origin What it is for
F Pyflakes Undefined names, unused imports, unused variables — the errors
E4,E7,E9 pycodestyle Import placement, statement-level mistakes, syntax/runtime errors
W pycodestyle Trailing whitespace, tab indentation, invalid escape sequences
B flake8-bugbear Real bug patterns: mutable defaults, loop-variable capture, assert on a tuple
UP pyupgrade Idioms superseded by your target-version
SIM flake8-simplify Collapsible ifs, if x: return True else: return False
RUF Ruff-native Rules with no upstream equivalent, including some genuinely good ones
I isort Import ordering and grouping
C4 flake8-comprehensions list(x for x in y)[x for x in y]
PIE flake8-pie Miscellaneous unnecessary constructs
T20 flake8-print print() left in library code
ICN flake8-import-conventions import numpy as np, not as numpy

Note what is not in Bronze: the whole formatting argument. E1, E2, E3 and E5 — indentation, whitespace, blank lines, line length — are absent because ruff format owns those and a formatter never argues. If a rule can be fixed mechanically with zero judgment, it should never be capable of failing a build; it should already have been applied on save.

Silver adds the reviewer’s rules: ANN (annotations present), BLE (blind except), TRY (exception antipatterns), EM (exception messages as variables, so tracebacks are readable), RET (return-flow simplification), ARG (unused arguments), SLF (private-member access across objects), N (naming), PTH (pathlib over os.path), DTZ (timezone-naive datetimes), S (bandit security), PERF, FBT (boolean-trap parameters), TID (import hygiene), TC (type-checking-only imports), C90 (McCabe complexity, max-complexity = 8), PLR/PLW/PLC (Pylint), FURB (refurb), A (shadowing builtins), ERA (commented-out code), G and LOG (logging), INP (implicit namespace packages), PYI (stub files).

💡T20 bans print(). Your team has a CLI whose entire job is to print things. How do you configure this without either disabling the rule or littering the CLI with # noqa? click to reveal

Per-file ignores. Every serious linter config has this section, and using it is not a defeat — it is the config expressing a real structural fact about the repo.

[tool.ruff.lint.per-file-ignores]
"src/myapp/cli/*.py" = ["T20"]
"tests/*" = ["S101", "PLR2004"]

Three things make this better than the alternatives. It is declarative — the exemption is one reviewable line in one file, rather than fifty comments scattered across the codebase where nobody can count them. It is scoped by path, which means it encodes a genuine architectural claim: “the CLI layer is allowed to write to stdout, the domain layer is not.” And it fails closed — the day someone adds a print() to src/myapp/domain/pricing.py, it is still an error, which is the case you actually cared about.

The two test-directory entries are the same idea. S101 (bandit’s “do not use assert“) is correct for production code, where python -O strips asserts and your security check evaporates. In a test file, assert is the API. PLR2004 (magic value in comparison) is likewise right in production and pure noise in a test that asserts result == 42.

The rule of thumb: if you find yourself writing the same # noqa more than three times in one directory, you have discovered a per-file-ignore, not a stubborn rule.

Seven checks to display and never gate on

These are all worth measuring. None of them should be able to block a merge.

1. Coverage percentage. A number that can be raised without improving a single test. Gate on coverage and you get tests that call every line and assert nothing — plus a strong incentive to delete the hard-to-test error handling rather than test it. Display the number, alert on a drop, and review the diff of what became uncovered.

2. --disallow-any-expr. mypy’s most aggressive Any check: it flags every expression whose type is Any, not just annotations. Point it at a codebase that touches json, requests or an untyped ORM and it produces thousands of errors, most of which cannot be fixed without wrapping a boundary you were not planning to touch today. It is a magnificent instrument for finding out where Any enters your system, and a terrible gate. Run it, count it, put the count on a dashboard, and fix the top three sources per quarter.

3. Blanket D (pydocstyle). D checks that docstrings exist and are punctuated. It cannot check that they are true. Gate on it and every def get_user in the repo acquires a docstring reading “Get the user.” — forever, on every function — noise that actively makes the real docstrings harder to find. If you want documentation enforcement, gate on the public API only (D under a per-file-ignore inverted to src/myapp/api/*), and put your energy into ANN instead: a complete signature communicates more than a restated function name.

4. S101 in tests. Covered above. Bandit is right about production and wrong about pytest.

5. PLR0913 (too many arguments) on keyword-only constructors. The rule exists because a six-positional-parameter function is unreadable at the call site — you cannot tell what Foo(True, False, None, 3, "x", []) means. A constructor whose parameters are all keyword-only does not have that problem: Config(retries=3, verify_tls=True, timeout=None) is self-describing at every call site regardless of how many there are. The rule counts parameters and cannot see the *, so it fires on exactly the design you wanted.

6. PLC0415 (import not at top of file) where the deferral is deliberate. Moving an import into a function body is sometimes exactly right: breaking a cycle, avoiding a two-second module-level import in a CLI that usually does not need it, or making an optional dependency genuinely optional. The rule cannot distinguish those from laziness. Gate on it and every legitimate deferred import needs a comment arguing with the linter.

7. S603 / S607 (subprocess calls, partial executable paths). These fire on essentially every correct use of subprocess.run. S603 warns that you are calling a subprocess at all; S607 warns that you wrote "git" rather than "/usr/bin/git". The advice behind them is real but situational, and their signal-to-noise ratio in a codebase that legitimately shells out is close to zero. The result is a # noqa on every subprocess call — which means when a genuinely dangerous shell=True appears, it will be wearing the same comment as everything else.

💡What is the actual cost of a rule with a 40% false-positive rate, given that engineers can suppress it in five seconds? click to reveal

The cost is not the five seconds. It is that you have taught the codebase a new word.

Before the bad rule, # noqa is unusual. Seeing one in a diff makes a reviewer stop and ask why. That reaction is the entire value of the linter — not the automated check, but the social one, where suppressing a warning is a thing you have to justify to another person.

After the bad rule, # noqa is ambient. There are 200 of them, 190 of which are legitimate exemptions from a rule everyone agrees is wrong. A reviewer skims past them, because reading each one has become a chore with a 95% chance of being pointless. The tenth one — the one where somebody silenced B008 because a mutable default was “fine here, actually” — sails through.

The same dynamic destroys # type: ignore, alert channels, deprecation warnings, and flaky test suites. In every case the mechanism is identical: a signal that is usually wrong trains its audience to stop reading it, and it takes the correct instances of that signal down with it.

Which gives you the practical test for whether a rule belongs in the gate. Not “is this rule correct?” — almost all of them are correct sometimes. Ask: “when this rule fires, what fraction of the time will the right response be to change the code?” Below about 90%, it goes on the dashboard, not in CI.

Pin the linter — harder than you pin the checker

Ruff 0.16, released 2026-07-23, expanded the default rule set from 59 rules to 413.

Read that again with an unpinned ruff in your CI. On Wednesday the build is green. On Thursday morning, without a single line of your code changing, it is 4,000 errors red — and the person who finds out is whoever pushed first, who has no idea what happened and no context for triaging it. They will do the fastest green-making thing available, and the fastest thing is never the right thing.

Two defences, and you want both:

Pin the exact version. ruff==0.16.0 in your dev dependencies and your pre-commit config, upgraded deliberately in its own commit whose diff is “adopt ruff 0.16, enable 9 of the 354 new rules, fix the 60 findings”.

Never rely on the default select. Write the list out explicitly, as above. An explicit selection is immune to a change in the defaults, it is reviewable, and it means the answer to “why is this rule on?” is a line in a config file with a commit message behind it rather than “it came with the tool.”

This is the same argument as pinning mypy, only more urgent, because the linter’s rule set moves much faster than the checker’s flag list.

Two rules people get wrong in the other direction

Worth knowing, because both are cases where the “modern” instinct is a regression.

G — logging format. logger.info("processed %s rows", n) is correct and is what the G rules enforce. logger.info(f"processed {n} rows") is worse, for three separate reasons: the string is formatted even when the level is disabled; structured log processors lose the message template, so you can no longer group by “this log line” across differing values; and logging‘s own error handling around %-substitution goes away. A blanket “migrate everything to f-strings” pass over a codebase will happily break all three. Logging calls are the one place %-formatting is not legacy.

E722 — bare except:. Still the only thing standing between you and except: swallowing KeyboardInterrupt and SystemExit, because PEP 760, which proposed making bare except: a language-level error, was withdrawn. The language is never going to help you here. The linter is all there is.