Adopting Nullable Reference Types in an Existing C# Solution: A Visual Studio Decision Guide
A decision guide for rolling out C# nullable reference types in an existing Visual Studio solution: compare enable vs warnings vs per-file modes, then implement and validate a warnings-first migration.
22 Sept 2025, 00:08 UTC

You have a legacy C# solution open in Visual Studio and you want the null-safety benefits of nullable reference types (NRTs) without breaking the build, flooding the team with warnings, or stalling CI. The practical takeaway: start with <Nullable>warnings</Nullable> in each project file, treat the warnings as design feedback, annotate incrementally, and only flip to full enable once the warning count is manageable and your tests stay green.
The decision and its constraints
Nullable reference types are a compile-time feature introduced in C# 8. They let the compiler warn when a reference that is not annotated as nullable (for example string) might actually hold null. The feature is purely static analysis: it changes no runtime behavior, so it cannot fix or break running code by itself. That makes it safe to adopt, but the volume of warnings in legacy code is the real risk.
Your constraints typically look like this:
- Build safety: CI must keep passing. If your pipeline treats warnings as errors (
TreatWarningsAsErrors), a naive adoption breaks everything immediately. - Developer friction: thousands of warnings on day one teach people to ignore the Error List entirely.
- Tooling version: NRTs require C# 8 or later and SDK-style projects work best. Visual Studio 2022 (17.x) ships a compiler with mature NRT support; Visual Studio 2019 supports it but with an older compiler. Verify your
LangVersionand SDK before assuming behavior. - Dependencies: libraries compiled without nullable annotations are treated as "oblivious" — the compiler won't warn on their boundaries, but it also can't protect you there.
Comparing the supported options
| Option | Coverage | Migration cost | CI risk | Best for |
|---|---|---|---|---|
<Nullable>enable</Nullable> project-wide | Full: warnings and annotations active everywhere | High on legacy code; large warning backlog | High if warnings-as-errors is on | New projects or small, well-tested codebases |
<Nullable>warnings</Nullable> project-wide | Warnings active; annotations not required in your code | Low to moderate; fix hotspots incrementally | Low if warnings are not errors | Existing solutions starting adoption |
#nullable enable per file | Only annotated files | Very low per step; slow overall | Minimal | Very large solutions, file-by-file migration |
<Nullable>annotations</Nullable> | Annotations parsed, no warnings emitted | Low; lets you annotate silently | Minimal | Preparing a public API surface before enabling warnings |
The warnings mode is the sweet spot for an existing solution: the compiler analyzes nullability and reports warnings, but your own files are not yet expected to carry annotations, so you avoid the chicken-and-egg problem of annotating everything before seeing useful diagnostics.
Trade-offs to weigh
Warnings-only vs. full enable. Warnings-only limits false positives from unannotated legacy code and lets you fix the worst null-handling bugs first. Full enable gives stronger guarantees — every unannotated reference is a contract — but demands broader code changes and sometimes test updates where tests deliberately pass null.
Third-party noise. Libraries without annotations are "oblivious" and generally quiet, but partially annotated libraries can produce warnings at call sites you can't fix. Use #pragma warning disable CS8604 (or the relevant code) sparingly, or file-level #nullable disable around genuinely unfixable interop boundaries.
Warnings are feedback, not noise. A CS8602 ("dereference of a possibly null reference") in a 10-year-old method often marks a latent bug. Budget time to triage rather than blanket-suppress.
Concrete implementation
Run these edits per project, or in Directory.Build.props at the solution root to apply them everywhere at once (Visual Studio 2022, .NET SDK-style projects, C# 8+):
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>warnings</Nullable>
</PropertyGroup>
</Project>If you use Directory.Build.props, check that no individual .csproj overrides Nullable — a project-level value wins over the imported props file.
Then, in Visual Studio:
- Rebuild the solution (Build → Rebuild Solution). No special permissions needed; this is a normal local build.
- Open the Error List and filter to Warnings. Group by project to find hotspots.
- On a file you want to fully migrate, add
#nullable enableat the top, then use the light-bulb quick actions (Ctrl+.) on nullability warnings to add?annotations or null checks. - Optionally configure a Code Cleanup profile (Tools → Options → Text Editor → C# → Code Cleanup) to apply formatting while you annotate, keeping diffs readable.
Validating the rollout
- Baseline first: before any change, run your full unit test suite and record the pass count. NRTs change no runtime behavior, so any test failure after enabling means you accidentally changed logic while annotating.
- Confirm the compiler is participating: in a scratch file inside a migrated project, write
string s = null;and confirm warning CS8600 appears in the Error List, thens.ToString();for CS8602. If no warning appears, checkLangVersionand that the project is SDK-style. - Check CI: confirm your pipeline does not treat the new warnings as errors. If it does, either keep
Nullableatwarnings(warnings are still warnings, not errors) or temporarily relaxTreatWarningsAsErrorsfor nullable codes only. - Track the burn-down: record the nullable warning count after the first build and review it in sprint retros. A rising count means new code is being written without null discipline.
Limitations
- NRTs are compile-time only; they cannot prevent nulls arriving from reflection, deserialization, or oblivious third-party code. Keep runtime guards at system boundaries.
- Compiler behavior for NRTs has been refined across releases; diagnostics in Visual Studio 2019 differ from 2022 and from preview channels. Pin your SDK (via
global.jsonif needed) so local and CI builds agree. - Moving from
warningstoenablelater is a real migration step of its own — plan for it rather than assuming it is a one-line flip.
The end state to aim for: <Nullable>enable</Nullable> in Directory.Build.props, a near-zero warning count, and CI enforcing it for new code. Reaching that state gradually, with warnings mode and per-file opt-in, is the supported path that keeps the build green the whole way.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.