You've built your first Roslyn analyzer. RegisterSyntaxNodeAction is wired up, your DiagnosticDescriptor is defined, and you ship it to the team. Then someone opens a bug: "The analyzer fires on valid code" or "It misses the pattern when the type is aliased." You start digging and realize the root problem -- you reached for syntax when you needed semantics, or you reached for symbols when you needed IOperation Roslyn tree traversal.
This is the ceiling most analyzer authors hit after their first few rules. The Roslyn compiler platform exposes three distinct analysis layers, and knowing which one to use is the difference between a rule that is reliable and one that is brittle. This article breaks down all three layers -- SyntaxNode, ISymbol, and IOperation Roslyn -- and gives you a decision framework for choosing the right one in .NET 10 projects. Understanding when to apply IOperation Roslyn analysis vs syntax or symbol analysis is what separates beginner analyzer code from production-quality rules.
If you want a refresher on the foundational setup before diving in, the Roslyn Analyzers guide covers the full picture from NuGet packages to packaging.
The Three Layers of Roslyn Analysis
The Roslyn compiler processes code in phases. Each phase exposes progressively richer information, but at a progressively higher cost.
| Layer | API Surface | What It Knows | Cost |
|---|---|---|---|
| Syntax | SyntaxNode, SyntaxToken |
Text, structure, whitespace, trivia | Very low |
| Symbols | ISymbol, INamedTypeSymbol, IMethodSymbol |
Types, namespaces, declarations, attributes | Medium |
| Operations | IOperation, IInvocationOperation, IAssignmentOperation |
Semantics, control flow, data flow, language-agnostic | Higher |
Think of syntax as reading the raw text of your code in a structured tree. The compiler knows the shape of the code -- what tokens are present and where they are -- but it has not resolved any names or types yet. Symbols are what you get after resolution: the compiler has bound identifiers to their declarations and knows that var x = new Foo() is an instance of the type Foo from a particular assembly. IOperation Roslyn analysis sits on top of both: the IOperation Roslyn tree represents what the code does semantically, independent of whether it was written in C# or Visual Basic. IOperation Roslyn is the right choice when you need to catch bugs that are invisible at the text or declaration level.
When you learn to move fluidly between all three layers, your analyzers become dramatically more accurate and easier to reason about. This is not an either/or choice -- you will frequently use all three in a single rule.
SyntaxNode Analysis: RegisterSyntaxNodeAction
RegisterSyntaxNodeAction is where nearly every analyzer author starts, and for good reason. It is fast, easy to understand, and perfectly suited to a broad class of rules.
What SyntaxNode Sees
A SyntaxNode is a node in the Concrete Syntax Tree (CST). Unlike an Abstract Syntax Tree, the CST preserves everything -- whitespace, comments, and punctuation. Every token your source file contains exists somewhere in this tree. You can inspect node kinds using SyntaxKind (for C#) or SyntaxKind from the VB namespace.
Syntax analysis runs early in the compiler pipeline. There is no semantic information available -- you cannot call .GetTypeInfo() or .GetSymbolInfo() at this stage without explicitly requesting the semantic model.
What SyntaxNode Misses
Because syntax analysis runs before name binding, the compiler has not yet resolved identifiers to their declarations. This is the root cause of the false positives and false negatives that plague syntax-only rules -- two expressions that look different in source can mean exactly the same thing, and two that look identical can refer to entirely different types. The biggest gaps are:
- Type resolution -- You cannot distinguish
System.Stringfrom a localStringalias or a customStringclass. - Overload resolution -- You cannot tell which overload of a method is being called.
- Inherited members -- You cannot walk the inheritance hierarchy.
- Implicit conversions and casts -- Compiler-generated operations are not present in the syntax tree.
Best Use Cases for Syntax Analysis
Syntax rules are the right choice when the correctness of the check does not depend on what the code means -- only on what it says. Because no semantic model is required, these rules are the cheapest to run and the easiest to unit-test with raw syntax trees:
- Style and formatting rules (spacing, brace placement, naming patterns by regex)
- Structural pattern enforcement (e.g., "don't use nested ternaries deeper than two levels")
- Comment presence or absence rules
- Detecting specific syntactic constructs regardless of their types
Code Example -- Detecting var in Field Declarations
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoVarInFieldsAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
id: "DL0001",
title: "Do not use var in field declarations",
messageFormat: "Field '{0}' uses var; use an explicit type instead",
category: "Style",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
// Pure syntax -- no semantic model needed
context.RegisterSyntaxNodeAction(AnalyzeFieldDeclaration, SyntaxKind.FieldDeclaration);
}
private static void AnalyzeFieldDeclaration(SyntaxNodeAnalysisContext context)
{
var fieldDecl = (FieldDeclarationSyntax)context.Node;
var typeSyntax = fieldDecl.Declaration.Type;
// IdentifierNameSyntax with identifier "var" is an implicit type
if (typeSyntax is IdentifierNameSyntax { IsVar: true })
{
var variableName = fieldDecl.Declaration.Variables.FirstOrDefault()?.Identifier.Text ?? "unknown";
context.ReportDiagnostic(Diagnostic.Create(Rule, typeSyntax.GetLocation(), variableName));
}
}
}
This rule works entirely at the syntax layer. It does not need to know what type var resolves to -- the presence of var in a field declaration is itself the violation, regardless of the inferred type.
Symbol Analysis: RegisterSymbolAction
Once you need to work with the type system -- inspecting what a class inherits from, checking that a method follows a naming convention based on its return type, or verifying that an attribute is present on a specific symbol -- you move to RegisterSymbolAction.
What the Symbol Layer Exposes
ISymbol is the root interface. Every named element in a C# program has a symbol: namespaces, types, methods, properties, fields, parameters, and local variables. The key derived interfaces for analyzer work are:
INamedTypeSymbol-- Classes, interfaces, structs, enums, delegates. Exposes.BaseType,.Interfaces,.GetMembers().IMethodSymbol-- Methods. Exposes.ReturnType,.Parameters,.IsAsync,.MethodKind.IPropertySymbol-- Properties. Exposes.GetMethod,.SetMethod,.IsIndexer.IFieldSymbol-- Fields. Exposes.IsConst,.IsReadOnly,.AssociatedSymbol.IParameterSymbol-- Method parameters. Exposes.RefKind,.Type,.HasExplicitDefaultValue.
The Roslyn semantic model for type system introspection gives you information that would normally require runtime reflection at compile time -- with zero performance penalty at runtime.
Best Use Cases for Symbol Analysis
Symbol analysis is the right layer when the rule's correctness depends on resolved declarations rather than raw text. The symbol layer already has type-system binding computed by the compiler, so these rules can rely on structural information that would be impossible to derive from syntax alone:
- Naming convention enforcement based on type or modifier (e.g., interfaces must start with
I) - Checking that
sealedtypes do not implement certain interfaces - Verifying attribute presence on specific symbol kinds
- Inspecting a type's members for completeness or correctness
Code Example -- Enforcing Interface Naming
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class InterfaceNamingAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
id: "DL0002",
title: "Interface names must start with I",
messageFormat: "Interface '{0}' does not start with 'I'",
category: "Naming",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
// SymbolKind.NamedType fires for classes, interfaces, structs, enums
context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
}
private static void AnalyzeNamedType(SymbolAnalysisContext context)
{
var type = (INamedTypeSymbol)context.Symbol;
if (type.TypeKind != TypeKind.Interface)
{
return;
}
if (!type.Name.StartsWith("I", StringComparison.Ordinal))
{
// Use the symbol's declaration location for the diagnostic
context.ReportDiagnostic(Diagnostic.Create(
Rule,
type.Locations.FirstOrDefault(),
type.Name));
}
}
}
This would fail at the syntax layer because you would need to know which type declarations are interface declarations -- a determination that requires semantic binding. At the symbol layer, type.TypeKind == TypeKind.Interface gives you that answer directly.
IOperation Analysis in Roslyn: RegisterOperationAction
IOperation in Roslyn -- often called the IOperation Roslyn API -- is the highest-fidelity analysis layer. IOperation Roslyn represents the bound semantic tree: what your code does after all type inference, overload resolution, implicit conversions, and compiler-generated code has been applied. This is the layer that catches real semantic bugs. When developers talk about the Roslyn IOperation API, they mean this bound operation tree.
What IOperation Exposes in Roslyn
The IOperation interface is the root of the IOperation Roslyn tree. Every statement and expression in your code has an IOperation representation in Roslyn. Common types you'll work with:
IInvocationOperation-- A method call. Exposes.TargetMethod(fully resolvedIMethodSymbol),.Arguments,.Instance.IAssignmentOperation-- Assignment. Exposes.Targetand.Value.IBinaryOperation-- Binary operators (+,==, etc.). Exposes.LeftOperand,.RightOperand,.OperatorKind.ILocalReferenceOperation-- A reference to a local variable.IReturnOperation-- A return statement. Exposes.ReturnedValue.IConditionalOperation-- Anif/ternary. Exposes.Condition,.WhenTrue,.WhenFalse.
A key strength of the IOperation Roslyn layer is language agnosticism. The same IInvocationOperation shape is produced whether the code is C# or Visual Basic. This lets you write a single rule that works across both languages.
The relationship between IOperation Roslyn and expression trees vs Roslyn operations is worth understanding: both represent code as data, but expression trees are a runtime construct, while IOperation is a compile-time representation produced by the compiler itself -- making IOperation more complete and accurate.
Best Use Cases for IOperation Roslyn Analysis
- Detecting method calls to specific APIs (e.g.,
Thread.Sleepin async code) - Finding assignments that violate invariants (e.g., assigning a mutable object to a readonly context)
- Tracking data flow across simple expressions
- Cross-language rules for shared libraries
- Any semantic bug pattern that does not reduce to a pure syntax or naming check
The IOperation Roslyn tree is particularly powerful when the same semantic violation can be expressed in multiple syntactic forms -- something that makes syntax-only rules unreliable.
Code Example -- Detecting Thread.Sleep in Async Methods with IOperation Roslyn
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoThreadSleepInAsyncAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
id: "DL0003",
title: "Use Task.Delay instead of Thread.Sleep in async methods",
messageFormat: "Thread.Sleep blocks the thread; use await Task.Delay in async context",
category: "Async",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
// IOperation layer -- fires for every invocation operation in the tree
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
}
private static void AnalyzeInvocation(OperationAnalysisContext context)
{
var invocation = (IInvocationOperation)context.Operation;
var method = invocation.TargetMethod;
// Check the fully-qualified method name -- no string parsing of syntax needed
if (!method.Name.Equals("Sleep", StringComparison.Ordinal))
{
return;
}
if (!method.ContainingType.ToDisplayString().Equals(
"System.Threading.Thread",
StringComparison.Ordinal))
{
return;
}
// Walk up the operation tree to see if we are inside an async method
var containingSymbol = context.ContainingSymbol;
if (containingSymbol is IMethodSymbol { IsAsync: true })
{
context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.Syntax.GetLocation()));
}
}
}
The critical difference from the syntax approach: invocation.TargetMethod gives you the fully resolved IMethodSymbol. There is no ambiguity about whether Sleep refers to System.Threading.Thread.Sleep or some other Sleep method. No string parsing required -- the compiler has already done the resolution work. This is the fundamental advantage of the IOperation Roslyn layer over syntax-based matching.
Combining All Three Layers
Real-world analyzers rarely live at a single layer. A common pattern is to use each layer for what it does best:
- Compilation start action to resolve the attribute type symbol once and register per-block analysis
- Operation block start action (registered inside compilation start) -- checks
blockStart.OwningSymbolto scope analysis to public async methods in[ApiController]types - Operation action (registered inside the block start action) -- detects the target call;
operation.Syntax.GetLocation()provides the diagnostic location
Here is a combined pattern: checking that every public async method in a class marked with [ApiController] does not call Thread.Sleep. This requires all three layers.
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(compilationStart =>
{
// Resolve the attribute symbol once per compilation for efficiency
var apiControllerAttr = compilationStart.Compilation
.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.ApiControllerAttribute");
if (apiControllerAttr is null)
{
return;
}
// Operation layer: RegisterOperationBlockStartAction must be registered on compilationStart,
// not inside a SymbolAnalysisContext callback. Check the containing type inside the block.
compilationStart.RegisterOperationBlockStartAction(blockStart =>
{
if (blockStart.OwningSymbol is not IMethodSymbol
{
IsAsync: true,
DeclaredAccessibility: Accessibility.Public,
ContainingType: INamedTypeSymbol owningType
})
{
return;
}
// Symbol layer check: only fire for types carrying [ApiController]
bool isApiController = owningType.GetAttributes().Any(a =>
SymbolEqualityComparer.Default.Equals(a.AttributeClass, apiControllerAttr));
if (!isApiController)
{
return;
}
blockStart.RegisterOperationAction(opContext =>
{
var invocation = (IInvocationOperation)opContext.Operation;
var method = invocation.TargetMethod;
if (method.Name == "Sleep" &&
method.ContainingType.ToDisplayString() == "System.Threading.Thread")
{
// Syntax layer: IOperation.Syntax gives us the original node for reporting
opContext.ReportDiagnostic(Diagnostic.Create(
Rule,
invocation.Syntax.GetLocation()));
}
}, OperationKind.Invocation);
});
});
}
When choosing your analysis hook, you will notice that nesting registration actions inside RegisterCompilationStartAction gives you access to per-compilation state -- like the resolved apiControllerAttr symbol -- without having to resolve it on every individual invocation. That is the correct architecture for any rule that needs to match against a specific well-known type.
Using the Syntax Visualizer in Visual Studio
Before you can write a syntax-layer rule, you need to know the SyntaxKind to register for. The Syntax Visualizer window in Visual Studio is the fastest way to figure this out.
To open it: go to View → Other Windows → Syntax Visualizer. Click anywhere in your C# source file. The tree will highlight the node your cursor is on and show its SyntaxKind, RoslynType, and all its properties.
Practical tips:
- Click on a node to see its properties in the Property Grid pane. The
RawKindandKindvalues tell you exactly whichSyntaxKindto pass toRegisterSyntaxNodeAction. - Right-click a node and choose "View Directed Syntax Graph" for a visual representation of the subtree.
- Toggle "Enable Item Selection" to have the tree track your cursor position automatically.
- For IOperation inspection during development, set a breakpoint inside an
OperationActioncallback and inspectcontext.Operationin the debugger's watch window -- expand the tree to seeKind,Type, and child operations. There is no dedicated IOperation visualizer; the debugger is the standard tool.
Performance Considerations
Roslyn analyzers run during every keystroke in the editor. Every analysis callback must complete quickly. Here are the patterns that matter most for .NET 10 development.
RegisterCompilationStartAction + Nested Actions
The most important performance pattern is compiling shared state once per compilation and reusing it across nested action registrations:
context.RegisterCompilationStartAction(compilationStart =>
{
// Resolve expensive lookups ONCE per compilation
var threadType = compilationStart.Compilation
.GetTypeByMetadataName("System.Threading.Thread");
if (threadType is null)
{
return; // Library not referenced -- skip the entire analyzer
}
// Register cheap inner actions that reuse the resolved symbol
compilationStart.RegisterOperationAction(opContext =>
{
// threadType is already resolved -- no repeated GetTypeByMetadataName calls
var invocation = (IInvocationOperation)opContext.Operation;
if (SymbolEqualityComparer.Default.Equals(
invocation.TargetMethod.ContainingType, threadType))
{
// ...
}
}, OperationKind.Invocation);
});
Without the outer RegisterCompilationStartAction, every OperationAction callback would need to call compilation.GetTypeByMetadataName(...) -- which is not free and runs once per invocation node in the entire project.
Cancellation Token Awareness
Every analysis callback receives a CancellationToken via the context. Long-running analyzers -- particularly ones that walk large IOperation trees -- should check context.CancellationToken.ThrowIfCancellationRequested() at natural loop boundaries. Roslyn will cancel analysis when the user makes further edits, and stale analysis running beyond its window consumes CPU for no benefit.
private static void AnalyzeBlock(OperationBlockAnalysisContext context)
{
foreach (var operation in context.OperationBlocks)
{
// Check for cancellation on each block -- not every node
context.CancellationToken.ThrowIfCancellationRequested();
WalkOperationTree(operation, context.CancellationToken);
}
}
Per-Compilation vs Per-File Actions
RegisterSyntaxNodeAction and RegisterOperationAction run on every file, including files that are unchanged since the last keystroke. RegisterCompilationStartAction runs once when compilation begins. If your analyzer needs to aggregate data across multiple files (e.g., detecting duplicate enum values across a project), you must use RegisterCompilationStartAction with a mutable concurrent collection and a RegisterCompilationEndAction to report.
Understanding this model is similar to understanding how reflection at runtime works -- both involve inspecting code structure, but Roslyn does it at compile time, which means you need to reason about compilation boundaries rather than runtime lifetimes.
Choosing the Right Layer: Decision Guide
Here is a practical decision tree for picking your analysis approach:
Start with: what is the rule checking?
- Formatting, whitespace, structural patterns, pure text → SyntaxNode layer (
RegisterSyntaxNodeAction) - Naming conventions, type hierarchy, attribute presence, member inspection → Symbol layer (
RegisterSymbolAction) - Method call semantics, argument values, data flow, async patterns, cross-language → IOperation Roslyn layer (
RegisterOperationAction) - Multi-file aggregation, project-wide checks →
RegisterCompilationStartAction+ nested actions +RegisterCompilationEndAction - Precise diagnostic location from semantic analysis → IOperation Roslyn layer to find the violation, then
operation.Syntax.GetLocation()to report it
When in doubt, start at the symbol layer. It resolves the most common need -- "I want to check something about this type or method" -- without the full overhead of IOperation Roslyn analysis.
FAQ
What is IOperation in Roslyn?
IOperation is the interface at the root of Roslyn's bound operation tree. It represents what a statement or expression does semantically -- after type inference, overload resolution, and compiler-generated code have been applied. The IOperation Roslyn tree is language-agnostic, meaning the same interface hierarchy represents both C# and VB.NET code. This makes it ideal for writing analyzers that work across both languages in a shared library.
When should I use RegisterSyntaxNodeAction vs RegisterOperationAction?
Use RegisterSyntaxNodeAction when your rule is purely about the structure or text of code -- spacing, brace style, naming patterns that can be checked by regex without type information. Use RegisterOperationAction (the IOperation Roslyn API) when your rule requires semantic understanding -- knowing the resolved type of an expression, detecting a specific method overload, or catching patterns that can be expressed differently in syntax but mean the same thing semantically. Many semantic rules benefit from the IOperation layer, particularly when the violation can appear in multiple syntactic forms or involves method call resolution.
Can I access the semantic model from a SyntaxNodeAction?
Yes -- SyntaxNodeAnalysisContext exposes a .SemanticModel property. You can call context.SemanticModel.GetTypeInfo(node) or context.SemanticModel.GetSymbolInfo(node) inside a syntax action. However, if you find yourself doing that frequently, you may be better served by moving the rule to the symbol or operation layer instead, which provides cleaner, more structured access to the same information.
What is the difference between ISymbol and IOperation?
ISymbol represents a declaration -- a type, method, field, or parameter as defined in source or metadata. IOperation represents a usage -- a specific call site, assignment, or expression in the code body. You use ISymbol to reason about what something is (its name, modifiers, attributes, containing type). You use IOperation to reason about what something does at a particular location in the code.
How do I navigate from an IOperation back to the SyntaxNode for diagnostics?
Every IOperation exposes a .Syntax property that returns the SyntaxNode it corresponds to. For diagnostics, call operation.Syntax.GetLocation() to get the Location to pass to Diagnostic.Create(...). This is the standard bridge between the semantic and syntax layers when reporting a diagnostic found through IOperation analysis.
Does IOperation Roslyn analysis work with source generators?
IOperation Roslyn analysis runs on source-generated code in the same way it runs on hand-written code, but you must opt in. By default, context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) suppresses IOperation Roslyn analysis on generated files. Change this to GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics if your rule should apply to source-generated output as well.
Conclusion
Roslyn exposes three analysis layers, and picking the right one is a key skill for writing reliable, maintainable analyzers. Syntax analysis is fast and simple -- perfect for style and structure rules. Symbol analysis brings in type resolution and declaration inspection, which covers naming conventions and type hierarchy checks. IOperation Roslyn analysis sits at the top: the IOperation Roslyn tree exposes the fully-bound semantic intent of your code, catches cross-language patterns, and is the right choice for any rule that depends on what code does rather than how it looks.
The path forward is combining all three. Use symbol actions to scope your analysis, IOperation to detect the violation, and syntax to report the right location. Wrap expensive lookups in RegisterCompilationStartAction and always respect the CancellationToken. That pattern covers the vast majority of real-world analyzer rules in .NET 10.
For a broader look at the full analyzer ecosystem, revisit the Roslyn Analyzers guide. And if you are thinking about how compile-time analysis compares to other runtime introspection approaches, the article on expression trees as a reflection alternative is worth a read -- both approaches treat code as data, but they operate at fundamentally different points in the lifecycle.
The content in this article was written by Nick Cosentino, Dev Leader. All code examples have been reviewed for accuracy against .NET 10 and Microsoft.CodeAnalysis.CSharp 4.12. Check out more content at devleader.ca.

