What is the billion-dollar mistake?
The concept of null runs through programming languages. We encounter it in different forms: None, 0, NULL, nil, nullptr, undefined. All of these expressions denote the same thing: a missing or unassigned value that we are referring to. Tony Hoare (Sir Charles Antony R. Hoare), a Turing Award winner and the author of the Quicksort algorithm, described the introduction of the null reference as his "billion-dollar mistake" at OOPSLA 2009. In 1965, he was working on the type system of ALGOL W with the goal of ensuring safe references checked at compile time. He nevertheless added the null reference as a fallback, which later became the source of many bugs and vulnerabilities. Hoare later admitted that he would not make the same decision again.
Null eventually made its way into many modern languages. Languages with pointers (C, C++) use it as a special pointer value; later it was also adopted by Java, Python, and C#, because it made interoperability easier and matched programmer expectations. Null became the de facto standard for representing missing values, but its presence brought a number of problems. From the perspective of the type system, null is a weakness: it is not possible to express unambiguously whether a reference can be null. That means almost every reference is potentially null and has to be checked before use.
Why is the cost of null so high?
Hoare was talking about the cumulative costs that null has imposed on the software industry. Even today (17 years later), exceptions such as NullReferenceException are among the most common runtime errors. null dereference (CWE-476: NULL Pointer Dereference) regularly appears in rankings such as CWE Top 25 Most Dangerous Software Weaknesses. Every such problem costs time spent debugging, rolling back deployments, managing incidents, and in the worst case also introduces a security risk. If we add up those costs across decades and billions of lines of code, a number with nine zeroes suddenly no longer feels like hyperbole. The total will of course continue to grow, because null is still in use and will remain in use.
An example of how unchecked null can cause large-scale damage was the global Google Cloud outage in June 2025. A bug in newly deployed code led to null dereferences in a critical part of the control plane, resulting in widespread service outages for several hours. Detailed analyses of the incident can be found in the ThousandEyes and Google status reports.
Mitigating the impact
There are several strategies for mitigating the impact of null, and in practice they are often combined:
- Defensive programming: checking values before use (for example in constructors). This approach leads to verbose code and can still leave room for bugs.
- Null object pattern: instead of
null, we return an object that implements the required interface and safely does nothing. Example in C#:
public interface ILogger
{
void Log(string message);
}
public class NullLogger : ILogger
{
public void Log(string message)
{
// No operation
}
}
- Option/Optional/Maybe: the algebraic data type
Option<T> explicitly expresses the possibility of a missing value. This approach is common in languages with ADTs (F#, Rust, OCaml). Example in F#:
type Option<'T> =
| Some of 'T
| None
Null aware type system: the language distinguishes at the type level whether a reference can be null. This is used, for example, in Kotlin, Swift, and Dart, and since version 8 also in C#.
ADTs in C#?
The C# community has been discussing ADTs since at least 2017, and various proposals for introducing them keep resurfacing. Pattern matching, added in C# 7.0 and improved in later versions, makes it possible to emulate ADTs, but support for exhaustive checking is still missing: the compiler does not know that not all possible cases are handled if you add a new subtype. Here is an example of ADT emulation using record inheritance in modern C#:
abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double W, double H) : Shape;
double Area(Shape s) => s switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.W * r.H,
_ => throw new UnreachableException()
};
Currently, ADTs in C# are in the proposal stage.
Those who do not want to wait for native support can turn to community libraries. LanguageExt provides a full-fledged functional toolkit including Option<T>, Either<L, R>, Try<T>, and dozens of other types inspired by functional languages. Optional, by contrast, is a minimalist alternative focused primarily on Option<T> and Option<T, TException>. Both libraries help enforce explicit handling of both branches when working with Option<T> through Match functions:
Option<string> name = GetName();
string result = name.Match(
Some: n => $"Hello, {n}",
None: () => "Hello, stranger"
);
C#'s path to safety
The defensive-programming approach leads to chains of null checks, which results in verbose and hard-to-read code like this:
if (user != null)
if (user.Address != null)
if (user.Address.Country != null)
Console.WriteLine(user.Address.Country.Name);
As an initial attempt to mitigate the null problem in C#, we can look at the null coalescing and null conditional operators:
Console.WriteLine(user?.Address?.Country?.Name ?? "Unknown");
The code is certainly shorter and easier to read. But it still does not solve the problem that user may not have an address, even if that state should not be valid.
Null annotations
Null annotations (nullable reference types) were introduced in C# 8.0 and follow a simple principle: the compiler divides reference types into two categories depending on whether they can be null or not. The key element is ? after the type name:
string name = "Alice"; // must not be null, the compiler guards this through warnings
string? nickname = null; // explicitly declared as possibly null
In practice, the compiler tracks data flow and warns about places where a null dereference is possible:
string? nickname = GetNickname();
Console.WriteLine(nickname.Length); // Warning CS8602: possible null dereference
if (nickname != null)
Console.WriteLine(nickname.Length); // Fine, the compiler knows nickname is not null
A positive side effect is that flow analysis also eliminates a number of unnecessary checks - if the compiler can infer that a variable cannot be null, it does not require an explicit check.
Null annotations can be enabled at the project level using <Nullable>enable</Nullable> in the .csproj file, or at the file level using the #nullable enable directive.
Základní analyzér je celkem dobrý, ale stále existují situace, kdy není schopen odvodit bezpečnost referencí správně. Např. když konstruktor deleguje inicializaci referencí do jiné metody:
public class Service
{
private string value;
public Service()
{
InitializeValue();
}
private void InitializeValue()
{
value = "Logger initialized";
}
}
Type vs. annotation
At first glance, reference null annotations look the same as for value types (int?, DateTime?), which C# has known since version 2.0. The unified syntax is intentional, but the semantics are different. For value types, int? is a different type (Nullable<int>), while for reference types it is only an annotation. At runtime, string and string? are identical.
Unfortunate defaults
The documentation describes two states a reference can have in a null aware context:
not-null: The expression is known to be non-null.maybe-null: The expression might be null.
If your annotated project references a legacy library that is not annotated, all of its references will surprisingly be marked as not-null. This leads to overly optimistic assumptions, and in this case the analysis is effectively powerless. It should also be noted that this is not described especially clearly in the documentation, and the naming of both categories is quite confusing. In a way, this is just another one of the unfortunate defaults that C# has for historical reasons: sealed is not the default, immutable is not the default, etc.
The decision was certainly not simple, and it certainly has its reasons. Unfortunately, there is also no switch we can use to change it. In practice, that leaves us with one of the following options:
- Rewrite the legacy library and add
null annotations. - Create a boundary using adapters or wrappers that will translate unannotated references.
- Write a custom Roslyn analyzer that will label
null oblivious references better. - Accept that
null annotations have gaps and use defensive programming alongside them.
Changing the development style
If we decide to use null annotations and make them our main way of mitigating null dereferences, it makes sense to adjust the development style as well. First of all, it is a good idea to turn on warnings as errors (at least) for all relevant warnings:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>CS8600;CS8601;CS8602;CS8603;CS8604;CS8605</WarningsAsErrors>
</PropertyGroup>
The next step should be to establish rules for using the suppression operator ! (null-forgiving operator). It should be used only in cases where we are sure that the reference is not null, but the compiler cannot infer that. At the same time, each use of ! should be accompanied by a comment explaining why it is safe (! is easy to overlook in code). The Roslyn analyzer Nullable.Extended.Analyzer can help enforce these rules.
The built-in analyzer is quite good, but there are still situations where it cannot infer reference safety correctly. For example, when a constructor delegates reference initialization to another method:
public class Service
{
private string value;
public Service()
{
InitializeValue();
}
private void InitializeValue()
{
value = "Logger initialized";
}
}
The analyzer reports this as warning CS8618: Non-nullable field 'value' must contain a non-null value when exiting constructor. The solution is to adjust the initialization so that it really happens in the constructor. The result is a satisfied analyzer and cleaner code; for example, we can mark the field as readonly:
public class Service2
{
private readonly string value;
public Service2()
{
value = InitializeValue();
}
private string InitializeValue()
{
return "Logger initialized";
}
}
Dependency injections through a property or method are not very compatible with null annotations. The analyzer will keep warning that the dependencies may be null, and it will be right. So if you want to use null annotations, it is a good idea to avoid this form of DI and move to constructor injection where possible.
The same applies to other forms of two-step initialization, which are better consolidated into a single step. If for some reason you need to initialize in multiple steps, it is worth considering the builder pattern. This gives us a type system where uninitialized types are not representable.
How to migrate a legacy project?
Simply turning on <Nullable>enable</Nullable> at the project level can generate hundreds or thousands of warnings. A better approach is to enable annotations gradually at the file level using #nullable enable. This lets you clean up warnings file by file and add annotations where needed.
The strategy can vary depending on the type of project:
- Libraries and GUI applications: a bottom-up approach: start with utility classes and low-level components without dependencies. Then continue toward higher layers.
- Services: a top-down approach: start with the API/DTO layer, which is the most exposed and contains the contract with other parts of the system. Then continue toward the implementation.
- Test projects: at first, it is better to ignore test projects entirely, and even later the added value is rather questionable. It is more or less irrelevant whether a test fails with an assertion error or a
NullReferenceException.
It is worth considering AI agents for automatic code conversion. A typical iteration can look like this:
1) Generate a list of warnings for the entire project with nullable temporarily enabled (dotnet build /p:Nullable=enable > log.txt). 2) Start with the files that have the smallest number of warnings. Files with zero warnings require no code changes; you only need to add #nullable enable. In files with a small number of warnings, the AI agent has fewer decisions to make and a smaller chance of producing systematically wrong annotations. 3) Run the build, record the warnings, and iterate based on the results.
When reviewing the results, it is a good idea to focus on common AI mistakes in these tasks: excessive use of !, or mechanical addition of ? without an explicit domain decision.
Conclusion
In modern C#, the impact of the billion-dollar mistake can definitely be mitigated. In the case of null annotations, it is necessary to follow certain rules and be aware of the limitations of the mechanism. The ADT path offers greater certainty, but at the same time it requires much larger changes and wrapping almost all external code. 100% elimination of null dereferences in C# is not realistic; we would have to leave C# and, with it, the enormous burden of backward compatibility. It is necessary to choose a strategy, set protective guardrails (warnings-as-errors, code review rules), migrate gradually, and not rely on tooling to catch everything. Null is not a solved problem: it is a compromise that every C# project must deal with consciously.