BrandGhost
DiagnosticDescriptor in C#: Configuring Rule IDs, Severity, and Diagnostic Messages

DiagnosticDescriptor in C#: Configuring Rule IDs, Severity, and Diagnostic Messages

Every Roslyn analyzer you build lives or dies by the quality of its DiagnosticDescriptor. Get it wrong and developers either ignore your rule, fight it, or suppress it entirely. Get it right and your rule becomes a trusted part of how teams communicate and enforce coding standards automatically -- during every build, in every IDE session.

DiagnosticDescriptor C# -- the DiagnosticDescriptor class in Microsoft.CodeAnalysis -- defines everything about a diagnostic: its unique identifier, how severe it is, what message to display, whether it fires by default, and where developers can learn more. If you have already seen DiagnosticDescriptor in your analyzer, you know how central this type is to the analyzer lifecycle. This article goes deeper -- covering each constructor parameter in detail, how severity interacts with the build pipeline, how .editorconfig overrides work, and how to write descriptors that your users will actually trust.

If you are new to Roslyn analyzers entirely, the Roslyn Analyzers guide is the right starting point. Come back here when you are ready to move past "it runs" and into "it works well."


DiagnosticDescriptor C# Anatomy

DiagnosticDescriptor lives in the Microsoft.CodeAnalysis namespace and ships as part of the Roslyn SDK. The DiagnosticDescriptor C# primary constructor takes eight parameters, and understanding each one precisely is the difference between a rule that gets adopted and one that gets suppressed on first encounter.

using Microsoft.CodeAnalysis;

// Full constructor -- all eight parameters
var descriptor = new DiagnosticDescriptor(
    id: "DEV001",
    title: "Avoid empty catch blocks",
    messageFormat: "Method '{0}' contains an empty catch block",
    category: "Reliability",
    defaultSeverity: DiagnosticSeverity.Warning,
    isEnabledByDefault: true,
    description: "Empty catch blocks silently swallow exceptions, hiding bugs. " +
                 "Add logging, rethrowing, or an explicit comment explaining why silence is intentional.",
    helpLinkUri: "https://docs.devleader.ca/analyzers/DEV001"
);

Here is what each parameter controls and why it matters:

id -- The unique rule identifier such as DEV001. This is the string developers use in #pragma warning disable DEV001, .editorconfig severity overrides, and [SuppressMessage] attributes. Once published, this value is permanent. Changing it breaks every suppression and configuration entry your users have written.

title -- A short human-readable name for the rule. This appears in IDE rule lists, the Visual Studio Error List, and NuGet package analyzer documentation. Keep it under 60 characters and make it action-oriented -- "Avoid empty catch blocks" is better than "Empty catch block detected."

messageFormat -- The template for the actual diagnostic message shown inline at the violation site. Use composite format placeholders ({0}, {1}, etc.) that are filled in when you call Diagnostic.Create. Specificity here saves developers from having to navigate to your docs just to understand what went wrong.

category -- A string grouping related rules. Common values include "Design", "Reliability", "Performance", "Naming", and "Usage". Roslyn does not enforce specific values, so be consistent within your package. The category appears in IDE rule browsers and helps teams bulk-configure related rules in .editorconfig.

defaultSeverity -- The severity level applied when no .editorconfig override exists. This is your recommended severity for a developer who has not yet thought about the rule.

isEnabledByDefault -- Whether the rule produces diagnostics at all without explicit configuration. Setting this to false makes the rule opt-in, which is important for opinionated or high-noise rules.

description -- Optional but strongly recommended. A longer explanation shown in IDE tooltips and rule documentation. Write it as if explaining the rule to a developer who just saw the diagnostic for the first time and wants to understand why the pattern is problematic.

helpLinkUri -- A URL pointing to stable documentation for the rule. Appears as a clickable "?" link in the IDE. A broken or missing link is one of the fastest ways to erode trust in an analyzer package.

Wiring the DiagnosticDescriptor C# Type Into Your Analyzer

