Null represents the absence of a value. In early C# versions, any reference type could be null at any time and there was no way to express whether null was expected or a bug. NullReferenceException was (and remains) one of the most common runtime failures. C# has since added features to make null handling safer, more explicit, and more concise.
The two kinds of nullability
C# has two separate nullability systems because reference types and value types behave differently with null.
Value types (int, bool, DateTime, etc.) cannot be null. To represent "no value" you must opt in with Nullable<T> C# 2.0, written as T?. This wraps the value with a HasValue flag and is enforced at both compile time and runtime.
int count = 0; // always has a value
int? quantity = null; // explicitly nullable, backed by Nullable<int>
if (quantity.HasValue)
Console.WriteLine(quantity.Value);Reference types (string, object, arrays) could always be null. There was nothing in the type system to distinguish "this string is intentionally nullable" from "this string should never be null". Nullable reference types C# 8.0 changed this by letting you annotate reference types with ? to indicate they may be null, with the compiler warning when you use a non-nullable reference unsafely.
// With nullable reference types enabled
string name = "Alice"; // non-nullable: compiler warns if you assign null
string? nickname = null; // nullable: null is expected hereThe key difference: nullable value types (int?) are enforced at runtime via a Nullable<T> wrapper, while nullable reference types (string?) are purely compile-time analysis. A string? is still just a string at runtime.
Enabling nullable reference types
Nullable reference type analysis is controlled per-project or per-file. Add the following to your .csproj to enable it project-wide:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>You can also control it per-file with directives:
#nullable enable // enable for the rest of this file
#nullable disable // disable for the rest of this file
#nullable restore // return to the project-level setting| Setting | Annotations | Warnings |
|---|---|---|
enable | ? marks nullable types | Warns on potential null dereferences |
warnings | ? has no effect | Warns on potential null dereferences |
annotations | ? marks nullable types | No warnings |
disable | ? has no effect | No warnings |
enable is the default for new projects since .NET 6.
Null-handling operators
Null-coalescing: ??
The null-coalescing operator C# 2.0 returns the left operand if non-null, otherwise the right. It chains naturally for fallbacks.
string display = user.Nickname ?? user.FullName ?? "Anonymous";Null-conditional: ?. and ?[]
The null-conditional operators C# 6.0 short-circuit to null if the receiver is null, avoiding a NullReferenceException without verbose checks.
int? length = customer?.Orders?[0]?.Items?.Count;
// Combines well with ?? for a default
int count = customer?.Orders?.Count ?? 0;Null-coalescing assignment: ??=
The null-coalescing assignment operator C# 8.0 assigns the right side only if the left side is null.
_cache ??= LoadFromDatabase();Null-conditional assignment: ?. on the left
Null-conditional assignment C# 14.0 extends ?. to the left side of an assignment, setting a property only if the object is non-null.
order?.CancelledAt = DateTime.UtcNow;Null-forgiving operator: !
The null-forgiving operator C# 8.0 (also called the "dammit" operator) tells the compiler to suppress nullable warnings for an expression. It has no runtime effect.
Debug.Assert(connection != null);
connection!.Open();Use sparingly. Every ! is a spot where you override the compiler's analysis, and if you are wrong you get the NullReferenceException that nullable analysis was meant to prevent.
Null checks and pattern matching
The idiomatic way to check for null has evolved with pattern matching:
// C# 1.0: equality operator
if (value == null) { }
if (value != null) { }
// C# 7.0+: constant pattern
if (value is null) { }
// C# 9.0+: negated pattern (preferred)
if (value is not null) { }is null and is not null bypass overloaded equality operators, so they always test for actual null even if a type defines a custom ==.
Type patterns do not match null, so the bound variable is safe to use without additional checks:
string Describe(object? input) => input switch
{
string s => $"string of length {s.Length}",
int n => $"integer: {n}",
null => "nothing",
_ => input.GetType().Name
};Nullable attributes for API contracts
When flow analysis alone cannot determine nullability, .NET provides attributes in System.Diagnostics.CodeAnalysis to express null contracts.
| Attribute | Meaning |
|---|---|
[NotNull] | Output is never null, even if the type allows it |
[MaybeNull] | Output may be null, even if the type disallows it |
[AllowNull] | Input accepts null, even if the type disallows it |
[DisallowNull] | Input must not be null, even if the type allows it |
[NotNullWhen(bool)] | Output is not null when the method returns the specified bool |
[MaybeNullWhen(bool)] | Output may be null when the method returns the specified bool |
[NotNullIfNotNull(param)] | Output is not null if the named parameter is not null |
[MemberNotNull(member)] | The named member is guaranteed non-null after this method returns |
[MemberNotNullWhen(bool, member)] | The named member is non-null when the method returns the specified bool |
The BCL uses these extensively. For example, string.IsNullOrEmpty uses [NotNullWhen(false)] so the compiler knows the string is non-null when it returns false:
public static bool IsNullOrEmpty([NotNullWhen(false)] string? value);
if (!string.IsNullOrEmpty(name))
Console.WriteLine(name.Length); // no warning, name is known non-null hereNull vs missing in serialization
C# has one concept for "no value": null. But JSON and BSON distinguish between a property being present with a null value and missing entirely. These often mean different things: "set this field to nothing" vs "don't touch this field".
{ "name": "Alice", "nickname": null } // nickname is explicitly null
{ "name": "Alice" } // nickname is missing (unset)C# collapses both into null. When you deserialize either document into a class with string? Nickname, you get null in both cases and the distinction is lost. This is a problem for PATCH-style APIs where you need to know whether the caller wants to clear a value or leave it unchanged.
System.Text.Json
For serialization, use JsonIgnoreCondition to control whether null properties appear in the output:
public class User
{
public string Name { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Nickname { get; set; } // omitted from JSON when null
}
// Or globally
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};For deserialization, if you need to distinguish null from missing, use a wrapper type:
public readonly record struct Optional<T>(T? Value, bool IsPresent);
public class UserPatch
{
public Optional<string?> Nickname { get; set; }
// IsPresent = true, Value = null => caller sent null (clear it)
// IsPresent = false => caller omitted it (leave it alone)
}MongoDB / BSON
The MongoDB C# driver has explicit support for this distinction. BSON documents can contain a BsonNull value or omit the element entirely.
[BsonIgnoreIfNull]
public string? Nickname { get; set; } // omitted from BSON when null
[BsonIgnoreIfDefault]
public int Score { get; set; } // omitted when 0MongoDB's update builders preserve the difference naturally. $set sets a field (including to null) while $unset removes it:
var update = Builders<User>.Update
.Set(u => u.Nickname, null) // stores BsonNull
.Unset(u => u.Score); // removes the fieldGeneral approach
- Controlling output shape only?
JsonIgnoreCondition.WhenWritingNullor[BsonIgnoreIfNull]is usually enough. - Need to know what the caller sent? Model DTOs with an
Optional<T>wrapper, useJsonNode/JsonElementfor raw access, or track which fields were present separately. - Database updates? Use the driver's update builders (
$set/$unset, SQLCASE) rather than trying to round-trip the distinction through C# models.
Tips
- Enable nullable reference types in all projects. The warnings catch real bugs. Migrate incrementally with
#nullable enableper-file. - Prefer
is null/is not nullover== null/!= null. They are immune to operator overloads. - Use nullable attributes on public APIs.
[NotNullWhen]and[MemberNotNull]are especially useful. - Minimize use of
!. If you find yourself using it often, reconsider your design or add a proper null check. - Understand the runtime difference.
int?is aNullable<int>struct with real overhead.string?is just astringat runtime with none. The?means different things depending on the type.