Nullable value types in C#: the Nullable<T> struct, safe access patterns, lifted arithmetic and comparison operators, three-valued bool logic, boxing behaviour and database mapping.
int? is Nullable<int>, a struct that pairs a value with a HasValue flag, so a value type can represent "no value" (a nullable database column, an optional filter). Reading .Value when it is empty throws, so I use ??, GetValueOrDefault() or if (x is int v). Operators are lifted: null + 1 is null, and comparisons like null > 0 are false. Boxing is special: a null int? boxes to a null reference and a non-null one boxes to a plain boxed int. Unlike string?, int? is a real run-time type.
int? is shorthand for Nullable<int>, a struct that wraps a value type with a HasValue flag. It exists because value types cannot be null, but real data often has "no value" — an optional database column, a missing JSON field, "not yet calculated". The interesting parts are the members, lifted operators and the special boxing rules.
Nullable<T> ispublic struct Nullable<T> where T : struct
{
private readonly bool hasValue;
internal T value;
public bool HasValue => hasValue;
public T Value => hasValue ? value : throw new InvalidOperationException("Nullable object must have a value.");
public T GetValueOrDefault() => value;
public T GetValueOrDefault(T defaultValue) => hasValue ? value : defaultValue;
}
bool (with padding, int? is 8 bytes, long? and decimal? grow similarly).T must be a non-nullable value type, so int?? and string? as Nullable<string> are not allowed.null for an int? means HasValue == false; it is not a null reference.| Member | Behaviour | When I use it |
|---|---|---|
HasValue |
true if a value is present |
Rarely directly; patterns read better |
Value |
Returns the value or throws InvalidOperationException |
Only after a check, ideally never |
GetValueOrDefault() |
Value or default(T) — no check, no throw, fastest |
Hot paths where 0 is a valid fallback |
GetValueOrDefault(x) |
Value or x |
Explicit fallback |
?? x |
Same as above, idiomatic | Most code |
is int n / is { } n |
Test and unwrap in one step | Branching |
(int)nullable |
Explicit cast = .Value, throws if null |
Avoid |
Modern idiomatic code:
if (order.DiscountPercent is decimal discount)
total -= total * discount / 100m;
var retries = settings.MaxRetries ?? 3;
Operators defined on T are automatically lifted to T?:
+ - * / % & | ^ << >>): if either operand is null, the result is null. 5 + (int?)null == null.==, !=): null == null is true; 5 == null is false.< > <= >=): if either operand is null, the result is false — not null. So null < 5 and null >= 5 are both false, which breaks the usual "if not less then greater-or-equal" logic.bool? with & and | use three-valued (SQL-like) logic: null & false == false, null | true == true.This mirrors SQL NULL semantics, which is why EF Core can translate them, but it trips people up in C# conditions.
The runtime treats Nullable<T> specially when boxing:
int? with a value produces a boxed int, not a boxed Nullable<int>.int? without a value produces a null reference.int? a = 5; object oa = a; // boxed int
int? b = null; object ob = b; // null
Console.WriteLine(oa.GetType()); // System.Int32
Console.WriteLine(ob is null); // True
Console.WriteLine(ob?.GetType()); // null - GetType() on ob would throw NRE
And you can unbox a boxed int directly to int?. This makes nullable values behave sensibly in object-typed APIs, but it means you can never observe Nullable<int> via GetType() — use Nullable.GetUnderlyingType(typeof(T)) in reflection.
int NULL, datetime2 NULL) maps to int?/DateTime?. Making a property non-nullable makes the column NOT NULL in migrations.System.Text.Json writes null for empty nullables and can ignore them with JsonIgnoreCondition.WhenWritingNull.int? page distinguish "not provided" from 0.int? cannot distinguish "not sent" from "set to null" — that needs a wrapper like Optional<T> or JSON Patch.A reporting endpoint filtered orders with if (order.CompletedAt > cutoff) and an else branch that assumed "not completed after the cutoff" meant "completed before". For orders with CompletedAt == null the comparison was false, so incomplete orders were counted as completed-before-cutoff, inflating a KPI. We rewrote it with explicit patterns (order.CompletedAt is { } done && done > cutoff) and a separate branch for null, and added test cases for null on every nullable comparison in that report.
Checks basic correctness with optional data, a daily source of production bugs.
Green flags
int? is Nullable<int>, a struct with HasValue..Value.Red flags
null > 0 evaluates to.## 1. Memory layout
`Nullable
namespace Types;
public sealed record OrderFilter(decimal? MinTotal, DateOnly? From);
public static class NullableValueTypes
{
public static void Main()
{
int? quantity = null;
// Safe access
Console.WriteLine(quantity ?? 1); // 1
Console.WriteLine(quantity.GetValueOrDefault(5)); // 5
if (quantity is int q) Console.WriteLine(q); // skipped
// Lifted operators
int? sum = quantity + 10;
Console.WriteLine(sum is null); // True
Console.WriteLine(quantity > 0); // False
Console.WriteLine(quantity <= 0); // False as well!
// bool? three-valued logic
bool? isVip = null;
Console.WriteLine((isVip & false) == false); // True
Console.WriteLine(isVip == true); // False: the safe way to test
// Boxing
object boxedNull = quantity;
object boxedFive = (int?)5;
Console.WriteLine(boxedNull is null); // True
Console.WriteLine(boxedFive.GetType()); // System.Int32
int? back = (int?)boxedFive; // unbox to int? works
// Real-world filter with explicit null handling
var filter = new OrderFilter(null, new DateOnly(2026, 7, 1));
Console.WriteLine(Describe(filter));
}
public static string Describe(OrderFilter f)
{
var parts = new List<string>();
if (f.MinTotal is decimal min && min > 0) parts.Add($"total >= {min}");
if (f.From is { } from) parts.Add($"from {from:yyyy-MM-dd}");
return parts.Count == 0 ? "all orders" : string.Join(" and ", parts);
}
}
.Value without checkingWhat happens: order.ShippedAt.Value.ToString("d") on a nullable that is often null.
Why it is wrong: throws InvalidOperationException: Nullable object must have a value, which is less obvious in logs than an NRE.
Do instead: use is DateTime shipped patterns, ?? fallbacks, or ?. (order.ShippedAt?.ToString("d")).
null comparisons work like normal comparisonsWhat happens: if (score > 50) Pass(); else Fail(); where score is int?.
Why it is wrong: for null, score > 50 is false, so missing scores are treated as failures; !(a < b) is not equivalent to a >= b when nulls are involved.
Do instead: handle null explicitly first, then compare.
GetValueOrDefault() where 0 is meaningfulWhat happens: discount.GetValueOrDefault() treats "no discount configured" and "0% discount" the same.
Why it is wrong: sometimes absence must mean "inherit from parent" or "not yet calculated".
Do instead: decide what null means in the domain and handle it explicitly.
GetType() on a boxed nullableWhat happens: reflection or logging code calls value.GetType() on an object that came from an int?.
Why it is wrong: a null nullable boxes to null (NRE), and a non-null one reports Int32, never `Nullable
Seniors use nullability to model the domain honestly and keep null handling at the edges.
| Situation | Recommendation |
|---|---|
| Optional DB column | T? property, mapped to NULL column |
| Optional API input | T? parameter, validated at the boundary |
| "Unknown" versus "zero" matters (e.g. discount) | decimal?, never a magic -1 |
| Internal domain logic | convert to non-null values early |
| Tri-state flags | consider an enum (Yes, No, Unknown) instead of bool? for clarity |
DateTime.MinValue, -1) to mean "missing"; they leak into reports and analytics.null and "missing" mean the same thing (important for PATCH semantics).Nullable reference types make nullability part of the declared type for static analysis: string means "should never be null" and string? means "may be null". The compiler tracks a null-state for every expression and warns when a maybe-null value is dereferenced or assigned to a non-nullable. It is compile-time only: there are no run-time checks, and ! merely silences a warning. Attributes like [NotNullWhen(true)] on TryGet methods or [MemberNotNull] on initialisers tell the analyser about contracts it cannot infer. Nulls can still arrive from deserialisation, arrays, default and unannotated code, so I still validate at the boundaries.
Every type has a default value: the value you get from all-zero memory. That is null for reference types and nullable types, 0 for numbers, false for bool, and for structs every field zeroed. default(T) gives it explicitly and the default literal infers the type, as in CancellationToken ct = default. Generic code uses it to mean "no value", which is why FirstOrDefault returns 0 rather than null for int. The traps are that default skips struct constructors, gives enums the value 0 even if it is not a defined member, and makes some structs like ImmutableArray<T> unusable until initialised.
Last updated 30 Oct 2026.