Declare DiagnosticDescriptor instances as static readonly fields on your analyzer class. This ensures they are created exactly once and correctly registered through SupportedDiagnostics:

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class EmptyCatchAnalyzer : DiagnosticAnalyzer
{
    // Static readonly -- created once, shared across all analysis runs
    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        id: "DEV001",
        title: "Avoid empty catch blocks",
        messageFormat: "Method '{0}' contains an empty catch block",
        category: "Reliability",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Empty catch blocks silently swallow exceptions and hide bugs. " +
                     "Add logging, rethrowing, or an explicit comment if silence is intentional.",
        helpLinkUri: "https://docs.devleader.ca/analyzers/DEV001"
    );

    // SupportedDiagnostics is called by the runtime before Initialize
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        // Required by the Roslyn analyzer SDK -- enables safe concurrent execution and skips generated code
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterSyntaxNodeAction(AnalyzeCatchClause, SyntaxKind.CatchClause);
    }

    private static void AnalyzeCatchClause(SyntaxNodeAnalysisContext context)
    {
        var catchClause = (CatchClauseSyntax)context.Node;

        if (catchClause.Block.Statements.Count > 0)
        {
            return; // Not empty -- no violation
        }

        var methodName = catchClause.Ancestors()
            .OfType<MethodDeclarationSyntax>()
            .FirstOrDefault()
            ?.Identifier.Text ?? "(unknown)";

        // Create the diagnostic -- messageFormat placeholder {0} is the method name
        var diagnostic = Diagnostic.Create(
            Rule,
            catchClause.CatchKeyword.GetLocation(),
            methodName
        );

        context.ReportDiagnostic(diagnostic);
    }
}

DiagnosticDescriptor C# Severity Levels: Hidden, Info, Warning, Error

DiagnosticSeverity has four members. Each produces different behavior in the IDE, build output, and CI pipeline -- and choosing the right severity for each rule is a genuine design decision.

// The four DiagnosticSeverity values in .NET 10
DiagnosticSeverity.Hidden   // IDE analysis panel only; invisible in build output
DiagnosticSeverity.Info     // Shown as an informational message; never build-breaking
DiagnosticSeverity.Warning  // Surfaced in IDE and dotnet build; not build-breaking by default
DiagnosticSeverity.Error    // Build-breaking; fails dotnet build and CI immediately

Hidden diagnostics are completely invisible to the build pipeline. They only appear in IDE analysis panels as light-bulb suggestions or code style improvements. Use Hidden for refactoring opportunities or style nudges where you never want to interrupt a build. The IDE can still surface quick-fix code actions from a Hidden diagnostic even though it produces no squiggle.

Info diagnostics appear as informational messages in the error list and dotnet build output, but they do not increase the warning count and never break a build. This works well for documenting deprecated API usage -- surfacing the issue visually without forcing immediate remediation.

Warning is the right choice for lint-style rules. It makes the violation visible in both the IDE and dotnet build output without breaking CI unless the project opts into <TreatWarningsAsErrors>true</TreatWarningsAsErrors>. Most analyzer rules should default to Warning.

Error means dotnet build exits non-zero. Reserve this for rules that catch genuinely broken code -- incorrect security annotations, missing required attributes, or API misuse that guarantees a runtime failure. Applying Error to style or opinion rules is aggressive and will create friction with adoption. Developers will suppress or uninstall an analyzer that breaks their build over a style preference.

A useful mental model: if a developer could ship code that violates the rule and the application would still run correctly in production, default to Warning or lower. If the violation guarantees a runtime exception or security vulnerability, Error is appropriate.


Configuring DiagnosticDescriptor C# Severity via .editorconfig

One of the most powerful features of the DiagnosticDescriptor C# system is that your defaultSeverity is just the starting point. Developers and teams can override it without touching analyzer code, using .editorconfig:

# .editorconfig -- applied to all C# files in this directory and below
[*.cs]

# Upgrade DEV001 from Warning to Error -- this project treats empty catches as build failures
dotnet_diagnostic.DEV001.severity = error

# Downgrade a noisy rule to suggestion (light-bulb only)
dotnet_diagnostic.DEV002.severity = suggestion

# Silence a rule entirely in this project
dotnet_diagnostic.DEV003.severity = silent

# Turn off a rule so it produces no output at all
dotnet_diagnostic.DEV004.severity = none

The valid .editorconfig severity values map to the following behaviors:

Value IDE behavior Build behavior
error Red squiggle Fails build
warning Yellow squiggle Appears in output; does not fail build
suggestion Light-bulb only No build output
silent No squiggle No build output
none Not shown Not emitted
default Reverts to defaultSeverity Reverts to defaultSeverity

This system works in Visual Studio 2019+, Rider, and dotnet build without any additional configuration beyond having a compatible SDK. The toolchain reads .editorconfig files automatically, including per-directory inheritance -- a subfolder's .editorconfig can tighten or relax rules set at the root level.

Because teams can tune DiagnosticDescriptor C# severity through .editorconfig, your job as an analyzer author is to set defaultSeverity to the most sensible value for a developer who has never thought about the rule yet. Consider the range of codebases and teams that might install your analyzer package and set the default accordingly.


DiagnosticDescriptor C# Rule ID Conventions

