C# & .NET (C# 14, .NET 10 LTS) Module 9 – Null safety
Nullable reference types and flow analysis
Turn on C# nullable reference types, read the warnings CS8600, CS8602, CS8618 and CS8625, and let flow analysis prove a value is not null before it is used.
What you will learn
- Switch on nullable reference types and explain what CS8600, CS8602, CS8618 and CS8625 report
- Write null checks that flow analysis follows, so values are proven not null before they are read
- Use the null-forgiving operator only where you can show the value is not null
- Choose T?, class, struct or notnull so that generic code says exactly what may be null
- Configure nullable contexts and nullable warnings as errors for a project and for single files
Before you start
What you’ll learn · Before you start
What you will learn
- Switch on nullable reference types and explain what CS8600, CS8602, CS8618 and CS8625 report
- Write null checks that flow analysis follows, so values are proven not null before they are read
- Use the null-forgiving operator only where you can show the value is not null
- Choose T?, class, struct or notnull so that generic code says exactly what may be null
- Configure nullable contexts and nullable warnings as errors for a project and for single files
Before you start
On this page
An export of 1,200 customers stopped at row 412 with NullReferenceException: one customer had no email address,
and the code called customer.Email.Trim(). The type string had always allowed null, and nothing at compile time
said which strings might be missing.
Nullable reference types put that information into the types, and the compiler checks it. You will turn them on, read the warnings they raise, and write null checks that the compiler can follow, so that a build without warnings means every read was checked.
string? says a reference may be null, and the compiler checks the rest
With nullable reference types on, string means a string that is never null, and string? one that may be. New
projects have had them on since the .NET 6 templates, through <Nullable>enable</Nullable> in the project file, and
so do file-based apps: the project that dotnet project convert writes for one sets it too. warnings/Program.cs
breaks the rules in four ways, and the session builds and runs it:
// Four kinds of nullable warning. The build still succeeds, and the program runs.
string? found = Lookup("C-101");
string missing = Lookup("C-102");
Console.WriteLine($"found has {found.Length} characters");
Send(null);
Console.WriteLine($"City is null: {new Customer().City is null}");
Console.WriteLine($"missing has {missing.Length} characters");
static string? Lookup(string id) => id == "C-101" ? "asha@example.com" : null;
static void Send(string address) => Console.WriteLine($"sending to {address ?? "nobody"}");
class Customer
{
public string City { get; set; }
} from session import run
# warnings builds with four kinds of nullable warning, and then runs until it reads the null it was warned about.
run("""
cd warnings
dotnet build -v q
!dotnet bin/Debug/net10.0/App.dll
""") Output
$ cd warnings
$ dotnet build -v q
warnings/Program.cs(15,19): warning CS8618: Non-nullable property 'City' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [warnings/App.csproj]
warnings/Program.cs(3,18): warning CS8600: Converting null literal or possible null value to non-nullable type. [warnings/App.csproj]
warnings/Program.cs(4,32): warning CS8602: Dereference of a possibly null reference. [warnings/App.csproj]
warnings/Program.cs(5,6): warning CS8625: Cannot convert null literal to non-nullable reference type. [warnings/App.csproj]
warnings/Program.cs(7,34): warning CS8602: Dereference of a possibly null reference. [warnings/App.csproj]
Build succeeded.
warnings/Program.cs(15,19): warning CS8618: Non-nullable property 'City' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [warnings/App.csproj]
warnings/Program.cs(3,18): warning CS8600: Converting null literal or possible null value to non-nullable type. [warnings/App.csproj]
warnings/Program.cs(4,32): warning CS8602: Dereference of a possibly null reference. [warnings/App.csproj]
warnings/Program.cs(5,6): warning CS8625: Cannot convert null literal to non-nullable reference type. [warnings/App.csproj]
warnings/Program.cs(7,34): warning CS8602: Dereference of a possibly null reference. [warnings/App.csproj]
5 Warning(s)
0 Error(s)
Time Elapsed …
$ dotnet bin/Debug/net10.0/App.dll
found has 16 characters
sending to nobody
City is null: True
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<Main>$(String[] args) in warnings/Program.cs:line 7
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py
The build succeeded with five warnings, and each one names a line:
| Warning | Line | What the compiler saw |
|---|---|---|
| CS8600 | 3 | Lookup returns string?, and its result went into a string |
| CS8602 | 4 and 7 | .Length read on a value that may be null |
| CS8625 | 5 | the literal null passed for a string parameter |
| CS8618 | 15 | City is a string, and no constructor or initializer sets it |
Then the program ran. Line 4 got lucky, because C-101 has an email address, and line 7, which CS8602 pointed at, threw
NullReferenceException. The annotations changed nothing at run time: string and string? are both
System.String, and the compiler adds no check. Everything nullable reference types do happens while the code
compiles.
Key idea
string? declares that a reference may be null; the compiler then warns wherever a maybe-null value goes into a
string or is read, and the program runs exactly as it would without the annotations.
Checkpoint
Check: string, string? and the warnings they raise
Flow analysis proves a value is not null before it is read
The compiler gives every expression a null-state: What the compiler knows about a reference at one point in the code: not-null or maybe-null. Assignments and null checks change it, and reading a maybe-null value draws warning CS8602., not-null or maybe-null, and updates it
after each assignment and each null check. That tracking is flow analysis: The compiler's tracking of null-state through the statements of a method: it follows assignments, checks, patterns and early returns, but not the bodies of the methods a line calls..
checks/Program.cs reads the same lookups after a check, a pattern, ?? and required:
// The same lookups, written so that flow analysis can prove each value is not null where it is read.
string? found = Lookup("C-101");
if (found is null)
{
return;
}
Console.WriteLine($"found has {found.Length} characters");
string? missing = Lookup("C-102");
Console.WriteLine(missing is not null ? $"missing has {missing.Length} characters" : "C-102 has no email");
string email = Lookup("C-102") ?? "no-reply@example.com";
Console.WriteLine($"C-102 is sent to {email}");
var customer = new Customer { City = "Pune" };
Console.WriteLine($"{customer.City} has {customer.City.Length} letters");
static string? Lookup(string id) => id == "C-101" ? "asha@example.com" : null;
class Customer
{
public required string City { get; set; }
} from session import run
# checks reads the same values after a null check, a pattern, ?? and required: it builds without a warning.
run("""
cd checks
dotnet build -v q
dotnet bin/Debug/net10.0/App.dll
""") Output
$ cd checks
$ dotnet build -v q
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed …
$ dotnet bin/Debug/net10.0/App.dll
found has 16 characters
C-102 has no email
C-102 is sent to no-reply@example.com
Pune has 4 letters
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checks_session.py
No warnings. Step through the null-state of each value:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- maybe-null. Lookup returns string?, so found starts as maybe-null. A read of found.Length here would draw CS8602.
- A check that leaves. if (found is null) return; ends the program on the null path. Every statement after it runs only when found has a value.
- not-null. After the check the compiler knows found is not-null, so found.Length draws no warning. The run printed found has 16 characters.
- One branch at a time. missing is maybe-null again, because C-102 has no email. In missing is not null ? … : …, the first branch sees not-null; the run took the other one and printed C-102 has no email.
- A default. ?? "no-reply@example.com" gives a string that is never null, so email can be a plain string and is not-null from the start.
- required. City is required, so every object initializer must set it, and the compiler treats it as not-null: no CS8618, and Pune has 4 letters.
A check that leaves the method, if (found is null) return;, proves found for every line after it. Inside
missing is not null ? … : … only the first branch reads missing. ?? gives a value that is never null, and
required makes every object initializer set City, which removes CS8618. Microsoft’s guide states the limit: the
analysis does not trace into the bodies of methods, so a check hidden inside a helper proves nothing at the call site.
Making the warning go away by changing the type
Turning string missing into string? missing removes CS8600, and the warning moves to the next read of
missing. That is progress only if the read gets a check: a ? says that the value may be missing, and the line
that reads it must then handle that.
Checkpoint
Check: Null-state and flow analysis
Three tools go beyond these checks. The null-forgiving operator: A ! written after an expression, as in name!: it tells the compiler to treat the expression as not null and removes its nullable warnings. It changes nothing when the program runs. ! tells
the compiler that a value is not null, and changes nothing when the program runs. In generic code, T? and the
notnull constraint say whether a type argument may be null. #nullable directives and WarningsAsErrors decide
where the checks apply and whether a nullable warning stops the build.
The ! operator and the nulls flow analysis cannot see, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A build without warnings proves less than it seems: ! removes a warning without removing the null, and some nulls
never draw a warning at all. Each case below was built and run on the .NET 10 SDK.
In this part:
- The null-forgiving operator removes the warning and keeps the crash
- Four nulls that draw no warning, measured
- When the null-forgiving operator is the right tool: a decision table
4 runnable examples (C#, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Generic code: what T? means for int and for string, measured Pro: opens with a Pro, Premium or Ultimate pass
T may turn out to be a class or a struct, so T? cannot mean the same thing for both. The run below shows the case
that catches callers out: a missing value that comes back as 0.
In this part:
- T? on an unconstrained T gives 0 for int, reproduced
- notnull, measured on a registry and on Dictionary
3 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Stricter builds: nullable warnings as errors, file by file Pro: opens with a Pro, Premium or Ultimate pass
A warning that the build accepts can be ignored, and an error cannot. This part makes nullable warnings stop the build, and shows how one file can stay outside the checks while the rest of the project is strict.
In this part:
- WarningsAsErrors set to nullable, measured
- Nullable contexts: two switches per file
- Coming from Java: annotations against Optional
4 runnable examples (C#, CSPROJ, Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: an address book that builds with warnings as errors
addressbook treats every nullable warning as an error, and its build fails with four. The comment at the top of
Program.cs lists the four lines it prints once it builds:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
</Project> // Practice: make this project build with nullable warnings as errors. Change types, add checks, and use ! at most
// once, with a comment that says why it is safe. When it builds, dotnet run prints these four lines:
// Asha Rao: asha@example.com, 0 characters
// Ravi Iyer: no email, 11 characters
// Meena Das: not in the book
// last search: Meena Das
var book = new AddressBook();
book.Add(new Contact("Asha Rao", "asha@example.com", null));
book.Add(new Contact("Ravi Iyer", null, "98200 11122"));
foreach (string name in new[] { "Asha Rao", "Ravi Iyer", "Meena Das" })
{
Contact contact = book.Find(name);
Console.WriteLine($"{name}: {contact.Email.ToLowerInvariant()}, {contact.Phone.Length} characters");
}
Console.WriteLine($"last search: {book.LastSearch}");
class Contact
{
public Contact(string name, string email, string phone)
{
Name = name;
Email = email;
Phone = phone;
}
public string Name { get; }
public string Email { get; }
public string Phone { get; }
}
class AddressBook
{
private readonly List<Contact> _contacts = [];
public string LastSearch { get; private set; }
public void Add(Contact contact) => _contacts.Add(contact);
public Contact Find(string name)
{
LastSearch = name;
foreach (Contact contact in _contacts)
{
if (contact.Name == name)
{
return contact;
}
}
return null;
}
} from session import run
# The practice project as it starts: built with nullable warnings as errors. The compiler reports its errors in the
# order it finds them, which can change from one build to the next, so they are sorted, each shown once.
run("""
cd addressbook
dotnet build -v q | grep -E "error CS|Error\\(s\\)" | sort -u
""") Output
$ cd addressbook
$ dotnet build -v q | grep -E "error CS|Error\(s\)" | sort -u
4 Error(s)
addressbook/Program.cs(39,19): error CS8618: Non-nullable property 'LastSearch' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable. [addressbook/App.csproj]
addressbook/Program.cs(54,16): error CS8603: Possible null reference return. [addressbook/App.csproj]
addressbook/Program.cs(8,54): error CS8625: Cannot convert null literal to non-nullable reference type. [addressbook/App.csproj]
addressbook/Program.cs(9,35): error CS8625: Cannot convert null literal to non-nullable reference type. [addressbook/App.csproj]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 addressbook_session.py
Make addressbook build
Copy the addressbook folder. Decide which values may really be missing and give them ?, initialize
LastSearch, let Find return Contact?, and check its result before reading it. Each change moves a warning to
the lines that use the value, so build after each one. Run dotnet build until it reports 0 errors, then
dotnet run.
Then take the self-check on the first two sections, string? and flow analysis: it starts with whichever of them you
have answered less well so far.
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between string and string? in C#?
Both are System.String when the program runs: a variable of either type can hold null, and nothing checks it at
run time. The difference is a promise that the compiler checks. With nullable reference types on, string promises
that the value is never null, so the compiler warns when a value that may be null is assigned to it (CS8600) or
when a non-nullable property is left unset (CS8618). string? says the value may be missing, so the compiler warns
when it is read without a check (CS8602). The annotations cost nothing at run time, and they turn the question “can
this be null?” into a warning at the line where it matters.
-
Mid to senior
Nullable reference types are on and the build has no warnings, yet a nightly export crashes with NullReferenceException on customer.Email.Trim(). Email is declared string, and customers are read from a JSON file. How did the null get there, how do you fix the crash, and how do you stop it happening again?
-
Mid to senior
A pull request turns nullable warnings into errors in a large project and makes the build pass by adding the ! operator in 140 places. Would you merge it as it is? How would you check those changes, and what would you ask for instead?
-
Mid
A generic method T? Find<T>(IEnumerable<T> items, Func<T, bool> match) returns default when nothing matches. For stock counts, callers cannot tell "not found" from a real count of 0. Explain why T? did not help, and design a version that works for value types and reference types.
Key takeaways
- With nullable reference types on,
stringmeans nevernullandstring?means may benull; the compiler warns where the two meet, and the program runs exactly as it would without the annotations. - CS8600, CS8602, CS8618 and CS8625 point at an assignment, a read, an unset member and a
nullliteral; each marks a line where anullcan arrive or be read. - Flow analysis tracks maybe-null and not-null through assignments, checks, patterns,
??andrequired, but not into the bodies of the methods a line calls.
Terms in this lesson
- flow analysis
- The compiler's tracking of null-state through the statements of a method: it follows assignments, checks, patterns and early returns, but not the bodies of the methods a line calls.
- null-forgiving operator
- A ! written after an expression, as in name!: it tells the compiler to treat the expression as not null and removes its nullable warnings. It changes nothing when the program runs.
- null-state
- What the compiler knows about a reference at one point in the code: not-null or maybe-null. Assignments and null checks change it, and reading a maybe-null value draws warning CS8602.
References
- Nullable reference types (C# fundamentals) (Microsoft Learn)
- Nullable reference types (C# reference) (Microsoft Learn)
- Nullable reference type warnings (Microsoft Learn)
- ! (null-forgiving) operator (Microsoft Learn)
- Compiler options: errors and warnings (WarningsAsErrors) (Microsoft Learn)
- Respect nullable annotations (System.Text.Json) (Microsoft Learn)
- The history of C# (Microsoft Learn)
- Enumerable.FirstOrDefault method (Microsoft Learn)
- Constraints on type parameters: the notnull constraint (Microsoft Learn)
- Optional (Java SE 25 and JDK 25 API) (Oracle)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress