Nullable Reference Types in C#: Enable Them and Fix the Warnings

The most common mistake when a team turns on C# nullable reference types is silencing every warning with the ! operator. The build goes quiet, but the app still crashes, because ! only hides the warning:

var tyler = FindCustomer("TX-1042");
Console.WriteLine(tyler!.Email!.ToUpper());

static Customer? FindCustomer(string id) =>
    id == "TX-1042" ? new Customer("Tyler Brooks", null) : null;

record Customer(string Name, string? Email);
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<Main>$(String[] args) in C:\DotNetDemo\NullableDemo\Crash.cs:line 2

Quick answer: keep <Nullable>enable</Nullable> on, mark values that can be missing as string?, and fix each warning with a null check, ??, a constructor or a required member.

Below is the playbook we follow on .NET 10, with real compiler output for each warning.

What You Need Before You Turn On Nullable Reference Types

  • The .NET 10 SDK. Every demo is a single-file app (how dotnet run app.cs works), plus one small project.
  • The sample: a US customer and order book (Tyler Brooks in Austin, Jenna Morales, ZIP codes, shipping addresses).
  • One habit: nullable warnings print only when the compiler really runs. A cached dotnet run prints nothing, so I use dotnet run --no-cache File.cs or dotnet build --no-incremental.

Step 1: Enable Nullable in the .csproj (c# nullable enable)

New projects from the .NET 6 and later templates already have this line, and .NET 10 file-based apps turn it on by default. Older projects need it added to the PropertyGroup (not sure which SDK you’re on? check your .NET version first):

<Nullable>enable</Nullable>

The setting takes four values, and the same names work in a #nullable directive at the top of a file:

Value? annotationsWarningsWhen we use it
enableOnOnEvery new project; the end goal
disableOffOffOld default; legacy code you aren’t touching
warningsOffOnPhase 1 of a migration: find real null bugs first
annotationsOnOffLibrary authors annotating a public API first

#nullable enable, #nullable disable and #nullable restore override the project value for the lines that follow; restore goes back to the .csproj setting.

Does string? Change Anything at Run Time?

No. A nullable reference type is a compile-time annotation only. string and string? are the same System.String, and nothing stops a null from getting in:

string name = "Jenna Morales";
string? nickname = null;

Console.WriteLine(name.GetType() == typeof(string));
Console.WriteLine(nickname is null);

string forced = null!;            // no warning, still null
Console.WriteLine(forced is null);

Output:

True
True
True

All three lines print True. That’s the key difference from nullable value types like int?, where int? really is a different type (Nullable<int>) with HasValue and Value.

Step 2: Read the Six Nullable Warnings You’ll See First

I packed five of the six warnings our team hits most into one file; CS8618 gets its own step. They only apply to reference types like string and classes, and each line is marked with the warning it causes:

string zip = LookupZip("Boise");            // CS8600
string? city = LookupZip("Boise");
Console.WriteLine(city.Length);             // CS8602
string? label = LookupZip("Boise");
PrintLabel(label);                          // CS8604
PrintLabel(null);                           // CS8625
Console.WriteLine(Shorten(zip));

static string? LookupZip(string city) => city == "Boise" ? "83702" : null;
static string Shorten(string? text) => text; // CS8603
static void PrintLabel(string text) => Console.WriteLine($"Label: {text}");

Output:

C:\DotNetDemo\NullableDemo\AllWarnings.cs(1,14): warning CS8600: Converting null literal or possible null value to non-nullable type.
C:\DotNetDemo\NullableDemo\AllWarnings.cs(3,19): warning CS8602: Dereference of a possibly null reference.
C:\DotNetDemo\NullableDemo\AllWarnings.cs(5,12): warning CS8604: Possible null reference argument for parameter 'text' in 'void PrintLabel(string text)'.
C:\DotNetDemo\NullableDemo\AllWarnings.cs(6,12): warning CS8625: Cannot convert null literal to non-nullable reference type.
C:\DotNetDemo\NullableDemo\AllWarnings.cs(10,40): warning CS8603: Possible null reference return.
5
Label: 83702
Label: 
83702
C# nullable reference type warnings CS8600, CS8602, CS8604, CS8625 and CS8603 printed by dotnet run --no-cache in the VS Code terminal on .NET 10
One file, five nullable warnings, and the program still runs

Every one is a warning, not an error, so the program still printed 5 and the labels. That’s why warnings pile up unless the team treats them as work.

WarningWhat the compiler is telling you
CS8600You store a maybe-null value in a non-nullable variable
CS8602You dereference (.Length, a method call) something that might be null
CS8603A method declared to return string might return null
CS8604You pass a maybe-null argument to a non-nullable parameter
CS8618A non-nullable property or field is still null when the constructor ends
CS8625You pass or assign the null literal to a non-nullable type

Step 3: Fix CS8600, CS8602, CS8603, CS8604 and CS8625

Same ZIP lookup, now fixed. Reno isn’t in the lookup, so this run also proves the fallbacks work:

string zip = LookupZip("Reno") ?? "00000";        // CS8600: ??
string? city = LookupZip("Reno");
Console.WriteLine(city?.Length ?? 0);             // CS8602: ?.
string? label = LookupZip("Boise");
if (label is not null) PrintLabel(label);         // CS8604: null check
PrintLabel(zip);                                  // CS8625: pass a real value
Console.WriteLine($"[{Shorten(null)}]");          // CS8603: fallback

static string? LookupZip(string city) => city == "Boise" ? "83702" : null;
static string Shorten(string? text) => text ?? "";
static void PrintLabel(string text)
{
    ArgumentNullException.ThrowIfNull(text);      // guard public APIs
    Console.WriteLine($"Label: {text}");
}

Output:

0
Label: 83702
Label: 00000
[]

The build is clean. Reno’s city length falls back to 0, Boise prints its label, Reno’s ZIP becomes 00000, and Shorten(null) returns an empty string. The ways to check for null in C# cover is null versus == null in detail.

Our rule of thumb: use ?? when there’s a sensible default, ?. when skipping is fine, and a null check when the logic branches. For text that also can’t be blank, string.IsNullOrEmpty does both checks, and the compiler understands it.

Note: annotations don’t stop callers with nullable turned off from passing null. Public methods still need ArgumentNullException.ThrowIfNull.

Step 4: Fix CS8618 with a Constructor, required or a Default

CS8618 is the warning that floods a project on day one, mostly from model classes like this one:

var contact = new Contact();
Console.WriteLine(contact.Name is null);

class Contact
{
    public string Name { get; set; }
}

Output:

C:\DotNetDemo\NullableDemo\Cs8618.cs(6,19): warning CS8618: Non-nullable property 'Name' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
True

The warning even suggests the fixes, and the second line proves the point: Name really is null. There are four honest fixes, and I put all four in one file:

var tyler = new Customer("Tyler Brooks", "tyler@example.com");
var jenna = new Contact { Name = "Jenna Morales" };

Console.WriteLine($"{tyler.Name} <{tyler.Email}>");
Console.WriteLine($"{jenna.Name}, {jenna.Phone ?? "no phone"}");
Console.WriteLine($"Notes: '{jenna.Notes}'");

class Customer(string name, string email)   // 1
{
    public string Name { get; } = name;
    public string Email { get; } = email;
}

class Contact
{
    public required string Name { get; init; }  // 2
    public string? Phone { get; init; }         // 3
    public string Notes { get; set; } = "";     // 4
}

Output:

Tyler Brooks <tyler@example.com>
Jenna Morales, no phone
Notes: ''
C# CS8618 fixed four ways in VS Code: primary constructor, required member, nullable string? property and a default value, with clean dotnet run output
Four fixes for CS8618, and the build prints no warnings

In order: 1 is a constructor for values known at creation, 2 is required (C# 11+) for object-initializer models, 3 is string? for values that can really be missing, and 4 is a default like "", only when empty means something.

required moves the check to the caller. Forget the property and you get a compile error instead of a null:

var contact = new Contact();
Console.WriteLine(contact.Name);

class Contact
{
    public required string Name { get; init; }
}

Build output:

C:\DotNetDemo\NullableDemo\RequiredMissing.cs(1,19): error CS9035: Required member 'Contact.Name' must be set in the object initializer or attribute constructor.

The build failed. Fix the build errors and run again.

That’s CS9035, and it’s exactly what we want: the missing name is caught at build time, not in production.

Step 5: Use the Null-Forgiving Operator (!) Only as a Last Resort

The ! in the opening example tells the compiler “trust me”. It changes nothing at run time, which is why Tyler’s missing email still threw NullReferenceException.

C# null-forgiving operator ! hiding nullable warnings while dotnet run still throws System.NullReferenceException for a missing customer email, in VS Code
The ! operator silences the warning, not the crash

We allow ! in two places only: tests that pass null! on purpose, and members a framework fills in, like an EF Core DbSet<T> declared = null! (our Web API CRUD with EF Core build skips even that with => Set<Product>()).

Everywhere else, a pull request with ! needs a comment saying why.

Step 6: Teach the Compiler with [NotNullWhen], [MaybeNull] and [MemberNotNull]

The compiler doesn’t look inside your methods, so a helper that checks for null still leaves callers with warnings. The attributes in System.Diagnostics.CodeAnalysis describe the contract instead:

using System.Diagnostics.CodeAnalysis;

var book = new CustomerBook();
if (book.TryGetEmail("Tyler Brooks", out string? email))
    Console.WriteLine(email.ToUpperInvariant());     // no CS8602
Console.WriteLine(book.Region.Length);
string[] states = ["Texas", "Ohio"];
Console.WriteLine(FirstMatch(states, s => s.StartsWith('V')) is null);

[return: MaybeNull]
static T FirstMatch<T>(IEnumerable<T> items, Func<T, bool> match)
{
    foreach (T item in items) if (match(item)) return item;
    return default;
}

class CustomerBook
{
    private readonly Dictionary<string, string> emails = new() { ["Tyler Brooks"] = "tyler.brooks@example.com" };
    public string Region { get; private set; }
    public CustomerBook() => Load();

    [MemberNotNull(nameof(Region))]
    private void Load() => Region = "Pacific Northwest";

    public bool TryGetEmail(string name, [NotNullWhen(true)] out string? email) =>
        emails.TryGetValue(name, out email);
}

Output:

TYLER.BROOKS@EXAMPLE.COM
17
True

No warnings. [NotNullWhen(true)] says email isn’t null when TryGetEmail returns true, [MemberNotNull] says Load() sets Region, so there’s no CS8618, and [return: MaybeNull] lets the generic method return default.

The output is the uppercase email, 17 (the length of Pacific Northwest), and True because no state starts with V. The pattern matching guide shows how is { } and is not null narrow null the same way.

Step 7: Turn Nullable Warnings into Build Errors

Warnings that never fail a build get ignored. Once a project is clean, we lock it with WarningsAsErrors. Here’s the file from a plain console project in VS Code:

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <WarningsAsErrors>nullable</WarningsAsErrors>
  </PropertyGroup>

</Project>

And a Program.cs with two classic bugs, a missing Customer initializer and an unchecked ShipTo:

var order = new Order { Id = 1042, Customer = "Tyler Brooks" };
Console.WriteLine(order.ShipTo.City);

class Order
{
    public int Id { get; init; }
    public string Customer { get; init; }
    public Address? ShipTo { get; init; }
}

record Address(string City, string State);

dotnet build output:

  Determining projects to restore...
  Restored C:\DotNetDemo\NullableDemo\Orders\Orders.csproj (in 73 ms).
C:\DotNetDemo\NullableDemo\Orders\Program.cs(7,19): error CS8618: Non-nullable property 'Customer' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [C:\DotNetDemo\NullableDemo\Orders\Orders.csproj]
C:\DotNetDemo\NullableDemo\Orders\Program.cs(2,19): error CS8602: Dereference of a possibly null reference. [C:\DotNetDemo\NullableDemo\Orders\Orders.csproj]
Build FAILED.
C:\DotNetDemo\NullableDemo\Orders\Program.cs(7,19): error CS8618: Non-nullable property 'Customer' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [C:\DotNetDemo\NullableDemo\Orders\Orders.csproj]
C:\DotNetDemo\NullableDemo\Orders\Program.cs(2,19): error CS8602: Dereference of a possibly null reference. [C:\DotNetDemo\NullableDemo\Orders\Orders.csproj]
    0 Warning(s)
    2 Error(s)
Time Elapsed 00:00:01.18
dotnet build failing with error CS8602 and error CS8618 because the .csproj sets WarningsAsErrors to nullable, shown in the VS Code terminal
WarningsAsErrors set to nullable turns CS8602 and CS8618 into build errors

The string nullable covers every nullability warning, so new ones fail CI without a list of numbers. <TreatWarningsAsErrors>true</TreatWarningsAsErrors> fails on every warning, nullable or not; we turn that on only in clean, mature projects.

How Do You Migrate an Existing Project Gradually?

Don’t flip a 300-file project to enable in one pull request. Set the project to enable, add #nullable disable to the files you haven’t migrated, and remove one directive per file as you fix it:

Console.WriteLine(LegacyFormat(null, null));
Console.WriteLine(ModernFormat(null));

#nullable disable
static string LegacyFormat(string name, string? suffix) =>
    name == null ? "(no name)" : name.ToUpper() + suffix;
#nullable restore

static string ModernFormat(string? name) =>
    name is null ? "(no name)" : name.ToUpper();

Output:

C:\DotNetDemo\NullableDemo\LegacyHelper.cs(5,47): warning CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
(no name)
(no name)

Both calls print (no name) and LegacyFormat(null, null) raises no warning, because its parameters are oblivious. The string? inside the disabled region causes CS8632, a sign that file is half-migrated.

StrategyProject settingBest for
Enable by defaultenable + #nullable disable in old filesActive apps; new files start strict
Disable by defaultdisable + #nullable enable per migrated fileStable code that rarely changes
Warnings firstwarnings, then enableHunting real NullReferenceException bugs
Annotations firstannotations, then enableLibraries publishing a NuGet API

Start with the leaf classes (models, helpers) and work outward, since annotating a type creates warnings in its callers. Moving to .NET 10 at the same time? Do the .NET 8 to .NET 10 upgrade first, then the nullable pass.

Our Team Rules for Nullable Reference Types

  1. <Nullable>enable</Nullable> in every project; no new #nullable disable.
  2. ? means “can really be missing”. If it can’t, fix the initialization instead.
  3. ! and = null! need a code comment, and only for tests or framework-filled members.
  4. Models use constructors or required; no empty-string defaults just to quiet CS8618.
  5. Clean projects set <WarningsAsErrors>nullable</WarningsAsErrors>.

Common Mistakes to Avoid

MistakeWhat happensFix
! on every warningSilence now, NullReferenceException laterNull check, ?? or a real value
Expecting string? to block nulls at run timeIt’s compile-time onlyGuard public methods
Plain dotnet run after a fixCached build shows no warnings--no-cache or --no-incremental
= "" on every propertyEmpty strings hide missing datarequired or a constructor

Next Steps

Once warnings are under control, try the newer null-friendly syntax, such as null-conditional assignment (customer?.Email = ...) in what is new in C# 14. Records also pair well with non-nullable properties; see record vs class vs struct.

Good follow-ups from our team’s reading list:

Frequently Asked Questions

Are nullable reference types enabled by default in C#?

In new projects, yes: the .NET 6 and later templates add <Nullable>enable</Nullable>, and .NET 10 file-based apps enable it by default. Older projects stay disabled until you add the setting.

Do nullable reference types prevent NullReferenceException?

No. They’re compile-time warnings only. A null can still reach a non-nullable variable through ! , older libraries or callers with nullable disabled, so guard public methods.

How do I fix CS8618 in C#?

Set the member in a constructor, mark it required (C# 11+), give it a meaningful default, or declare it nullable with ? if it can really be missing. Use = null! only for members a framework fills in.

What is the difference between string and string? in C#?

Nothing at run time; both are System.String. The ? tells the compiler the value can be null, so it warns when you dereference it without a check.

How do I make nullable warnings errors?

Add <WarningsAsErrors>nullable</WarningsAsErrors> to the project file. The errors and warnings compiler options page on Microsoft Learn documents the nullable shorthand.

Where is the full list of nullable warnings?

The nullable reference type warnings page covers every CS86xx warning and the fix for each.