DiagnosticDescriptor C# rule IDs are permanent identifiers. Once you publish a rule ID, changing it is a breaking change for every user of your package. Any #pragma warning disable, .editorconfig entry, or [SuppressMessage] attribute in user code that references the old ID stops working silently.

Naming Conventions

A rule ID consists of a prefix (identifying your analyzer package) and a number (uniquely identifying the rule within it):

DEV001   -- "DEV" prefix for a DevLeader-style package
CA1234   -- Microsoft's Roslyn code analysis prefix
IDE0045  -- IDE-specific analyzer rules
CS0168   -- C# compiler diagnostic
SA1101   -- StyleCop Analyzers prefix
MA0001   -- MetaAnalyzer prefix

Choose a prefix that is short (2--5 characters), unique to your package, and does not conflict with established prefixes. Before committing to a prefix, search NuGet and the Roslyn Analyzers catalog for existing packages using it.

Padding and Grouping Numbers

Within your package, pad numbers to at least three digits and leave gaps so you can insert related rules later:

// Organized numbering -- leave room for future rules in each category
// DEV001--DEV009: Core reliability rules
// DEV010--DEV019: Naming convention rules
// DEV020--DEV029: Performance rules
// DEV030--DEV049: API design rules (wider gap for a larger category)

private static readonly DiagnosticDescriptor EmptyCatchRule = new(
    id: "DEV001", // Reliability group
    ...
);

private static readonly DiagnosticDescriptor NullCheckRule = new(
    id: "DEV002", // Reliability group
    ...
);

private static readonly DiagnosticDescriptor MutablePublicFieldRule = new(
    id: "DEV010", // Naming / design group
    ...
);

Applying single responsibility in code rules to your rule IDs means each ID group has one clear concern. Reviewers and users can scan your ID space and immediately understand what category of issues each range addresses.


Localizable Messages with DiagnosticDescriptor C#

For analyzer packages targeting enterprise teams or global audiences, DiagnosticDescriptor C# supports LocalizableString for the title, messageFormat, and description parameters. This lets you drive diagnostic strings from resource files rather than hard-coding them -- enabling multilingual support without changing analyzer code.

Setting Up Resource Files

Add a resource file to your analyzer project (Resources.resx) and define string entries:

EmptyCatch_Title    = Avoid empty catch blocks
EmptyCatch_Message  = Method '{0}' contains an empty catch block
EmptyCatch_Desc     = Empty catch blocks silently swallow exceptions and hide bugs.
                      Add logging, rethrowing, or an explicit comment.

Then reference the resource file using LocalizableResourceString:

using Microsoft.CodeAnalysis;
using System.Resources;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class EmptyCatchAnalyzer : DiagnosticAnalyzer
{
    // Resource-backed localizable strings
    private static readonly LocalizableString Title =
        new LocalizableResourceString(
            nameof(Resources.EmptyCatch_Title),
            Resources.ResourceManager,
            typeof(Resources));

    private static readonly LocalizableString MessageFormat =
        new LocalizableResourceString(
            nameof(Resources.EmptyCatch_Message),
            Resources.ResourceManager,
            typeof(Resources));

    private static readonly LocalizableString Description =
        new LocalizableResourceString(
            nameof(Resources.EmptyCatch_Desc),
            Resources.ResourceManager,
            typeof(Resources));

    // DiagnosticDescriptor accepts LocalizableString for title, messageFormat, description
    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        id: "DEV001",
        title: Title,
        messageFormat: MessageFormat,
        category: "Reliability",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: Description,
        helpLinkUri: "https://docs.devleader.ca/analyzers/DEV001"
    );

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
        => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterSyntaxNodeAction(AnalyzeCatchClause, SyntaxKind.CatchClause);
    }

    private static void AnalyzeCatchClause(SyntaxNodeAnalysisContext context)
    {
        // ... analysis logic unchanged
    }
}

This pattern mirrors how attribute-based configuration works in .NET -- you define metadata declaratively once and the runtime resolves it at the point of use, without coupling the caller to the storage mechanism.

When to Use Localizable Strings

For most open-source or internal packages targeting English-speaking teams, hard-coded strings are perfectly fine and easier to maintain. Reach for LocalizableResourceString when:

  • You are distributing the analyzer to a global audience across multiple languages
  • Your organization's development teams span regions with different primary languages
  • You want to isolate diagnostic text from analyzer logic for future localization without code changes

The ResourceManager handles thread-safe lazy resolution automatically -- no extra work required once the resource file is wired in.


Enabling and Disabling DiagnosticDescriptor C# Rules by Default

