Ruff Already Rewrites Your Python to Be More Idiomatic
Open any Python codebase that’s been through a few hands and you’ll find the same fossils: a for loop adding items to a set one at a time, a conditional slice that could be removesuffix(), a two-line open()/read() that pathlib handles in one call. The code works. It passes every linter you’ve enabled. And a reviewer who knows Python well would rewrite all of it.
Ruff can already do that rewriting. It ported 36 rules from refurb into a category called FURB, and every one of them ships with an auto-fix. Most projects haven’t turned them on because Ruff’s defaults only enable Pyflakes (F) and a subset of pycodestyle (E). The rules that catch non-idiomatic patterns sit behind an opt-in flag, invisible to the people who’d benefit most.
Here are three patterns you’ve probably written this month.
The set-add loop
s = set()
for x in items:
s.add(x)FURB142 rewrites this to s.update(items). One method call instead of N, and the intent is immediately clear: you’re building a set from an iterable, not doing something conditional inside the loop. The same logic applies to consecutive .append() calls (FURB113 collapses them into .extend()).
This is the kind of pattern that persists because it’s never wrong. No test catches it. No type checker flags it. It just sits there, making the reader do slightly more work than necessary on every encounter.
The hand-rolled removesuffix
Python 3.9 added str.removeprefix() and str.removesuffix(), but codebases that predate 3.9 still carry the old conditional slice:
filename = filename[:-4] if filename.endswith(".txt") else filename
if text.startswith("pre"):
text = text[3:]FURB188 rewrites both:
filename = filename.removesuffix(".txt")
text = text.removeprefix("pre")The removesuffix version is shorter, but the real win is eliminating off-by-one bugs. When someone changes the suffix from ".txt" to ".json" and forgets to update the slice from [:-4] to [:-5], the old version silently mangles filenames. The new version can’t have that bug.
The open/read two-step
with open("config.json") as f:
data = f.read()FURB101 collapses this to Path("config.json").read_text(). It’s the same operation in fewer moving parts. pathlib has shipped this method since Python 3.5, but the open()/read() pattern is so deeply embedded in tutorials and muscle memory that it persists even in codebases that use Path everywhere else.
Turn them on
Add two keys to pyproject.toml:
[tool.ruff]
preview = true
[tool.ruff.lint]
extend-select = ["FURB"]Many FURB rules are still in Ruff’s preview tier, so preview = true is needed to activate them. Then ruff check --fix . applies all safe fixes at once.
FURB covers different ground than the two related categories you may already have enabled. UP (pyupgrade) modernizes syntax: f-strings over %-formatting, X | Y over Union[X, Y]. SIM (flake8-simplify) simplifies control flow: collapsing nested if statements, rewriting not not x. FURB targets a different layer: hand-rolled logic that the standard library already handles. The three don’t overlap and don’t conflict. The Ruff complete guide covers all of them, and configuring recommended Ruff defaults includes FURB in the curated rule set.
Where this comes from
The standalone refurb tool (2.5k GitHub stars, created by dosisod) originated these rules. It runs on mypy’s analysis engine and catches some patterns Ruff hasn’t ported yet. For most projects, Ruff’s FURB subset covers the patterns that matter, and you get them without adding another tool to the chain.
The full list of 36 rules is in the Ruff FURB rules reference.