Roslyn Analyzers in C#: The Complete Guide
If you've used StyleCop, enabled nullable reference types, or seen those squiggly underlines in Visual Studio warning you to dispose an HttpClient or check a potentially-null value -- you've already experienced roslyn analyzers at work. You just might not have known they had a name.
Roslyn analyzers are one of the most powerful and underused tools in the .NET developer's toolkit. They let your toolchain catch mistakes that unit tests and code reviews often miss, because they run directly inside the compiler. No extra CI step, no manual audit, no waiting for a reviewer to remember the rule. The analysis runs every time the file is saved.
This guide covers everything you need to understand roslyn analyzers from the ground up in .NET 10: what they are, how they fit into the compilation pipeline, why teams build their own instead of relying solely on third-party packages, and how to write your first working diagnostic. By the end, you'll have a clear mental model of the entire roslyn analyzer ecosystem -- and you'll know exactly what to read next.
What Is a Roslyn Analyzer?
A roslyn analyzer is a piece of code that plugs into the .NET compiler platform -- the open-source compiler project called "Roslyn" -- and inspects your source code while it compiles. It can report warnings, errors, or informational messages based on rules you (or someone else) define.
The key phrase is "compiler-as-a-platform." Roslyn doesn't just turn C# source into IL bytecode. It exposes a rich API that lets you walk the syntax tree, query the semantic model, and observe every type, method, field, and expression in a program. Analyzers are first-class consumers of that API.
When you reference an analyzer (either as a NuGet package or as a project reference), the .NET 10 SDK loads it into the build process and the IDE simultaneously. That means you get two things:
- Real-time IDE feedback -- as you type, the analyzer runs and squiggle-underlines violations immediately, without a full build.
- Build-time enforcement -- when you run
dotnet build, violations produce real MSBuild diagnostics (warnings or errors) that show up in CI just like compiler errors do.
This dual-mode behavior is what makes them so much more effective than linting scripts or code review checklists. The rule is enforced everywhere, automatically, on every save and every build.
What an Analyzer Actually Does
At its core, the analyzer does three things:
- Declares what it looks for -- it registers interest in specific syntax nodes, symbols, operations, or compilation events.
- Inspects those targets -- when the compiler encounters a matching node, it calls your analyzer's registered action with context about what was found.
- Reports a diagnostic -- if the code violates the rule, the analyzer creates a
Diagnosticwith a rule ID, a severity (warning, error, info, or hidden), a message, and the precise location in source code where the violation occurred.
The result shows up as a squiggle in the IDE, a line in dotnet build output, or an entry in a SARIF file for GitHub Advanced Security integration.
In .NET 10, they are a core part of the SDK. The .NET SDK ships with built-in analyzers that enforce nullable reference type usage, async/await correctness, and platform compatibility. When you add <Nullable>enable</Nullable> to your project file, you're activating one of the most widely used roslyn analyzers on the planet.
Why Build Your Own Analyzer?
The built-in analyzers and popular packages like StyleCop or Roslynator cover a lot of ground. So why would a team bother writing a custom analyzer?
Because every codebase has rules that no generic package can know about.
Enforcing Team Conventions
Your team might have decided that all public methods on repository classes must return Task<Result<T>> instead of throwing exceptions. Or that service classes must never be instantiated directly -- only resolved through the DI container. Or that a specific legacy API is deprecated and should never be called in new code.
These aren't language-level rules. No standard analyzer will catch them. But a custom diagnostic can -- and it will catch the violation the moment someone types it, not three days later in a code review.
Catching Domain-Specific Anti-Patterns
Domain logic often has constraints that only make sense in context. A financial application might require that all monetary calculations use decimal, never double or float. A healthcare system might require that all patient data access goes through a specific audit-logging service. These constraints are impossible to express with general-purpose analyzers.
A custom diagnostic turns these into compiler-enforced contracts. The rule lives in code, it applies to everyone on the team equally, and it doesn't rely on anyone remembering to check.
Structured Log Validation
Structured logging with Serilog or Microsoft.Extensions.Logging uses message templates where property names matter enormously. A call like _logger.LogInformation("Processing {orderId}", order.Id) might look fine, but _logger.LogInformation("Processing {OrderId}", order.Id) could silently break your log parsing pipeline if your aggregator expects a lowercase key.
A custom analyzer can inspect the string template and the argument count and names, catching mismatches before they reach production. That kind of structural validation is exactly what the roslyn code analysis API was designed for.
Null Safety Enforcement
Even with nullable reference types enabled in .NET 10, there are patterns the compiler can't catch without additional context. Maybe your codebase uses a custom NotNullWhen helper that the standard flow analysis doesn't understand, or maybe your team has domain-specific invariants around certain factory methods. A custom analyzer can reason about those patterns and fill the gap.
This kind of deep integration with your architecture's idioms is precisely the scenario where custom analyzers add value that no off-the-shelf package can match.
How Roslyn Analyzers Work
Understanding the Roslyn compilation pipeline is essential before you write a single line of analyzer code. You don't need to understand every internal detail, but you do need to understand the three layers your analyzer can target: the syntax tree, the semantic model, and the IOperation tree.
Layer 1 -- The Syntax Tree
The syntax tree (more precisely, SyntaxTree and its SyntaxNode descendants) is the raw structural representation of your source code. It's produced directly from text parsing, with no name resolution or type binding involved. Every token, every keyword, every bracket and semicolon is represented as a node in the tree.
This layer is fast and cheap to query. If you want to find every method whose name starts with Get, you can do that with syntax analysis alone. You don't need to know what types are involved.
Syntax analysis is appropriate when the rule is purely structural: naming conventions, code formatting, the presence or absence of specific keywords or modifiers.
Layer 2 -- The Semantic Model
The semantic model adds meaning to the syntax tree. It answers questions like "what type does this expression have?", "what method is this call actually invoking?", and "is this symbol a local variable, a field, or a parameter?"
Most interesting diagnostic rules need the semantic model. Detecting that someone called Thread.Sleep instead of Task.Delay requires knowing that the method being called is actually System.Threading.Thread.Sleep -- not just any method named Sleep.
Semantic analysis is more expensive than syntax analysis, but it's still fast enough to run in real time in the IDE. The Roslyn compiler pipeline is designed for incremental computation, so it only recalculates what's actually changed.
Layer 3 -- IOperation
The IOperation tree is a semantic abstraction layer that sits above both the syntax tree and the semantic model. It normalizes different syntactic forms of the same operation into a consistent representation. A foreach loop and a while loop that manually advances an enumerator both produce distinct operation types -- but they share common base types that let you reason about control flow without caring about the exact syntax the developer wrote.
IOperation is the right target when you're writing rules about program behavior: data flow, control flow, assignment sequences, or method invocation patterns. It's more complex to work with than raw syntax nodes, but it gives you a significantly more reliable view of what the code actually does.
When Each Layer Fires
Analyzers register actions during initialization using the same API that has been available since the Roslyn compiler platform was introduced. In .NET 10:
RegisterSyntaxNodeAction-- fires once per matching syntax node during syntax analysisRegisterSymbolAction-- fires for each named symbol (type, method, property, field) after name bindingRegisterOperationAction-- fires for eachIOperationnode during semantic analysisRegisterCompilationStartAction/RegisterCompilationEndAction-- fires before or after the entire compilation, which is useful for aggregate analysis spanning multiple files
Most analyzers use RegisterSyntaxNodeAction or RegisterSymbolAction for the majority of their rules. RegisterOperationAction is the right choice when you need flow-sensitive analysis or want to reason about code behavior rather than code structure.
Roslyn Analyzers vs Source Generators
Roslyn analyzers and source generators are often mentioned together because both use the Roslyn compiler API. But they serve completely different purposes, and confusing the two leads to reaching for the wrong tool.
| Roslyn Analyzer | Source Generator | |
|---|---|---|
| Purpose | Report diagnostics about existing code | Synthesize new code to add to compilation |
| Output | Warnings, errors, code fixes | New .cs files added to compilation |
| Execution trigger | Any time the compiler analyzes code | During compilation after user code is parsed |
| Can modify user code? | No (code fixes suggest, don't auto-apply) | No (only adds new code, never modifies existing) |
| When to use | Enforce rules, catch anti-patterns | Eliminate boilerplate, generate serializers, registrations |
| API entry point | DiagnosticAnalyzer |
IIncrementalGenerator |
The right mental model: analyzers are watchdogs, source generators are factories. An analyzer watches your code and says "that looks wrong." A source generator looks at your code and says "I'll write the boilerplate you'd have to write by hand."
If you've used the [JsonSerializable] attribute to get AOT-compatible JSON serialization, you've used a source generator. If you've seen a nullable warning telling you a value might be null, you've seen one in action. They both use the same underlying compiler API, but they solve opposite problems.
You can read more about the distinction and when to choose each approach in the source generators deep-dive, which also compares them to C# reflection for a complete picture of compile-time vs runtime metaprogramming.
Roslyn Analyzers vs StyleCop and Roslynator
Before you build a custom roslyn analyzer in C#, it's worth asking: does the rule you want already exist?
StyleCop.Analyzers is a NuGet package providing hundreds of coding style rules: naming conventions, layout, member ordering, and documentation comment requirements. If you want to enforce that private fields start with an underscore, that using directives are inside the namespace, or that access modifiers are always explicit -- StyleCop probably already has that rule.
Roslynator is a broader-purpose roslyn code analysis package covering a huge range of quality and simplification rules. Redundant assignments, simplifiable conditionals, missing ConfigureAwait, unused parameters -- Roslynator covers an enormous amount of ground with its several hundred built-in rules.
The question to ask when evaluating these packages is: are you a user or a builder?
Using StyleCop or Roslynator means referencing the NuGet package, configuring which rules are enabled (typically via .editorconfig), and letting someone else maintain the diagnostic logic. That's the right choice for most style and quality rules -- the heavy lifting is already done, the rules are well-tested, and the community maintains them.
Building a custom analyzer makes sense when:
- The rule is specific to your domain, your architecture, or your team's patterns
- Existing packages don't cover the use case
- The rule requires access to information only your codebase has (custom attributes, project-specific type hierarchies, internal API contracts)
- You want to evolve the rule as your architecture evolves, without waiting on an external package
Many teams do both: they use StyleCop and Roslynator for general-purpose rules, and build a small internal library of custom analyzers for the domain-specific constraints that are unique to their application. Working with custom attributes is a particularly common pattern in custom analyzer design -- you use an attribute to mark something as significant, and the analyzer enforces rules based on that marker.
Setting Up Your First Roslyn Analyzer Project
One of the most persistent misconceptions about roslyn analyzers is that you need Visual Studio and a specific project template. In .NET 10, you don't. You can build a fully functional roslyn analyzer project with only the CLI and a text editor.
Create the Project
Start by creating a new class library targeting netstandard2.0 with the .NET CLI.
dotnet new classlib -n MyAnalyzers
cd MyAnalyzers
Add the Required NuGet Packages
You need two packages:
dotnet add package Microsoft.CodeAnalysis.CSharp
dotnet add package Microsoft.CodeAnalysis.Analyzers
Microsoft.CodeAnalysis.CSharp provides the entire Roslyn API: syntax trees, the semantic model, DiagnosticAnalyzer, and everything else you'll use to build your diagnostics. Microsoft.CodeAnalysis.Analyzers provides meta-analyzers -- roslyn analyzers that analyze your analyzer code itself, catching common mistakes like forgetting to handle cancellation tokens or misusing the analysis context.
Update the Project File
Your .csproj needs a few adjustments to work correctly as a roslyn code analysis package:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<!-- Use the version that matches your target SDK; for .NET 10, this is 4.14.x -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
Notice the target framework is netstandard2.0, not net10.0. Roslyn analyzer assemblies must target netstandard2.0 because the host process that loads them -- the compiler server, Visual Studio, Rider -- may not run on .NET 10. The netstandard2.0 target ensures the broadest possible compatibility with any host that can consume roslyn analyzers.
The EnforceExtendedAnalyzerRules property activates the meta-analyzers from Microsoft.CodeAnalysis.Analyzers that check your analyzer code against best practices. This is optional but strongly recommended -- it catches common mistakes early.
Your First Diagnostic: A Minimal Example
Let's build a complete custom analyzer that warns whenever someone calls Console.WriteLine in production code. This is a realistic rule for many teams -- direct console output in business logic is often a sign that proper structured logging hasn't been wired up, and it's a pattern worth enforcing.
The Full Analyzer Class
With the individual components implemented, here is the complete analyzer class:
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace MyAnalyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoConsoleWriteLineAnalyzer : DiagnosticAnalyzer
{
// Every rule needs a unique ID -- conventionally prefixed with your team or org code
public const string DiagnosticId = "MY001";
private static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticId,
title: "Avoid Console.WriteLine in production code",
messageFormat: "Replace Console.WriteLine with structured logging",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Console.WriteLine bypasses structured logging infrastructure and should not appear in production code."
);
// The host calls this property to discover which diagnostics this analyzer can produce
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
// Skip auto-generated files -- avoids false positives in scaffolded or source-generated code
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
// Allow the host to call this analyzer concurrently on multiple threads
context.EnableConcurrentExecution();
// Register interest in invocation expressions (any method or delegate call)
context.RegisterSyntaxNodeAction(
AnalyzeInvocation,
SyntaxKind.InvocationExpression);
}
private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;
// Resolve the syntax to an actual method symbol via the semantic model
var symbolInfo = context.SemanticModel.GetSymbolInfo(
invocation,
context.CancellationToken);
if (symbolInfo.Symbol is not IMethodSymbol method)
{
return;
}
// Use fully-qualified type name to avoid false positives on user-defined Console types
if (method.ContainingType.ToDisplayString() == "System.Console" &&
method.Name == "WriteLine")
{
var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation());
context.ReportDiagnostic(diagnostic);
}
}
}
What Each Part Does
[DiagnosticAnalyzer(LanguageNames.CSharp)] -- This attribute marks the class as an analyzer and declares it targets C#. The host (compiler, IDE) discovers analyzer classes by scanning for this attribute.
DiagnosticDescriptor -- This is the rule definition. It bundles together the rule ID, the human-readable title and message, the category, and the default severity. The messageFormat supports {0}, {1} placeholders if you want dynamic context in the diagnostic message.
SupportedDiagnostics -- The compiler calls this property to learn which diagnostics your analyzer can produce. Every ID your analyzer ever passes to ReportDiagnostic must be declared here. Undeclared IDs are silently dropped.
Initialize -- The entry point for registering your analysis actions. ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) tells the host to skip auto-generated files. EnableConcurrentExecution declares that your analyzer is thread-safe and can be called concurrently.
RegisterSyntaxNodeAction -- This registers AnalyzeInvocation to fire whenever the compiler encounters an InvocationExpression -- which covers every method call, delegate invocation, and indexer access.
GetSymbolInfo -- This is where the semantic model comes in. The syntax node tells us there's a call expression, but only the semantic model can resolve it to the actual IMethodSymbol and tell us which containing type owns it.
ReportDiagnostic -- This is how you raise a diagnostic. The Location you pass in determines exactly where the squiggle appears in the IDE and which line is reported in dotnet build output.
Wiring It Up as a Project Reference
To use your roslyn analyzer in another project during development, add it as an analyzer reference:
<ItemGroup>
<ProjectReference
Include="..MyAnalyzersMyAnalyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
The OutputItemType="Analyzer" attribute tells the SDK to load the output DLL as an analyzer rather than a runtime dependency. The ReferenceOutputAssembly="false" attribute prevents the consuming project from depending on the analyzer assembly at runtime -- analyzers are a build-time concern only and should never end up in your published output.
With this reference in place, MY001 will appear as a warning squiggle in your IDE and in dotnet build output the moment someone writes Console.WriteLine in the consuming project.
Building extensible plugin systems often leads naturally to scenarios where a custom analyzer enforces correct usage of plugin extension points -- and that's a pattern worth exploring once you've got the basics down.
What's Next: The Complete Roslyn Analyzer Cluster
This guide has given you the foundation: the conceptual model, the three layers of the Roslyn API, the distinction between analyzers and source generators, and a complete first diagnostic. But there's significant depth beyond the basics.
The articles below build on everything covered here. They go deeper into specific topics -- testing, distribution, real-world patterns, and the trickier parts of the API that catch most developers by surprise the first time through.
Start with the mechanics of writing and designing rules:
Build Your First Roslyn Analyzer in C# -- A full walkthrough building a non-trivial roslyn analyzer from scratch, covering edge cases and patterns not addressed in the minimal example above.
DiagnosticDescriptor, Severity Levels, and Rule IDs in C# -- Everything you need to know about designing rules that behave well: severity levels, configurable rules, and
.editorconfigintegration for consumer control.IOperation vs SyntaxNode vs Symbol in Roslyn C# -- When to use each layer of the Roslyn API and how to choose the right analysis target for a given rule.
Once your first analyzer is working, the next challenges are testing it reliably, shipping it to your team, and handling the more complex API patterns that real-world rules demand. Each of those areas has its own depth -- and the order you tackle them matters. The articles below cover each topic in detail; pick the one that matches your immediate next step:
Testing Roslyn Analyzers with Microsoft.CodeAnalysis.Testing -- The official testing framework for roslyn analyzers: how to write unit tests that verify diagnostic output with precise source locations.
Distributing Roslyn Analyzers as NuGet Packages -- Packaging, versioning, and distributing your analyzer so any team can reference it with a single
dotnet add package.Roslyn Analyzers vs Incremental Source Generators: The Pipeline Comparison -- A detailed comparison of the two Roslyn-based extension points, including how to use them together in the same workflow.
Real-World Roslyn Analyzer Examples in C# -- Production-quality roslyn analyzer examples in C#: a naming convention enforcer for async methods, a null guard detector for public APIs, and a structured logging validator that bans string interpolation in log calls.
Frequently Asked Questions
The questions below cover the most common sticking points when building Roslyn analyzers.
What is a Roslyn analyzer in C#?
A roslyn analyzer is a class that implements DiagnosticAnalyzer and plugs into the .NET compiler platform to inspect C# source code during compilation. It can report warnings, errors, and informational diagnostics based on rules you define. Roslyn analyzers run both in the IDE in real time and during dotnet build, which means violations are caught immediately -- before code ever reaches a code review or a CI pipeline.
Do I need Visual Studio to build a Roslyn analyzer?
No. In .NET 10, you can create, build, and test an analyzer entirely from the command line using dotnet new classlib, dotnet add package, and dotnet build. Any editor with C# language server support (VS Code, Rider, Neovim) will give you IDE feedback. Visual Studio project templates exist and are convenient, but they add no capability that you can't achieve with the .NET 10 SDK CLI.
What is the difference between a Roslyn analyzer and a source generator?
A roslyn analyzer inspects your existing code and reports diagnostics -- it does not add or change code. A source generator reads your existing code and synthesizes new .cs files that are added to the compilation. Both use the Roslyn compiler API, but they serve opposite purposes: analyzers are watchdogs that report problems, generators are factories that produce output. You can explore this distinction further in the source generators comparison.
Can I ship a Roslyn analyzer as a NuGet package?
Yes, and this is the standard distribution mechanism for custom analyzers in C#. You include the analyzer assembly under the analyzers/dotnet/cs/ folder in the NuGet package. When a project references the package, the .NET 10 SDK automatically discovers and loads the analyzer assembly at build time without any additional configuration. Packages like StyleCop.Analyzers and Roslynator work exactly this way.
Are Roslyn analyzers the same as code fixes?
No, but they're closely related. A roslyn analyzer detects a problem and produces a Diagnostic. A CodeFixProvider is a separate class that optionally accompanies an analyzer -- it provides one or more automated repair actions that IDEs offer when you click the lightbulb or press the quick-fix shortcut. An analyzer can exist without a code fix, and most start that way. A code fix always belongs to a specific analyzer's diagnostic ID.
Can Roslyn analyzers catch null reference exceptions?
Custom analyzers can enforce null safety rules beyond what the compiler's built-in nullable reference types analysis covers. You can write a custom diagnostic that detects patterns where the compiler doesn't have enough context to infer nullability -- for example, custom factory methods with domain-specific invariants, or conditional-null patterns that the standard flow analysis doesn't track. The built-in nullable analysis in .NET 10 is itself implemented as a set of roslyn analyzers, and custom analyzers can extend or complement it.
Will Roslyn analyzer rules apply to auto-generated code?
Well-written analyzers call context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) during initialization, which tells the host to skip files marked as generated -- files with // <auto-generated> headers or files in the obj directory. This is the correct default because generated code typically can't be edited directly, so diagnostics against it produce noise without any actionable path forward. If you want your rule to apply to generated code too, you can pass GeneratedCodeAnalysisFlags.Analyze instead, but this is rarely what you want.
Conclusion
Roslyn analyzers in C# are a natural extension of the compiler itself -- a way to encode your team's conventions, your domain's constraints, and your architecture's invariants directly into the build pipeline. They run everywhere the compiler runs, they catch violations the moment code is typed, and they're far more reliable than relying on code reviews or documentation to keep patterns consistent across a growing team.
In .NET 10, building your own roslyn analyzer is more accessible than it's ever been. You don't need a complex setup or a heavyweight IDE. A couple of NuGet packages, a netstandard2.0 class library, and a solid understanding of the syntax tree / semantic model / IOperation layering is all it takes to get a working diagnostic in front of your team.
If you're maintaining SOLID code quality across a large codebase, enforcing roslyn analyzer rules is one of the highest-leverage investments you can make. The rule runs automatically for every developer, on every machine, on every commit -- no reminders required. And because the analysis lives in source-controlled code alongside the rest of the project, it evolves with the codebase rather than lagging behind it in a separate doc or wiki page that nobody reads.
Start with the minimal example above. Get it loading in a test project. Watch the warning appear. That first squiggle you created yourself is the beginning of a completely different relationship with how your codebase enforces its own rules.
This article was written by Nick Cosentino, Principal Engineering Manager at Microsoft and content creator at Dev Leader. The views expressed here are his own.

