Nullable reference types in C# let you state which references may be null and have the compiler warn you when code breaks that contract, long before a NullReferenceException reaches production. This guide is for developers who maintain real codebases: it explains how the compiler's null-state flow analysis works, how to enable the feature per project or per file, when the null-forgiving operator is legitimate, how attributes such as [NotNullWhen] and [MemberNotNull] describe richer contracts, and how to migrate a large solution or annotate a library without drowning in warnings.
What Are Nullable Reference Types?#
Nullable reference types (NRT), introduced in C# 8, are a compile-time feature. When the feature is enabled, a plain string means "this reference should never be null" and string? means "this reference may be null". Both are the same runtime type, System.String. The compiler records your annotations as metadata attributes and uses them to produce warnings, but it adds no runtime checks and changes no generated behavior.
Three building blocks work together:
- Annotations. The
?suffix on a reference type declares intent: nullable or not. - Null-state analysis. The compiler tracks whether each expression is not-null or maybe-null at every point in a method.
- Attributes. Types in
System.Diagnostics.CodeAnalysisdescribe contracts that a simple?cannot, such as "the out parameter is non-null when the method returns true".
Since .NET 5, the .NET runtime libraries are fully annotated, so the analysis already knows that string.IsNullOrEmpty performs a null check and that Dictionary<TKey, TValue>.TryGetValue may produce a null value when it returns false. Project templates since .NET 6 enable the feature by default, which means most code written today is already nullable-aware, while many older solutions are not.
How Nullable Reference Types Work: Null-State Flow Analysis#
Inside a method, every reference-typed expression has a null-state. A non-nullable variable starts as not-null, and a nullable one starts as maybe-null. Assignments and null checks change the state as the compiler walks through the code, including if statements, is null and is not null patterns, ?. and ??, early returns and loops:
string? nickname = FindNickname(7);
Console.WriteLine(nickname.Length); // CS8602: dereference of a possibly null reference
if (nickname is not null)
{
Console.WriteLine(nickname.Length); // OK: not-null inside the check
}
nickname ??= "guest";
Console.WriteLine(nickname.ToUpperInvariant()); // OK: ??= guarantees not-null
string display = FindNickname(42); // CS8600: possible null to non-nullable
static string? FindNickname(int userId) => userId == 42 ? "Ace" : null;The analysis is deliberately local. It does not look inside the bodies of the methods you call; it only reads their signatures and nullable attributes. That is why annotating APIs correctly matters: callers reason about your method purely through its declaration.
A few warnings account for almost all day-to-day work:
| Warning | Meaning | Typical fix |
|---|---|---|
| CS8600 | A null or maybe-null value is converted to a non-nullable type | Make the target nullable or check first |
| CS8602 | Dereference of a possibly null reference | Add a null check, a pattern or ?. |
| CS8603 | Possible null reference return | Return a real value or make the return type nullable |
| CS8604 | Possible null reference argument | Check before the call, or let the callee accept null |
| CS8618 | A non-nullable member is uninitialized when the constructor exits | Initialize it, use required, or add [MemberNotNull] |
| CS8625 | A null literal is converted to a non-nullable type | Use a nullable type or a meaningful value |
Getting Started: Enabling Nullable Reference Types#
The nullable context has two independent flags. The annotation flag controls whether ? declares a nullable reference type and whether unannotated references are non-nullable. The warning flag controls whether the compiler reports nullable diagnostics. The <Nullable> project property sets both:
| Value | Annotations | Warnings | Typical use |
|---|---|---|---|
enable | On | On | New projects and actively developed code |
warnings | Off | On | Migration phase one: find likely null dereferences first |
annotations | On | Off | Annotate a public API before fixing internals |
disable | Off | Off | Legacy code, and the default when the property is absent |
Setting the property once in Directory.Build.props applies it to every project in the repository, and the nullable shorthand in WarningsAsErrors turns every nullability warning into an error once a project is clean:
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
</PropertyGroup>
</Project>The #nullable directive overrides the project setting for the rest of a file or a region: #nullable enable, #nullable disable and #nullable restore change both flags, and suffixes such as #nullable enable warnings or #nullable disable annotations change one flag. Generated code is special: files marked as generated, for example those ending in .g.cs or starting with an <auto-generated> comment, are treated as disabled unless the generator emits its own #nullable enable.
Annotations, var and the Null-Forgiving Operator#
Once annotations are on, every reference type you write without ? is non-nullable, with one subtlety: locals declared with var always get the nullable version of the inferred type. The compiler still tracks their null-state precisely, so you can assign null to a var local later without a warning, and dereferencing it afterwards is what gets flagged.
The null-forgiving operator, a postfix !, tells the compiler to treat an expression as not-null. It changes nothing at runtime; it only suppresses the warning. Every ! is a claim the compiler can no longer check, so treat each one as a code review item:
// Weak: a missing setting becomes a NullReferenceException far away from its cause.
string weak = configuration.GetConnectionString("Orders")!;
// Better: fail fast with a message that explains what is wrong.
string connectionString = configuration.GetConnectionString("Orders")
?? throw new InvalidOperationException("Connection string 'Orders' is not configured.");
// Acceptable: EF Core translates this to SQL, where the optional navigation cannot throw.
var orders = await db.Orders
.Where(o => o.ShippingInfo!.Carrier == "DHL")
.ToListAsync(cancellationToken);Legitimate uses of ! are narrow: expressions translated by a query provider such as EF Core, test code right after an assertion, and invariants guaranteed by a framework the compiler cannot see. Initializers like = null! and = default! are a migration crutch; the official migration guidance recommends removing them once the code is fully annotated.
Nullable Attributes: Describing Real-World Contracts#
A ? expresses a single fact about a type. Real APIs have conditional contracts, and the attributes in System.Diagnostics.CodeAnalysis describe them to callers:
| Attribute | Category | Meaning |
|---|---|---|
[AllowNull] | Precondition | A non-nullable parameter or property setter accepts null |
[DisallowNull] | Precondition | A nullable parameter or property must not be set to null |
[MaybeNull] | Postcondition | A non-nullable output may be null, common with generics |
[NotNull] | Postcondition | A nullable output or ref argument is not null after the call |
[NotNullWhen(bool)] | Conditional | An argument is not null when the method returns the given value |
[MaybeNullWhen(bool)] | Conditional | An argument may be null when the method returns the given value |
[NotNullIfNotNull(name)] | Conditional | The output is not null if the named argument is not null |
[MemberNotNull] | Member | Listed fields or properties are not null after the method returns |
[MemberNotNullWhen(bool, ...)] | Member | Listed members are not null when the method returns the given value |
[DoesNotReturn] | Flow | The method always throws, so analysis stops after the call |
[DoesNotReturnIf(bool)] | Flow | The method never returns if the argument has the given value |
The Try pattern, null-in-null-out helpers and throw helpers cover most needs:
using System.Diagnostics.CodeAnalysis;
public sealed record Customer(string Id, string Name);
public sealed class CustomerDirectory(IReadOnlyDictionary<string, Customer> customers)
{
public bool TryFind(string id, [NotNullWhen(true)] out Customer? customer)
{
customer = customers.GetValueOrDefault(id);
return customer is not null;
}
[return: NotNullIfNotNull(nameof(email))]
public static string? NormalizeEmail(string? email) => email?.Trim().ToLowerInvariant();
[DoesNotReturn]
public static void ThrowNotFound(string id) =>
throw new KeyNotFoundException($"Customer '{id}' was not found.");
}
public static class CustomerEndpoints
{
public static string Greet(CustomerDirectory directory, string id)
{
if (!directory.TryFind(id, out var customer))
{
CustomerDirectory.ThrowNotFound(id);
}
return $"Hello, {customer.Name}"; // no warning: TryFind returned true
}
}The member attributes solve a different problem: state initialized in helper methods. The compiler checks that constructors initialize every non-nullable field, but it does not follow calls into helpers unless you tell it what they guarantee:
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Sockets;
using System.Text;
public sealed class TelemetryChannel
{
private StringBuilder _buffer;
private Socket? _socket;
private string _name = "default";
public TelemetryChannel() => Reset(); // no CS8618 thanks to [MemberNotNull]
[AllowNull] // assigning null restores the default instead of storing null
public string Name
{
get => _name;
set => _name = value ?? "default";
}
[MemberNotNullWhen(true, nameof(_socket))]
public bool IsConnected => _socket is not null;
[MemberNotNull(nameof(_buffer))]
public void Reset() => _buffer = new StringBuilder();
[MemberNotNull(nameof(_socket))]
public void Connect(EndPoint endpoint)
{
_socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
_socket.Connect(endpoint);
}
public void Flush()
{
if (!IsConnected)
{
return;
}
_socket.Send(Encoding.UTF8.GetBytes(_buffer.ToString())); // no warnings
Reset();
}
}The compiler also verifies your side of the contract: if IsConnected could return true while _socket is null, you get a warning inside the property. Attributes inform callers; they do not add runtime checks.
Required Members and Object Initialization#
CS8618 is the warning people meet first, usually on DTOs and options classes with non-nullable properties and no constructor. There are four honest fixes: initialize the member to a real default, set it in every constructor, make it nullable if absence is valid, or mark it required (C# 11). A required member must be set in every object initializer, which moves the obligation to the caller and makes CS8618 disappear:
using System.Diagnostics.CodeAnalysis;
var fromInitializer = new ShippingAddress
{
Line1 = "1 Main St",
City = "Oslo",
CountryCode = "NO",
};
var fromConstructor = new ShippingAddress("1 Main St", "Oslo", "NO");
public sealed class ShippingAddress
{
public required string Line1 { get; init; }
public string? Line2 { get; init; }
public required string City { get; init; }
public required string CountryCode { get; init; }
public ShippingAddress() { }
// Tells the compiler this constructor sets every required member. It is not verified.
[SetsRequiredMembers]
public ShippingAddress(string line1, string city, string countryCode)
{
Line1 = line1;
City = city;
CountryCode = countryCode;
}
}Omitting City from the object initializer is a compile error, not a warning. Note the orthogonality: required means "must be assigned" and nullability means "may be null", so a required string? Line2 is legal. [SetsRequiredMembers] disables the check for callers of that constructor, so use it only on constructors that really assign everything.
Generics and Nullability#
Generics are where nullable annotations get subtle, because a type parameter can stand for a reference type, a value type or an already-nullable type. For an unconstrained T, the annotation T? means "T, or its default value": it is string? when T is string, but plain int when T is int. Only the struct constraint turns T? into Nullable<T>.
| Constraint | Allows | Effect on nullability |
|---|---|---|
| None | Any type | T? means "may be default"; no guarantees about null |
where T : class | Non-nullable reference types | Box<string?> produces a warning |
where T : class? | Nullable or non-nullable reference types | Both string and string? are fine |
where T : notnull | Non-nullable reference or value types | Used by Dictionary<TKey, TValue> for keys |
where T : struct | Non-nullable value types | T? becomes Nullable<T> |
string[] names = ["Ada", "Linus"];
int[] numbers = [1, 2, 3];
string? name = SequenceHelpers.FirstMatch(names, n => n.StartsWith('G')); // null
int number = SequenceHelpers.FirstMatch(numbers, n => n > 5); // 0, not null
public static class SequenceHelpers
{
// Unconstrained T? means "default(T) is possible", which is null only for reference types.
public static T? FirstMatch<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (var item in source)
{
if (predicate(item))
{
return item;
}
}
return default;
}
// notnull keys: int and string are allowed, string? produces a warning.
public static Dictionary<TKey, List<TValue>> GroupInto<TKey, TValue>(
IEnumerable<TValue> values, Func<TValue, TKey> keySelector)
where TKey : notnull
{
var groups = new Dictionary<TKey, List<TValue>>();
foreach (var value in values)
{
var key = keySelector(value);
if (!groups.TryGetValue(key, out var list))
{
groups[key] = list = [];
}
list.Add(value);
}
return groups;
}
}The int result is a classic surprise: callers of an unconstrained generic API that returns T? cannot distinguish "not found" from a legitimate default value. When that matters, return a bool with an out parameter annotated with [MaybeNullWhen(false)], as TryGetValue does, which also works for value types. The generics guide covers constraints in more depth.
Nullable Reference Types at Runtime Boundaries#
Because annotations are compile-time only, anything that creates objects without calling your constructors can still produce nulls in non-nullable members. Three boundaries matter most in ASP.NET Core applications.
JSON deserialization. By default, System.Text.Json ignores nullable annotations. Since .NET 9, JsonSerializerOptions.RespectNullableAnnotations rejects explicit null values for non-nullable properties and constructor parameters, and RespectRequiredConstructorParameters rejects missing constructor arguments. A missing property is not the same as an explicit null, so pair nullability with required or [JsonRequired] when presence matters. The System.Text.Json guide goes further.
using System.Text.Json;
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
RespectNullableAnnotations = true, // .NET 9 and later
RespectRequiredConstructorParameters = true, // .NET 9 and later
};
try
{
JsonSerializer.Deserialize<Product>("""{"sku":null,"name":"Desk"}""", options);
}
catch (JsonException ex)
{
Console.WriteLine(ex.Message); // explains that 'Sku' doesn't allow null values
}
public sealed record Product(string Sku, string Name);The feature has documented limits: it cannot enforce nullability on top-level types, collection elements or generic members, because nullability of those is not visible through reflection metadata.
EF Core. Entity Framework Core reads nullability to decide whether a property is required. Enabling NRT on an existing model can therefore turn optional string columns into required ones and generate migrations that alter column nullability, so review the first migration after enabling the feature carefully. Since EF Core 7, uninitialized DbSet<T> properties on a DbContext no longer produce warnings. See the EF Core guide for modeling advice.
ASP.NET Core model validation. MVC treats non-nullable parameters and bound properties as if they had [Required(AllowEmptyStrings = true)]. That is usually what you want, and it can be switched off with MvcOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes.
Nullable Reference Types for Library Authors#
For a library, annotations are part of the public contract, and consumers see them the moment they enable NRT. The .NET runtime team's published guidelines are a useful model:
- Keep runtime validation. Continue to throw
ArgumentNullExceptionfor non-nullable parameters. Many callers compile without NRT, use another .NET language or suppress warnings with!. - Annotate intent, not accidents. Mark a parameter nullable if it is documented to accept null or the method handles null without throwing. Prefer nullable when evidence conflicts.
- Treat annotation changes as API changes. Making a return value nullable, or a parameter non-nullable, creates new warnings for consumers.
- Polyfill older targets. The attributes are missing from
netstandard2.0, but the compiler recognizes them by full name, so internal copies inSystem.Diagnostics.CodeAnalysiswork when you multi-target.
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace Contoso.Text;
public static class Slug
{
public static string Create(string title, int maxLength = 80)
{
ArgumentNullException.ThrowIfNull(title); // still needed: callers may be oblivious
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLength);
var slug = string.Join('-', title.ToLowerInvariant()
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
return slug.Length <= maxLength ? slug : slug[..maxLength];
}
public static bool TryNormalize(string? input, [NotNullWhen(true)] out string? slug)
{
slug = string.IsNullOrWhiteSpace(input) ? null : Create(input);
return slug is not null;
}
}
public static class NullabilityReport
{
// Frameworks can read annotations at runtime (.NET 6 and later).
public static IEnumerable<string> Describe(Type type)
{
var context = new NullabilityInfoContext();
foreach (var property in type.GetProperties())
{
NullabilityInfo info = context.Create(property);
yield return $"{property.Name}: read {info.ReadState}, write {info.WriteState}";
}
}
}In TryNormalize, the call Create(input) compiles without a warning because string.IsNullOrWhiteSpace is annotated with [NotNullWhen(false)]. NullabilityInfoContext is how serializers, validation libraries and ORMs recover annotation data, since string and string? are indistinguishable through Type alone.
Best Practices#
- Enable NRT everywhere by default. Set it in
Directory.Build.props, and treat#nullable disableas temporary migration debt. - Make nullability meaningful. Use
?only where absence is a valid state, and use empty collections instead of null collections. - Validate at the edges, trust the types inside. Check external input once at the boundary, then let non-nullable types carry the guarantee through the domain.
- Annotate helpers. Add
[NotNullWhen],[MemberNotNull]or[DoesNotReturn]to custom guard andTrymethods so callers do not need!. - Prefer
is nullandis not null. Patterns never call user-defined equality operators and read clearly. - Use
requiredfor data objects. It documents mandatory members and removes the need for= null!initializers.
Common Pitfalls#
- Believing NRT prevents null at runtime. Reflection, deserializers, oblivious callers and
!can all deliver null into non-nullable references. - Default structs and new arrays.
default(MyStruct)andnew string[10]hold null references without a warning. - Filtering does not narrow types.
Where(x => x is not null)still yieldsIEnumerable<string?>;OfType<string>()yields non-nullable elements. - Sprinkling
!to silence warnings. It hides real defects and makes later refactoring riskier. - Enabling NRT on an EF Core model without reviewing migrations. Column nullability can change silently.
- Assuming unconstrained
T?meansNullable<T>. For value types it does not, as theFirstMatchexample shows.
Nullable Reference Types vs Nullable Value Types#
The two features share the ? syntax but work very differently:
| Aspect | Nullable value types (int?) | Nullable reference types (string?) |
|---|---|---|
| Introduced | C# 2 | C# 8 |
| Runtime representation | Nullable<int>, a distinct struct with HasValue | Same type as string, plus metadata attributes |
| Enforcement | Type system: you must unwrap the value | Compiler warnings only |
| Memory impact | Extra flag stored with the value | None |
| Reflection | Nullable.GetUnderlyingType | NullabilityInfoContext |
| In generics | Requires where T : struct for T? | Unconstrained T? means "may be default" |
Frequently Asked Questions#
Do nullable reference types prevent NullReferenceException at runtime?#
No. They are a compile-time analysis that produces warnings, and the generated code is unchanged. They dramatically reduce null bugs in code the compiler can see, but values from deserializers, reflection, oblivious callers or ! suppressions can still be null.
Should I use the null-forgiving operator?#
Rarely. Use ! only when you know something the compiler cannot, such as inside an EF Core query or right after a test assertion. In application code, prefer a null check, a throw expression, required or a nullable attribute.
How do I enable nullable reference types for only part of a project?#
Use the #nullable directive. With the project set to disable, add #nullable enable at the top of migrated files; with the project set to enable, add #nullable disable to files you have not migrated yet. Remove the directives once the whole project is clean.
Do I still need ArgumentNullException checks when nullable is enabled?#
Yes, for public and protected APIs. Callers might compile without nullable analysis, use another .NET language, or suppress warnings, so keep ArgumentNullException.ThrowIfNull guards on your public surface.
What does T? mean for an unconstrained generic type parameter?#
It means "T or its default value". For reference types, that is the nullable reference type, but for a value type like int, T? is still int. Use the struct constraint when you need Nullable<T>.
Summary#
- Nullable reference types are compile-time annotations plus null-state flow analysis; runtime behavior does not change.
- Enable them in every project, and use
#nullabledirectives only as a temporary migration tool. - Attributes such as
[NotNullWhen],[MemberNotNull]and[DoesNotReturn]describe contracts that?cannot. requiredmembers replace= null!initializers on data objects.- Guard runtime boundaries: JSON, EF Core and model binding interpret annotations differently.
- Library authors should annotate intent and keep runtime argument validation.