Nullable Reference Types: Avoiding NullReferenceException

Enable nullable reference types in C#: nullability annotations, the ! operator, required members, nullability attributes, and a legacy migration path.

Executive Summary: Nullable reference types add a question mark to a promise: this value is never null, and the compiler enforces the claim as you write the code. Enabling the feature changes no runtime behavior; it turns the most common exception in .NET, NullReferenceException, into a set of compile-time warnings that point at the exact line where a null can slip through.

The bug report says: 500 error, stack trace, one line of your code, NullReferenceException: Object reference not set to an instance of an object. No parameter names, no hint which reference was null, and nothing about why. Every .NET developer has paid this tax, usually at hours when nobody wants to read stack traces, and the language feature designed to collect it shipped years ago. It costs one line in the project file, and most teams still run without it.

Nullable reference types is a compile-time analysis feature: with the feature enabled, string means “not null, guaranteed by the compiler”, string? means “may be null, check before dereferencing”, and the compiler warns on any path where the guarantee could break. It is not a runtime type change and adds no runtime checks. The nullable reference types guide on Microsoft Learn is the full reference.

The examples run on .NET SDK 10.0 with C# 14, where new project templates enable the feature by default. Older projects opt in explicitly. The difference between a nullable reference type and a nullable value type, string? versus int?, matters here, and the C# basics guide covers that memory model background.

Migrating a few legacy services convinced me of one thing: nullable warnings are the highest-value warnings in the language. Teams that treat them as noise are leaving their crash reports on. However, the migration has a right way and a wrong way. Doing it with ! as a mute button is the wrong one.

What nullable reference types are, precisely

The feature introduces two flavors of every reference type. With the feature on, string is the non-nullable flavor. The compiler assumes it never holds null and warns when code could make it null. Appending ? gives the nullable flavor, string?: code must check before dereferencing. Both compile to the same runtime type, System.String; the ? exists for the compiler’s analysis, not for the CLR.

public class Project
{
    public string Name { get; set; }        // warning-free: never null, promised
    public string? Description { get; set; } // nullable: callers must check
}

The compiler reads every assignment and branch, narrowing what it knows as the code runs. That is called flow analysis, and it is why the feature feels like a reviewer rather than a linter:

void Print(string? title)
{
    Console.WriteLine(title.Length);          // CS8602: possibly null

    if (title is not null)
    {
        Console.WriteLine(title.Length);      // safe: flow analysis knows
    }
}

Enabling the feature

In a project file, one property, and every file in the project is analyzed. New templates since .NET 8 write it for you; older projects add it manually:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Three modes exist: enable, disable, and warnings (analysis runs, annotations are ignored). A single file can opt out with #nullable disable at the top, which is the migration escape hatch. Teams that want the guarantee promoted to law add TreatWarningsAsErrors so a nullable warning fails the build instead of waiting in a list nobody reads.

The warnings that matter most

Four warnings cover most daily encounters. Knowing them by code makes the output readable:

Warning The compiler is telling you Typical fix
CS8602 You dereference a value that may be null Check for null first, or use ?. and ??
CS8600 A nullable value is being stored into a non-nullable slot Guard the value or widen the type
CS8625 You assigned a null literal to a non-nullable variable Make the type nullable, if null is honest
CS8618 A non-nullable property leaves the constructor still null Initialize it, make it required, or assign a default

The last one deserves special attention because it is the one the template generates for every new class you write before you set a value. It is also the warning that pushes you toward the constructor discipline covered in the object-oriented C# guide: an object should not exist half-initialized.

The ! operator: trust, not proof

The null-forgiving operator, !, tells the compiler “I know this is not null, stop warning”. It compiles to nothing and proves nothing; it removes the warning and keeps the risk:

string title = maybeNull!;   // CS8600 silenced; if maybeNull IS null,
                              // title is null and a crash is scheduled

Legitimate uses exist: test setup where you control the input, or a value the API contract guarantees but the type system cannot see. The rule is that every ! should be defensible in review, in one sentence, from the person who wrote it. A migration that replaces thinking with ! produces code that compiles and crashes exactly as before. The feature only pays when the warnings are answered with types and checks.

