Skip to content

Ruff 0.16 Turned On 354 Rules. Here's What They Caught

I ran Ruff 0.15 and 0.16 with default settings on 60 untouched Python files. Findings went from 30 to 129. Here's what was real, what was noise, and why.

· · 6 min read
Dark computer screen filled with lines of source code

The number everyone quoted, and the one nobody did

On 23 July 2026 Ruff 0.16.0 shipped and flipped its default rule set from 59 rules to 413. That headline got repeated everywhere within a week. Simon Willison ran the new defaults across Datasette, sqlite-utils and LLM and reported “hundreds of minor issues”, with sqlite-utils alone coming back with 1,618 errors, 1,538 of them auto-fixable. Then he fixed them, with a test suite behind him and two coding agents doing the typing.

What I couldn’t find anywhere was the next number. Of the findings a jump like that produces on code nobody asked it to look at, how many are defects, how many are style, and how many are the tool being confidently wrong? That’s the only number that says whether the new default earns its place in your CI.

So I measured it, on a monorepo I maintain: 60 Python files, 9,345 lines, image-processing scripts, a topic-discovery engine and a handful of tests. None of it had ever been run through Ruff. That matters. Code that already passed a linter tells you nothing about a new default; code that was never asked is the honest test.

Both runs used --isolated so no stray config could tilt the result. Ruff 0.15.22 with its defaults: 30 findings. Ruff 0.16.6 with its defaults: 129 findings.

But the delta isn’t 99. Twelve of the old 30 disappeared, because 0.16 also dropped 18 opinionated pycodestyle and pyflakes rules from the defaults; my semicolons and one-line multi-imports (E702, E401) stopped being anyone’s problem. Eighteen carried over. So the new rules contributed 111 fresh complaints, and that’s the pile worth sorting.

A quarter of the new findings were one rule about file permissions

Fifty-three of the 111 came from a single rule: EXE001, shebang present but file not executable. Fifty-five of my scripts open with #!/usr/bin/env python3. Exactly two of them are marked executable in git.

Hands sifting white flour through a metal sieve
Photo by Tatev Ayvazyan on Unsplash

Is that a bug? It’s a mismatch, and a real one: the shebang promises ./script.py works and the mode bit says it doesn’t. But nothing in the code is wrong, the fix is chmod +x rather than an edit, and most teams hit it the way I did, by running everything as python script.py and never noticing. A linter is an odd place to learn about your file modes. Correct, and odd.

Another 19 were I001, unsorted imports. Pure style, auto-fixable, zero risk. Together, two rules that will never change how a program behaves explain 72 of the 111 new findings.

What was actually worth the noise

The remaining 39 are where the release earns or loses its argument, and my honest tally is that most of them are real.

Fifteen naive datetimes (DTZ001, DTZ005, DTZ007, DTZ011): datetime.now() and strptime() with no timezone, in tools that stamp runs and compute “days since”. One is in my own radar script, in the function that decides whether a topic is stale. I checked. It compares naive local to naive local, so it’s consistent today, and it breaks the first time a cron box and a laptop disagree on what “now” means. That’s the kind of bug that stays quiet for two years.

Nine blind excepts (BLE001): except Exception: around image downloads and API calls, each one turning a failure into a skipped item and a clean exit code. I wrote every one of those with a deadline in mind, and every one has, at some point, hidden something I’d have wanted to know.

Five files opened without a context manager (SIM115), one subprocess.run without check= (PLW1510, in a test, which means a test that cannot fail), a dict iterated the slow way, an unused unpacked variable. Small, specific, an afternoon’s work.

And my favourite: four RUF100 findings, unused noqa directives. All four were # noqa: E402, and all four went dead the moment 0.16 removed E402 from the defaults. The linter didn’t just find new problems. It found four suppressions now lying about why they existed. Nobody would have caught that in review, ever.

Call it 34 findings I’d act on out of 129. Twenty-six percent. Told that ratio in advance, I’d have called it a bad gate. Having read every line, I don’t, because the 26% includes a class of bug (timezones) I’d never have gone looking for, and the 74% cost me --fix plus one chmod.

Does the new default earn its cost?

Here’s the objection I’d expect from a team lead: 129 red lines on a Tuesday, on a pipeline that was green on Monday, from a tool nobody upgraded on purpose. Astral even ships the escape hatch in the release post, one line of select = ["E4", "E7", "E9", "F"] to get the old world back.

Don’t take it. Reverting the default is an eslint-disable comment applied at config scope to 354 rules at once, and I wrote about where that road ends in What Happens When a Team Starts Suppressing Lint Rules. The escape hatch is for buying a week, not for living in.

Cluster of yellow and red warning signs mounted on a wall
Photo by Chelaxy Designs on Unsplash

What I’d do is narrower than either “fix all 129” or “go back to 59”. Pin the Ruff version so the change arrives when you choose. Run ruff check --statistics once before touching anything, because the shape of your 129 will differ from mine. Decide per rule, not per finding: EXE001 goes off in a repo where scripts are launched with python, and stays on in one where they aren’t. Let --fix take the imports. Then read the timezone and blind-except findings by hand, all of them, because those are what the release was made for.

Where does a lint gate sit against the other checks fighting for the same CI minutes? I ranked twelve of them in Twelve CI Gates: Which I Put First, and Why in That Order, and nothing here moved linting up or down that list. It moved my opinion of what a default is for.

What I haven’t measured yet

I haven’t proven any of the 15 naive datetimes ever produced a wrong number in production; the one I checked was merely fragile. I haven’t run this on a codebase with a real test suite, which is where Willison’s “fix everything” is the right answer and mine is too cautious. And I have no idea how many of the 413 rules fire on typical Django or FastAPI code, because there’s none of it in this repo.

What I can say is that on 9,345 lines nobody had linted, the new default found 34 things I’m glad to know and 95 I could clear in an hour. That ratio would have to get a lot worse before 59 was the better number.