isEnabledByDefault determines whether the DiagnosticDescriptor C# rule fires when no .editorconfig configuration is present. Setting it to false makes the rule opt-in -- it exists in your package and will be listed in IDE rule browsers, but it produces no diagnostics unless a developer explicitly enables it.

// Opt-in rule -- only fires when a developer sets severity in .editorconfig
private static readonly DiagnosticDescriptor PrimaryConstructorRule = new DiagnosticDescriptor(
    id: "DEV050",
    title: "Consider using a primary constructor",
    messageFormat: "Type '{0}' could be simplified with a primary constructor (C# 12+)",
    category: "Style",
    defaultSeverity: DiagnosticSeverity.Info,
    isEnabledByDefault: false,   // opt-in -- not everyone has adopted C# 12+ style yet
    description: "Primary constructors reduce boilerplate for simple dependency injection scenarios. " +
                 "Consider enabling this rule after your team has agreed to the pattern.",
    helpLinkUri: "https://docs.devleader.ca/analyzers/DEV050"
);

To enable an opt-in rule, users add a single line to their .editorconfig:

[*.cs]
dotnet_diagnostic.DEV050.severity = suggestion

When to Set isEnabledByDefault = false

Use isEnabledByDefault: false when:

  • The rule is opinionated -- valid, correct code can violate it. Many style preferences fall here.
  • The rule is experimental -- you want early feedback but do not want to break CI for existing users.
  • The rule is high-noise -- it fires frequently in typical codebases and most teams would disable it anyway.
  • The rule requires team consensus -- enforcing an architectural pattern that only makes sense after a team discussion about that pattern.
  • The rule targets a newer language version -- like primary constructors or collection expressions. Teams on older C# versions should not be flagged for not using features they cannot use.

A practical heuristic: if you are genuinely unsure whether most adopters would want a rule enabled out of the box, start with isEnabledByDefault: false. You can always flip it to true in a later version. Going the other direction -- from true to false -- is technically safe but may confuse teams who relied on the default behavior and suddenly see the rule go silent after an upgrade.


Describing Your DiagnosticDescriptor C# Rule for Users

The quality of a DiagnosticDescriptor C# rule's documentation directly determines its adoption rate. A DiagnosticDescriptor with a clear description, an accurate helpLinkUri, and a specific messageFormat will be understood and trusted. A descriptor with a cryptic message and a missing link will be suppressed and eventually removed from the project's analyzer list.

Writing Good Diagnostic Messages

The messageFormat appears directly at the violation site -- this is usually the first and only thing a developer reads. It should:

  • Name the specific element that violated the rule ('{0}' for method name, type name, etc.)
  • Be specific enough to understand without reading the documentation
  • Avoid jargon unless your intended audience are all familiar with it
// ❌ Vague -- says nothing useful
messageFormat: "Code quality issue detected"

// ❌ Technical jargon without context
messageFormat: "Symbol '{0}' violates semantic rule DEV001"

// ✅ Specific -- names the element and the exact problem
messageFormat: "Method '{0}' contains an empty catch block -- add logging, rethrowing, or a comment"

// ✅ Specific with two context parameters
messageFormat: "Property '{0}' on type '{1}' is mutable but exposed through a public getter"

The helpLinkUri Pattern

Every rule should have a helpLinkUri pointing to a stable URL with at minimum:

  1. The rule ID and title
  2. What the rule checks and why the pattern is problematic
  3. A minimal example that triggers the rule
  4. The correct pattern after fixing the violation
  5. Known false positives or caveats
// Documentation-first rule design -- write the docs page before publishing the NuGet package
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
    id: "DEV001",
    title: "Avoid empty catch blocks",
    messageFormat: "Method '{0}' contains an empty catch block",
    category: "Reliability",
    defaultSeverity: DiagnosticSeverity.Warning,
    isEnabledByDefault: true,
    description: "Empty catch blocks silently discard exceptions. " +
                 "Add logging, rethrowing, or an explicit comment explaining " +
                 "why the exception is intentionally ignored.",
    helpLinkUri: "https://docs.devleader.ca/analyzers/DEV001"   // must resolve before publish
);

A broken helpLinkUri is one of the fastest ways to lose developer trust. If a developer clicks the link and receives a 404, the next action is usually to suppress the rule and move on. Adopt a documentation-first practice: write the rule documentation page before publishing the NuGet package.

The category parameter also contributes to discoverability. IDEs group rules by category in rule configuration panels. A consistent, meaningful category (not just "General" for everything) helps developers find and reason about your rules as a set rather than individual isolated diagnostics.


