C# Nullable Reference Types
The Null Problem
Every reference type in C# can hold null, and before C# 8 the compiler had no way to say which variables were expected to. A method that returned string might return null, and nothing at the call site said so.
string name = GetName(); // might return null
int length = name.Length; // NullReferenceException if it did
Nullable reference types (NRT), added in C# 8, let you declare which references may be null and have the compiler warn when code might dereference one. string means “never null” and string? means “may be null,” and the compiler tracks each variable’s state through the method to decide whether a dereference is safe.
All of this happens at compile time. A string? compiles to exactly the same string as before. The annotations become metadata in the assembly, the compiler emits warnings, and the runtime enforces nothing. A caller compiled without NRT, a reflection call, or a deserializer can still hand your method a null through a string parameter. The feature removes most accidental nulls from code you compile. It does not turn null into an impossible value.
Turning It On
The Two Contexts
NRT has two independent switches. The annotation context decides whether ? on a reference type means anything, and whether an unannotated reference type is non-nullable. The warning context decides whether the compiler reports null-safety warnings. The <Nullable> project property sets both at once:
<Nullable> value |
Annotations | Warnings | Effect |
|---|---|---|---|
enable |
on | on | Full NRT. string is non-nullable, string? is nullable, and misuse is warned. |
warnings |
off | on | Flow analysis warns on possible null dereferences, but reference types stay oblivious and ? is itself a warning (CS8632). |
annotations |
on | off | ? is accepted and recorded in metadata, with no warnings. |
disable |
off | off | Pre-C# 8 behaviour. This is the default when the property is absent. |
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
New projects created from .NET 6 or later templates already set enable. Existing projects stay at disable until someone opts them in.
Overriding a Region of Source
The project setting is the default for every file. A #nullable directive overrides it from that line down to the end of the file, or until the next directive. Each switch can be set separately:
#nullable enable // both contexts on
#nullable disable // both contexts off
#nullable restore // back to the project setting
#nullable enable warnings // turn on warnings only; annotations unchanged
#nullable disable annotations // turn off annotations only; warnings unchanged
The compiler treats generated files (recognized by markers such as a name ending in .g.cs or .designer.cs, or a first comment containing <auto-generated>) as if the nullable context were disabled, whatever the project says. A source generator that emits nullable-aware code opts back in by writing #nullable enable at the top of its output.
Warnings or Errors
Null-safety diagnostics are warnings by default, which makes them easy to accumulate. The compiler groups them all under the name nullable, so one property promotes every one of them to an error:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
That is the usual setting for a codebase that has finished migrating. Without it, a new warning in a large build output goes unnoticed.
Annotating Types
With the annotation context on, an unannotated reference type is non-nullable, and ? marks the ones that may be null:
public class Customer
{
public string Name { get; set; } // never null
public string? MiddleName { get; set; } // may be null
public Customer(string name)
{
Name = name;
// MiddleName starts as null, which its type allows
}
}
Customer c = null; // CS8600: null assigned to a non-nullable type
string middle = c.MiddleName; // CS8602: c may be null
// CS8600: a string? assigned to a string
The compiler also checks that every non-nullable field and property has a value by the time a constructor finishes. Leave Name unassigned in the constructor above and the compiler reports CS8618 on it. The fixes are in Migrating an Existing Codebase.
To use a nullable value, first establish that it isn’t null:
if (customer.MiddleName is not null)
{
int length = customer.MiddleName.Length; // safe here
}
int? middleLength = customer.MiddleName?.Length; // or propagate the null
Null-State Analysis
The declared type is only half of what the compiler uses. It also tracks a null state for every reference variable at every point in the method: not-null or maybe-null. A non-nullable variable starts as not-null, a nullable one as maybe-null, and each check, assignment, and branch updates the state. The compiler warns when you dereference a maybe-null variable, or assign one to a non-nullable target.
public void Process(Customer? customer)
{
Console.WriteLine(customer.Name); // CS8602: customer is maybe-null here
if (customer is null)
return;
Console.WriteLine(customer.Name); // no warning: the return ruled null out
}
Anything that rules null out on a code path moves the state to not-null on that path:
if (customer is null) return; // early return
if (customer is null) throw new ArgumentNullException(nameof(customer));
ArgumentNullException.ThrowIfNull(customer); // a guard method (see NotNull below)
var valid = customer ?? throw new ArgumentNullException(nameof(customer));
if (customer is not null) { /* not-null inside */ }
if (customer is { } c) { /* c is not-null */ }
Pattern matching needs care. The empty property pattern { } matches only non-null values, so it establishes not-null. The var pattern matches anything, null included, so it establishes nothing:
if (customer is { MiddleName: var m }) { int a = m.Length; } // CS8602: var also matched null
if (customer is { MiddleName: { } m2 }) { int b = m2.Length; } // no warning
The state is local to one method body. The compiler doesn’t look inside the methods you call to see what they do with a variable, which is why the attributes later in this guide exist.
Oblivious Code
A reference type declared where the annotation context is off, whether in an older library, a #nullable disable region, or a project that never enabled NRT, is oblivious. The compiler knows nothing about it, so it warns about nothing: assigning an oblivious value to string is silent, and so is dereferencing it.
// LegacyLibrary was compiled without nullable annotations
string result = LegacyLibrary.GetValue(); // no warning
int length = result.Length; // no warning, and it can still throw
If you know an oblivious API can return null, declare the receiving variable as nullable yourself. The compiler then checks everything downstream of that line:
string? result = LegacyLibrary.GetValue();
if (result is null) { /* handle it */ }
The base class library in current .NET is annotated, so obliviousness mostly shows up with older NuGet packages and your own unmigrated code.
Where the Analysis Can’t See
The analysis is designed to catch common mistakes without drowning code in warnings. It has known blind spots, and each one is a place a NullReferenceException can still come from in a fully enabled project:
- Array elements.
new string[10]creates ten nulls typed as non-nullablestring, andarr[0].Lengthcompiles with no warning. - Callers outside your compilation. A consumer of your library with NRT disabled, a reflection
Invoke, or a deserializer can pass null into a non-nullable parameter. - Suppressions. Every
!andnull!is a claim the compiler accepted without checking. - Oblivious values from unannotated code, as above.
So a public method still validates its arguments at run time, even when the parameter type says null is impossible. The annotation documents the contract, and the guard enforces it against callers the compiler never saw:
public Order(string orderNumber, Customer customer)
{
ArgumentNullException.ThrowIfNull(orderNumber);
ArgumentNullException.ThrowIfNull(customer);
OrderNumber = orderNumber;
Customer = customer;
}
Internal and private methods, whose callers all compile under the same analysis, usually don’t need the guard.
The Null-Forgiving Operator
A postfix ! tells the compiler to treat an expression as not-null. It changes the compiler’s view and nothing else. No check is generated, and if the value is null the code throws later, wherever it is first dereferenced.
Use it where you know something the analysis can’t follow. A LINQ filter is the classic case, because the compiler doesn’t carry the predicate in Where into the next operator:
List<string?> names = ["Ann", null, "Bo"];
var lengths = names.Where(n => n != null).Select(n => n!.Length); // ! needed
var lengths2 = names.OfType<string>().Select(n => n.Length); // no ! needed
The second form is better. OfType<string>() returns IEnumerable<string>, so the type itself carries the filtered fact. Reaching for ! is usually a sign that a better-typed alternative exists.
null! is the other common form. It initializes a non-nullable field to null while promising it will be set before use, typically by a test framework’s setup method:
private IOrderService service = null!; // assigned in Setup
[SetUp]
public void Setup() => service = CreateService();
Each ! is an unchecked claim, and a wrong one moves the crash from the line that has the bug to wherever the value is next used. When the compiler can’t see why a value is non-null, first try fixing that: a guard, a better-typed API, or one of the attributes below.
Nullability Attributes
A ? is fixed at declaration: a type is nullable or it isn’t. Real APIs have contracts ? can’t express, such as “this out parameter is non-null when the method returns true” or “the result is null only if the argument was.” The attributes in System.Diagnostics.CodeAnalysis describe those contracts so the analysis can follow a variable across a method call. The caller’s compiler reads them, and the method’s own compiler checks the body against most of them.
| Attribute | Applies to | Contract |
|---|---|---|
AllowNull |
Input (parameter, property setter) | Accepts null although the type is non-nullable |
DisallowNull |
Input | Rejects null although the type is nullable |
MaybeNull |
Output (return, out parameter, getter) | May produce null although the type is non-nullable |
NotNull |
Output, or a parameter | Non-null after the method returns normally |
MaybeNullWhen(bool) |
Out parameter | May be null when the method returns the given value |
NotNullWhen(bool) |
Parameter | Non-null when the method returns the given value |
NotNullIfNotNull(name) |
Return or parameter | Non-null if the named parameter was non-null |
MemberNotNull(names) |
Method or property | Listed fields and properties are non-null when it returns |
MemberNotNullWhen(bool, names) |
Method or property | Listed members are non-null when it returns the given value |
DoesNotReturn |
Method | Never returns normally, so code after the call is unreachable |
DoesNotReturnIf(bool) |
Bool parameter | Doesn’t return if the argument has the given value |
Guards: NotNull and DoesNotReturn
NotNull on a parameter says the argument is non-null if the method returns at all. The caller’s variable moves from maybe-null to not-null after the call. That is how ArgumentNullException.ThrowIfNull narrows its argument, and how to write your own guard:
public static class Guard
{
public static void AgainstNull<T>([NotNull] T? value,
[CallerArgumentExpression(nameof(value))] string? name = null) where T : class
{
if (value is null)
throw new ArgumentNullException(name);
}
}
Guard.AgainstNull(customer);
Console.WriteLine(customer.Name); // no warning
DoesNotReturn covers throw helpers that take no argument to narrow. After if (x is null) ThrowHelper.Fail(); the compiler treats the rest of the method as reachable only when x is not null. Debug.Assert carries DoesNotReturnIf(false) on its condition, so Debug.Assert(x is not null) narrows too.
Try Methods: NotNullWhen and MaybeNullWhen
A Try method’s out parameter is null on failure and set on success. NotNullWhen(true) states that, so a caller inside if (TryX(...)) gets a not-null value without a second check:
public bool TryFindCustomer(int id, [NotNullWhen(true)] out Customer? customer)
{
customer = customers.FirstOrDefault(c => c.Id == id);
return customer is not null;
}
if (TryFindCustomer(42, out var found))
{
Console.WriteLine(found.Name); // no warning
}
The same attribute works on an input. string.IsNullOrEmpty([NotNullWhen(false)] string? value) is why if (!string.IsNullOrEmpty(s)) leaves s not-null.
The compiler checks the method body against the contract only where it can: a return true while customer might be null is warning CS8762. A computed return customer is not null isn’t checked, so the attribute becomes a promise you keep yourself.
Generic Try methods use the inverse, MaybeNullWhen(false). Dictionary<TKey, TValue>.TryGetValue declares [MaybeNullWhen(false)] out TValue value, because TValue might itself be a nullable type, and writing TValue? would promise something different (see Generics below).
State Set by Another Method: MemberNotNull
The analysis doesn’t follow a variable into a called method, so a field set by a helper still looks maybe-null afterward. MemberNotNull names the fields the method guarantees:
public class ReportWriter
{
private StreamWriter? writer;
[MemberNotNull(nameof(writer))]
private void EnsureOpen()
{
writer ??= new StreamWriter("report.txt");
}
public void Write(string line)
{
EnsureOpen();
writer.WriteLine(line); // no warning
}
}
This one is fully checked: if EnsureOpen could exit with writer still null, the compiler reports CS8774 on the method. That makes it safer than !, which would silence the call site with no check at all.
Asymmetric Properties: AllowNull and DisallowNull
A property’s type applies to both its getter and its setter, and sometimes the two need different contracts. AllowNull lets a non-nullable property accept null on the way in, typically because the setter replaces it with a default. DisallowNull does the reverse: the getter can return null, but you can’t set null.
public class Person
{
private string name = "Unknown";
[AllowNull]
public string Name
{
get => name; // never null
set => name = value ?? "Unknown"; // null accepted, replaced
}
private string? notes;
[DisallowNull]
public string? Notes
{
get => notes; // null until first set
set => notes = value; // the compiler rejects p.Notes = null
}
}
Null In, Null Out: NotNullIfNotNull
A method that passes null through but always returns a value for non-null input has to return string? to cover the null case. That forces every caller who passed a non-null argument to handle a null that can’t happen. NotNullIfNotNull ties the return’s state to the argument’s:
[return: NotNullIfNotNull(nameof(path))]
public static string? NormalizePath(string? path) => path?.Replace("\\", "/");
string? maybe = GetPath();
string? a = NormalizePath(maybe); // result is maybe-null
string b = NormalizePath("/some/path"); // result is not-null, no warning
Generics and T?
T? in a generic declaration means one of three things, depending on the constraint:
| Declaration | T? means |
With T = string |
With T = int |
|---|---|---|---|
where T : struct |
Nullable<T> |
not allowed | int? |
where T : class (C# 8) |
nullable reference | string? |
not allowed |
| no constraint (C# 9) | “T, or its default” |
string? |
int |
The last row is the trap. For an unconstrained T, T? does not become Nullable<T> when T is a value type. It stays T, and “null” becomes default(T):
public static T? FirstOrNothing<T>(IEnumerable<T> items)
{
foreach (var item in items) return item;
return default;
}
string? s = FirstOrNothing(Array.Empty<string>()); // null
int i = FirstOrNothing(Array.Empty<int>()); // 0, and the return type is int
A caller can’t tell “found 0” from “found nothing” in the int case. When absence has to be distinguishable for value types, return a bool with an out parameter or constrain T to struct.
Before C# 9 an unconstrained T? was a compile error, and [return: MaybeNull] T was the only way to say “may return default.” Code written since can use T? instead. MaybeNull and MaybeNullWhen remain for contracts T? can’t express, like the conditional one on TryGetValue.
The notnull constraint goes the other way. where TKey : notnull accepts any non-nullable type argument, value or reference, and warns when the caller supplies a nullable one. Dictionary<TKey, TValue> declares it on TKey.
int? and string? Are Different Things
The same ? syntax covers two unrelated mechanisms. int? is a different type from int: it is Nullable<int>, a struct with its own HasValue and Value members, and it exists at run time. string? is the same type as string, carrying an annotation that only the compiler and reflection-based tools read. So you can’t overload a method on string versus string?, and typeof(string?) is an error.
Frameworks That Read the Annotations
The annotations are metadata, and some libraries read that metadata at run time. Enabling NRT on an existing project changes their behaviour, not only the compiler’s.
| Framework | What it does with a non-nullable reference type |
|---|---|
| Entity Framework Core | Configures the property as required, which maps to a non-nullable column. With NRT disabled, every reference-type property is optional. Turning NRT on can therefore generate a migration that alters column nullability. |
| ASP.NET Core MVC model validation | Treats non-nullable parameters and bound properties as if they had [Required(AllowEmptyStrings = true)], so a missing value is a validation error. SuppressImplicitRequiredAttributeForNonNullableReferenceTypes turns this off. |
| System.Text.Json (.NET 9+) | With JsonSerializerOptions.RespectNullableAnnotations = true, an explicit JSON null for a non-nullable property throws JsonException. It is off by default. A missing property still doesn’t throw, which is what required is for. |
All three follow from the same fact: once the annotations are on, string versus string? is a statement about your data model that other code acts on.
Common Patterns
A Default Object Instead of a Nullable Field
A dependency that’s optional can be a nullable field checked at every use, or a non-nullable field holding a do-nothing implementation. The second keeps the null checks out of the rest of the class:
public sealed class NullNotifier : INotifier
{
public static readonly INotifier Instance = new NullNotifier();
private NullNotifier() { }
public void Send(string message) { }
}
public class OrderService
{
private readonly INotifier notifier;
public OrderService(INotifier? notifier = null)
{
this.notifier = notifier ?? NullNotifier.Instance;
}
public void Complete(Order order)
{
notifier.Send($"Order {order.OrderNumber} complete"); // never null
}
}
Nullable Return for “Not Found”
A lookup that can come back empty says so in its return type, and the caller’s compiler insists on handling it:
public Customer? FindCustomer(int id) => customers.FirstOrDefault(c => c.Id == id);
var customer = FindCustomer(123);
if (customer is not null)
{
ProcessCustomer(customer);
}
var orGuest = FindCustomer(123) ?? CreateGuestCustomer();
Migrating an Existing Codebase
Switching a large, older project to enable in one step produces thousands of warnings at once. Microsoft’s guidance is to sequence the work, and the two orderings suit different goals:
| Order | Steps | Choose it when |
|---|---|---|
| Warnings first | Set warnings. Fix every possible-dereference warning while types stay oblivious. Then switch to enable and add ? where null is valid. |
Hardening running code against existing NullReferenceException bugs is the priority |
| Annotations first | Set annotations. Add ? across the public API with no warnings in the way. Then switch to enable and fix what the warnings find. |
Publishing an accurate contract to consumers is the priority, as for a library |
Either order can also run file by file. Set the project default, then add a #nullable directive to each file as you migrate it, starting with the leaf types that depend on nothing else. Annotating a type creates warnings in its callers, so working from the bottom of the dependency graph upward avoids fixing the same caller twice.
The work is finished when the project sets enable, no #nullable directives remain, and every null! added only to quiet a warning has been replaced with an actual value or a nullable type.
Fixing an Uninitialized Non-Nullable Property
CS8618, the “non-nullable property is uninitialized” warning, is the most common one during migration. Which fix is right depends on what the property means:
public string Name { get; set; } // CS8618
public string? Name { get; set; } // absence is a valid state
public string Name { get; set; } = ""; // a sensible default exists
public required string Name { get; set; } // every caller must set it (C# 11)
public string Name { get; } // set in every constructor
Making the property nullable only because the warning is inconvenient moves the problem to every reader of the property.
Key Takeaways
NRT is a compile-time contract. It removes most accidental nulls from code the compiler sees and enforces nothing at run time. Public APIs still guard their arguments.
Turn warnings into errors once migrated. <WarningsAsErrors>nullable</WarningsAsErrors> keeps new warnings from piling up unseen.
Nullable means null is a valid state. Use ? to describe the data, not to silence a warning.
Treat ! as an unchecked claim. Prefer a guard, a better-typed API, or an attribute that the compiler can check.
Attributes carry null state across method calls. NotNullWhen, MemberNotNull, and NotNull let helper methods participate in the analysis instead of forcing ! at every call site.
Unconstrained T? is not Nullable<T>. For a value type it is just T, and “null” is default.
Enabling NRT changes framework behaviour. EF Core columns, ASP.NET Core validation, and optionally System.Text.Json all read the annotations.
Found this guide helpful? Share it with your team:
Share on LinkedIn