Nullability attributes: teaching the analyzer your contracts

Some invariants live in method semantics, not types: TryGetValue returns true only when the out value exists. The nullability attributes express those contracts to the compiler:

public bool TryFind(Guid id, [MaybeNullWhen(false)] out TaskItem item)
{
    // returns true with a non-null item, false with null
}

The ones you will actually use: [NotNullWhen] marks a method’s return value as a null guarantee for a parameter (the pattern behind string.IsNullOrEmpty), [MaybeNullWhen] is its inverse, and [AllowNull] permits assigning null into a non-nullable property when the setter treats it specially. These attributes are why the base class library feels smart after enabling nullable types. The entire .NET API surface is annotated.

required: construction must be complete

CS8618 has three fixes, and the newest is the required keyword (C# 11). The compiler refuses to construct the type without the member being set:

public record CreateTaskRequest
{
    public required string Title { get; init; }
    public string? Description { get; init; }
}

var request = new CreateTaskRequest { Title = "Ship the API" };  // fine
var broken  = new CreateTaskRequest();                          // compile error

required plus init gives you immutable-by-default payloads that cannot exist half-built, which is why the DTO patterns in the Web API guide lean on it. The required members reference documents the exact rules, including SetsRequiredMembers for constructors that satisfy the requirement.

Migrating a legacy codebase

Enabling nullable analysis on an old project produces hundreds of warnings on day one, which is the feature showing you the bug list you already had. The staged path that works:

  1. Enable analysis per file or per directory. Add #nullable enable at the top of files you are touching, or migrate directory by directory, leaving the project-level Nullable property for last.
  2. Fix by type, not by warning. Start with entities and domain types; their nullability decisions ripple outward through services. ? where null is honest, constructor or required where it is not.
  3. Answer every warning with code. A null check, a ?? default, a guard clause, or a type change. Reach for ! only when you can state the invariant out loud.
  4. Promote to errors when the count reaches zero. TreatWarningsAsErrors with the nullable warnings in scope, so regressions cannot re-enter through a pull request.

Two edges deserve respect during migration. Deserialization bypasses the compiler: a JSON payload can deliver null into a non-nullable property at runtime, so API boundaries validate input (the model validation covered in the Web API guide). Third-party libraries compiled without annotations interact with your code as “oblivious”, producing warnings you cannot fully resolve at their boundary. The escape hatch there is a local #nullable disable region or a thin annotated wrapper.

How real systems do this

  • Day-one enablement on new projects. Templates already set Nullable to enable. Serious teams keep it that way and add TreatWarningsAsErrors early, while the warning count is small.
  • Guard clauses with ArgumentNullException.ThrowIfNull. Public entry points validate incoming references in one line. The method throws ArgumentNullException with the parameter name before anything else runs.
  • Database nullability matches property nullability. An entity property marked non-nullable sits on a NOT NULL column; a nullable column maps to string?. The EF Core code-first guide shows the mapping, because a mismatch between column and property nullability is a runtime surprise generator.

A migration I have seen pay off: enabling nullable analysis on a mature service surfaced over four hundred warnings, and roughly one in ten marked places where production had already crashed with NullReferenceException or would soon. The team spent two weeks, mostly adding checks and constructor initialization, and crash alerts from that service dropped noticeably afterward. What stuck with me: the warnings are not pedantry; they are a map of your past incidents.

Decision framework: annotate, check, or trust

  1. Can the value genuinely be null? Make the type T? and force callers to check. A non-nullable annotation on a value that can be null is a lie the compiler will eventually catch.
  2. Can you guarantee non-null at construction? Constructor assignment or required; CS8618 disappears with a guarantee instead of a shrug.
  3. Is the invariant invisible to the compiler? A nullability attribute: [NotNullWhen], [MaybeNullWhen], or [AllowNull], documenting what the method really promises.
  4. Do you know more than the compiler, honestly? A single ! with a comment or a stated invariant. If you cannot state the reason, you do not know more than the compiler.
  5. Is this an unannotated third-party boundary? Isolate it in a wrapper with explicit checks. As a result, the rest of your code stays warning-clean without lies.

When NOT to lean on the machinery

  • When deserialization owns the values. JSON, query strings, and form bodies deliver runtime data the compiler never sees. Validation at the boundary, not annotations, is what protects request DTOs.
  • When a type should not exist half-initialized at all. Instead of optional properties plus null checks, redesign to a constructor (or required members) that makes invalid states unrepresentable. Fewer warnings beat suppressed warnings.
  • When migrating under a deadline. Enabling project-wide nullable analysis the week before a release turns a safety feature into a stress test. Stage it per directory, and finish the promotion to errors when there is room to fix what it finds.

Common mistakes

  • Using ! as a mute button. The null-forgiving operator silences the warning and keeps the crash. Every ! should carry a one-sentence justification in review.
  • Assuming the feature enforces nulls at runtime. Annotations are compile-time only; deserialization, reflection, and unannotated libraries can still deliver null, so public boundaries validate.
  • Confusing string? with int?. A nullable value type, int?, is a real runtime wrapper (Nullable<int>). A nullable reference type, string?, is an annotation. Same syntax, entirely different mechanism.
  • Fixing CS8618 with defaults. Assigning string.Empty to silence the warning hides a missing value behind an indistinguishable one. Prefer constructor assignment or required.
  • Widening everything to nullable. Marking every property T? to clear warnings pushes the burden onto every caller forever. Make non-null the default and justify each ?.
  • Skipping the promotion to errors. Warnings left as warnings decay into wallpaper. TreatWarningsAsErrors keeps the guarantee real for every future change.

Key takeaways

  • Nullable reference types are compile-time flow analysis: T promises non-null, T? admits possible null, and the compiler warns where the promise could break.
  • Enable with <Nullable>enable</Nullable> (already the template default) and consider promoting warnings to errors once clean.
  • CS8602, CS8600, CS8625, and CS8618 cover most daily warnings; read them as the compiler describing the bug it prevented.
  • ! removes warnings, not risk; use it rarely, with a stated invariant.
  • required members and constructor assignment make invalid states unrepresentable instead of annotated.
  • Migrate legacy code in stages: per file or directory, fix types first, then promote to errors; validate at deserialization boundaries regardless.

FAQ

What are nullable reference types in C#?

A compile-time analysis feature where a plain reference type, like string, is treated as never null, and appending a question mark, string?, marks a value that may be null. The compiler warns whenever a null could reach a non-nullable slot or a nullable value is dereferenced without a check. Nothing changes at runtime; the annotations guide the compiler’s checks.

Do nullable reference types exist at runtime?

No. string and string? compile to the same System.String type. The question mark is consumed by the compiler during analysis and disappears from the compiled code. That is why runtime sources, such as deserialized JSON or reflection, can still deliver null into non-nullable properties. Boundaries need validation.

How do I enable nullable reference types in an existing project?

Add the Nullable property set to enable in the project file, or add #nullable enable at the top of individual files to migrate gradually. Expect a burst of warnings on legacy code: each one marks a spot where a null could flow in. The staged fix is types first, checks second, warnings-as-errors last.

What does the ! operator do in C#?

The null-forgiving operator tells the compiler to stop warning about a possible null at that spot. It emits no runtime check, so it removes the warning while keeping the risk. Use it only when you can state why the value cannot be null, such as a contract the compiler cannot see.

What does the required keyword mean in C#?

Added in C# 11, required marks members that must be set during object construction or the code does not compile. On records and classes with init-only properties it makes half-built objects impossible, which resolves the uninitialized-property warning at its root instead of papering over it.

Make the compiler prove the nulls are gone

Null safety in C# is not a runtime discipline anymore; it is a type discipline, checked while you write. Enable the analysis, answer warnings with constructors, required, and honest ? annotations, and reserve ! for the rare case you can defend out loud. With the language track done, the series turns to ASP.NET Core, starting with the middleware request pipeline, and every guide lives on the .NET series index. The rule of thumb: annotate honestly, check deliberately, and never let a null warning ride to production unexplained.

Last updated on 23 October 2026

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *