Ruff now enables 413 rules by default. What that means for your .NET analyzers

Ruff v0.16.0 jumped from 59 to 413 default rules. A case for strict-by-default linting, with a concrete plan for Roslyn analyzers, warnings-as-errors and CI in .NET.

Jean-Pierre Broeders

Freelance .NET Developer

July 27, 20268 min. read
Ruff now enables 413 rules by default. What that means for your .NET analyzers

Ruff v0.16.0 sat near the top of Hacker News this week. The headline: the number of rules that are on by default went from 59 to 413. That is the first serious rework of the default set since v0.1.0. The comment thread filled up fast, because this touches a nerve. Who decides what counts as good code, you or your linter?

I don't write Python. I write C#. And yet this stuck with me, because the underlying question maps straight onto .NET. How strict do you make your static analysis by default, and where in the pipeline do you let that strictness bite? Ruff now picks strict-unless-you-say-otherwise. Most .NET projects I walk into do the opposite. Almost everything is off, and nobody remembers why.

What Ruff actually did

The core of this release is a philosophical call, not a technical one. Astral, the team behind Ruff, argue that a lot of rules catch real problems. Syntax issues, things that break at runtime, patterns that are almost always a bug. Rules like that should show up without you turning them on by hand. So they flipped the default to open.

Twelve rules graduated from preview to stable, including airflow3-incompatible-function-signature (AIR303) and missing-copyright-notice (CPY001). There is new Markdown formatting for Python code blocks, new suppression comments like ruff: ignore, and an --add-ignore flag that inserts those comments for you. Useful, but secondary.

The real story is the default. And they are honest about the cost. If you want the old behaviour back, two lines of config gets you there.

# pyproject.toml
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]

That's the old world. Four categories, 59 rules, otherwise quiet. The new world talks back a lot more.

Why a .NET developer should care

Since .NET 5, Roslyn ships a whole battery of analyzers inside the SDK. CA rules for correctness and security, IDE rules for style. They are all in there. The catch: most of them sit at warning or even none, and a warning nobody reads is not a warning. It's noise.

I see this on nearly every engagement. A build with 340 warnings that everyone scrolls past. Somewhere in those 340 hides a CA2100 (SQL injection risk) or a CA5359 (certificate validation disabled). Nobody spots it, because the signal-to-noise ratio is shot. Ruff fixes this at the root by making the default strict. In .NET you still have to make that choice yourself, and that's exactly where it falls apart.

The good news: .NET gives you more knobs than Ruff, and you can tune them per project. The bad news: you have to flip them yourself, and the default rewards doing nothing.

The .NET version of "strict unless"

Start at your project, or better, at a Directory.Build.props in the root so every .csproj inherits it.

<!-- Directory.Build.props -->
<Project>
  <PropertyGroup>
    <EnableNETAnalyzers>true</EnableNETAnalyzers>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
    <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Let me unpack what happens here. AnalysisLevel with the recommended suffix turns on a stricter set of CA rules than the bare default. You can pick from minimum, default, recommended and all. That last one is usually too much, because you'll get rules that contradict each other. For most teams recommended is the right place to start.

EnforceCodeStyleInBuild pulls the IDE style rules (the IDExxxx family) into the build. By default you only see those in Visual Studio, not on the command line and therefore not in CI. This flag changes that.

Then TreatWarningsAsErrors. This is the knob that gives everything else teeth. Without it, every finding stays an optional suggestion. With it, your build breaks. That feels harsh, and that is the point.

Warnings-as-errors is not all-or-nothing

The biggest fear I hear from teams: "if I turn that on, the build explodes on day one." Correct. Which is why you turn it on with nuance instead of in one go.

You steer per-rule behaviour in .editorconfig. That file is by far the nicest place to manage analyzer config anyway, because it travels with your code and behaves the same in the IDE and on the build.

# .editorconfig
root = true

[*.cs]
# Correctness and security: fail hard
dotnet_diagnostic.CA2100.severity = error
dotnet_diagnostic.CA2007.severity = none
dotnet_analyzer_diagnostic.category-security.severity = error

# Style: warn, don't break yet
dotnet_diagnostic.IDE0090.severity = warning

