Adopting nullable reference types at scale: what <Nullable> does, annotations and warnings, flow analysis and attributes, a project-by-project or file-by-file migration plan, handling public APIs and EF Core, and making nullable warnings errors.
<Nullable>enable</Nullable> makes reference types non-nullable by default: string? means a value can be null and string means it can't. The compiler then warns when code might dereference null. It's purely compile-time, so nothing changes at run time. In a big codebase I migrate bottom-up: shared libraries and domain types first, because their annotations flow out to everyone else. I go project by project, or file by file with #nullable enable, and give each step a small pull request so feature work carries on. When a project is clean I make its nullable warnings errors with WarningsAsErrors=nullable, so it can't slip back. I use attributes such as [NotNullWhen] where flow analysis needs help and keep ! for real boundaries. I also watch for two things: EF Core migrations, where nullability decides whether columns are required, and public APIs, where annotations are part of the contract.
"
<Nullable>enable</Nullable>makes reference types non-nullable by default and turns on the CS86xx warnings, purely at compile time; it can also be set to annotations or warnings only, or per file with#nullable enable. My approach is to measure warnings per project in CI, migrate bottom-up from shared and domain libraries so consumers get accurate annotations, then go project by project or file by file in small pull requests, locking each clean project withWarningsAsErrors=nullableand requiring new files to be nullable-enabled. I use attributes like[NotNullWhen]and[MemberNotNull]where flow analysis needs help, and the mistake I see is silencing warnings with!, which only hides the bug.For example, at [company] null reference exceptions were our top production error, and turning nullable on across our [120] projects produced about [40,000] warnings. My task was to lead the rollout without stopping feature work for [eight] teams. I measured warnings per project in CI, migrated shared libraries first, then had teams adopt nullable file by file as they touched code, locking each project once it was clean. I also reviewed the EF Core migrations that nullability changed, because non-nullable properties become required columns, and treated annotation changes on public APIs as contract changes. Over [two quarters] every project was nullable-enabled, and null reference exceptions in production fell by [about 70%].
Nullable adoption is really a change-management problem. Annotations are compile-time promises, so I still validate at boundaries, and the build, a warnings dashboard and analysers are what stop it sliding back."
| Setting | Effect |
|---|---|
enable |
Annotations and warnings on |
warnings |
Warnings on, but all types treated as "oblivious" for annotations |
annotations |
You can write ?, but no warnings |
disable |
Legacy behaviour (default for old projects) |
It's compile-time only. The IL contains attributes (NullableAttribute) that other compilers and tools read; the runtime ignores them. A string parameter can still receive null from a caller with nullable disabled, so public entry points still need ArgumentNullException.ThrowIfNull.
<Nullable>warnings</Nullable> in CI only (or on a branch) and count CS86xx warnings per project. That's the size of the job.<Nullable>enable</Nullable> in the project file, fix everything in one PR.#nullable enable at the top of files as you touch them (the "boy scout rule"), and require it for new files.<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors> so new nullable warnings fail the build.<Nullable>enable</Nullable> and the warnings-as-errors setting to Directory.Build.props and delete the per-file directives.? and handle it. Don't sprinkle ! to silence warnings.[NotNullWhen(true)] on TryGet patterns, [MemberNotNull(nameof(_x))] on initialisation helpers, [return: NotNullIfNotNull(nameof(input))].required members and primary constructors make it easier to guarantee non-null properties on DTOs.string properties become required (NOT NULL) columns. Enabling it on an entity project can generate a migration that alters columns. Review it, and annotate entities to match the real schema.A 120-project insurance platform had about 40,000 nullable warnings when enabled. The team migrated over two quarters: shared libraries first in dedicated PRs, then per-file adoption in applications during normal feature work, with a CI dashboard of warnings per project. Projects were locked with WarningsAsErrors=nullable as they reached zero. Production NullReferenceExceptions in error logs fell by roughly 70% over the period, and the EF Core review caught three entities whose annotations didn't match the database.
!; it turns compile-time warnings back into run-time exceptions.Probes experience leading a cross-cutting code-quality change.
Green flags: explains what nullable does and doesn't do; plans bottom-up, incremental adoption; locks progress with warnings as errors; uses attributes instead of !; mentions EF Core and public API impacts.
Red flags: big-bang enable; ! everywhere; thinks it adds run-time checks.
Nullable reference types are controlled by two independent switches. The <Nullable> property and the #nullable directive set them together or separately:
| Setting | Annotation context | Warning context | What you get |
|---|---|---|---|
disable |
off | off | Legacy C#: every reference type is "oblivious" and string? is an error |
annotations |
on | off | You can write string? and annotate APIs, but the compiler doesn't warn |
warnings |
off | on | Warnings for obvious null misuse in the code, but your own types stay oblivious |
enable |
on | on | Full feature: string means non-null, string? means maybe-null, and CS86xx warnings |
The same values work as directives inside a file: #nullable enable, #nullable disable, #nullable enable warnings, #nullable disable annotations and #nullable restore (go back to the project setting). That is what makes file-by-file and region-by-region migration possible.
source: public string Name { get; set; } public string? Notes { get; set; }
| |
compiler: null-state analysis, CS86xx warnings <-- all of the checking happens here
|
IL: [Nullable(1)] string Name [Nullable(2)] string Notes
(metadata for other compilers and tools; the runtime ignores it)
|
runtime: both are just System.String references. Null can still arrive.
Null can still reach a non-nullable string from:
default structs and arrays (new string[10] holds ten nulls);! operator, which tells the compiler to stop checking.So validation at trust boundaries stays: ArgumentNullException.ThrowIfNull on public entry points, and model validation on API requests.
The compiler tracks a null state for every expression: not-null or maybe-null.
string? preferred = customer.PreferredName; // maybe-null (declared string?)
if (preferred is null) // after this branch...
return customer.LegalName;
return preferred.ToUpperInvariant(); // ...not-null here, no warning
It understands is null, is not null, == null, ??, ??=, ?., pattern matching and throw expressions. It does not follow calls into other methods unless those methods carry attributes that describe their behaviour, which is what section 5 is about.
Two things it deliberately doesn't track well:
_cache was set to null in it. Copy the field into a local before checking it.list[0] has the annotation of the element type, regardless of what you stored.| ID | Example | Typical fix |
|---|---|---|
| CS8618 | public string Name { get; set; } with no constructor setting it |
required, a constructor parameter, or string? if it really can be missing |
| CS8602 | customer.Address.Postcode where Address is Address? |
Check for null, or use ?. and handle the null result |
| CS8600 | string s = dict.GetValueOrDefault(key); |
Declare string?, or use TryGetValue |
| CS8601 | order.Notes = request.Notes; where Notes is string? |
Make the target nullable, or supply a default with ?? |
| CS8603 | return _cache.Find(id); from a method returning Customer |
Return Customer?, or throw when not found |
| CS8604 | Send(customer.Email) where Email is string? |
Check before the call, or change the parameter |
| CS8625 | Process(null) where the parameter is string |
Pass a real value, or make the parameter nullable |
| CS8619/CS8620 | List<string?> passed where List<string> is expected |
Fix the generic type argument |
WarningsAsErrors=nullable and NoWarn=nullable are shorthands that cover the whole nullable warning set, so you don't need to list IDs.
From System.Diagnostics.CodeAnalysis:
| Attribute | Where | Tells the compiler |
|---|---|---|
[NotNullWhen(true)] |
out parameter of a TryGet method |
When the method returns true, the argument is not null |
[MaybeNullWhen(false)] |
out T in a generic TryGet |
When it returns false, the value may be default |
[NotNull] |
parameter | After the call returns, the argument is not null (for example a guard method) |
[AllowNull] / [DisallowNull] |
property or parameter | Setter accepts null but getter never returns it, or the reverse |
[MaybeNull] |
return or generic T |
May return default even though T is non-nullable |
[return: NotNullIfNotNull(nameof(input))] |
return value | Returns non-null whenever input is non-null |
[MemberNotNull(nameof(_client))] |
initialisation helper method | After it runs, _client is set |
[MemberNotNullWhen(true, nameof(Value))] |
bool property such as HasValue |
When true, Value is not null |
[DoesNotReturn] / [DoesNotReturnIf(false)] |
throw helpers and asserts | Code after the call is unreachable |
public sealed class ConnectionHolder
{
private SqlConnection? _connection;
[MemberNotNull(nameof(_connection))]
private void EnsureOpen()
{
_connection ??= new SqlConnection(_connectionString);
}
public Task<int> CountAsync()
{
EnsureOpen();
return _connection.ExecuteScalarAsync<int>("select count(*) from Claims"); // no warning
}
}
This is where "compile-time only" stops being the whole story. Some libraries read the nullable metadata at run time:
| Component | Behaviour with nullable enabled | What to do |
|---|---|---|
| EF Core | Non-nullable reference properties become required (NOT NULL) columns, and navigations become required relationships |
Run dotnet ef migrations add after enabling it on an entity project and read the migration before merging |
| ASP.NET Core MVC and Razor Pages | Non-nullable reference properties on models are treated as if they had [Required] during model validation |
Expect new 400 responses for previously optional fields; make optional fields string?, or set SuppressImplicitRequiredAttributeForNonNullableReferenceTypes while migrating |
| System.Text.Json (.NET 9+) | Ignores annotations by default; RespectNullableAnnotations = true makes it throw when JSON null goes into a non-nullable member |
Turn it on deliberately, not as a side effect |
| Minimal APIs | A non-nullable parameter is required, and a missing value returns 400 | Declare optional parameters as nullable or give them defaults |
The EF Core and MVC rows are why "enable nullable" on an entity or API project is a behaviour change that needs testing, not just a warning clean-up.
Step 1: measure Step 2: order by dependency
-------------------------- -------------------------------------
dotnet build Shared.Kernel (leaf, no deps) <-- first
-p:Nullable=enable Domain -> Shared.Kernel
-flp:"logfile=nrt.log; Infrastructure -> Domain
warningsonly" Api / Workers -> everything <-- last
count CS86xx per project
-p:Nullable=enable in a CI job, which applies to every project without changing a file, and count CS86xx warnings per project from the log. That number is the size of the job, and the per-project breakdown shows where to start.<Nullable>enable</Nullable> in the project file and fix it in one focused pull request.disable, add #nullable enable to each file as it's touched, and require it for new files.enable and add #nullable disable to the top of every existing file with a script. New files are then nullable by default, and the task becomes "remove the directive from one file at a time". This is often easier to track, because the remaining work is a simple count of files containing #nullable disable.<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>, so a new warning fails its build.<Nullable>enable</Nullable> and the warnings-as-errors setting to Directory.Build.props, delete the per-project settings and any leftover directives, and add a check in CI that no #nullable disable remains outside generated code.<auto-generated> header or names such as .g.cs and .designer.cs) is treated as nullable disabled unless it contains a #nullable directive, so it doesn't add to the count.new followed by Init()) fights the analysis. Fix it with constructor initialisation or [MemberNotNull], not with !.required members so construction sites must set them, and string? for genuinely optional fields.= default! or = null! is acceptable only for members a framework always sets, such as EF Core DbSet<T> properties. Each one is a promise the compiler can't check.| Symptom | Likely cause | Check |
|---|---|---|
| No warnings at all after enabling | Setting overridden later, or the file has #nullable disable |
dotnet msbuild -getProperty:Nullable and search the file for the directive |
| Warnings appear in a project nobody changed | A dependency was just annotated, so calls are no longer oblivious | Look at the dependency's recent pull requests |
| A new EF Core migration alters many columns | Entity project switched to nullable and properties didn't match the schema | Compare the migration with the database and annotate the entities to match it |
| APIs start returning 400 for optional fields | MVC implicit [Required] on non-nullable properties |
Make those properties nullable |
NullReferenceException despite zero warnings |
!, default!, reflection, or an oblivious caller |
Search for ! near the stack frame; add boundary checks |
null; validate request models and keep guard clauses on public APIs.! operator turns a compile-time warning back into a possible run-time exception. Review each one, and consider an analyser rule or a review checklist that flags new uses..NET 11 (due in November 2026, an STS release with 24 months of support, currently in preview and release candidates) doesn't change how nullable reference types work. The migration approach in this answer applies to .NET 10 LTS and .NET 11 alike; check the release notes of each SDK upgrade for new nullable-related warnings or analyser defaults.
<Nullable>enable</Nullable>, and paste in CustomerDirectory from the code example.[NotNullWhen(true)] from TryGet and see CS8602 appear in DisplayName.<WarningsAsErrors>nullable</WarningsAsErrors> and confirm the warning now fails the build.#nullable disable at the top of the file and see the warnings disappear; replace it with #nullable restore.dotnet build -p:Nullable=enable -flp:"logfile=nrt.log;warningsonly" and count warnings per project to size a real migration.string property to a request model, post JSON without it and note the 400 response. Change it to string? and try again.<!-- Per project during migration -->
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
</PropertyGroup>
using System.Diagnostics.CodeAnalysis;
namespace Insurance.Customers;
public sealed record Customer(Guid Id, string LegalName, string? PreferredName, string? Abn);
public sealed class CustomerDirectory
{
private readonly Dictionary<Guid, Customer> _customers = [];
public void Add(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer); // callers without nullable can still pass null
_customers[customer.Id] = customer;
}
public bool TryGet(Guid id, [NotNullWhen(true)] out Customer? customer) =>
_customers.TryGetValue(id, out customer);
public string DisplayName(Guid id)
{
if (!TryGet(id, out var customer))
{
return "Unknown customer";
}
// Flow analysis knows customer is not null here
return customer.PreferredName ?? customer.LegalName;
}
}
// A file in a large legacy project, migrated as it was touched
#nullable enable
namespace Insurance.Claims;
public sealed class ClaimSummary
{
public required string ClaimNumber { get; init; }
public string? AssessorNotes { get; init; }
}
Mistake: turning it on for the whole solution in one pull request.
A change with tens of thousands of warnings can't be reviewed, conflicts with every other branch and usually gets abandoned, or gets "fixed" with ! everywhere.
Instead: measure first, then migrate project by project or file by file in small pull requests that merge alongside feature work.
Mistake: silencing warnings with the ! operator.
! tells the compiler to stop checking, so the warning disappears but the NullReferenceException stays, now hidden.
Instead: model reality. Make the type string? and handle null when a value can be missing, and use attributes such as [NotNullWhen(true)] or [MemberNotNull] where the code is correct but the compiler can't see why. Keep ! for a few documented framework boundaries.
Mistake: starting with the applications. Calls into unannotated libraries are oblivious, so the application looks clean. When the libraries are annotated later, a second wave of warnings arrives in code that was already "done". Instead: migrate bottom-up: shared and domain libraries first, then infrastructure, then applications.
Mistake: removing run-time null checks because the type is non-nullable.
Annotations are compile-time only. Null still arrives from callers without nullable, serialisers, reflection and other languages.
Instead: keep ArgumentNullException.ThrowIfNull on public entry points and validate request models at trust boundaries.
Mistake: treating it as a warnings-only change for EF Core and ASP.NET Core projects.
EF Core makes non-nullable properties required columns, and MVC treats them as [Required], so enabling nullable can generate a schema migration or start rejecting requests.
Instead: after enabling it on an entity project, run dotnet ef migrations add and review the result. On API projects, mark optional request fields as nullable and run the API tests.
Mistake: not locking in progress.
A project that reaches zero warnings drifts back as new code adds warnings nobody notices.
Instead: add <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors> as soon as a project is clean, and track the remaining warning count per project in CI.
Mistake: changing public library annotations silently. Consumers with nullable enabled get new warnings, or new errors if they treat warnings as errors, after a minor package update. Instead: treat annotation changes on public APIs as API changes: review them, note them in release notes and ship them in a planned version.
Mistake: using = null! to make CS8618 go away on every property.
Each one is an unchecked promise, so a forgotten assignment becomes a run-time null.
Instead: use required members or constructor parameters, make genuinely optional members nullable, and keep = null! for members a framework always sets, such as DbSet<T>.
Mistake: believing nullable adds run-time checks or slows the app. Neither is true. No checks are emitted and there is no run-time cost. Instead: explain it as compile-time analysis plus metadata, and keep explicit checks where they matter.
Nullable adoption is a change-management problem more than a technical one. A senior engineer sizes it, orders it by dependency, makes each step small enough to merge alongside feature work, and uses the build (WarningsAsErrors=nullable, a CI warning dashboard, "new files must be nullable-enabled") to stop regression. They're honest about the limits: annotations are compile-time promises, so boundaries still validate, and EF Core and public APIs need explicit review. The payoff is fewer production null reference exceptions and APIs that document themselves.
The file under review turns on TreatWarningsAsErrors and then hides 25 warnings with NoWarn. That list includes nullable warnings, obsolete-API warnings and a security rule, so the policy looks strict while silencing the warnings that matter most. I'd remove those suppressions. Rules that genuinely don't apply would get a severity in .editorconfig, scoped to the right folders and commented. Rules we can't fix yet would go in WarningsNotAsErrors, so they stay visible as warnings. I'd also pin AnalysisLevel so an SDK upgrade can't break the build overnight, apply the same settings locally and in CI, and ratchet a legacy codebase towards zero warnings rather than flipping everything at once.
I generate migrations locally with dotnet ef migrations add, then make deployment a separate, reviewed step. The pipeline produces an artefact: either an idempotent SQL script (dotnet ef migrations script --idempotent) for a DBA to review, or a migration bundle (dotnet ef migrations bundle), a self-contained executable that applies pending migrations. That artefact runs once, before the new app version starts, with a dedicated deployment identity. I avoid Database.Migrate() at startup because several instances can race, the app needs DDL permissions it shouldn't have, and a failed migration turns into a crash loop.
Analysers are plug-ins for the Roslyn compiler. They report diagnostics in the editor and during the build. CA rules (CA1xxx to CA5xxx) are the SDK's code quality analysers, covering design, performance, reliability and security. The .NET analysers are enabled by default for net5.0+ projects (EnableNETAnalyzers), but only a default subset of rules reports as warnings; AnalysisLevel and AnalysisMode set how many apply. IDE rules (IDE0xxx) are code style rules, such as var usage or file-scoped namespaces. They show in the editor but only run in the build when EnforceCodeStyleInBuild is true, which is why style issues often pass CI. Severities for both are set in .editorconfig (or a .globalconfig) with dotnet_diagnostic.<id>.severity. Third-party analysers come in as NuGet packages. For a team, I put the levels in Directory.Build.props, the severities in one root config file, and tighten them step by step.
How to tell it (STAR)
**Situation:** At [company], null reference exceptions were our top production error, and turning on nullable across our [120] projects produced about [40,000] warnings. **Task:** I led the rollout without stopping feature work for [eight] teams. **Action:** I measured warnings per project in CI, migrated shared libraries first, then had teams adopt nullable file by file as they touched code. Each project was locked with nullable warnings as errors once clean, and I reviewed the EF Core migrations that nullability changed. **Result:** Over [two quarters] every project was nullable-enabled, null reference exceptions in production fell by [about 70%], and we caught [three] entity mismatches with the database along the way.
Last updated 1 Nov 2026.