Adopting C# Nullable Reference Types: A Decision Guide for Existing Codebases
Nullable reference types move null bugs from runtime to build time — but enabling them naively floods legacy code with warnings. Compare the enablement modes and adopt them incrementally.
04 Aug 2025, 16:19 UTC

The decision you're actually making
Every non-trivial C# codebase has null-related defects hiding in it: a NullReferenceException in a log file, a defensive if (x == null) scattered everywhere because nobody remembers which methods can return null. Nullable reference types (NRT), introduced in C# 8, let the compiler track which references may be null and warn you at build time instead of at 2 a.m. in production.
The real decision is not whether the feature is useful — it is how to turn it on without drowning in warnings, and how strictly to enforce the annotations afterward. This guide compares the supported enablement options, then shows a concrete incremental adoption pattern.
Enablement options compared
| Option | How to apply | Effect | Best for |
|---|---|---|---|
| Project-wide enable | <Nullable>enable</Nullable> in the .csproj | Annotations and warnings active for every file | New projects; small-to-medium codebases |
| Warnings only | <Nullable>warnings</Nullable> | Compiler warns, but ? annotations are not required to compile | Surveying how much work a migration will take |
| Annotations only | <Nullable>annotations</Nullable> | You can write string?, but no warnings are produced | Documenting intent in a library before full adoption |
| Per-file opt-in | #nullable enable at the top of a file | Feature active only in that file | Incremental migration of large legacy code |
| Disabled (default on old targets) | <Nullable>disable</Nullable> | Pre-C# 8 behavior; no warnings | Legacy code you cannot touch yet |
Note that <LangVersion> must be 8 or higher for any of this to work. Modern .NET SDK projects default to a recent language version, but a project pinned to an older LangVersion will silently ignore the Nullable setting.
Trade-offs that matter in practice
Warning volume. On a large legacy project, flipping <Nullable>enable</Nullable> can produce hundreds or thousands of warnings in one build. That is the most common reason teams abandon the feature. The per-file #nullable enable approach avoids this: you fix one file or one project at a time, and the build stays green throughout.
The null-forgiving operator is a liability. Writing name! tells the compiler "trust me, this is not null" and suppresses the warning. It emits no runtime check — if you're wrong, the NullReferenceException still happens, just with less warning. Treat every ! as a code-review flag. Legitimate uses exist (for example, when a framework initializes a field via reflection, as with some dependency-injection or test frameworks), but suppression should be the exception, not the migration strategy.
Warnings vs. errors. You can escalate with <TreatWarningsAsErrors>true</TreatWarningsAsErrors> or by building with dotnet build -warnaserror. This is excellent for keeping already-clean code clean, but brutal during migration. A common pattern is to enable warnings-as-errors only in CI for projects that have finished migrating.
A concrete incremental adoption pattern
Start with the leaf projects in your solution — the ones with no internal dependencies — because their annotations flow outward to callers. In the project file:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>Then annotate the public surface honestly. A method that can return null should say so:
public class OrderRepository
{
// Caller is warned if they use the result without a null check.
public Order? FindById(int id) =>
_orders.SingleOrDefault(o => o.Id == id);
// Parameter contract: null is not acceptable here.
public void Save(Order order)
{
ArgumentNullException.ThrowIfNull(order);
_orders.Add(order);
}
}Callers then get the compiler's help for free:
var order = repo.FindById(42);
Console.WriteLine(order.Total); // CS8602: possible dereference of null
if (order is not null)
Console.WriteLine(order.Total); // OK: flow analysis narrows the typeThe warnings you will see most often: CS8602 (dereference of a possibly-null reference), CS8603 (possible null return), and CS8618 (non-nullable field or property left uninitialized by the constructor). CS8618 usually means one of three things: the property should be nullable (string?), it should be initialized inline or in the constructor, or it is set by a framework and genuinely needs = null! with a comment explaining why.
Validating the migration
Run this from the solution directory (no special permissions required):
dotnet build -warnaserrorOn a fully migrated project this should succeed with zero CS86xx diagnostics. During migration, run a normal dotnet build and count warnings per project to track progress; in Visual Studio, filter the Error List to warning codes starting with CS86. A practical definition of done for each project: it builds clean with <Nullable>enable</Nullable> and -warnaserror, and a search for ! used as the null-forgiving operator returns only documented, intentional cases.
Limitations to keep in mind
NRT is a compile-time analysis, not a runtime guarantee. Nulls can still arrive from code compiled without annotations (older libraries), from reflection, from deserialization, or through the ! operator. The compiler's flow analysis is also deliberately conservative in some cases — it cannot always see that a check in one method guarantees non-null in another, which is where [NotNullWhen], [MemberNotNull], and related attributes from System.Diagnostics.CodeAnalysis help you express cross-method contracts. Finally, generics need explicit constraints (where T : notnull or annotating T?) for the analysis to be meaningful. The feature meaningfully reduces null defects, but it does not eliminate the need for validation at system boundaries.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.