Last Updated: May 17, 2026
Real data is messy. A customer might not enter a promo code, a discount might not apply, a shipping address might be missing a building number. C# needs a way to say "this value is present" or "nothing is here," and it needs to say it for both value types like int and reference types like string. This lesson covers how nullable types work, the operators built around them, and the traps that come with both.
A plain int in C# can never be null. It always holds a number, and if you don't assign one, it defaults to 0. That's fine when 0 is a meaningful value, but it falls apart fast when you're modeling the real world.
Picture a product table in a database with a discount_percent column. Some products are discounted, most are not. If you load the row into an int, what does "no discount" look like? You can't use 0 because 0 is a real, valid discount that just happens to do nothing. You can't use -1 because someone will eventually forget the convention and apply a negative percentage. The honest answer is that the value is simply absent, and your type system should be able to say so.
Reference types like string have the opposite problem. They've always been allowed to hold null, whether you wanted that or not. A string customerName might point at "Alice" or it might be null, and the compiler used to give you no warning before you called .Length on it. Then you'd crash at runtime with NullReferenceException, the most famous bug in .NET history.
C# fixes both ends of this with two features:
int, decimal, and DateTime hold null in addition to their normal range.null and you didn't handle it.The two features look similar on the surface, they both use the ? suffix, but they're solving different problems with different mechanics. We'll take them one at a time.
A nullable value type is a value type that can also hold null. The syntax is the type name followed by ?:
int? is just shorthand. The compiler expands it to System.Nullable<int>, a generic struct in the BCL. int? and Nullable<int> are the same type, and you can use either in your code, though int? is what you'll see in practice.
Nullable<T> itself is a struct, not a class. It wraps the underlying value with a bool flag:
The diagram shows the two pieces inside a nullable value type. The HasValue flag tells you whether a real value is there, and Value gives you the underlying number when it is. When HasValue is false, reading .Value throws.
You can construct nullable values four ways:
In practice you'll almost always use the first two forms. The new int?() syntax exists because Nullable<T> is a real struct under the hood, but writing it that way is unusual.
Assigning a plain int to an int? works without any cast because the compiler provides an implicit conversion. Going the other way, from int? back to a plain int, needs either a cast, a call to .Value, or one of the helper methods below.
The cast works because discountPercent actually has a value. If it were null, the cast would throw InvalidOperationException, the same exception .Value throws. We'll come back to that in the pitfalls section.
Reference types have a longer history with null. A string variable has always been allowed to hold null. The same goes for any class type, any array, and object itself. That permissiveness is the source of an enormous number of bugs, because the compiler couldn't tell you when a null was likely.
C# 8.0 introduced nullable reference types, usually shortened to NRT. The feature doesn't change what reference types can hold at runtime, they can still be null just like before. What it changes is what the compiler warns you about at compile time.
When NRT is on, the type string means "I do not intend this to be null." The type string? means "this might be null, handle it." The compiler reads your code, tracks which variables might be null, and warns when you ignore the distinction:
The second Console.WriteLine produces:
Notice that's a warning, not an error. The code still compiles and runs, and if promoCode is null at the moment that line executes, you get NullReferenceException at runtime. NRT is a static analysis tool layered on top of the existing language. It points at probable bugs, but it doesn't change runtime behavior.
You turn NRT on and off in two places. The first is a per-file directive:
The second is in the .csproj project file:
<Nullable>enable</Nullable> turns NRT on for the entire project. New projects from dotnet new console have this enabled by default, which is why the templates already show string? for Console.ReadLine. There are other values, disable, warnings, and annotations, that give you finer control, but enable is what you'll use in nearly every modern project.
A useful mental model: with NRT enabled, string and string? are two separate things to the compiler. Same runtime type, different contracts. string says "the author promises this is not null." string? says "this might be null, check before using."
The non-nullable variables don't need any guarding. The nullable one gets a fallback via the ?? operator, which we'll look at next.
C# has a small family of operators that work with nullable values and reduce the amount of branching you have to write by hand. Each one solves a specific shape of problem.
| Operator | Name | What it does |
|---|---|---|
?? | Null-coalescing | Returns the left side if it's not null, otherwise returns the right side. |
??= | Null-coalescing assignment | Assigns the right side to the left only if the left is currently null. |
?. | Null-conditional member access | Returns null without evaluating the right side if the left is null. |
?[] | Null-conditional indexer | Same as ?. but for indexing into arrays, lists, or dictionaries. |
! | Null-forgiving | Tells the compiler "trust me, this isn't null." No runtime effect. |
Let's see each one in context.
?? Null-CoalescingThe null-coalescing operator returns the first operand when it isn't null, and the second operand when the first is null. It's the cleanest way to apply a default:
discountPercent ?? 0 evaluates to 0 because discountPercent is null. The discount becomes 100 * 0 / 100, which is 0, so finalPrice stays at the original 100. Change discountPercent to 10 and the output becomes $90.00.
The diagram traces what ?? does at runtime. It's a single decision: if the left side is null, return the right side, otherwise return the left side unwrapped.
?? works for both nullable value types and nullable reference types:
??= Null-Coalescing AssignmentThe null-coalescing assignment operator (since C# 8.0) assigns the right operand to the left only if the left is currently null. It's a shorter way to write "set this if it isn't already set":
The first ??= assigns because shippingNote was null. The second ??= is a no-op because the value is already set, so the existing string sticks around.
?. Null-Conditional Member AccessThe null-conditional operator (sometimes called the "Elvis operator" because of its shape) short-circuits a member access when the target is null. Instead of throwing, the whole expression evaluates to null:
You can chain ?. to walk through several levels safely:
If any link in the chain is null (customer, customer.Address, or customer.Address.City), the whole expression evaluates to null and the rest of the chain is skipped. Without ?., you'd need three nested if checks to get the same behavior.
?. combines naturally with ?? to provide a default at the end of a chain:
The exact GUID will differ because Guid.NewGuid() generates a fresh one each run, but the point is the same: if order is null, we fall back to a brand-new GUID.
?[] Null-Conditional IndexerThe null-conditional indexer is the same idea as ?., just for indexing. If the collection is null, the whole expression is null:
Note what ?[] does and doesn't protect against. It guards against the collection itself being null. It does not guard against an out-of-range index. If recentSearches is non-null but empty, recentSearches?[0] still throws ArgumentOutOfRangeException because the collection exists, the index just doesn't.
.ValueThe single most common bug with nullable value types is calling .Value on a null instance. The struct is happy to expose the property, but reading it throws:
That second line throws at runtime:
The fix is to never blindly read .Value. You have three safer options.
Option 1: `GetValueOrDefault()` returns the value if there is one, otherwise the default for the underlying type (0 for int, 0m for decimal, default(DateTime) for DateTime, and so on):
There's also an overload that takes the default value you want:
Option 2: `??` is the same idea, just inline:
Option 3: Check `HasValue` first and use .Value only inside the branch where you know it's safe:
Most C# developers prefer ?? for its brevity. GetValueOrDefault() is useful when you want the underlying type's default and want the code to read as a method call. The HasValue check is the right choice when you want different logic for each branch, not just a different value.
What's wrong with this code?
quantity is null, so quantity.Value throws InvalidOperationException before the multiplication ever happens. The error message is Nullable object must have a value.
Fix:
Now quantity ?? 0 substitutes 0, and the multiplication produces 0 without any exception.
Boxing is what happens when a value type gets stored as object. The runtime allocates space on the heap, copies the value into it, and hands back a reference. Nullable value types have one quirk worth knowing.
When you box a Nullable<T> whose HasValue is false, the result is a plain null reference, not a boxed Nullable<T> struct with HasValue == false:
Two things to notice. First, boxed is straight-up null. There is no Nullable<int> wrapper on the heap, the conversion sees HasValue == false and just returns null. Second, when HasValue is true, the runtime boxes the underlying int, not the wrapper. boxed2.GetType() reports System.Int32, not System.Nullable<System.Int32>.
This makes nullable values play well with code that treats null as the universal "missing" marker, like ADO.NET's DBNull handling or JSON serialization. You don't have to special-case Nullable<T>, it boxes to exactly what the rest of the framework expects.
Cost: Boxing allocates on the heap and adds GC pressure. Don't box nullables in tight loops, hot paths, or large collections. Prefer keeping them as value types and unwrapping with ?? or GetValueOrDefault when needed.
Sometimes you know more than the compiler does. NRT is conservative, and it will warn about cases where you've already verified the value isn't null through logic the analyzer can't follow. The null-forgiving operator ! is the escape hatch.
x! tells the compiler "I've decided this isn't null, drop the warning." It has no runtime effect. If you're wrong, the program throws NullReferenceException at runtime just like it would without the !.
The compiler doesn't know that IsValidCoupon returning true implies promoCode is non-null, so without the !, it would warn on .Length. The ! suppresses the warning.
Use ! sparingly. Every ! is a place where you're overriding the compiler's analysis with your own judgment. The more !s you scatter through your code, the closer you get to having no real null safety at all. A few rules of thumb:
is not null, is string s) often does the job.[NotNull], [NotNullWhen(true)], or [MemberNotNull] attributes on helper methods so the compiler learns when a method guarantees non-null.! is genuinely needed, leave a comment explaining why. Future readers won't have your context.What's wrong with this code?
The ! silences the warning, but the underlying value is still null. Calling .Length on null throws NullReferenceException at runtime. The operator hides a bug instead of fixing it.
Fix:
?. returns null without throwing, and ?? substitutes 0.
Nullable<T> (shorthand T?) is a generic struct that wraps a value type with a HasValue flag. It lets value types represent "absent" in addition to their normal range.string? is dereferenced without a null guard.#nullable enable or per project with <Nullable>enable</Nullable> in the .csproj. New projects enable it by default.?? to provide a default for a possibly-null value, ??= to assign only when the variable is currently null, ?. to chain member access without throwing, and ?[] to index safely into a possibly-null collection..Value on a nullable that has no value throws InvalidOperationException. Use GetValueOrDefault(), ??, or a HasValue check instead.Nullable<T> with HasValue == false produces a plain null reference. Boxing one with a value boxes the underlying type, not the wrapper.! is a compile-time-only assertion. It silences NRT warnings but has no runtime effect, so use it sparingly and only when you're certain the value isn't null.The _Type Casting & Conversion_ lesson covers the difference between implicit casts, explicit casts, and helper methods like Convert.ToInt32() and int.Parse().