These DiagnosticDescriptor C# fundamentals apply across all Roslyn analyzer scenarios. The Roslyn Analyzers guide covers how descriptors fit into the broader analyzer architecture, and DiagnosticDescriptor in your analyzer shows how to connect descriptors to the full DiagnosticAnalyzer lifecycle, registration callbacks, and symbol analysis.

For the Microsoft official reference, the DiagnosticDescriptor API documentation and the Roslyn SDK analyzer walkthrough are the canonical sources for .NET 10 specifics.


Frequently Asked Questions

What is DiagnosticDescriptor C# and why does it matter?

DiagnosticDescriptor is the class in Microsoft.CodeAnalysis that defines all metadata for a single Roslyn diagnostic rule -- its ID, title, message template, category, severity, and help link. Every diagnostic your analyzer reports must reference a descriptor. The descriptor is how the IDE, build output, .editorconfig system, and #pragma warning directives all coordinate to label, display, and configure your rule consistently.

How do I set the DiagnosticDescriptor C# severity in .editorconfig?

Use the dotnet_diagnostic.<RULEID>.severity key in your .editorconfig file. For example:

dotnet_diagnostic.DEV001.severity = error

Valid values are error, warning, suggestion, silent, none, and default. This override takes precedence over the defaultSeverity set in the DiagnosticDescriptor constructor and applies to the entire directory and any subdirectories unless overridden again.

What is the difference between DiagnosticSeverity.Warning and DiagnosticSeverity.Error?

Warning surfaces the diagnostic in the IDE and dotnet build output but does not fail the build unless the project sets <TreatWarningsAsErrors>true</TreatWarningsAsErrors>. Error always fails the build immediately. Use Warning for rules where valid, working code can trigger the violation. Use Error only when the violation guarantees broken or dangerous behavior -- for example, a missing required security attribute that will cause an access control failure at runtime.

When should I use isEnabledByDefault = false for a DiagnosticDescriptor?

Use isEnabledByDefault: false for opinionated, experimental, or high-noise rules -- situations where a developer who has not thought about the rule would prefer it stays silent by default. Style preferences, architectural patterns that require team consensus, and rules targeting C# features newer than your minimum supported language version are all good candidates. Teams that want the rule can enable it in .editorconfig without it being imposed on everyone else.

How do I choose a rule ID prefix for my Roslyn analyzer?

Pick a short (2--5 character) prefix that does not conflict with established prefixes such as CA (Roslyn code analysis), CS (C# compiler), IDE (IDE analyzers), SA (StyleCop), or MA (MetaAnalyzer). Search NuGet and the Roslyn Analyzers GitHub catalog to verify your prefix is not already in use. Once chosen, never reuse or rename rule IDs after publishing -- the ID is permanent from the moment the first user adds a #pragma warning disable referencing it.

Can I change a DiagnosticDescriptor's defaultSeverity after publishing a NuGet package?

Yes, but treat any increase in severity as a breaking change. Upgrading from Warning to Error will break CI for any project that was previously tolerating the warning. Decreasing severity (e.g., Warning to Info) is generally safe. The most critical constraint is the id itself -- never change or reuse a rule ID. All other parameters can be updated across versions, though significant changes to messageFormat may confuse developers who have memorized the previous wording.

How does .editorconfig roslyn severity interact with TreatWarningsAsErrors?

They are independent and cumulative. If a rule's effective severity is warning (either by default or from .editorconfig) and the project has <TreatWarningsAsErrors>true</TreatWarningsAsErrors>, the build will fail. If the effective severity is suggestion or silent, TreatWarningsAsErrors has no effect. Setting dotnet_diagnostic.RULEID.severity = error in .editorconfig makes the rule build-breaking regardless of TreatWarningsAsErrors. This layering means teams can use .editorconfig to precisely control which Roslyn diagnostics are build-breaking without modifying the project file.


This article was authored by Nick Cosentino, Principal Engineering Manager at Microsoft and content creator at Dev Leader. Nick writes about C#, .NET, software architecture, and engineering leadership.

Distributing Roslyn Analyzers as NuGet Packages in .NET

Learn to package and distribute your roslyn analyzer nuget package with the right folder structure, metadata, versioning, and CI/CD automation in .NET 10.

Roslyn Analyzers in C#: The Complete Guide

Learn what roslyn analyzers are, how they work in the .NET 10 compiler pipeline, why teams build custom diagnostics, and how to write your first rule in C#.

Build Your First Roslyn Analyzer in C#: Diagnostics and Code Fix Walkthrough

Build roslyn analyzer c# from scratch -- complete project setup, DiagnosticAnalyzer scaffold, CodeFixProvider implementation, debugging, and unit tests.

An error has occurred. This application may no longer respond until reloaded. Reload