Build Your First Roslyn Analyzer in C#: Diagnostics and Code Fix Walkthrough
If you've ever wanted your compiler to enforce your team's coding standards automatically -- or flag patterns that no static linter catches -- then learning to build a Roslyn analyzer in C# is where that capability lives. Roslyn analyzers plug directly into the compiler pipeline and raise diagnostics -- warnings, errors, or suggestions -- with zero runtime overhead. Pair one with a CodeFixProvider and you also get a one-click fix inside Visual Studio and VS Code.
This guide is for developers who already understand what analyzers do and want a complete working example they can copy and adapt. For broader context on the analyzer ecosystem -- NuGet packaging, deployment, and integration with SDK-style projects -- the Roslyn Analyzers in C# guide covers that ground. Here, we go straight to code.
The example we'll build detects throw new Exception("some message") in application code and suggests replacing it with a more specific exception type. It is a real-world pattern, the analysis logic is approachable, and the code fix transformation is non-trivial enough to teach all the important concepts. By the end you'll have a working analyzer, a working code fix, and a unit test suite that proves both behave correctly.
Project Setup from Scratch
A Roslyn analyzer is a class library that ships as a NuGet package or a direct analyzer reference. The project must target netstandard2.0 -- not net10.0 or any other TFM -- because the Roslyn host (the compiler process or the IDE language service) loads analyzers into its own process, which may be running on an older runtime. Targeting netstandard2.0 guarantees compatibility across all Roslyn host environments while still allowing you to use modern C# language features via <LangVersion>latest</LangVersion>.
Create the project:
mkdir MyAnalyzers
cd MyAnalyzers
dotnet new classlib -n MyAnalyzers -f netstandard2.0
cd MyAnalyzers
Now edit MyAnalyzers.csproj to add the required NuGet references and the build properties that mark the output as an analyzer:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<!-- Prevents the analyzer DLL from being added as a runtime reference -->
<IncludeBuildOutput>false</IncludeBuildOutput>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>
<!-- Core Roslyn analysis APIs -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="4.12.0"
PrivateAssets="all" />
<!-- Meta-analyzer: catches common mistakes in your own analyzer code -->
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers"
Version="3.11.0"
PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- Place the compiled DLL into the analyzers/dotnet/cs folder in the .nupkg -->
<None Include="$(OutputPath)$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
</Project>
A few things are worth unpacking here. PrivateAssets="all" on both package references prevents Roslyn from flowing into the consuming project's dependency graph -- consumers should not need to reference Roslyn directly just because they install your analyzer. The None item with Pack="true" is what places your compiled assembly at analyzers/dotnet/cs inside the .nupkg, which is the path the .NET SDK inspects when loading NuGet-based analyzers. Without it, the package installs fine but silently does nothing.
IncludeBuildOutput=false prevents the analyzer DLL from ending up in the consuming project's bin folder as a runtime dependency. Your analyzer code runs inside the compiler host, not at application runtime -- so it must never appear on the application's load path.
The DiagnosticAnalyzer Scaffold
Every analyzer starts by inheriting from DiagnosticAnalyzer and applying the [DiagnosticAnalyzer] attribute to declare language support. Create ExceptionUsageAnalyzer.cs:
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
namespace MyAnalyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ExceptionUsageAnalyzer : DiagnosticAnalyzer
{
// The diagnostic ID surfaces in build output, .editorconfig, and IDE rule lists
public const string DiagnosticId = "MY001";
private static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticId,
title: "Avoid throwing base Exception directly",
messageFormat: "Replace 'new Exception("{0}")' with a domain-specific exception type",
category: "Design",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Throwing System.Exception directly makes catch blocks imprecise. " +
"Use or create a domain-specific exception type instead.");
// SupportedDiagnostics must list every descriptor this analyzer can produce
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
// Required: opt out of analyzing auto-generated code
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
// Required: safe for multi-threaded execution inside the IDE
context.EnableConcurrentExecution();
// Register on object creation expressions -- see next section for why
context.RegisterSyntaxNodeAction(
AnalyzeObjectCreation,
SyntaxKind.ObjectCreationExpression);
}
private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context)
{
// Implementation in the next section
}
}
EnableConcurrentExecution and ConfigureGeneratedCodeAnalysis are not optional ceremony. The Microsoft.CodeAnalysis.Analyzers meta-package raises its own diagnostics (RS1026, RS1025) if you omit them, and those diagnostics will appear in your own build output. Both calls protect Roslyn's analysis pipeline: concurrent execution improves IDE responsiveness on large solutions, and opting out of generated code prevents noise from auto-generated files like EF Core migrations and Protobuf stubs.
DiagnosticDescriptor is immutable and expensive to construct. Always declare it as a static readonly field at the class level rather than constructing it inside the callback. The IDE's analysis loop calls your callback thousands of times per editing session.
Choosing Your Analysis Hook
The AnalysisContext you receive in Initialize exposes several registration methods. Picking the right one is the first real design decision you make when you build a Roslyn analyzer in C#, and the wrong choice can make your analyzer noticeably slow inside the IDE.
| Hook | Best used for | Performance cost |
|---|---|---|
RegisterSyntaxNodeAction |
Syntax-only checks -- shape of code, not its meaning. | Very low |
RegisterSymbolAction |
Inspecting declared symbols: types, methods, properties. | Low |
RegisterSemanticModelAction |
Walking the full semantic model of a file when node-level context isn't enough. | Medium |
RegisterOperationAction |
Language-agnostic operation tree analysis; handles implicit operations and dataflow. | Medium-high |
RegisterCompilationAction |
Cross-file or cross-assembly checks. Runs once per full compilation. | High |
For detecting new Exception(...), a RegisterSyntaxNodeAction on SyntaxKind.ObjectCreationExpression is sufficient. We can confirm the type name syntactically with a pattern match and then do a targeted semantic lookup to verify the type resolves to exactly System.Exception. Reaching for RegisterSemanticModelAction would be overkill and would slow the IDE's responsiveness unnecessarily.
When your check needs to verify relationships between symbols across files -- for example, ensuring every class that implements a given interface also applies a specific attribute -- RegisterSymbolAction is the right tool. This kind of design enforcement parallels the work you do when designing extensible interfaces: in both cases you're encoding architectural constraints into a form that can be mechanically verified.
RegisterOperationAction is the most powerful hook and the least commonly needed. Use it when your rule is about observable behavior rather than code shape -- for instance, detecting that a Task return value is never awaited or assigned, regardless of the syntactic form in which the call appears.
Implementing the Analysis Logic
With the hook registered, fill in AnalyzeObjectCreation. The goal: detect new Exception("some literal") precisely and report a diagnostic without false positives on subclasses like ArgumentException or IOException.
using Microsoft.CodeAnalysis.CSharp.Syntax;
private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context)
{
var creation = (ObjectCreationExpressionSyntax)context.Node;
// Fast syntactic pre-filter: does the type name look like "Exception"?
// This avoids the more expensive semantic lookup for the common case.
if (creation.Type is not IdentifierNameSyntax { Identifier.Text: "Exception" }
and not QualifiedNameSyntax { Right.Identifier.Text: "Exception" })
{
return;
}
// Semantic confirmation: does the type actually resolve to System.Exception?
var typeSymbol = context.SemanticModel.GetTypeInfo(creation).Type;
if (typeSymbol is null)
{
return;
}
// Retrieve the well-known type from the compilation for an accurate comparison
var exceptionType = context.SemanticModel.Compilation
.GetTypeByMetadataName("System.Exception");
// Only flag the exact base type -- ArgumentException, IOException etc. must not trigger
if (!SymbolEqualityComparer.Default.Equals(typeSymbol, exceptionType))
{
return;
}
// Extract the message argument text for inclusion in the diagnostic message
var messageArg = creation.ArgumentList?.Arguments.FirstOrDefault();
var messageText = messageArg?.Expression is LiteralExpressionSyntax lit ? lit.Token.ValueText : messageArg?.ToString() ?? string.Empty;
var diagnostic = Diagnostic.Create(
Rule,
creation.GetLocation(),
messageText);
context.ReportDiagnostic(diagnostic);
}
The two-step approach -- syntactic pre-filter followed by semantic confirmation -- is a fundamental Roslyn best practice. Syntax operations are cheap and run on every keystroke in the IDE. Semantic model access is more expensive because it may trigger lazy type resolution across the compilation. Short-circuiting on the syntax check before calling GetTypeInfo keeps the analyzer fast in practice.
SymbolEqualityComparer.Default is the correct way to compare ISymbol instances. Do not use == or .Equals directly on symbols -- those can behave unexpectedly when the same type appears in references from different compilation contexts, which happens often in multi-project solutions.
This kind of static compile-time enforcement is conceptually related to code generation at compile time -- both source generators and analyzers live in the compiler pipeline and give you feedback before the binary is produced. Analyzers enforce constraints; generators synthesize code. Both are stronger guarantees than custom attributes and reflection at runtime, which only catches problems after the application has already shipped.
Adding a CodeFixProvider
A DiagnosticAnalyzer raises warnings. A CodeFixProvider offers the repair. The two are loosely coupled: your fix registers interest in a specific diagnostic ID, and the IDE presents it as a lightbulb action wherever that diagnostic appears. Create ExceptionUsageCodeFix.cs:
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace MyAnalyzers;
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ExceptionUsageCodeFix))]
[Shared]
public sealed class ExceptionUsageCodeFix : CodeFixProvider
{
// Must match the DiagnosticId from the analyzer
public override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(ExceptionUsageAnalyzer.DiagnosticId);
// Provides "fix all occurrences in document / project / solution" for free
public override FixAllProvider GetFixAllProvider() =>
WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document
.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
var diagnostic = context.Diagnostics[0];
var diagnosticSpan = diagnostic.Location.SourceSpan;
// Walk up to the ObjectCreationExpression that spans the diagnostic
var node = root?.FindNode(diagnosticSpan)
.FirstAncestorOrSelf<ObjectCreationExpressionSyntax>();
if (node is null)
{
return;
}
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with InvalidOperationException",
createChangedDocument: ct =>
ReplaceWithInvalidOperationExceptionAsync(context.Document, node, ct),
equivalenceKey: nameof(ExceptionUsageCodeFix)),
diagnostic);
}
private static async Task<Document> ReplaceWithInvalidOperationExceptionAsync(
Document document,
ObjectCreationExpressionSyntax creation,
CancellationToken cancellationToken)
{
var root = await document
.GetSyntaxRootAsync(cancellationToken)
.ConfigureAwait(false);
if (root is null)
{
return document;
}
// Build the replacement node: preserve all arguments, swap only the type name
var newTypeName = SyntaxFactory
.IdentifierName("InvalidOperationException")
.WithTriviaFrom(creation.Type);
var newCreation = creation.WithType(newTypeName);
var newRoot = root.ReplaceNode(creation, newCreation);
return document.WithSyntaxRoot(newRoot);
}
}
Several design details in this code deserve attention before you move on.
[Shared] alongside [ExportCodeFixProvider] is a MEF composition attribute. The IDE uses MEF to discover and instantiate code fix providers. Without [Shared], MEF creates a new instance for every fix request, which is wasteful. With [Shared], one instance handles all requests for the lifetime of the IDE session.
WellKnownFixAllProviders.BatchFixer gives users the "Fix all occurrences" command without any additional implementation on your part. It calls your single-document fix repeatedly across every matching diagnostic in the document, project, or solution. Implementing a custom FixAllProvider is only necessary when your fix involves inter-document transformations that the batch fixer cannot handle.
Document.WithSyntaxRoot is Roslyn's immutable transformation model. Nothing is mutated in place -- you produce a new syntax root, which produces a new Document, which the IDE applies to its workspace state. Understanding this immutable tree pattern is important before you build more complex fixes that involve adding using directives or creating new files, since each transformation chains off the previous result.
.WithTriviaFrom(creation.Type) copies whitespace and comment trivia from the original type node to the replacement. Without it, the formatter may introduce extra whitespace or strip leading spaces, which produces an unnecessary diff in the user's file.
The immutable document model has a deeper implication for complex code fixes: if you need to make two replacements to the same syntax tree in sequence, you cannot call root.ReplaceNode twice on the same root object and then apply both results. The second call operates on the original root, making the two replacements independent, and only one will survive when you call document.WithSyntaxRoot. The correct pattern for multiple replacements is to either chain them through intermediate roots or use root.ReplaceNodes (plural) with a dictionary of old-to-new node mappings computed upfront before any replacement happens.
SyntaxAnnotation and the AnnotationExtensions helpers (WithAdditionalAnnotations, GetAnnotatedNodes) become essential when a fix spans multiple transformation steps. Suppose your Roslyn analyzer's code fix needs to both add a using directive and rename a node in the same document. You annotate the target node before the first transformation, then locate it in the updated tree by looking up the annotation rather than by reference equality. After any ReplaceNode call, the original node reference is no longer part of the tree -- but the annotation survives because it travels with the node's data, not its object identity. This is the reliable way to track "the same logical node" through a chain of immutable tree transformations.
Debugging Your Analyzer
Debugging analyzers has a reputation for being awkward, but with the right setup it is manageable and predictable.
Launch an Experimental Visual Studio Instance
The standard approach is to create a VSIX project that references your analyzer, then press F5. Visual Studio launches an "experimental instance" -- a separate IDE profile with its own extension registry -- that loads your VSIX. Inside that instance you can open a test project, trigger the diagnostic, and hit breakpoints in your analyzer code with the full VS debugger attached.
If you do not want to set up a VSIX project, you can attach to the Roslyn language service process directly. Open a solution in VS, then in a separate VS instance go to Debug → Attach to Process and search for ServiceHub.RoslynCodeAnalysisService.exe (the exact name varies by VS version and architecture). Set breakpoints in your analyzer before attaching.
Setting up the VSIX project correctly is worth the fifteen-minute investment. Add a new "VSIX Project" to your solution, then open its source.extension.vsixmanifest file. On the Assets tab, click "New" and add an asset of type Microsoft.VisualStudio.MefComponent, source "A project in current solution", pointing at your analyzer project. Once that asset is declared, pressing F5 launches the experimental instance with your analyzer loaded. The experimental instance runs under %LOCALAPPDATA%MicrosoftVisualStudio{version}Exp and has its own extensions registry, so it never pollutes your main VS installation.
When attaching the debugger to the Roslyn language service, be aware that breakpoints may not bind immediately. The Roslyn analysis service starts lazily -- it spins up when the first file is opened in the experimental instance. Open a .cs file in the test solution first, wait for IntelliSense to initialize, then attach from the outer VS instance. After attachment, trigger your diagnostic by typing the pattern it targets; the breakpoint should hit within a second or two. If it does not, check that the DLL loaded by the language service is the DEBUG build. A mismatch between the loaded DLL and the PDB causes breakpoints to be marked "The breakpoint will not currently be hit" with no further explanation.
Use the Syntax Visualizer
The "Roslyn Syntax Visualizer" ships with the .NET Compiler Platform SDK Visual Studio workload. With it open alongside a C# file, clicking anywhere in the editor highlights the corresponding node in the tree panel, showing its exact SyntaxKind, span, and properties. This is the fastest way to confirm you're registering the right SyntaxKind and navigating to the correct parent node.
For the new Exception("message") example, clicking on Exception in the editor reveals ObjectCreationExpression → IdentifierName. That confirms SyntaxKind.ObjectCreationExpression is the right registration point and creation.Type is the right property to inspect.
One common surprise with the Syntax Visualizer: the tree panel and the cursor position in the editor can appear out of sync when auto-formatting is active. If you place the cursor inside a nested expression -- say, inside the argument list of a method call -- the Visualizer may highlight the innermost leaf token rather than the outer expression you intended. Use the "DirectedSyntaxGraph" view (accessible from the View menu inside the Visualizer window) to walk up the parent chain and confirm the SyntaxKind you are targeting is an ancestor of the node the cursor is on. Also note that the Visualizer reflects the parser's view of the unsaved buffer, not the file on disk. If your Roslyn analyzer registers on a SyntaxKind and you are not hitting the expected node, save the file, close and reopen it, and re-inspect -- a stale buffer is a surprisingly frequent source of confusion during initial development.
Debug.Fail Patterns
Inside analyzer callbacks, you cannot throw exceptions to signal unexpected states -- an unhandled exception in an analyzer silently swallows the diagnostic and sometimes destabilizes the IDE language service. Use Debug.Fail or conditional breakpoints to surface unexpected states during development:
#if DEBUG
if (creation.ArgumentList is null)
{
System.Diagnostics.Debug.Fail(
$"Unexpected null ArgumentList at {creation.GetLocation()}");
}
#endif
The #if DEBUG guard ensures the check is stripped in release builds, so it never affects IDE performance in a published NuGet package. Conditional breakpoints in the VS debugger are another good option -- set a breakpoint with a condition on creation.GetLocation().ToString().Contains("MyFile.cs") to avoid stopping on every file in a large solution.
Putting It All Together: Your Complete Build Roslyn Analyzer in C#
Here is the complete picture: both files side by side, followed by the unit test scaffold. The test project uses Microsoft.CodeAnalysis.Testing, which is the official Microsoft testing library for Roslyn analyzers.
Create the test project and add the required packages:
dotnet new xunit -n MyAnalyzers.Tests
cd MyAnalyzers.Tests
dotnet add package Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit
dotnet add package Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit
dotnet add reference ../MyAnalyzers/MyAnalyzers.csproj
Now write the tests. The CSharpCodeFixVerifier generic type wires together both the analyzer and the fix provider, so a single test class covers both behaviors:
using Microsoft.CodeAnalysis.Testing;
using Xunit;
using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.CodeFixVerifier<
MyAnalyzers.ExceptionUsageAnalyzer,
MyAnalyzers.ExceptionUsageCodeFix>;
namespace MyAnalyzers.Tests;
public sealed class ExceptionUsageAnalyzerTests
{
[Fact]
public async Task ThrowNewException_RaisesMY001Diagnostic()
{
// {|MY001:...|} marks the exact span where the diagnostic must appear
var source = """
using System;
class C
{
void M()
{
throw {|MY001:new Exception("bad state")|};
}
}
""";
await Verifier.VerifyAnalyzerAsync(source);
}
[Fact]
public async Task ThrowNewException_CodeFix_ReplacesWithInvalidOperationException()
{
var source = """
using System;
class C
{
void M()
{
throw {|MY001:new Exception("bad state")|};
}
}
""";
var fixedSource = """
using System;
class C
{
void M()
{
throw new InvalidOperationException("bad state");
}
}
""";
await Verifier.VerifyCodeFixAsync(source, fixedSource);
}
[Fact]
public async Task ThrowNewArgumentException_DoesNotRaiseDiagnostic()
{
// ArgumentException is a subclass of Exception -- must not be flagged
var source = """
using System;
class C
{
void M(string value)
{
throw new ArgumentException("must not be null", nameof(value));
}
}
""";
await Verifier.VerifyAnalyzerAsync(source);
}
[Fact]
public async Task ThrowNewSystemException_FullyQualified_RaisesDiagnostic()
{
// The syntactic pre-filter handles both simple and qualified names
var source = """
class C
{
void M()
{
throw {|MY001:new System.Exception("qualified")|};
}
}
""";
await Verifier.VerifyAnalyzerAsync(source);
}
}
The {|MY001:...|} markup is part of the testing framework's inline diagnostic notation. It tells the verifier exactly where in the source a diagnostic with ID MY001 must appear -- no line/column numbers or separate XML annotation files needed. When the diagnostic is absent, fires at the wrong location, or carries the wrong ID, the test fails with a clear, readable message.
The third and fourth tests are as important as the first two. The false-positive test (ArgumentException) confirms that derived types are not flagged, which is the most common way a too-broad analyzer destroys team trust. The fully-qualified name test confirms the syntactic pre-filter handles both Exception and System.Exception forms, which is an easy edge case to miss in the initial implementation.
Frequently Asked Questions
Can I build a Roslyn analyzer in C# that analyzes .NET 10 application code?
Yes. The analyzer project itself targets netstandard2.0 for hosting compatibility, but the code it analyzes can target any TFM including .NET 10. When you build a Roslyn analyzer in C# targeting modern applications, the analyzer receives the full semantic model of the analyzed project, which includes all .NET 10 APIs, new C# 14 language features, and any NuGet references in that project. You can also write your analyzer code using the latest C# language features by setting <LangVersion>latest</LangVersion> in the analyzer project, even though the compiled binary is netstandard2.0.
What is the difference between RegisterSyntaxNodeAction and RegisterOperationAction?
RegisterSyntaxNodeAction works on the raw syntax tree -- the textual representation of code parsed into nodes and tokens. RegisterOperationAction works on the operation tree, a language-semantic layer that normalizes different syntactic forms of the same behavior. For example, a foreach loop, a LINQ query continuation, and a hand-written IEnumerator pattern all produce similar operation nodes even though their syntax differs significantly. Use operations when your rule is about what the code does, not what it looks like. Use syntax nodes when the shape of the code is itself the thing being enforced.
How do I write and test a Roslyn analyzer without Visual Studio?
Entirely with the .NET CLI and any editor. To build a Roslyn analyzer in C# without VS, add Microsoft.CodeAnalysis.CSharp via NuGet, write your DiagnosticAnalyzer, and validate with dotnet test. The Syntax Visualizer is VS-only, but you can inspect syntax trees programmatically -- create a scratch console project and call CSharpSyntaxTree.ParseText(source).GetRoot().DescendantNodes() to print the tree. The Microsoft.CodeAnalysis.Testing packages work in any xUnit or MSTest project and give you full analyzer and code fix verification without needing the IDE at all.
Should I use RegisterSemanticModelAction or RegisterSymbolAction to check all usages of a type?
Neither, in most cases. RegisterSymbolAction fires for each declared symbol and is ideal for inspecting the symbol itself -- its attributes, accessibility modifiers, or interface implementations. Finding usages of a type (call sites, instantiation points, cast expressions) requires walking the syntax tree of each file, which is better done with RegisterSyntaxNodeAction filtered to the relevant node kinds, confirmed with a semantic lookup. RegisterSemanticModelAction is the fallback when you need the full semantic model but cannot identify a clean node-level hook, and it should be treated as a last resort because it fires once per file per analysis pass.
Can I ship my analyzer inside the same NuGet package as the library it enforces rules for?
Yes, and this is common practice. When a PackageReference includes a DLL at analyzers/dotnet/cs inside the package, the .NET SDK loads it automatically as an analyzer without any additional configuration by the consumer. Libraries like Microsoft.EntityFrameworkCore and System.Text.Json ship analyzers inside their main packages this way. The .csproj configuration in the setup section above produces exactly this layout -- the analyzer DLL lands at the correct path when you run dotnet pack.
Common Edge Cases and Gotchas
Every Roslyn analyzer you build in C# will eventually encounter these situations. Understanding them upfront prevents subtle bugs that are difficult to reproduce because they only appear in specific project configurations or team workflows.
Analyzer Running on Generated Code
By default, Roslyn routes diagnostics from generated files (.g.cs files, EF Core migrations, Protobuf stubs, source-generated output) through the same pipeline as hand-written code. Most analyzers should not flag generated code because developers cannot directly act on those warnings -- the generated file is overwritten on the next build. ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) opts out entirely. If your Roslyn analyzer is specifically designed to inspect generated code -- for example, to validate the output of your own source generator -- use GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics to explicitly opt in. The default behavior changed between SDK versions, so always call ConfigureGeneratedCodeAnalysis explicitly rather than relying on any implicit default.
Concurrent Execution Safety
EnableConcurrentExecution tells Roslyn it may invoke your analyzer callbacks from multiple threads simultaneously. This means every lambda or local function you register as a callback must be capture-safe: no mutable shared state, no non-thread-safe collections, no writes to class-level fields inside the callback. The static readonly fields -- DiagnosticDescriptor, Rule, DiagnosticId -- are safe because they are initialized once and never modified. If you ever need to accumulate results across files -- say, to detect a pattern that spans multiple type declarations -- use a CompilationStartAnalysisContext to open a compilation-scoped context, register a completion callback, and have each per-file callback push into a ConcurrentBag or similar thread-safe collection that the completion callback aggregates.
Analyzer Ordering Is Not Guaranteed
Roslyn does not guarantee any execution order between different Roslyn analyzers running in the same compilation. If you have two analyzers and you want one to suppress a diagnostic from the other, use the SuppressMessageAttribute or .editorconfig suppression mechanism rather than relying on the assumption that analyzer A always runs before analyzer B. Execution order varies between the IDE background analysis pass and the CLI build pass (dotnet build), so any timing dependency between analyzers will manifest as hard-to-reproduce inconsistencies that only appear on certain machines or in CI.
Wrapping Up
The process to build a Roslyn analyzer in C# follows a consistent pattern once you've done it once: scaffold the DiagnosticAnalyzer, pick the right registration hook, implement the syntax-then-semantic check, attach a CodeFixProvider for the repair, and validate everything with the Microsoft.CodeAnalysis.Testing library. The example here -- detecting new Exception(...) and suggesting a more specific type -- is realistic enough to teach the non-obvious parts: the two-step pre-filter pattern, correct use of SymbolEqualityComparer, the immutable document transformation model, and the MEF attributes that wire the code fix into the IDE.
From here, natural next steps include configuring diagnostic severity via .editorconfig, packaging the analyzer for distribution on NuGet, and building more complex code fixes that add using directives or create entirely new source files. Each of those builds directly on the foundation covered here.
This article was written by Nick Cosentino, Principal Engineering Manager at Microsoft and content creator at Dev Leader. All code examples are reviewed for accuracy against the .NET 10 SDK and Microsoft.CodeAnalysis.CSharp 4.12.

