A C# compiler turns your source into IL, but it only enforces what the language spec demands. Analyzers fill the gap between "this compiles" and "this is good code." They run during the build, walk your syntax and semantic trees, and report bugs, performance traps, security issues, and style problems before the binary ever ships. This chapter covers what Roslyn analyzers are, how to configure their severity, which packages to install, how to suppress them when you must, and how to wire the whole setup into a build that fails on real violations.
A typical bug fix costs more the later you find it. A NullReferenceException caught while typing is a 5-second fix. The same bug caught in code review is a 30-minute round trip. The same bug in production is a war room. Analyzers push detection as far left as possible, often into the IDE itself.
Consider this method that looks fine at a glance:
It works. It's also wrong in two ways the compiler won't tell you about. If prices is null, the foreach throws a NullReferenceException. If the list is huge and you call this method on a hot path, allocating a List<decimal> for every cart is heavier than passing an IReadOnlyCollection<decimal> or a Span<decimal>. A configured analyzer flags both at edit time. The first comes from nullable reference type warnings (CS8602 with enable mode). The second comes from CA1002 ("Do not expose generic lists") or CA1859 ("Use concrete types when possible for improved performance").
The same idea applies across the codebase. Disposed objects you forgot to dispose, awaited tasks you forgot to await, dictionary keys you mistyped, strings concatenated in a loop, secrets logged at info level, all of these have analyzer rules that catch them automatically.
There's a deeper benefit too. Analyzers encode team conventions in a way humans can't enforce by sheer willpower. A code reviewer who tells you "use StringBuilder here" is doing the analyzer's job badly. A rule that fails the build does the same job consistently and without ego.
A small story makes the point. A team I worked with had a recurring bug where developers called await SomeAsync() inside a Task.Run, which is almost always wrong. Code reviews caught it half the time, and the other half made it to production, where it caused thread pool starvation on busy nights. After two outages, someone wired up MA0040 ("Forward the CancellationToken to methods that take one") and MA0080 ("Use a cancellation token") through Meziantou.Analyzer. The build started failing on the same patterns, and the bug class disappeared. The fix wasn't smarter developers; it was making the wrong thing impossible to commit.
That's the larger pattern. The job of the analyzer isn't to replace judgment; it's to handle the patterns where judgment fails consistently. Type mismatches are caught by the compiler. Logic bugs are caught by tests. Everything in between, the slow drift toward unreadable code, the small mistakes that pile into outages, is where analyzers live.
Roslyn is the compiler platform for C# and Visual Basic. Unlike older compilers, it exposes the entire compilation as an API: the syntax tree, the semantic model, the bound expressions, the type system. Analyzers are .NET classes that subscribe to compiler events ("when you see a method declaration", "when you see an await expression") and report diagnostics back through that API.
The flow looks like this:
Every analyzer ships with a set of diagnostic descriptors. Each descriptor has a stable ID (like CA1822), a default severity (info, warning, error), a title, and a message format. The ID is the key you'll reference everywhere: in .editorconfig, in suppression attributes, in CI logs.
The ID prefix tells you who owns the rule:
| Prefix | Source | Focus |
|---|---|---|
CA | Microsoft.CodeAnalysis.NetAnalyzers | Design, performance, reliability, security, usage |
IDE | Roslyn built-in (ships with the SDK) | C# language style and refactoring suggestions |
CS | C# compiler itself | Language errors and warnings |
SA / SX | StyleCop.Analyzers | Formatting and naming conventions |
RCS | Roslynator | Refactoring suggestions and quality |
S | SonarAnalyzer.CSharp | Bugs, code smells, security hotspots |
MA | Meziantou.Analyzer | Modern C# patterns and pitfalls |
You'll see all of these mixed in a real codebase. Two analyzers can flag the same issue under different IDs, which is fine: configure one and silence the duplicate.
Analyzers run during every build and every IDE keystroke (in incremental mode). A large rule set on a large solution can add a few seconds to clean builds. The cost is usually worth it, but for a sluggish IDE, the first thing to check is the number of installed analyzer packages and active rules.
Starting with .NET 5, the SDK ships Microsoft.CodeAnalysis.NetAnalyzers automatically. You don't need to add a NuGet reference; the package is part of the targeting pack. By default, only a small subset of rules is enabled, and most are at "suggestion" severity, so they show up as IDE hints but don't break builds.
To turn the analyzers on for a project, set the analysis level in the csproj:
Each property changes a different part of the pipeline:
| Property | What it does |
|---|---|
AnalysisLevel | Which version of the rule set to use. latest picks the newest available; you can pin to 7.0, 8.0, etc. |
AnalysisMode | Which rules to enable by default. All enables every CA rule as a warning. Recommended enables the curated subset. Default is conservative. None disables them. |
EnforceCodeStyleInBuild | When true, IDE-prefixed style rules run during command-line builds, not just inside the IDE. |
TreatWarningsAsErrors | Any warning, from any source, breaks the build. |
A small e-commerce class shows what AnalysisMode=All catches that the default doesn't:
With AnalysisMode=All, this file produces a handful of warnings:
CA1002: Products is a List<string> exposed publicly. Use Collection<T> or IList<T> instead, because changing the field type later breaks callers.CA1051: Public fields shouldn't be visible. Make Products a property.CA1304 / CA1311: name.ToLower() is culture-dependent. Use ToLowerInvariant() or pass an explicit CultureInfo.CA1859: The local search can be a HashSet<string> lookup instead of a linear scan.None of these are bugs in the strict sense. They're traps that bite in production. Multiplied across a real codebase, the analyzer pays for itself within a sprint.
A cleaner version that satisfies the rules:
The public surface is now an IReadOnlyCollection<string>, the field is encapsulated, the comparison is culture-stable, and the lookup is O(1) instead of O(n). Same intent, no warnings.
The CA family is grouped by category, which is reflected in the rule numbers themselves. A rough breakdown:
| Range | Category | Sample rules |
|---|---|---|
| CA1000-CA1099 | Design | CA1002 (no public List<T>), CA1024 (use properties when appropriate), CA1051 (no public fields) |
| CA1300-CA1399 | Globalization | CA1303 (no literal strings in calls), CA1305 (specify IFormatProvider), CA1311 (specify culture for case change) |
| CA1700-CA1799 | Naming | CA1707 (no underscores), CA1715 (interface starts with I), CA1724 (no type name conflicts with namespace) |
| CA1800-CA1899 | Performance | CA1822 (mark static), CA1825 (avoid empty array allocation), CA1859 (use concrete types) |
| CA2000-CA2099 | Reliability | CA2007 (ConfigureAwait), CA2008 (specify task scheduler), CA2012 (use ValueTask correctly) |
| CA2100-CA2199 | Security | CA2100 (SQL injection), CA2153 (catch corrupted state exceptions), CA2200 (rethrow correctly) |
| CA2200-CA2299 | Usage | CA2208 (correct argument names in exceptions), CA2227 (collection properties read-only), CA2245 (no self-assignment) |
Knowing the category from the number speeds up triage. A failing CA22xx is a usage bug. A failing CA18xx is a perf hint. A failing CA17xx is a naming issue. You don't have to look up every rule individually to know roughly what it's about.
The IDE prefix rules are built into Roslyn and ship with the SDK. They cover formatting and language style, things like "prefer var over explicit type", "use expression body for methods", "remove unnecessary using directives", "prefer pattern matching over is cast".
These rules drive the green squiggles in Visual Studio and the auto-fix actions in dotnet format. They have no effect on runtime behavior; they're about consistency.
A short demonstration. The original code:
Several IDE rules flag this:
IDE0046: Convert if-else to a conditional expression.IDE0270: Use coalesce expression for null check.IDE0058: Expression value is never used (depending on call sites).IDE0011: Add braces (passes here).After applying the suggested fixes:
Whether you like that style is a taste call. Some teams prefer the verbose form for debuggability. The rule is configurable: turn it off, change its severity, or apply it project-wide. The analyzer doesn't dictate; it surfaces an opinion.
To make IDE rules also fail the command-line build (not just show squiggles), set EnforceCodeStyleInBuild to true:
Without this flag, IDE rules only run inside the IDE. With it, dotnet build reports them too, and TreatWarningsAsErrors can then break the build on style violations.
.editorconfig.editorconfig is the central file where you control analyzer behavior. It lives at the root of the repo (or anywhere up the directory tree) and applies to every project beneath it. Each rule can be set to one of five severities:
| Severity | Effect in IDE | Effect in build |
|---|---|---|
none | Rule is disabled entirely. | No output. |
silent | Rule still runs, but produces no visible message. Useful for refactorings that show only when you ask. | No output. |
suggestion | Shown as a hint (dots under the code). | No output unless EnforceCodeStyleInBuild is on, then an info message. |
warning | Shown as a green/yellow squiggle. | Logged as a warning. |
error | Shown as a red squiggle. | Breaks the build. |
A minimal .editorconfig for an e-commerce project:
The pattern is consistent. Diagnostic IDs use dotnet_diagnostic.{ID}.severity = {level}. Style preferences use a named key with the severity after a colon. Both kinds live in the same file.
A useful trick is scoping severities by path. The file is hierarchical: every [pattern] section applies only to matching files.
The generated_code = true flag is a special signal that tells most analyzers to skip the file entirely. Generated files (Entity Framework migrations, gRPC stubs, scaffolded Razor pages) often violate every style rule you have, and you don't want to fix them by hand.
A diagram of how severity flows from configuration to outcome:
The takeaway from the diagram: a rule at warning severity only becomes a build error if TreatWarningsAsErrors is on. If you want one specific rule to break the build without flipping the global flag, set its severity directly to error in .editorconfig.
dotnet formatA code fix is the lightbulb action that appears next to an analyzer warning in the IDE. Most CA rules ship with one or more fixes. Putting your cursor on a flagged piece of code and pressing Ctrl+. (or Alt+Enter in Rider) opens a menu of suggested rewrites.
For batch application across the whole solution, the dotnet format CLI does the same work:
The command runs every analyzer that has a code fix and applies the safe ones at or above the given severity. There are three sub-commands:
| Command | What it fixes |
|---|---|
dotnet format whitespace | Indentation, trailing whitespace, final newlines. |
dotnet format style | IDE-prefixed style rules. |
dotnet format analyzers | CA-prefixed and third-party analyzer rules. |
dotnet format (no arg) | All of the above. |
In CI, you can use the --verify-no-changes flag to fail the build if formatting drift is detected:
This is a lightweight way to enforce a style baseline. It runs fast and surfaces drift before review.
Not every fix is safe to apply blindly. A rule like CA1822 ("Mark members as static") changes a method's signature; if the method is called via reflection or virtual dispatch, the fix breaks downstream code. dotnet format skips fixes marked as "needs review", but it's still worth running it on a clean working tree so you can diff the changes.
Code fixes come in two flavors: document-level and solution-level. A document fix touches one file at a time and is what the lightbulb menu offers. A solution fix can refactor across files, like renaming a symbol that's referenced from twelve different projects. The IDE handles both. In dotnet format, only document-level fixes get applied, because cross-file rewrites are usually too risky to batch without review.
A handy pattern in CI is running dotnet format in two passes. The first pass fixes formatting and easy analyzer warnings. The second pass runs --verify-no-changes and fails the build if the first pass changed anything. The combination means a contributor never has to think about formatting; the bot does it, and any drift fails fast:
For analyzer rules without a code fix (and there are plenty, especially for security rules that can't be safely auto-fixed), the IDE shows the warning but doesn't offer a lightbulb. Those have to be addressed by hand, which is the right default: a security rule firing should make you think, not get silently rewritten.
The built-in CA rules cover a lot, but third-party packages fill in opinions and patterns Microsoft chose not to ship by default. The five most widely used:
| Package | Focus | When to add |
|---|---|---|
Microsoft.CodeAnalysis.NetAnalyzers | Design, performance, reliability, security. Built into the SDK from .NET 5+. | Always on. Set AnalysisMode to control breadth. |
StyleCop.Analyzers | Formatting, naming, documentation comments. | Teams that care about strict layout and want enforceable XML doc rules. |
Roslynator.Analyzers | Refactorings, code quality, "this could be simpler". | Solo and small teams who want IDE suggestions for cleaner C#. |
SonarAnalyzer.CSharp | Bug detection, code smells, security hotspots. | Teams already using SonarCloud/SonarQube, or anyone wanting the SonarSource rule set at no cost. |
Meziantou.Analyzer | Modern C# patterns, async pitfalls, defensive coding. | Library authors and teams who write a lot of async or public APIs. |
Add them to a project as analyzer references, not regular package references:
PrivateAssets=all keeps the analyzer from flowing to downstream consumers of your NuGet package. You almost always want this; otherwise, your library forces its rule set on every project that depends on it. IncludeAssets is the standard incantation that tells NuGet to bring in the analyzer DLLs but not the runtime assemblies.
A real e-commerce service might combine the NetAnalyzers (always on by default), Meziantou.Analyzer for async correctness, and SonarAnalyzer.CSharp for security. Each has its own rule prefix, so .editorconfig entries don't collide.
A combined set of warnings on a sample method:
Four packages, four flags, none of them a compiler error:
MA0042 (Meziantou): Task.Delay(100) is not awaited; the task runs but the method doesn't wait for it.S2696 (Sonar): GetOrder returns object; the analyzer flags weak typing on a domain method.CA1822 (NetAnalyzers): GetOrder doesn't use instance state, so it should be static.IDE0079 (Roslyn): The async keyword adds overhead because no await is consumed.The same body would compile clean before any of these packages were added. After they're added, four separate signals point at issues that would otherwise become tickets.
One question that always comes up: how many packages is too many? There's no single answer, but there are diminishing returns past three. The NetAnalyzers and one async-focused package (Meziantou or SonarAnalyzer) catch the bulk of real bugs. Adding StyleCop on top is reasonable if the team wants strict layout. Adding Roslynator on top of that mostly produces noise unless the team enjoys micro-refactorings.
A safer adoption order:
AnalysisMode=All. This is free, and the rules ship with the SDK. Fix the warnings or scope-suppress the ones that don't fit.Meziantou.Analyzer. Its async and defensive coding rules catch real bugs that the CA rules miss.SonarAnalyzer.CSharp if the team values bug-pattern detection and security hotspots. Many of its rules overlap with NetAnalyzers; pick the one you prefer and disable the duplicates.StyleCop.Analyzers only if the team has strong feelings about formatting and is willing to spend a sprint fixing the existing violations.Roslynator is best used selectively. Its refactoring suggestions are useful in the IDE; the analyzers are often too opinionated to fail the build on.Each new package adds build time and signal noise. Add them one at a time, fix the warnings as a batch, and only then add the next. The wrong order is "enable everything at once," which produces 2,000 warnings and a team that gives up on the whole idea.
No rule is right in every situation. Sometimes a CA warning is wrong for your context. Sometimes you're working around a legacy API. Sometimes a fix is on the roadmap but not for this sprint. C# gives you four ways to suppress an analyzer, each with a different scope and audit trail.
The narrowest tool is #pragma warning disable, which turns a rule off for the lines between two pragmas:
The pragma's scope is exactly the lines between disable and restore. If you forget to restore, the rule stays off for the rest of the file, which is almost never what you want. Always pair them.
The next step up is [SuppressMessage], which attaches to a method, class, or assembly:
The first argument is the category, the second is the diagnostic ID and title, and the Justification is required by the analyzer rules (CA1014 flags suppression attributes with no justification). The attribute makes the suppression visible at the point it applies, which is useful for code review.
For codebase-wide suppressions, drop a GlobalSuppressions.cs file at the root of the project. It's just a file containing assembly-level attributes:
Scope and Target together describe what the suppression applies to. Common scopes are namespaceanddescendants, type, member, and module (the whole assembly). Visual Studio generates these for you when you choose "Suppress in source > In suppression file" from the lightbulb menu.
The fourth option, .editorconfig with severity none, disables a rule for everyone touching the file. Use it when the team has decided collectively that a rule isn't a fit, not when one developer wants to silence personal warnings.
A practical rule of thumb for which tool to use:
| Situation | Use |
|---|---|
| A single line where the rule is genuinely wrong | #pragma warning disable / restore |
| A method or class with a known exception, reviewable in context | [SuppressMessage] attribute |
| Many files share the same exception (tests, generated code, legacy folder) | .editorconfig with a path-scoped section |
| A handful of long-term project-wide exceptions, kept in one auditable file | GlobalSuppressions.cs |
The worst pattern is scattering pragmas across the codebase to silence the same rule everywhere. When this becomes routine, the rule probably isn't right for the project, and the fix is to disable it in .editorconfig with a comment explaining why.
Suppressions accumulate. A codebase with hundreds of #pragma warning disable lines is harder to clean up than one with twenty [SuppressMessage] attributes that explain their reasoning. The justification field exists for a reason.
The single most impactful flag in a serious project is TreatWarningsAsErrors. With it set to true, every warning, whether from the compiler, a CA rule, an IDE rule, or a third-party analyzer, breaks the build:
The reasoning is simple. A warning you ignore today is a warning you'll ignore forever. Once the output is noisy, no one reads it. Forcing warnings to be errors keeps the signal high.
The downside is that it can be hard to adopt incrementally on a large codebase. You can flip the switch on a new project from day one, but a five-year-old codebase with 3,000 warnings is a different problem. There are two graceful paths.
The first is per-rule control with WarningsAsErrors and WarningsNotAsErrors:
This says "warnings stay warnings, except for these five, which break the build." You can start with a handful of high-value rules (nullable warnings, async warnings) and add more as the codebase gets cleaner.
The second is the inverse:
This says "everything breaks the build, except these noisy rules that are still being cleaned up." It's a fast path to getting most of the value while leaving an explicit list of exceptions to address.
A common combination for a new e-commerce service:
Five lines of config, and the build is now a wall against most categories of mistakes.
There's a third axis: NoWarn. It mutes a rule completely so it never appears in the build output, not even as a warning:
The $(NoWarn) prefix preserves any existing values (often inherited from Directory.Build.props). Use NoWarn when a rule isn't just noisy, it's actively wrong for your project, and you don't want it in any log. The trade-off is silence: there's no record in the build output that the rule is being skipped. Prefer .editorconfig with severity = none when you want the suppression visible to anyone reading the config.
A common combination of these flags in a serious project:
| Goal | Configuration |
|---|---|
| Treat every warning as an error | TreatWarningsAsErrors=true |
| Promote only specific rules to errors | WarningsAsErrors=CS8602;CA2007 |
| Keep "treat as errors" on but exempt a handful | WarningsNotAsErrors=CA1303;CA1707 |
| Hide a rule from the output entirely | NoWarn=CS1591 |
| Disable a rule at a specific path | .editorconfig section with severity = none |
Pick the narrowest tool for the job. The rule of thumb is: the more visible the exception is to the next person reading the project, the better. Five years from now, "why is CA1303 disabled?" should have an answer in the codebase.
Most teams never need to write a custom analyzer. The off-the-shelf packages cover the common cases, and the remaining gaps are usually a project-specific naming or layering rule. When you do need one, here's the shape.
A Roslyn analyzer is a class that inherits from DiagnosticAnalyzer. It declares which diagnostics it produces and what syntax it cares about. Roslyn calls registered actions when it walks the compilation:
The pattern: register a callback against a SyntaxKind, inspect the node when called, and report a diagnostic at a specific location. The framework handles incremental compilation, parallelism, and IDE integration.
A code fix lives in a separate class and is paired with the analyzer by diagnostic ID:
That's the whole skeleton. You package the analyzer and code fix as a NuGet package with the analyzers build asset, reference it like any of the third-party packages above, and it shows up in the IDE and the build.
The depth here gets deep fast. Roslyn ships a template (dotnet new analyzer) that scaffolds the project, and the official docs at learn.microsoft.com cover the API in full. For a course chapter, the takeaway is: when the team has a recurring convention no off-the-shelf rule catches, a hundred lines of analyzer code can enforce it forever.
Three patterns come up over and over in custom analyzers:
OrderIdNamingAnalyzer above is one example: it only needs to see the parameter name and a type-name string.context.SemanticModel.GetTypeInfo(...) to resolve symbols and types properly. They're slower but more accurate. A rule like "don't pass null to a non-nullable string parameter" needs the semantic model.Most project-specific rules fit in the first bucket. If you find yourself reaching for the semantic model, double-check that an existing CA rule doesn't already cover the case; the bar for custom analyzer maintenance goes up sharply once symbols enter the picture.
Consider an e-commerce backend at the moment a new lead engineer arrives. The build is clean, but only because nothing meaningful is enforced. The team has 14 services, an established style nobody writes down, and a recurring set of bugs that always make it past code review (unawaited tasks, public mutable collections, culture-sensitive comparisons on customer input). The plan is to set up analyzers across the solution and wire them into CI.
Before. Run dotnet build on the current Catalog.Api project:
Zero warnings, no analyzers configured. Now the lead adds a .editorconfig at the repo root:
A shared Directory.Build.props at the repo root pulls the analyzer settings into every project:
Now run the build again:
Seven issues, three of them serious enough to break the build. The team commits the config in a single PR, fixes the three blockers, agrees the four warnings get cleaned up over the next sprint, and lowers their severity to error once the count hits zero.
The CI pipeline runs the same build, plus a format check:
A GitHub Actions snippet that enforces this:
After. Two weeks later, the same dotnet build reports:
Same output as before the change, but the meaning is different. The first "0 warnings" was 0 because nothing was checked. The second is 0 because everything is checked and the code passed.
Three weeks after that, a developer writes a new service method:
The build fails before the PR is opened:
That's the system working. A bug that would have shipped without the analyzers gets caught at the developer's desk.
The numbers from the before-and-after are worth tracking explicitly. Before the rollout: 0 warnings in dotnet build, 0 errors, and a recurring cadence of bugs found in code review or production. After the rollout: 0 warnings, 0 errors, and a measurable drop in the same bug class. The output looks identical, but the underlying meaning is different. The first "zero" was a silence; the second is a clean signal.
Teams that successfully adopt this kind of setup usually share two habits. First, they fix violations the day they're surfaced, not "later this sprint". Letting a warning sit for two weeks normalizes ignoring it, and within a month the team is back to silence. Second, they treat changes to .editorconfig and Directory.Build.props like changes to production code: PR-reviewed, with a written justification in the commit message. The config files are the team's quality contract, and they deserve the same care as the code they govern.
10 quizzes