Mert Özen Further With Every Line
Software Design Coding Conventions and Team Standards — #03

Choosing a C# Formatter: dotnet format or CSharpier? Giving Each Rule a Single Owner

Mert Özen Nov 14, 2026 12 min 3 views
Choosing a C# Formatter: dotnet format or CSharpier? Giving Each Rule a Single Owner

When two formatters own the same rule the loop never ends: on a 1,000-file Bookstore solution, dotnet format found 6,002 problems in CSharpier's output and CSharpier found 1,001 unformatted files in dotnet format's. The fix is to give whitespace to CSharpier and style to the analyzer; the post compares both tools' output and timings, shows why turning off IDE0055 is not enough, and pins the version with a dotnet tool manifest.

Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402); the formatter is CSharpier 1.4.0 (1.3.0 in the version comparison).

Guess First: Two Formatters, One Repo

Two developers on the Bookstore team format code two different ways. Ada runs dotnet format, which ships with the SDK, before every commit. Grace has CSharpier on save in her editor. The repo's .editorconfig has two lines left over from an old decision: braces open at the end of the line (csharp_new_line_before_open_brace = none) and a blank line between using groups (dotnet_separate_import_directive_groups = true). CI runs both tools in check mode.

The solution has 1,000 files across four modules. Grace formats the whole repo with CSharpier and pushes; how many problems will dotnet format find in CI? Ada sees that, fixes it with dotnet format and pushes; how many files will CSharpier now find "not formatted"? After how many rounds does this loop stop?

dotnet format: Three Subcommands and Check Mode

dotnet format has been part of the SDK since .NET 6; you do not install it separately. We used it in the Clean Code series' "Formatting and File Layout" post to catch a formatting bug; here we look at it as a formatter candidate. It has three subcommands. whitespace fixes indentation, spacing and line endings according to the formatting options in .editorconfig (csharp_new_line_*, csharp_space_*, csharp_indent_*). style applies the code-style rules from the second post (the ones starting with IDE) through their code fixes. analyzers runs fixes for CA rules and third-party analyzers. dotnet format without a subcommand runs all three.

--verify-no-changes leaves files untouched, only reports, and returns a non-zero exit code when changes are needed; the value we measured is 2. --include and --exclude narrow the file set, --diagnostics picks a single rule, and --report produces a JSON (JavaScript Object Notation) report. whitespace --folder looks at the .cs files in a folder without loading a project file.

That last option is a hint about speed. style and analyzers load the solution through MSBuild, restore it and build a semantic model; the Microsoft Learn page also notes that the command may restore projects and run analyzers, so it should only be invoked on trusted code. The whitespace part needs none of that.

There is one more limit of dotnet format you need to know: it does not break lines. Even with max_line_length = 120 in .editorconfig, a 180-character LINQ chain stays on one line; the tool only fixes spacing and indentation within existing lines. We will see this in the comparison below.

CSharpier: An Opinionated Formatter

CSharpier started as the C# counterpart of Prettier from the JavaScript world; according to its docs, its printer was ported from Prettier and has since gone its own way. Its approach is fundamentally different from dotnet format: it parses the code, mostly ignores the existing layout and reprints it with its own rules. The result is the same output from the same input, no matter who wrote the code or how.

"Opinionated" is a design decision here. CSharpier offers only a few basic options (printWidth, useTabs, indentSize, endOfLine), and its docs declare new option requests out of scope. The constraint is deliberate: no options, nothing to argue about. This is the tool-side answer to the bikeshedding problem from the first post.

Options are read from a JSON or YAML .csharpierrc, .csharpierrc.json, .csharpierrc.yaml or .editorconfig. In .editorconfig, CSharpier understands indent_style, indent_size, max_line_length and end_of_line; when both files exist, .csharpierrc wins. We tried it: with max_line_length = 120 in .editorconfig, the solution was formatted and clean. We added only this file at the root:

{ "printWidth": 100 }
$ dotnet csharpier check src/Bookstore.Catalog/Service000.cs
Error ./src/Bookstore.Catalog/Service000.cs - Was not formatted.
  ----------------------------- Expected: Around Line 33 -----------------------------

      public decimal CalculateTotal(
          string currency,
  ----------------------------- Actual: Around Line 33 -----------------------------

      public decimal CalculateTotal(string currency, bool includeTax, decimal discountRate, CultureInfo culture)
      {

Checked 1 files in 130ms.

The 110-character signature fit at 120; at 100 it now has to be split. Across the solution, all 1,000 files became "not formatted" at once. So keep the line width in one place; Bookstore chose .editorconfig because editors read it too.

A few more CSharpier behaviors are worth knowing. It reads .gitignore and .csharpierignore, always skips obj and node_modules, and does not touch generated files such as .g.cs and .designer.cs. You can exclude a single member with a // csharpier-ignore comment and a range with csharpier-ignore-start and csharpier-ignore-end. It sorts using directives and puts System first, which makes CSharpier the owner of the "using order" rule too. Since 1.0 it also formats XML (Extensible Markup Language) files such as .csproj and .props. It has official plugins for Visual Studio, Visual Studio Code (VS Code) and Rider, and all of them support format on save.

Same File, Two Outputs

For the comparison we built a four-module Bookstore solution with a generator script: 1,000 service files, about 39 thousand lines, deliberately formatted inconsistently (some with tabs, some with two spaces, some with braces at the end of the line). .editorconfig specifies four spaces, Allman braces and max_line_length = 120. From the same starting commit we created two branches and ran dotnet format whitespace on one and dotnet csharpier format . on the other. The relevant part of one file, first the dotnet format output:

public sealed record BookCatalogService0Item(string Isbn, string Title, int Quantity, decimal UnitPrice, string Currency);

public sealed class BookCatalogService0(IClock clock, ILogSink log)
{
    private readonly List<BookCatalogService0Item> _items = [];
    private const decimal TaxRate = 0.20m;

    public void Add(BookCatalogService0Item item)
    {
        ArgumentNullException.ThrowIfNull(item);
        if (item.Quantity <= 0) throw new ArgumentOutOfRangeException(nameof(item), item.Quantity, "Quantity must be positive for book items in catalog.");
        _items.Add(item);
        log.Write($"{clock.UtcNow:O} added {item.Isbn} x{item.Quantity}");
    }

    public decimal CalculateTotal(string currency, bool includeTax, decimal discountRate, CultureInfo culture)
    {
        decimal subtotal = _items.Where(i => i.Currency == currency).Select(i => i.UnitPrice * i.Quantity).Where(v => v > 0m).Sum();
        decimal discounted = subtotal - (subtotal * discountRate);
        return includeTax ? Math.Round(discounted * (1 + TaxRate), 2, MidpointRounding.AwayFromZero) : Math.Round(discounted, 2, MidpointRounding.AwayFromZero);
    }

Tabs became four spaces, braces moved to their own lines, and spaces were added in places like Isbn,string and TaxRate=0.20m. But the 155-character if line and the 132-character LINQ chain stay as they were. The same part from CSharpier:

public sealed record BookCatalogService0Item(
    string Isbn,
    string Title,
    int Quantity,
    decimal UnitPrice,
    string Currency
);

public sealed class BookCatalogService0(IClock clock, ILogSink log)
{
    private readonly List<BookCatalogService0Item> _items = [];
    private const decimal TaxRate = 0.20m;

    public void Add(BookCatalogService0Item item)
    {
        ArgumentNullException.ThrowIfNull(item);
        if (item.Quantity <= 0)
            throw new ArgumentOutOfRangeException(
                nameof(item),
                item.Quantity,
                "Quantity must be positive for book items in catalog."
            );
        _items.Add(item);
        log.Write($"{clock.UtcNow:O} added {item.Isbn} x{item.Quantity}");
    }

    public decimal CalculateTotal(string currency, bool includeTax, decimal discountRate, CultureInfo culture)
    {
        decimal subtotal = _items
            .Where(i => i.Currency == currency)
            .Select(i => i.UnitPrice * i.Quantity)
            .Where(v => v > 0m)
            .Sum();
        decimal discounted = subtotal - (subtotal * discountRate);
        return includeTax
            ? Math.Round(discounted * (1 + TaxRate), 2, MidpointRounding.AwayFromZero)
            : Math.Round(discounted, 2, MidpointRounding.AwayFromZero);
    }

The difference is line breaking. CSharpier split every construct over 120 according to its own rules: a parameter list with one parameter per line and the closing parenthesis on its own line; a LINQ chain with one line per dot; a ternary into two branches. None of this is configurable; if you dislike a break, you either accept it or use csharpier-ignore. The diff size reflects this: dotnet format added 14,272 lines and deleted 12,928 across 1,000 files, CSharpier added 44,272 and deleted 15,928.

Measurement: Timings on 1,000 Files

On the same unformatted starting commit we ran each command three times in check mode and measured wall-clock time; restore and build had been done beforehand. These values come from a single developer machine; absolute times vary by machine, the ratios are what you should compare.

CommandWhat it checksThree runs (s)Exit code
dotnet csharpier check .All formatting (including line breaks)1.07 / 0.94 / 0.981
dotnet format whitespace --folder . --verify-no-changesSpacing and indentation, without loading projects1.36 / 1.42 / 1.352
dotnet format whitespace Bookstore.slnx --verify-no-changesSpacing and indentation, loading the solution2.37 / 2.26 / 2.092
dotnet format Bookstore.slnx --verify-no-changesWhitespace + style + analyzers10.87 / 11.95 / 12.322

CSharpier says "Checked 1007 files in 825ms" in its own report; of the 1,007 files, 1,001 are C# and the rest are five .csproj files and the .slnx. dotnet format whitespace produced a 20,936-line problem list on the starting commit. Full dotnet format is roughly ten times slower because it does semantic analysis on top of whitespace; that is not a flaw but the price of the work it does. The practical consequence: a formatter that runs on save or in pre-commit must be fast, while semantic checks can live in CI and the build.

The Format War: The Answer to Guess First

We played the opening scenario on the same 1,000-file solution. We added the K&R (from Kernighan and Ritchie's C book, opening brace at the end of the line) brace style and the blank line between using groups to .editorconfig, and ran dotnet format in check mode on CSharpier-formatted code:

$ dotnet format whitespace Bookstore.slnx --verify-no-changes
src/Bookstore.Catalog/Service000.cs(3,1): error WHITESPACE: Fix whitespace formatting. Insert '\n'.
src/Bookstore.Catalog/Service000.cs(15,68): error WHITESPACE: Fix whitespace formatting. Replace 1 characters with '\s'.
src/Bookstore.Catalog/Service000.cs(20,50): error WHITESPACE: Fix whitespace formatting. Replace 5 characters with '\s'.
...

6,002 lines of problems. Ada fixed them with dotnet format whitespace; according to git diff --shortstat (including the .editorconfig change), 1,002 files changed, with 6,004 lines added and 10,005 deleted. Then CSharpier's check:

$ dotnet csharpier check . | grep -c "Was not formatted"
1001

One thousand and one files. When Grace ran dotnet csharpier format . again, the code went back to its previous state and dotnet format found 6,002 problems again. The loop never stops; both tools believe they own the same two rules (brace placement and using layout), and each finds the other's output "wrong". Every round means conflicts in open PRs, meaningless commits in git blame and red builds in CI.

The sneakier part: the first fix that comes to mind is writing dotnet_diagnostic.IDE0055.severity = none into .editorconfig. We tried it, with the K&R line still in the file:

$ dotnet format Bookstore.slnx --verify-no-changes | wc -l
    5002
$ dotnet format whitespace Bookstore.slnx --verify-no-changes | wc -l
    5002
$ dotnet format style Bookstore.slnx --verify-no-changes | wc -l
       0

Turning off IDE0055 turns off the build warning, not the whitespace step of dotnet format; the whitespace formatter reads the options directly. The way to end the war is to change ownership, not severity.

Giving Each Rule a Single Owner

The rule is simple: every rule has exactly one owner. In Bookstore the split is as follows. Indentation, spacing, line breaking, brace placement and using order belong to CSharpier. Style preferences (var, namespace form, expression-bodied members) and naming belong to the IDE rules, that is, the analyzer. Quality rules (CA) belong to the analyzer as well; they are the subject of the upcoming Static Analysis series.

This split shows up in two files. In .editorconfig, the four basic settings CSharpier also reads remain, and whitespace options such as csharp_new_line_*, csharp_space_* and dotnet_separate_import_directive_groups are deleted, so no line is left on which the two tools could disagree:

[*.cs]
indent_style = space
indent_size = 4
max_line_length = 120

# Whitespace belongs to CSharpier: IDE0055 off
dotnet_diagnostic.IDE0055.severity = none

# Style rules belong to the analyzer
csharp_style_namespace_declarations = file_scoped
dotnet_diagnostic.IDE0161.severity = warning
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
csharp_style_var_elsewhere = false
dotnet_diagnostic.IDE0008.severity = warning

In CI, too, you run each tool only for its own job; dotnet format is no longer called without a subcommand or with whitespace:

dotnet tool restore
dotnet csharpier check .
dotnet format style Bookstore.slnx --verify-no-changes
dotnet format analyzers Bookstore.slnx --verify-no-changes

With this configuration, all four commands returned exit code 0 on the CSharpier-formatted solution; CSharpier printed "Checked 1007 files in 369ms", and the two dotnet format steps produced no output at all. The details of the hook and CI side are in the fifth post.

The opposite direction is a legitimate choice too: skipping CSharpier and leaving whitespace to dotnet format. In that case line breaking is no tool's responsibility and falls to review; if the team accepts that and does not want another tool, fine. A short comparison of the two options: CSharpier gives full consistency and speed, and in return you hand it the line-breaking decisions and version one more tool. dotnet format comes with the SDK and honors every formatting setting in .editorconfig, and in return long lines and the "how should we split this" debate remain. Bookstore chose CSharpier.

Pinning the Version: The dotnet tool Manifest

A formatter's output changes from version to version; if it does not, the formatter is not improving. CSharpier's 1.4.0 changelog says that calls whose last argument is a lambda with a block body can now stay on the same line without being split. We formatted the same file with both versions. 1.3.0:

        services.AddHttpClient(
            configuration["Shipping:ClientName"] ?? "carrier",
            client =>
            {
                client.BaseAddress = new Uri("https://carrier.example.com");
                client.Timeout = TimeSpan.FromSeconds(10);
            }
        );

1.4.0:

        services.AddHttpClient(configuration["Shipping:ClientName"] ?? "carrier", client =>
        {
            client.BaseAddress = new Uri("https://carrier.example.com");
            client.Timeout = TimeSpan.FromSeconds(10);
        });

If one team member uses 1.3.0 and another 1.4.0, every call like this bounces back and forth between them: a small version of the opening war. The fix is to tie the tool to the repo, that is, a local tool:

$ dotnet new tool-manifest
$ dotnet tool install csharpier --version 1.4.0
Tool 'csharpier' (version '1.4.0') was successfully installed. Entry is added to the manifest file /…/dotnet-tools.json.
{
  "version": 1,
  "isRoot": true,
  "tools": {
    "csharpier": {
      "version": "1.4.0",
      "commands": [
        "csharpier"
      ],
      "rollForward": false
    }
  }
}

This file is committed; everyone and CI get the same version with dotnet tool restore and call the tool as dotnet csharpier. A small detail: in SDK 10.0.402, dotnet new tool-manifest wrote the file to the repo root; the same command in SDK 8.0.419 produced .config/dotnet-tools.json. Both locations are read, but someone following an old README that looks for the .config folder may be surprised. According to CSharpier's changelog, 1.4.0 dropped the net8.0 and net9.0 targets and targets net10.0 and net11.0; it runs on any machine with the .NET 10 SDK. Upgrade the version in a separate PR with a standalone formatting commit; in the fifth post we will see how to keep that commit from polluting git blame.

dotnet format needs no separate manifest; its version is the SDK's version. We covered pinning the SDK with global.json in the GitHub Actions series' ".NET CI with GitHub Actions: setup-dotnet, global.json, NuGet Cache, Test Reports and Coverage" post; the same file pins dotnet format's behavior too.

Common Pitfalls

Giving the same rule to two tools. The opening loop is the result. The symptom is formatting commits following each other and the same files changing every time. Clean the formatting options out of .editorconfig and call dotnet format in CI only with style and analyzers.

Assuming that turning off IDE0055 is enough. We measured it: the build warning goes away, dotnet format whitespace keeps applying the options.

Not pinning the formatter version. A global install (dotnet tool install -g) means a different version on every machine; "latest" in CI means the same. Use the manifest and rollForward: false.

Noise between commits from different IDE settings. If one person uses CSharpier on save, another Visual Studio's Code Cleanup and a third neither, every commit arrives in a different format. Define a single format-on-save setup for the team; for VS Code, a .vscode/settings.json can be shared in the repo. Hooks and CI are the safety net.

Keeping the line width in two places. If both .csharpierrc and .editorconfig state a width, .csharpierrc wins in CSharpier, while the editor's ruler follows .editorconfig. As we measured, a single number change can make 1,000 files "not formatted" at once.

Running the formatter for the first time in the same commit as a bug fix. The first formatting run must be its own, separate commit; otherwise you produce a thousand-file version of the first post's 55-line diff.

A Small Experiment

★☆☆ Run dotnet format whitespace --verify-no-changes and dotnet csharpier check . on your own solution (install CSharpier as a local tool first). Compare the number of files each reports and the times.

★★☆ Format a file with CSharpier's default width of 100, then write max_line_length = 140 into .editorconfig and run it again. Which constructs were joined and which stayed split?

★★★ On a branch, add csharp_new_line_before_open_brace = none to .editorconfig and run dotnet format whitespace and dotnet csharpier format one after the other for two rounds, taking a git diff --shortstat after each. Then delete the whitespace options, repeat the experiment and show that the loop ends.

On the C# side we gave the rules to .editorconfig and the formatting to CSharpier. Bookstore's web UI, however, is in Angular. In the next post we set up the same division of responsibility on the TypeScript side: the decisions in the Angular Style Guide, and which rules Prettier and ESLint own.