# This specific rule is noise for our team
dotnet_diagnostic.CA1848.severity = suggestion

See that category-security.severity = error? One line sets a whole category to fatal. That's the .NET way to do what Ruff does with its default: the things that are genuinely wrong break the build, the rest stays visible but doesn't block. I set CA2007 to none here, because in an application (not a library) the ConfigureAwait rule is mostly just chatter.

One detail worth knowing: with TreatWarningsAsErrors, everything at warning becomes an error. If you want a handful of warnings to stay non-fatal, use WarningsNotAsErrors.

<PropertyGroup>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <WarningsNotAsErrors>CA1848;CA1031</WarningsNotAsErrors>
</PropertyGroup>

That keeps the reins tight without forcing you through 200 findings on the first afternoon.

The trick for existing code: a baseline

Ruff enables its 413 rules on a greenfield project as easily as on a ten-year-old codebase. In practice, that second case is where it hurts. For .NET there is a clean way out that a lot of people don't know about: suppress the current warnings through a generated suppression file, so new code is strict while old code doesn't wreck the build overnight.

The shortest version leans on dotnet format. You run the analysis, let it fix what it can, and put the rest on a list.

# Whatever can be auto-fixed
dotnet format analyzers --severity warn

# What's left, we see on the build
dotnet build -c Release

For bigger codebases a ratchet beats a big bang. Put a few categories on error, fix them completely, then widen the net. I usually do security first, then the correctness CAs, then style last. Style is the least exciting and draws the most pushback from the team, so I deliberately leave it until the end.

What about build time?

A fair objection that showed up in the HN thread too: more rules means more work for the analyzer, so a slower build. With Ruff the hit is small, because the thing is written in Rust and flies. With Roslyn analyzers the impact is real, especially with a stack of third-party packages like StyleCop, Roslynator and Meziantou piled on top.

Two things help. First, don't run analyzers in configurations where they add nothing. An incremental Debug build on your own machine doesn't need the full set.

<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
  <RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild>
</PropertyGroup>

That keeps your inner loop fast while the full analysis runs in Release and in CI, where it matters. Second, measure before you complain. With /p:ReportAnalyzer=true you get a per-analyzer breakdown of how much time each one costs, and nine times out of ten there's a single rule holding everything up. You disable that one on purpose instead of shooting the whole set.

dotnet build -c Release /p:ReportAnalyzer=true

Let CI be the referee

A linter that only runs on your machine does not exist for the rest of the team. The rules have to live in the pipeline, otherwise they're a suggestion. Here's a GitHub Actions step that enforces both formatting and analyzers hard.

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "9.0.x"

      - name: Restore
        run: dotnet restore

      # Fails if anything is not formatted
      - name: Check formatting
        run: dotnet format --verify-no-changes --severity warn

      # Fails on any analyzer error thanks to TreatWarningsAsErrors
      - name: Build
        run: dotnet build -c Release --no-restore

dotnet format --verify-no-changes is the equivalent of Ruff showing you the diff before it fixes. It writes nothing, it only fails if there's something to straighten out. Keep it as a separate step rather than buried in the build, so the PR check tells you at a glance whether it's a style nit or a real defect.

Where Ruff has it right

Part of the Hacker News thread was about autonomy. Some people find 413 rules paternalistic. I get that, but I think strict-by-default is the better call, for one simple reason. A default that does nothing is also a choice, just an invisible one. Someone pays the bill later, usually as a bug that an enabled rule would have caught.

What Ruff does well is making the escape hatch easy. Two lines of config and you're back to the old behaviour. That combination, strict by default with a low bar to opt out locally, is exactly what .NET lacks. Our default sits on passive, and the road to strict is a chore nobody volunteers for.

What I'd actually do

Grab one of your projects this week and set AnalysisLevel to latest-recommended with EnforceCodeStyleInBuild. Run the build and look at what comes out. The count will probably surprise you. Then put security and the hard CA rules on error, the rest on warning, and let CI guard the line. Add warnings-as-errors once the list is clean, not before.

Ruff showed that a tool is allowed to have an opinion about your code. In .NET those same opinions already ship in the box. You just have to switch them on.

Source: Ruff v0.16.0 on the Astral blog and the Hacker News discussion.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch