Nullable Reference Types in C#: A Practical Migration Guide for Legacy Code
NullReferenceException is the most common exception in .NET production logs — Microsoft's own telemetry has said so for years. Nullable reference types (NRT), introduced in C# 8, are the cure: the compiler tracks which references can be null and warns you at the exact line where you forgot to check. New projects get this for free (<Nullable>enable</Nullable> is in every modern template).
But most of us don't work in new projects. We work in eight-year-old solutions with 200,000 lines of code, where flipping that switch produces four thousand warnings and a strong urge to flip it back. I've led this migration on a large legacy codebase; here's the approach that worked.
What NRT actually changes
With nullable enabled, reference types are non-nullable by default. Nullability becomes part of the type:
Crucially, these are warnings, not runtime changes. Your compiled program behaves identically. That's what makes incremental migration possible.
The strategy: enable globally, disable locally, shrink the exceptions
Naive advice says "enable it file by file with #nullable enable." I recommend the opposite polarity, because it puts the finish line in sight and makes progress measurable:
Step 1 — Enable it solution-wide. In Directory.Build.props (so every project inherits it):
Step 2 — Silence existing code explicitly. Run a script (or IDE find/replace) that adds #nullable disable to the top of every existing file. You're now at zero warnings, and — this is the key — every new file is born nullable-aware. The codebase stops getting worse today.
Step 3 — Migrate files opportunistically and deliberately. Delete the #nullable disable line from a file whenever you touch it for other work, fix its warnings, commit. Track the count of remaining #nullable disable lines in CI or a dashboard; it only goes down.
Step 4 — Ratchet with warnings-as-errors. Once a project is clean, lock it in so regressions can't merge:
Fixing warnings without lying to the compiler
Most warnings fall into a few shapes, each with a right answer and a tempting wrong one.
"This can be null and I handle it" → make the type honest:
"This genuinely can't be null here" → prove it with flow, or assert it:
The null-forgiving operator ! — the tempting wrong answer. customer!.Name tells the compiler "trust me." Every ! is a place where NRT can no longer help you; a migration done with a thousand of them was a waste of time. My team's rule: ! requires a comment justifying it, which makes people find the honest fix instead. Legitimate uses exist (test setup, framework-guaranteed initialization) but they're rare.
DTOs and EF Core entities — the classic "non-nullable property must contain a value" (CS8618). For required data, use the required modifier (C# 11+), which forces initialization at every creation site:
On older language versions, constructor initialization or = null!; for ORM-materialized properties (one of the few conventional ! uses — document it once).
The boundaries where NRT can't see
The compiler's analysis stops at anything that isn't C# code it can see:
- Deserialization —
JsonSerializer.Deserialize<Order>(json)will happily put null into non-nullable properties. NRT is compile-time only; validate input at the boundary (or userequired, which System.Text.Json enforces since .NET 8). - Old libraries — dependencies compiled without NRT annotations are "nullable-oblivious": their return values produce no warnings even when they can return null. Treat un-annotated third-party returns with suspicion.
- Reflection, DI, mappers — anything constructing objects at runtime bypasses the checks.
Understand this and you'll avoid the post-migration false confidence of "we enabled NRT, nulls are solved." NRT eliminates the internal NREs — boundaries still need validation.
Is it worth it?
On the codebase where I drove this: NREs in production dropped to near zero for migrated modules, and — the part I didn't expect — the migration itself found real dormant bugs, because "fix the warning honestly" kept forcing the question "what should actually happen when this is null?" that the original author had skipped.
The compiler is offering to check two decades of null-handling assumptions on every build, forever, for free. Take the deal — just take it incrementally.
Where to go next
- C# Records vs Classes vs Structs — records pair beautifully with NRT for honest data models.
- Real-World Unit Testing in .NET — what NRT does and doesn't replace in your test suite.
- Consistent API Error Handling with ProblemDetails — handling the boundary failures NRT can't prevent.