Null, not null, forgiving null (or maybe not)

Why This Matters When AI Can Just Write the Code

An AI assistant can generate null checks in seconds — so why does understanding null handling matter if you can just ask for it? Because null-related bugs are almost never caught by generating code; they’re caught by reading code and recognising a gap in its reasoning, and that’s a skill no amount of code generation substitutes for.

Here’s a concrete version of the problem: an AI tool asked to fix a compiler warning about a possibly-null reference will very often reach for the null-forgiving operator (!) — it makes the warning disappear, which looks like success. But ! doesn’t add a safety check; it removes one, telling the compiler “trust me, this is never null” without verifying that’s actually true. If the assumption is wrong, the code now fails at runtime with a NullReferenceException, in a place that used to at least warn you at compile time. Whether that ! was a reasonable judgement call or a bug waiting to happen depends entirely on context the tool doesn’t reliably have — and if you don’t understand what ! actually does, you have no way to tell the difference between “the AI just fixed a warning” and “the AI just hid a real bug” by reading the diff.

There’s a second reason this topic specifically rewards genuine understanding: null handling sits at the intersection of the type system and runtime behaviour in a way that’s easy to get subtly wrong even when every individual line looks reasonable. Knowing the difference between a compile-time-only annotation and an actual runtime guarantee — which this post covers in detail — is exactly the kind of judgement that determines whether you trust a piece of generated code or double-check it. That judgement is yours to develop; no tool can hand it to you pre-installed.

Every C# codebase you’ll ever work in has to deal with the possibility that a value is missing, not yet computed, not yet loaded, or intentionally absent. null is the language’s way of representing “nothing is here” — and it is, by a wide margin, one of the most common sources of runtime crashes in real software. This post covers what null actually is at the language and memory level, every mainstream tool C# gives you for checking and guarding against it, the crucial and easily-confused distinction between nullable value types and nullable reference types, the attributes that let library authors describe null behaviour precisely, and the null-forgiving operator — a tool that’s easy to misuse precisely because it looks like a safety feature.

By the end of this post you should be able to explain, precisely, what null represents in memory for a reference type and why the same idea doesn’t apply to a value type; correctly predict, for any given line of code, whether a nullability rule is enforced at compile time, at runtime, or not enforced at all; choose the right null-checking idiom for a given situation and justify the choice; read and apply the nullability attributes ([NotNullWhen], [MaybeNull], and similar) that describe how a method’s nullability behaves; and recognise when the null-forgiving operator is a defensible judgement call versus a bug waiting to happen.

What Null Actually Is, at the Memory Level

To understand null precisely, it helps to think about what a variable actually contains for each kind of type.

For a reference type (a class, for instance), a variable never directly contains the object’s data. It contains a reference — conceptually, an address — pointing to where the actual object lives on the heap. null is simply the special, reserved value meaning “this reference doesn’t point anywhere at all.” No heap object is required to exist for a variable to hold null; the variable is, in a very literal sense, empty.

string name = null;   // name holds no address at all — there is no string object to find
Console.WriteLine(name.Length); // throws NullReferenceException

name.Length fails here because evaluating it requires the runtime to follow the reference stored in name to find an actual string object and read its Length property off of it. But name isn’t pointing at anything, so there’s no object to follow the reference to, and the runtime throws NullReferenceException rather than silently doing nothing or returning a default value. This single mechanism — attempting to dereference an empty reference — is the direct cause of the most common runtime exception in C# (and in every other mainstream language that adopted the same idea from reference-type null).

For a value type (like int or a struct), the story is completely different: the variable is the data, stored directly and inline, with no indirection at all. An int variable is quite literally four bytes holding a number — there’s no separate “int object” living elsewhere that the variable points to. Because there’s no reference involved, there’s nothing that could be “empty” — the variable always holds actual bits representing some value.

int x = null; // compile error CS0037 — there is no concept of "no address" for a value type

This is why the compiler rejects this outright, at compile time, rather than allowing it and failing later — the very idea of null doesn’t apply to how a plain value type is represented in memory. This distinction is the foundation for everything else in this post: Nullable<T> and nullable reference types are two entirely different mechanisms, built to solve the same conceptual problem (“how do I represent ‘no value’ for this type?”) for two categories of type that are represented in memory in fundamentally different ways.

It’s worth a brief historical note here, because it explains why the language ended up with two different mechanisms instead of one unified one: the inventor of the null reference, Tony Hoare, has publicly called it his “billion-dollar mistake” — a feature that seemed convenient to add to a type system in 1965, but which he estimates has since caused decades of subsequent bugs, crashes, and security vulnerabilities across virtually every mainstream language that copied the idea. Nullable<T> (added in C# 2.0) and nullable reference types (added in C# 8.0, over a decade later) represent two separate, successive attempts to make that original mistake less costly — one for value types, one for reference types — without breaking the enormous amount of existing code built on the assumption that any reference could always be null.

Nullable Value Types: Nullable<T> in Full

Since a plain value type can never be null, C# provides a real, concrete wrapper type — Nullable<T> — that adds an explicit “is a value actually present or not” flag alongside the underlying data. You’ll almost always see it written using the shorthand T?:

int? age = null;          // shorthand for Nullable<int> age = null;
Nullable<int> age2 = null; // exactly equivalent, spelled out in full

Nullable<T> is itself a struct, meaning int? remains, fundamentally, a value type — not a disguised reference type. Its actual definition is close to this simplified version:

public struct Nullable<T> where T : struct
{
public bool HasValue { get; }
public T Value { get; } // throws InvalidOperationException if HasValue is false
public T GetValueOrDefault();
public T GetValueOrDefault(T defaultValue);
}

So when you write int? age = null;, nothing about “emptiness” or “missing references” is happening — you’re creating an ordinary struct value whose HasValue field happens to be false. The variable is never “pointing at nothing”; it directly contains a small struct value that represents absence as one of its normal, well-defined states, the same way a bool can represent true or false.

int? age = null;
Console.WriteLine(age.HasValue);           // False
// Console.WriteLine(age.Value);           // throws InvalidOperationException — no value to return
Console.WriteLine(age.GetValueOrDefault()); // 0 — the default value of int, used safely instead of throwing

age = 30;
Console.WriteLine(age.HasValue); // True
Console.WriteLine(age.Value);    // 30

GetValueOrDefault() is often safer to reach for than .Value directly, precisely because it can never throw — it simply falls back to default(T) (or an explicit fallback you supply) when there’s no value present, rather than requiring you to check HasValue first every single time.

Lifted operators

C# does something convenient behind the scenes called “operator lifting”: arithmetic and comparison operators automatically work on Nullable<T> values, propagating null through the calculation, without you having to unwrap .Value manually:

int? a = 5;
int? b = null;

int? sum = a + b; // null — if either operand is null, the result is null
Console.WriteLine(sum.HasValue); // False

bool isGreater = a > b; // False — comparisons involving a null operand are always false, never throw

Notice the last line carefully: a > b doesn’t throw and doesn’t propagate null the way arithmetic does — it evaluates to false, because a comparison against “no value” is defined as never being true. This is a common source of subtle bugs: a > b being false does not mean a <= b is true when either side might be null — both comparisons can independently be false if one side is null, which surprises people expecting comparisons to be perfect opposites of each other the way they are for non-nullable numbers.

Boxing behaviour — a special case worth knowing

Normally, boxing a value type wraps it in an object on the heap, as covered in earlier value-type discussions. Nullable<T> has a special, deliberately designed exception to ordinary boxing rules: boxing a null-valued Nullable<T> produces an actual null reference, not a boxed struct with HasValue == false sitting on the heap.

int? x = null;
object boxed = x;
Console.WriteLine(boxed == null); // True — boxing a null Nullable<T> yields an actual null reference, not a boxed struct

This special-cased behaviour exists specifically so that boxed nullable value types behave the way programmers intuitively expect when compared against null or checked with is null — without it, a great deal of ordinary-looking code checking someObject == null would behave surprisingly for nullable value types passed around as object.

Nullable Reference Types (NRT): A Compile-Time Annotation, Not a Runtime Guarantee

Historically, every reference type in C# could always be null — there was no way to say “this particular string variable should never be null” and have the compiler enforce it. Since C# 8, when a project enables the nullable reference types feature (also called “the nullable context”), you can annotate reference type usages with ? to declare intent:

string name = "Alice";   // intended to always have a value — a "non-nullable" reference type
string? nickname = null; // intended to possibly have no value — a "nullable" reference type

Here is the single most important fact in this entire post, and the one students most reliably get wrong the first time: this ? is purely a compile-time annotation that the compiler uses to generate warnings. It changes absolutely nothing about what happens at runtime. Unlike Nullable<T>, which is a real wrapper struct that genuinely changes the shape of the underlying data, nullable reference type annotations are erased completely by the time your code runs. A string? and a string are the exact same type — System.String — at runtime, compiled to identical IL, with identical behaviour. The only difference between them exists in the compiler’s static analysis while you’re writing the code, not in anything the CLR (Common Language Runtime) knows or checks while your program is actually running.

You can prove this to yourself directly:

string name = null; // triggers compiler WARNING CS8600 — but this still compiles and runs!
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime, exactly as before NRT existed

Nothing stops this from compiling. The nullable reference types feature is, by design, a warning system layered on top of the language — not an enforced runtime guarantee the way Nullable<T> is. This is a deliberate, pragmatic tradeoff: C# has decades of existing code where every reference type could be null, and retroactively making non-nullable the enforced runtime default would have broken an enormous amount of that code the moment anyone upgraded their compiler. Instead, the compiler performs static flow analysis — tracking, as precisely as it can, which variables might be null at each point in your code — and surfaces a warning wherever it believes you might be about to dereference something that could be null, without physically preventing you from doing it anyway.

The nullable context: enabling and controlling NRT

Whether nullable reference type checking is even active is itself controlled, either project-wide (typically via <Nullable>enable</Nullable> in your .csproj file) or file-by-file, or even line-by-line, with a compiler directive:

#nullable enable
string? maybeNull = null; // annotations are meaningful here — warnings apply

#nullable disable
string alsoNull = null;   // in a disabled context, no warning at all — this is the pre-C#-8 world

#nullable restore          // returns to whatever the surrounding project-level setting was

Code written before nullable reference types existed, or code in a project that hasn’t opted in, is said to be in an “oblivious” context — the compiler doesn’t apply nullable warnings to it at all, and every reference type behaves exactly as it always did (implicitly nullable, no annotations, no warnings). This matters practically: if you’re calling into an older library that hasn’t been annotated for nullability, the compiler generally can’t warn you about null risks flowing out of that library’s methods, even in your own fully nullable-enabled code, because the library itself never declared its intentions.

Generic type parameters and NRT

Nullability annotations interact with generics in a way that’s worth flagging explicitly, since it trips people up:

class Box<T>
{
public T Value; // is this nullable or not? It depends entirely on what T ends up being!
}

Box<string> b1 = new Box<string> { Value = null };
Box<int> b2 = new Box<int> { Value = null };

The compiler has to reason about nullability generically here, because T might end up being a reference type or a value type depending on how Box<T> gets used — this is a genuinely more advanced corner of the feature, and library authors writing generic code often need extra annotations (like T? combined with constraints, or the [MaybeNull]/[AllowNull] attributes covered later) to precisely describe nullability that depends on the type parameter.

Advisory, not enforced

This all has a very practical consequence worth internalising: nullable reference type warnings are advisory, not enforced, by default. If your team ignores compiler warnings, or your build pipeline doesn’t treat warnings as errors, string? provides essentially no actual protection at all — it becomes documentation of intent that nothing is actually forcing anyone to respect. Many professional teams configure their build to treat nullable-related warnings as build-breaking errors (<WarningsAsErrors>Nullable</WarningsAsErrors> or similar) specifically to close this gap and make the annotations behave more like a genuine guarantee — which is worth knowing exists as an option even though it isn’t the default.

Checking for Null

C# provides several distinct ways to check whether something is null, and they aren’t fully interchangeable — the differences matter, sometimes a great deal.

== null vs. is null

if (name == null) { ... }
if (name is null) { ... }

Both usually produce the same result, but is null is the generally preferred idiom, for a specific and important reason: == is an operator, and operators can be overloaded by the type being compared. If the type of name overloads == with custom logic — records do exactly this automatically, since they generate their own == for value equality — then name == null invokes that custom operator rather than a guaranteed, unambiguous null check. A poorly written custom == overload could, in principle, behave unexpectedly when one side is null (for example, throwing instead of returning false, if the override doesn’t defensively handle a null argument). is null, by contrast, is a pattern match evaluated directly by the compiler using the runtime’s built-in notion of “this reference is empty” — it cannot be overridden, intercepted, or fooled by any type’s custom operator, so it always performs a true, unambiguous null check no matter what type you’re checking, which is exactly the guarantee you want from a null check.

The null-conditional operator: ?.

Rather than writing a nested chain of null checks by hand, ?. lets you safely navigate through a chain of references that might be null at any step, automatically short-circuiting to null the moment it encounters one:

// Without null-conditional:
string city = null;
if (customer != null && customer.Address != null)
city = customer.Address.City;

// With null-conditional — equivalent, far more concise:
string city = customer?.Address?.City;

If customer is null, the entire expression short-circuits immediately and evaluates to null — it never even attempts to access .Address, avoiding the exception a plain customer.Address.City would throw. You can also use it to conditionally invoke a method or event, calling it only if the reference isn’t null and otherwise doing nothing at all:

onCompleted?.Invoke(); // calls Invoke() only if onCompleted isn't null; silently does nothing otherwise

?. also combines with indexers and method calls in a chain, short-circuiting the moment any link is null:

int? firstItemLength = list?[0]?.Length; // safely handles list being null, or list[0] being null

Null-coalescing operators: ?? and ??=

?? lets you supply a fallback value to use when the left-hand side turns out to be null, and it chains naturally with ?.:\

string city = customer?.Address?.City ?? "Unknown";
// If the whole chain above evaluates to null at any point, "Unknown" is used instead

??= is a compound assignment version: it assigns the right-hand side only if the variable currently holds null, leaving it completely unchanged otherwise — useful for lazy initialisation and simple caching patterns:

string? cachedResult = null;
cachedResult ??= ComputeExpensiveResult(); // only computes and assigns if cachedResult was null
cachedResult ??= ComputeExpensiveResult(); // this second call never even executes ComputeExpensiveResult again

Pattern matching against null

Modern C# lets you use is/is not directly as readable null checks, and switch expressions can match null explicitly as one of several patterns, putting the null case on equal footing with every other case being matched rather than treating it as a special pre-check:

if (name is not null)
Console.WriteLine(name.Length);

string Describe(object? value) => value switch
{
null => "nothing here",
int n when n < 0 => $"a negative int: {n}",
int n => $"an int: {n}",
string s => $"a string: {s}",
_ => "something else"
};

The Try-pattern as an alternative to nullable returns

A very common and idiomatic way to sidestep null-checking altogether for lookups that might fail is the “Try” pattern, seen throughout the .NET standard library (Dictionary<TKey,TValue>.TryGetValue, int.TryParse, and many others):

Dictionary<string, int> ages = new() { ["Alice"] = 30 };

if (ages.TryGetValue("Bob", out int age))
Console.WriteLine(age);
else
Console.WriteLine("Not found");

Rather than returning a nullable value type and forcing every caller to check for null (or, worse, returning a sentinel value like -1 that’s easy to forget to check for), the method returns a bool indicating success and delivers the actual value through an out parameter only when it succeeded. This is often considered a cleaner API design than nullable returns for exactly this kind of “might not find anything” scenario, because the success/failure check and the value retrieval happen in a single, hard-to-misuse expression — you cannot accidentally use age without having gone through the success check first, since it isn’t definitely assigned otherwise.

The Null-Forgiving Operator: !

The null-forgiving operator is a single exclamation mark placed after an expression, and it exists specifically to interact with the compile-time warning system:

string? maybeName = GetNameOrNull();
string name = maybeName!; // "trust me, compiler — this won't actually be null"

Here is the fact students most often assume incorrectly, so it’s worth stating as plainly as possible: the ! operator does absolutely nothing at runtime. It performs no check, adds no safety, and throws no exception if the value genuinely turns out to be null. Its only effect is telling the compiler’s nullable-warning analysis “stop warning me about this specific expression — I know something you don’t.” If you’re wrong, and the value actually is null, the code compiles cleanly with no warning at all, and then throws a NullReferenceException at runtime exactly as it would have without the ! — except now there was no compiler warning to have caught the mistake ahead of time, which arguably makes the situation worse than not using NRT at all, since it creates false confidence.

string? maybeName = null;
string name = maybeName!; // compiles with zero warnings
Console.WriteLine(name.Length); // still throws NullReferenceException at runtime — the ! changed nothing here

This makes ! a genuinely double-edged tool, and it’s worth being explicit about when each side applies.

It’s a legitimate, reasonable choice when you have information the compiler’s flow analysis genuinely can’t see. A common example is right after a manual null check performed inside a separate helper method, which the compiler generally can’t trace through automatically:

void EnsureNotNull(string? value)
{
if (value is null) throw new ArgumentException();
}

string? input = GetInput();
EnsureNotNull(input);
string result = input!; // input can't actually be null here, but the compiler can't see that through the helper call

(Later we’ll cover [NotNull] and related attributes, which let you tell the compiler about exactly this kind of pattern without needing ! at all — a more precise, more scalable solution than sprinkling ! throughout your codebase.)

It’s a code smell — often papering over a real bug — when it’s used reflexively just to silence a warning without actually verifying the assumption behind it. If you find yourself typing ! purely because the compiler is complaining and you want the complaint to stop, that’s exactly the situation where you’re most likely to be quietly disabling a warning that was correctly flagging a genuine risk. A good habit: every time you write !, be able to state in one sentence why you’re certain the value can’t be null at that specific point — if you can’t articulate that reason, it’s a strong signal to add an actual check instead of suppressing the warning.

! used on more than just simple variables

The null-forgiving operator can be applied to any expression, not just a bare variable, which is worth knowing so you can recognise it in the wild:

var firstAdult = people.FirstOrDefault(p => p.Age >= 18)!; // asserts the result won't be null, suppressing the warning

This particular example is a common trap: FirstOrDefault returns null when no element matches the predicate, so asserting the result away with ! here is only safe if you’ve independently verified, through other logic, that a match is guaranteed to exist — otherwise you’ve silenced a warning that was correctly describing a real possibility.

Nullability Attributes: Precisely Describing a Method’s Null Behavior

Sometimes a method’s nullability can’t be fully captured by a simple ? or its absence — the actual behavior depends on what happens when the method runs, not just its static signature. C# provides a small set of attributes, primarily used by library authors, that let you describe this precisely so the compiler’s flow analysis can follow along correctly at every call site, without callers needing ! at all.

[NotNullWhen(bool)] — describes an out parameter (or return value) that is guaranteed non-null specifically when the method returns a particular bool value. This is exactly how TryGetValue-style methods are annotated in the standard library:

bool TryGetName(int id, [NotNullWhen(true)] out string? name)
{
if (id == 1) { name = "Alice"; return true; }
name = null;
return false;
}

if (TryGetName(1, out string? result))
Console.WriteLine(result.Length); // no warning here — the compiler trusts the attribute's promise

Without this attribute, the compiler would still warn about result.Length, since result’s declared type is string?. The attribute tells the compiler, precisely, “whenever this method returns true, treat that out parameter as non-null from that point forward” — letting the compiler’s flow analysis stay accurate without any ! needed anywhere at the call site.

[MaybeNull] — the opposite kind of promise: marks something whose declared type looks non-nullable, but which might actually return null at runtime anyway (often used for backward compatibility with older, unannotated APIs, or generic code where T might turn out to be a reference type).

[NotNull] — asserts that a parameter or return value will never be null, even though its declared type technically allows it (string?) — commonly used on out/ref parameters that a method always assigns a real value to before returning, regardless of input.

[AllowNull] / [DisallowNull] — used on parameters to describe input nullability independently of a property’s own get/set nullability — for example, a property whose setter accepts null (which then gets normalised to an empty string internally) but whose getter never returns null.

[DoesNotReturn] — marks a method that never returns normally (it always throws), which lets the compiler correctly treat any code after a call to it as unreachable — useful for custom guard-clause helper methods:

[DoesNotReturn]
void ThrowIfInvalid(string? input)
{
if (input is null) throw new ArgumentNullException(nameof(input));
throw new InvalidOperationException(); // this method always throws, one way or another
}

You won’t need most of these day-to-day as an application developer — but recognizing them when you see them in .NET’s own source, or in a well-annotated third-party library, is what lets you understand why the compiler correctly avoids warning you in some cases and correctly does warn you in others, rather than it feeling arbitrary.

Guard Clauses and Trusting the Type System

In real code, you’ll constantly face a judgement call: should you defensively check every argument for null at the top of every method, or trust that the type system (via nullable reference type annotations) has already ruled null out?

A common, concise pattern for explicit, defensive checks at the boundaries of your code — especially public APIs where callers might not respect your nullable annotations, might be calling from an oblivious (pre-NRT) context, or might be passing data that came from outside your program’s control entirely (deserialised JSON, user input, a database row) — is:

public void ProcessOrder(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
// from this point on, customer is guaranteed non-null, with a clear, immediate exception if it wasn't
Console.WriteLine(customer.Name);
}

ArgumentNullException.ThrowIfNull is a concise, standard-library way to fail fast and loudly, with a clear exception message naming exactly which argument was the problem — far more useful for debugging than an anonymous NullReferenceException thrown from somewhere deep inside the method body later on, potentially far removed from where the actual bad value entered your code.

The broader judgement call — defend everywhere vs. trust the type system — doesn’t have one universally correct answer, but a reasonable default is: check defensively at the boundaries of your code (public APIs, deserialised data, anything coming from outside your own program’s control), and trust your non-nullable annotations internally, once you’re confident your codebase takes nullable warnings seriously (ideally with warnings-as-errors enabled). Checking null obsessively on every single private method call, when the type system has already given you strong non-nullable guarantees internally, adds clutter without meaningfully reducing risk — but skipping checks entirely at your public boundaries, where you don’t control what callers pass in, removes a safety net you likely still need.

Required Properties: A Different Tool for “This Must Be Set”

Nullable reference type warnings are about whether a reference might be null. A related but genuinely distinct problem is object initialisation: how do you guarantee a property gets a value at all, without necessarily making it nullable? Since C# 11, the required modifier addresses this directly:

class Customer
{
public required string Name { get; init; }
public string? Nickname { get; init; } // genuinely optional
}

var c = new Customer { Nickname = "Al" }; // compile error! Name is required and wasn't set
var c2 = new Customer { Name = "Alice" }; // fine — Name provided, Nickname left null

This is a compile-time-enforced guarantee that a property must be explicitly set during construction, which is a stronger and more precise tool than simply leaving a property non-nullable and hoping every constructor path sets it — required makes the compiler actively check every object-creation site for a missing assignment, rather than relying on nullable-warning flow analysis to eventually notice a gap, which it might not always catch depending on how the object gets constructed.

null vs. default — A Related but Distinct Idea

It’s worth clearly separating null from a closely related but different concept: default(T), or its shorthand default. default means “the zero-initialised value for type T” — for reference types, that value happens to be null, but for value types, it’s a real, valid value (0 for int, false for bool, all-zero fields for a struct), not an absence of anything.

string s = default; // null — the default for any reference type is null
int i = default; // 0 — a completely valid, ordinary int value, not an absence of a value
Point p = default; // a Point with all fields zeroed out, if Point is a struct — still a real Point

This distinction matters because it’s easy to conflate “no value was provided” with “the default value was provided,” and they aren’t the same thing for value types: a Point that’s default is a perfectly real, usable Point sitting at the origin — it’s not “missing” the way Nullable<Point> being null would be. If you need to distinguish “a real, valid zero value was explicitly provided” from “no value was provided at all” for a value type, default alone cannot do that — you need Nullable<T> (or T?) specifically, because only Nullable<T> carries an explicit HasValue flag separate from the value itself.

Null in Collections: A Design Convention Worth Adopting

One more practical distinction worth internalising, since it comes up constantly in real code: an empty collection and a null collection mean two different things, and conflating them is a common source of bugs and defensive-code clutter.

List<string> tags = new(); // an empty list — "this customer has zero tags," a known, valid state
List<string>? tags2 = null; // no list at all — "we don't know this customer's tags," a fundamentally different state

A very widely adopted convention in professional C# code is: methods that return collections should return an empty collection instead of null, whenever there’s a meaningful “nothing here” case, specifically so every caller can safely foreach over the result without needing a null check first:

// Preferred:
public List<string> GetTags(Customer customer) => customer.Tags ?? new List<string>();

// Avoid, if at all possible:
public List<string>? GetTags(Customer customer) => customer.Tags; // forces every caller to null-check before iterating

This convention doesn’t apply universally — sometimes “we don’t know” genuinely needs to be distinguished from “we know there are zero” — but as a default, it eliminates an enormous number of unnecessary null checks scattered throughout a codebase, and it’s worth adopting deliberately rather than by accident.

Summary

null represents an empty reference — literally, a variable holding no address at all — which is why the concept applies naturally to reference types (which hold an address to data stored elsewhere) but not to plain value types (which hold their data directly, with no address to leave empty). Nullable<T> gives value types a real, runtime opt-in mechanism for representing absence anyway, implemented as an actual struct carrying a genuine HasValue flag, complete with lifted operators and a special boxing rule that makes a null Nullable<T> box to an actual null reference. Nullable reference type annotations (string?) work in a completely different way: they’re a compile-time-only warning system, controlled by the nullable context, with zero effect on runtime behaviour — a string can still be assigned null and still throws a NullReferenceException exactly as before, and the annotation only changes what the compiler warns about while you’re writing the code, not what’s enforced when it runs, unless your build explicitly treats those warnings as errors. For checking null, prefer is null over == null since it can’t be fooled by a type’s overloaded == operator, lean on ?./??/??= and pattern matching for concise handling, and reach for the Try-pattern over nullable returns when a lookup might reasonably fail. The null-forgiving operator ! does nothing at runtime — it only silences a compiler warning — so treat it as a deliberate, justifiable assertion, not a routine fix, and prefer the [NotNullWhen]/[NotNull]-style attributes when you’re the one writing an API whose nullability depends on its behaviour rather than its static signature. Keep default conceptually separate from null (a value type’s default is a real, valid value, not an absence of one), default to returning empty collections rather than null ones wherever “nothing here” is a meaningful, expected state, defend explicitly against null at the boundaries of your code where you don’t control incoming data, and trust your non-nullable annotations internally once your team takes warnings seriously.

C# class vs record vs struct vs record struct

Why This Matters When AI Can Just Write the Code

It’s a fair question: if you can ask an AI assistant to generate a class, a record, or a struct in seconds, why spend time learning the difference yourself?

An AI tool will happily generate any of the four — a class when you needed a record, a mutable record struct when you needed a readonly one, a struct the size of a small database row when a class would have been far cheaper to pass around. It will do this confidently and without warning you, because generating syntactically valid code isn’t the same as generating the right code for your situation. The tool doesn’t know whether your Order needs identity that persists over time or whether two orders with equal values really are “the same order.” Only you know that, because it depends on what your program actually needs to do — and that’s a design decision, not a syntax problem.

There’s also a more immediate, practical reason: you cannot evaluate, debug, or safely modify code you don’t understand — whether you wrote it or an AI did. If an AI hands you a record struct and a bug shows up because a value silently mutated somewhere it shouldn’t have, you need to already know that record struct is mutable by default to even suspect where to look. If a HashSet<T> lookup mysteriously starts failing after an update, you need to already understand the connection between mutability and hashing to diagnose it, rather than pasting the bug back into a chat window and hoping. AI is a powerful accelerant for someone who already has the judgement to steer it and the literacy to check its output — it’s a much weaker tool, and sometimes actively harmful, in the hands of someone using it as a substitute for that judgement rather than an extension of it.

The reasoning in this post — reference vs. value semantics, structural vs. identity equality, the cost of copying, the fragile-base-class-style thinking around mutability — isn’t C#-specific trivia. It’s how you’ll evaluate design tradeoffs in any language you touch for the rest of your career, AI-assisted or not. Learning to ask “does this thing have an identity, or is it just data?” is a transferable skill. Knowing the exact keyword to type is not, and that part genuinely is something a tool can help with once you already know what you’re asking for.

The Core Mental Model

Every C# type sits at the intersection of two independent ideas, and understanding these two ideas is the key to understanding everything else in this guide.

The first idea is storage and copy semantics: is the type a reference type or a value type? A reference type lives on the heap, and a variable of that type doesn’t hold the object itself — it holds a reference (essentially an address) pointing to where the object lives. When you assign one reference-type variable to another, you’re copying the address, so both variables end up pointing at the same object. A value type, by contrast, lives directly wherever it’s declared — on the stack, or inline inside an array or another object. When you assign one value-type variable to another, the entire contents get copied, producing two completely independent values.

The second idea is equality: when you compare two instances with == or .Equals(), does the language check whether they’re the same object (reference/identity equality), or whether they contain the same data (structural/value equality)?

These two axes combine to describe our four type kinds:

  • class is a reference type with reference equality by default. Two class instances with identical field values are still considered “different” unless you override equality yourself.
  • struct is a value type with value equality — but that default equality comes from System.ValueType and is implemented via reflection, which is functionally correct but noticeably slow.
  • record is a reference type, just like class, but the compiler automatically generates fast, correct, structural (value) equality for you, along with a few other conveniences.
  • record struct is a value type, just like struct, but again with the compiler automatically generating fast, correct, structural equality (no reflection this time either).

So a helpful way to think about it: a record is a class that the compiler has enriched with value semantics, and a record struct is a struct that the compiler has enriched in the same way. The “record” part of the name always means “give me generated equality, a readable ToString, and with-expressions” — it’s orthogonal to whether the underlying thing is a reference type or a value type.

Reference Types vs. Value Types, Explained Slowly

Since this distinction underlies everything else, it’s worth walking through carefully with an example, because it’s the part students get wrong most often.

class PointClass { public int X, Y; }

var a = new PointClass { X = 1, Y = 2 };
var b = a; // b now points to the SAME object as a
b.X = 99;
Console.WriteLine(a.X); // prints 99! Changing b changed a, because they're the same object

With a class, the variable a never actually contains a PointClass — it contains a reference to a PointClass sitting somewhere on the heap. When we write var b = a;, we’re copying that reference, not the object. Now a and b are two different “pointers” aimed at one shared object, so mutating through b is visible through a too.

Now compare that with a struct:

struct PointStruct { public int X, Y; }

var a = new PointStruct { X = 1, Y = 2 };
var b = a; // b gets an independent COPY of a's data
b.X = 99;
Console.WriteLine(a.X); // still prints 1 — a and b are separate values now

Here, a directly holds the X/Y values (no indirection through a heap address). When we write var b = a;, the runtime copies all of a’s bytes into b. From that point on, a and b are completely unrelated — changing one has no effect on the other. This copying happens every time you pass a struct into a method, return it from a method, or store it into a new variable, which is why struct size matters for performance: a big struct means a lot of copying.

This distinction directly explains why reference types can be null and value types generally can’t: null means “this reference doesn’t point to anything,” which is meaningless for a value type that isn’t a reference at all — it always has to hold some actual data.

record follows the class behavior above exactly (it’s a reference type), and record struct follows the struct behavior exactly (it’s a value type). Nothing about the word “record” changes this — it only changes what happens when you compare two instances or print them, which is the next topic.

Equality — Worked Examples for All Four

class: reference equality by default

class PointClass { public int X, Y; }

var a = new PointClass { X = 1, Y = 2 };
var b = new PointClass { X = 1, Y = 2 };
var c = a;

Console.WriteLine(a == b); // False: different objects, even though the data matches
Console.WriteLine(a.Equals(b)); // False: same reason
Console.WriteLine(a == c); // True: c is literally the same object as a

This often surprises beginners: a and b look identical on paper, but == returns false. That’s because class equality by default asks “are these the exact same object in memory?” — not “do these objects contain the same values?” If you want value-based comparison on a plain class, you have to override Equals, GetHashCode, and optionally ==/!= yourself, which is real, non-trivial boilerplate that’s easy to get subtly wrong (forgetting a field, mishandling null, inconsistent hash codes, etc.).

struct: value equality, but slow by default

struct PointStruct { public int X, Y; }

var a = new PointStruct { X = 1, Y = 2 };
var b = new PointStruct { X = 1, Y = 2 };

Console.WriteLine(a.Equals(b)); // True — value equality, as you'd hope
// But notice:
// Console.WriteLine(a == b); // Compile error! CS0019 — a struct doesn't even get a `==` operator by default

Good news: Equals does compare values correctly out of the box, because struct inherits it from System.ValueType, which uses reflection to inspect and compare every field. Bad news: reflection is slow — noticeably so if this Equals gets called in a hot loop or inside a HashSet/Dictionary. There’s also no == operator generated at all, so you can’t even write the natural comparison syntax without adding it yourself. In professional code you’d typically implement IEquatable<T> plus operator overloads by hand for any struct you plan to compare often — which is precisely the boilerplate record struct eliminates.

record: fast, compiler-generated value equality

record PointRecord(int X, int Y);

var a = new PointRecord(1, 2);
var b = new PointRecord(1, 2);

Console.WriteLine(a == b); // True — the compiler generated a real == operator for us
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False — they're still two separate heap objects

Notice the last line: a and b are still distinct objects living at different heap addresses (record is a reference type, remember), but the compiler-generated Equals/== compare their contents rather than their addresses, so the comparison behaves the way most people intuitively expect.

Records also correctly take runtime type into account when there’s inheritance involved, which matters a lot once you start building hierarchies:

record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name);

Animal a = new Dog("Rex", "Lab");
Animal b = new Dog("Rex", "Lab");
Animal c = new Animal("Rex"); // same Name, but a plain Animal, not a Dog

Console.WriteLine(a == b); // True — same runtime type (Dog) and same field values
Console.WriteLine(a == c); // False — even though "Name" matches, the runtime types differ

record struct: fast value equality, no reflection

record struct PointRecordStruct(int X, int Y);

var a = new PointRecordStruct(1, 2);
var b = new PointRecordStruct(1, 2);

Console.WriteLine(a == b); // True — compiler-generated, field-by-field comparison, no reflection involved

This is the “best of both worlds” case for equality: you get the value semantics of a struct (no heap allocation, independent copies) combined with the fast, correct, compiler-written comparison logic that record provides. You never have to write Equals/GetHashCode/== by hand, and you never pay the reflection tax that plain struct equality incurs.

ToString() — Why Records Are So Much Nicer to Debug

When you print a plain class or struct without overriding ToString(), you get almost nothing useful:

class PersonClass { public string Name = "Alice"; public int Age = 30; }
Console.WriteLine(new PersonClass());
// Output: PersonClass <- just the type name, no data at all

Records generate a genuinely useful ToString() automatically, listing every property and its current value:

record PersonRecord(string Name, int Age);
Console.WriteLine(new PersonRecord("Alice", 30));
// Output: PersonRecord { Name = Alice, Age = 30 }

The exact same thing happens for record struct. This might look like a small convenience, but in practice it’s one of the biggest quality-of-life differences students notice immediately: when you’re debugging, logging, or writing a failing test assertion, seeing PersonRecord { Name = Alice, Age = 30 } printed straight to the console instead of just PersonRecord saves an enormous amount of time you’d otherwise spend writing Console.WriteLine($"{p.Name}, {p.Age}") by hand, or attaching a debugger just to inspect field values.

Mutability & with-Expressions

record is immutable by default

When you declare a record using the concise positional syntax, each parameter becomes a property that can only be set once, at construction time (technically, it’s init-only rather than get; set;):

record Person(string Name, int Age);

var alice = new Person("Alice", 30);
// alice.Age = 31; // Compile error! Age can only be set during initialization, never after

This is a deliberate design choice, not an accident. Once you’ve built a Person, nothing else in your program can quietly reach in and change its Age behind your back — which eliminates an entire category of bugs where one part of a codebase mutates shared data and another part is surprised by it. It also makes instances inherently safe to share across threads, since there’s no mutable state to synchronise.

But you still need a way to “change” a value conceptually — for example, “give me a copy of Alice, but one year older.” That’s what with is for:

var olderAlice = alice with { Age = 31 }; // creates a brand-new object
Console.WriteLine(alice);      // Person { Name = Alice, Age = 30 } — completely unchanged
Console.WriteLine(olderAlice); // Person { Name = Alice, Age = 31 } — a new, separate instance

with takes the original object, copies every property you didn’t mention, overwrites the ones you did mention, and hands you a new object — all in one expression, with no manual copy-constructor to write or maintain.

record struct is MUTABLE by default — the single biggest gotcha in this whole topic

Students coming from record naturally assume record struct behaves the same way. It does not, and this trips up almost everyone the first time:

record struct Point(int X, int Y);

var p = new Point(1, 2);
p.X = 99; // Perfectly legal! record struct properties are ordinary mutable get/set by default
Console.WriteLine(p); // Point { X = 99, Y = 2 }

Unless you explicitly say otherwise, a record struct’s positional properties are regular, mutable { get; set; } properties — the exact opposite of what record does. If you want a record struct to behave immutably, the way record does automatically, you need to add the readonly modifier yourself:

readonly record struct Point(int X, int Y);

var p = new Point(1, 2);
// p.X = 99; // Now this is a compile error, exactly like the record case
var moved = p with { X = 99 }; // use `with` instead, same syntax as before

The practical takeaway for students: when in doubt, write readonly record struct, not just record struct, unless you have a specific, deliberate reason to want a mutable value type (a common one being a small “accumulator” struct you intentionally mutate in place inside a tight loop to avoid allocating a new instance on every iteration).

class and struct have no with support at all

Neither plain class nor plain struct gets any of this for free. If you want non-destructive “copy with one field changed” behaviour on a class, you write it by hand:

class PersonClass
{
public string Name; public int Age;
public PersonClass Clone(int newAge) => new PersonClass { Name = Name, Age = newAge };
}

This works, but it’s boilerplate that has to be manually kept in sync every time you add a new field — miss one, and your “clone” silently drops data. This is exactly the class of bug the compiler-generated with on records eliminates.

Inheritance — What’s Allowed and What Isn’t

class supports the full, familiar inheritance model you’d expect from an object-oriented language: unlimited depth, abstract base classes, virtual/override methods, sealed to stop further derivation, and so on.

class Shape
{
public virtual double Area() => 0;
}
class Circle : Shape
{
public double Radius;
public override double Area() => Math.PI * Radius * Radius;
}

record also supports inheritance, but with one important restriction: a record can only inherit from another record, never from a plain class (and vice versa). Within that restriction, it behaves a lot like class inheritance, and it pairs beautifully with pattern matching for modelling a fixed set of related shapes — often called “algebraic data type” style modelling:

abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;

Shape s = new Circle(2.0);
var area = s switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
_ => 0
};

struct and record struct, on the other hand, cannot inherit from anything (other than implementing interfaces) and are implicitly sealed. This isn’t a missing feature so much as a direct consequence of being a value type: inheritance relies on the idea that a Derived object can be substituted anywhere a Base reference is expected, which only makes sense when you’re dealing with references pointing at objects on the heap. Value types don’t have that kind of substitutability, so the language doesn’t offer struct inheritance at all. Both struct and record struct can still implement interfaces, though:

interface IShape { double Area(); }
record struct Circle(double Radius) : IShape
{
public double Area() => Math.PI * Radius * Radius;
}

Pattern Matching & Deconstruction

Because positional records generate a Deconstruct method automatically, you can immediately unpack them into separate variables, or use them in pattern-matching expressions, without writing any extra code:

record Point(int X, int Y);

var p = new Point(3, 4);
var (x, y) = p; // automatic deconstruction, thanks to the compiler-generated Deconstruct
Console.WriteLine($"{x},{y}"); // 3,4

if (p is (3, 4))
Console.WriteLine("Origin offset match!");

if (p is { X: > 0, Y: > 0 })
Console.WriteLine("In the first quadrant");

The same automatic deconstruction applies equally to record struct. A plain class or struct, by contrast, gets none of this unless you write a Deconstruct method yourself:

class PointClass
{
public int X, Y;
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}

Property patterns (the { X: > 0, Y: > 0 } syntax above) work on all four kinds of types — that part isn’t record-specific — but the tuple-style positional patterns and automatic var (x, y) = p; deconstruction specifically rely on the Deconstruct method that only records generate for you automatically.

Boxing — A Hidden Performance Trap for Value Types

“Boxing” is what happens when a value type gets wrapped up and placed on the heap so it can be treated as an object or through an interface reference. It’s easy to trigger without realising it:

struct PointStruct { public int X, Y; }

PointStruct p = new() { X = 1, Y = 2 };
object boxed = p; // BOXING happens here — a heap allocation is created
IComparable c = someComparableStruct; // also boxes, if the struct implements IComparable and is assigned through the interface

// The exact same thing can happen with record struct:
record struct PointRS(int X, int Y);
object boxedRs = new PointRS(1, 2); // also boxed

This matters because one of the main reasons people choose a value type in the first place is to avoid heap allocation — but if that value type frequently gets stored in a non-generic collection, assigned to an object variable, or passed through an interface, it quietly gets boxed anyway, and you lose the performance benefit you were trying to gain. class and record, being reference types already, never have this problem — assigning one to an object or interface variable is just copying a reference, with no extra allocation. This is one of the concrete reasons to think carefully before reaching for struct/record struct in code where the value will be stored generically or passed around as an interface a lot.

Performance Rule of Thumb: When Struct Copying Gets Expensive

Value types are copied every time they’re assigned, passed into a method, or returned from one. For a small struct like two ints, that copy is essentially free — a couple of machine words. But as a struct grows larger, that “free” copy stops being free:

// Cheap to copy — a reasonable struct candidate (16 bytes: two doubles)
readonly record struct Vector2D(double X, double Y);

// A poor struct candidate — copying this on every assignment, every method call,
// and every time it's added to a collection is expensive:
struct BigDataStruct
{
public Guid Id;
public string[] Tags; // this field is a reference, but the struct itself is still copied wholesale
public DateTime Created;
public decimal[] Amounts;
public byte[] Payload;
}
// -> this should be a `class` or `record` instead, so only a reference gets copied around

A commonly cited (though not strict) guideline is that once a value type grows past roughly 16 bytes, or gets copied very frequently, the copying overhead tends to outweigh the benefit of avoiding heap allocation, and you’re usually better off switching to a reference type (class/record).

Nullability

Because reference types are, at their core, just an address pointing at something, it’s entirely reasonable for that address to point at “nothing” — that’s exactly what null represents:

class PersonClass { }
record PersonRecord;

PersonClass? c = null; // fine
PersonRecord? r = null; // fine — record is a reference type under the hood

Value types don’t have this option, because there’s no “address” to leave empty — the variable directly is the data, and it must contain some value at all times:

struct PersonStruct { }
record struct PersonRecordStruct { }

PersonStruct s = null; // compile error, CS0037
PersonRecordStruct rs = null; // compile error, CS0037

PersonStruct? sNullable = null; // fine — wrapping in Nullable<T> adds an explicit "has no value" flag
PersonRecordStruct? rsNullable = null; // fine, same reasoning

PersonStruct? isn’t magic — it’s shorthand for Nullable<PersonStruct>, a small wrapper type that stores your value plus a separate boolean flag saying whether a value is actually present. This is a fundamentally different mechanism from reference-type null, even though the ? syntax looks identical in both cases.

Records vs. Record Structs — Revisiting the Immutability Difference

It’s worth returning to this point one more time, in its own section, because it’s the detail students most often get wrong on an exam or in a code review: a plain record’s positional properties are init-only, meaning they can only be assigned during construction and never again — so a record is immutable by default with no extra effort. A record struct’s positional properties, on the other hand, are ordinary mutable get; set; properties by default, so a record struct is not immutable unless you explicitly add the readonly modifier, either on the whole declaration (readonly record struct Point(int X, int Y);) or on individual members. Only once you’ve added readonly does a record struct become truly immutable and forbid reassigning its properties after construction, matching what record does automatically. If your goal is an immutable value object, the safe habit is to always write readonly record struct rather than relying on memory to add it later.

Choosing the Right Type — How to Decide

Start by asking yourself two questions about the type you’re designing, in order.

Question one: does this thing have an identity that matters independently of its data, and might it change over time? If you’re modelling a Customer, an Order, a background EmailService, or a DbContext, the answer is yes — two customers with the same name genuinely are two different customers, and an order’s status legitimately changes as it moves through your system. In that case, reach for a class. You want reference semantics (so everyone sharing the object sees the same updates) and you don’t need — and probably don’t want — structural equality baked in by default.

If instead the thing you’re modelling is really just data — where two instances containing the same values genuinely represent the same thing, like a Money amount, a coordinate, an event that happened, or a request/response payload — move to question two.

Question two: is this data small and copied frequently enough that avoiding heap allocation actually matters for performance, and do you not need inheritance? If yes — think coordinates, small math/geometry types, keys used heavily in a HashSet/Dictionary, currency values computed millions of times in a pricing loop — reach for readonly record struct. You get value-type performance (no GC pressure, independent copies) plus all the record conveniences (fast equality, readable ToString, with, deconstruction) with none of the reflection-based slowness a plain struct would give you by default.

If the answer to question two is no — the data is large, needs inheritance/polymorphism (for example, an abstract record Shape hierarchy), or will frequently be boxed/stored as object or through an interface — reach for a plain record instead. You keep all the same record conveniences, but as a reference type, so large payloads aren’t copied wholesale and boxing is never a concern.

Plain struct still has a place, but it’s a narrower one in modern C# (10 and later): reach for it specifically when you need low-level control that record struct doesn’t give you as cleanly — for example, precise interop layouts for P/Invoke via [StructLayout], or you’re intentionally avoiding the extra generated members for some specific reason. Absent one of those specific reasons, prefer readonly record struct over plain struct for new value types, since it gives you everything struct does plus fast equality, ToString, and with for free.

Full Syntax Reference

// ---- class ----
public class Car
{
public string Model { get; set; }
public Car(string model) => Model = model;
}

// ---- struct ----
public struct Car
{
public string Model { get; set; }
public Car(string model) => Model = model;
}
public readonly struct ImmutableCar
{
public string Model { get; }
public ImmutableCar(string model) => Model = model;
}

// ---- record (class) ----
public record Car(string Model); // positional, immutable, concise
public record class Car(string Model); // identical, explicit "class" keyword
public record Car { public string Model { get; init; } } // non-positional form

// ---- record struct ----
public record struct Car(string Model); // mutable properties by default!
public readonly record struct Car(string Model); // fully immutable — usually what you want

// ---- inheritance example (records only) ----
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Square(double Side) : Shape;

When and Why Use class

Use it when the type represents something with identity, lifecycle, or behaviour — not just a bundle of data.

Reach for class whenever identity matters more than value. Two Customer objects with the same name and email are still two different customers in the real world — reference equality is the correct semantic here, not a limitation to work around:

class Customer { public string Name; public string Email; }
var c1 = new Customer { Name = "Alice", Email = "a@x.com" };
var c2 = new Customer { Name = "Alice", Email = "a@x.com" };
// c1 and c2 are two distinct customer records in your system, even though the data matches.
// Reference equality (c1 == c2 is false) is exactly what you want here.

class is also the natural choice whenever a type has mutable state that changes over time — services, controllers, repositories, caches, connections, and anything else that gets updated in place (order.Status = OrderStatus.Shipped;). It’s the right tool whenever inheritance and polymorphism are core to the design: class hierarchies with virtual/override, abstract base classes, template-method patterns, and dependency-injected services implementing interfaces all rely on class semantics. It also fits large or complex object graphs well, since passing these around by reference avoids expensive copying, and lets multiple parts of the code share and mutate one instance (a DbContext, a Logger, a UI ViewModel). Finally, class is a reasonable default whenever you simply don’t need structural equality, or are willing to hand-roll Equals/GetHashCode for the rare cases where you do.

Typical uses: domain entities with a persistent identity (User, Order, Account), services (EmailService, PaymentGateway), ASP.NET Core controllers, ViewModels, repositories, anything wired through DI containers.

Avoid it when the type is just a small, interchangeable data value where two instances with equal data really are “the same thing” — that’s what record/struct are for.

When and Why Use record

Use it when the type represents an immutable value or a piece of data whose identity IS its content.

The clearest signal that you want a record is when value equality is the natural semantic for the thing you’re modelling. A Money(100, "USD") is the same value as another Money(100, "USD") — there’s no meaningful sense in which they’re “different objects”:

record Money(decimal Amount, string Currency);
var price1 = new Money(9.99m, "USD");
var price2 = new Money(9.99m, "USD");
Console.WriteLine(price1 == price2); // True — exactly the semantic you want

Immutability by default is another major reason to reach for record: because a record’s positional properties can’t be silently changed elsewhere in the code, you get compiler-enforced protection against a whole class of aliasing bugs, plus thread-safety for free, since immutable objects are inherently safe to share across threads without locking. On top of that, with-expressions make transformations concise and safe, replacing manual copy-constructor boilerplate with a single clear expression:

record OrderLine(string Sku, int Qty, decimal Price);
var line = new OrderLine("ABC123", 2, 19.99m);
var updated = line with { Qty = 3 }; // clear intent, no manual copy-constructor boilerplate

Records also give you a genuinely useful ToString() and equality out of the box, which pays off enormously in logging, debugging, and testing — Assert.Equal(expected, actual) on records “just works” structurally, with no need to compare field-by-field or override Equals in test fixtures. That combination makes record a natural fit for DTOs, API request/response models, events, and messages — anything that’s really “data plus identity-by-value,” where a UserCreatedEvent(Guid UserId, string Email) published twice with identical values should be treated as equal. Finally, records pair beautifully with pattern matching for algebraic-data-type-style modeling: an abstract record Shape with sealed record Circle/Rectangle subclasses lets you branch exhaustively and type-safely with a switch expression.

Typical uses: DTOs, API contracts, CQRS commands/queries, domain events, configuration snapshots, value objects in DDD (Address, Money, DateRange), discriminated-union-style modeling.

Avoid it when you need mutable, long-lived state, or your object has a distinct identity that persists independent of its field values — use class for those instead.

When and Why Use struct

Use it when you need a small, simple, performance-sensitive value type and don’t need any of the modern conveniences records add — or you’re targeting an older language version.

The most common reason to reach for struct is that avoiding heap allocation and GC pressure genuinely matters. In hot paths — tight loops, high-throughput services, game loops, numerical code — allocating thousands of small objects per second creates GC pauses that can hurt performance noticeably. A struct lives on the stack (or inline in an array or containing object) and simply doesn’t generate garbage the way repeatedly allocating small classes would:

struct Vector3 { public float X, Y, Z; }
Vector3[] particles = new Vector3[1_000_000]; // one contiguous block, no per-element heap allocation

struct is also the right choice when value copy semantics are exactly what you want: passing a struct to a method automatically gives that method its own independent copy, so there’s no risk of the callee mutating your data unexpectedly unless you explicitly pass it by ref. It’s essentially required for interop with unmanaged code and fixed memory layouts — struct supports [StructLayout], is necessary for P/Invoke signatures matching C structs, and works naturally with Span<T>/stackalloc scenarios. And of course, if you’re working in an older C# version (pre-C# 10) where record struct doesn’t exist yet, plain struct is your only option for value semantics. Finally, struct is a reasonable choice if you need default equality but don’t care about the reflection-based performance cost, or you’re planning to override Equals/GetHashCode/IEquatable<T> yourself anyway.

Typical uses: DateTime, Guid, TimeSpan-style primitives, math/graphics types (Vector2, Matrix4x4), pixel/colour structs, small fixed-size buffers, P/Invoke interop structs, enum-like value wrappers.

Avoid it when the type is large (rule of thumb: bigger than roughly 16 bytes), gets boxed frequently, needs inheritance, or you’d benefit from the auto-generated ToString/equality/with support that record struct provides. In nearly all new code targeting C# 10 or later, prefer record struct over a plain struct unless you have a specific reason not to, such as needing full manual control over generated members or interop attributes that conflict with the record struct’s generated code.

When and Why Use record struct

Use it when you want everything a struct gives you (stack allocation, value/copy semantics, no GC pressure) plus everything a record gives you (fast generated equality, readable ToString, with-expressions, deconstruction) — which is most of the time you’d reach for a struct in modern C#.

The strongest case for record struct is small immutable value objects that get compared or hashed a lot. Coordinates, money amounts, IDs, RGB colors, ranges — anything you’ll put in a HashSet<T> or use as a Dictionary<TKey, TValue> key benefits enormously from the compiler-generated, allocation-free Equals/GetHashCode:

readonly record struct Point(int X, int Y);
var visited = new HashSet<Point>();
visited.Add(new Point(1, 2));
visited.Contains(new Point(1, 2)); // True, fast, no boxing, no reflection

More broadly, record struct is the right choice whenever you want the ergonomics of records — concise positional syntax, with, ToString, deconstruction — without paying for heap allocation. This is the main reason record struct was added to the language in C# 10: plain struct never got these features, and hand-writing them for every small value type was tedious and error-prone. It’s an especially good fit for high-frequency value objects in performance-sensitive code that still benefit from readability — for example, a readonly record struct Money(decimal Amount, string Currency) used millions of times in a pricing engine gets no GC churn, but still keeps with, equality, and nice debug output.

The one thing to always keep in mind is the mutability gotcha covered earlier: pair record struct with readonly (readonly record struct) almost every time, unless you specifically want in-place field mutation — for example, a mutable accumulator struct updated in a tight loop to avoid allocating a new struct every iteration:

// Deliberately mutable — used as a scratch accumulator in a hot loop
record struct RunningTotal(decimal Sum, int Count)
{
    public void Add(decimal value) { Sum += value; Count++; }
}

Typical uses: geometry/math value types (Point, Vector2, Rect), currency/measurement value objects, small immutable keys for collections, tuples-with-names replacing (int, int) value tuples when you want named, equality-friendly semantics, hot-path DTOs in performance-critical services.

Avoid it when the type is large, needs inheritance/polymorphism, or is frequently boxed or stored as object/interface — those scenarios favour record (a reference type) instead.

Summary

If you remember nothing else from this guide, remember this: class and record are reference types that live on the heap and get copied by reference; struct and record struct are value types that live inline and get copied by value. The word “record” — on either kind — means “the compiler will generate fast structural equality, a readable ToString(), and with-expression support for you.” record is immutable by default because its positional properties are init-only; record struct is not immutable by default, because its positional properties are ordinary mutable properties — so write readonly record struct when you want a truly immutable value type, which is most of the time. Choose class when identity and mutable state matter; choose record when a piece of data’s value is its identity and you don’t need to avoid heap allocation; choose struct/record struct when the data is small, copied frequently, and you want to avoid GC pressure — and in modern C#, default to readonly record struct over plain struct unless you have a specific reason not to.

C# 14 Deep Dive

C# 14 (shipping with .NET 10) is not a paradigm-shifting release. It’s a refinement release: a set of focused changes that remove long-standing boilerplate and close gaps that forced you into workarounds. Two of them change how you write members, and they’re the ones you’ll reach for daily.

The field keyword: the missing middle of properties

For twenty years, C# properties had exactly two states and nothing in between. The auto-property, concise and logic-free:

public string Name { get; set; }   // compiler generates a hidden backing field, but you can't touch it

And the full property, the moment you needed any logic — validation, change notification, lazy init — which meant hand-declaring a backing field and both accessors:

private string _name;                                  // boilerplate
public string Name
{
get => _name;
set => _name = value ?? throw new ArgumentNullException(nameof(value));
}

That gap was pure friction. In roughly 95% of cases the manual field did nothing interesting, and — worse — that _name field was now visible to the rest of the class, so any method could bypass your validation by writing to it directly.

C# 14 introduces the field contextual keyword, which refers to the compiler-synthesized backing field from inside a property accessor. The middle state finally exists — the “semi-auto property”:

public string Name
{
get => field; // 'field' is the generated backing store
set => field = value ?? throw new ArgumentNullException(nameof(value));
}

// you can also implement just ONE accessor and let the compiler generate the other:
public int Age
{
get; // compiler-generated getter
set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
}
Illustration of the three states of a C# property: Auto-property, Full property, and Semi-auto property. Each state is described with code examples and key characteristics, highlighting aspects like validation and field accessibility.

Three things make this more than sugar. It eliminates the boilerplate field declaration. It preserves encapsulation — the backing store is reachable only through the accessors, so validation can’t be bypassed elsewhere in the class. And it’s the natural fit for the two most common property-logic scenarios: validation (as above) and change notification in MVVM view models:

public string Title
{
get => field;
set { field = value; OnPropertyChanged(); } // raise INotifyPropertyChanged, no manual field
}

Two facts to file away. field is a contextual keyword — it only has special meaning inside a property or indexer accessor, so existing code compiles unchanged. But that creates the release’s most-cited breaking change: if you already have a symbol named field in scope, inside an accessor it now resolves to the backing field, not your symbol. The fix is to disambiguate with @field or this.field, or simply rename the symbol — and the pragmatic advice is to avoid naming anything field. (This feature shipped as a C# 13 preview behind LangVersion=preview; C# 14 makes it official.)

Extension members: beyond extension methods

Extension methods have been a cornerstone since C# 3, but they were always just methods — and always instance-style. You could not write an extension property, an extension operator, or a static extension. Want an IsEmpty that reads like a property on IEnumerable<T>? You were stuck writing IsEmpty(this IEnumerable<T> source) and calling it as a method. C# 14’s extension members remove that ceiling.

The new syntax is the extension block — you declare the receiver once on the block, and everything inside extends it:

public static class EnumerableExtensions
{
// instance-style: receiver has a name, members act like instance members of IEnumerable<T>
extension<T>(IEnumerable<T> source)
{
public bool IsEmpty => !source.Any(); // extension PROPERTY
public T FirstOr(T fallback) => source.FirstOrDefault() ?? fallback; // extension method
}

// static-style: receiver is a type only (no name) — members act like static members of the type
extension<T>(IEnumerable<T>)
{
public static IEnumerable<T> Empty() => []; // static extension method
}
}

// used exactly like real members:
bool empty = numbers.IsEmpty; // reads like an instance property
var xs = IEnumerable<int>.Empty(); // reads like a static method on the type

The receiver form is the key distinction: extension<T>(IEnumerable<T> source) — a named receiver — produces members that behave like instance members; extension<T>(IEnumerable<T>) — a type-only receiver — produces members that behave like static members of the type. You can even declare extension operators, letting you add + or - to a type you don’t own.

A graphic illustrating extension members in programming, highlighting their block and boundary in C# 14, including supported and unsupported features such as methods, properties, and constructors.

Because the user’s interest is extending interfaces: yes — this works, and it’s one of the most useful cases. The IEnumerable<T> examples above are interface extensions. You can add methods, properties, and operators to any interface you don’t control, which previously required either awkward method-only extensions or wrapper types.

One precise correction worth making, since it’s easy to over-claim: C# 14 extension members cover methods, properties, and operators — instance and static. They do not yet support events, fields, indexers, nested types, or constructors; those are acknowledged as future work. (If you’ve seen “extension events” mentioned, it’s likely a conflation with C# 14’s separate partial events feature — a code-generation aid covered later — which is unrelated to extensions.) Two more rules matter: extension members only fill gaps — they never override or shadow a real instance member, and are considered only when no instance member matches — and the classic this-parameter syntax still works and coexists with blocks, so you don’t have to convert anything. The one breaking change: extension is now a contextual keyword, so a type or alias named extension must be renamed (error CS9306).

Generics, Resolution, and Expression Polish

Where previous features changed how you declare members, the next features change how the compiler resolves and how you express everyday operations. Several are small; one — overload resolution — is subtle enough to bite you, so it gets the most attention.

nameof for unbound generics

A small, welcome fix. Before C# 14, nameof required a closed generic type — you had to invent a placeholder type argument just to get a name:

// before: you had to supply a throwaway type argument
string n = nameof(List<int>); // "List" — but why should you need <int>?

C# 14 lets nameof accept an unbound generic type, so you write what you mean:

string list = nameof(List<>);          // "List"
string dict = nameof(Dictionary<,>); // "Dictionary"

It returns the name of the generic type definition — no type arguments required. This is squarely aimed at logging, diagnostics, reflection, source generators, and analyzers, where you want a type’s name without caring about (or having) its type parameters. It’s minor, but it removes a papercut that showed up constantly in framework and tooling code.

Enhanced overload resolution: a feature and a trap

This is the one to read carefully. C# 14 introduces first-class support for Span<T> and ReadOnlySpan<T> — new implicit conversions between T[], Span<T>, and ReadOnlySpan<T>, plus the ability for spans to act as extension receivers and participate in generic inference. The everyday benefit is real: array-to-span code just works, with fewer explicit .AsSpan() calls:

void ProcessSpan(ReadOnlySpan<int> data) { /* ... */ }

int[] array = [1, 2, 3];
ProcessSpan(array); // implicit T[] → ReadOnlySpan<T>, no .AsSpan()

ReadOnlySpan<char> chars = "hello"; // implicit string → ReadOnlySpan<char>

But making span overloads applicable in more scenarios means the compiler now sometimes picks a different overload than it did in C# 13 — and that is a genuine, documented behavioral breaking change, not a footnote. The canonical example: in C# 13, an extension method taking a Span<T> receiver did not apply to a T[], so calls on arrays bound to the System.Linq.Enumerable (IEnumerable<T>) overloads. In C# 14, the span-based System.MemoryExtensions overloads become applicable — and win.

A diagram explaining enhanced overload resolution in C#, comparing C# 13 and C# 14. It illustrates changes in method binding for array reversal, highlighting updates to array handling and consequences for developers.

The concrete ways this surfaces are worth memorizing, because some fail loudly and some fail silently at runtime:

// 1. Compile break: the Span<T> overload returns void, the Enumerable one returned a sequence
_ = strings.Reverse(); // C#13: Enumerable.Reverse (IEnumerable<T>); C#14: MemoryExtensions.Reverse (void) → error

// 2. Runtime throw: a covariant array can't convert to Span<object>
void M(object[] arr) => Util.Do(arr); // C#14 binds Span<T> overload → ArrayTypeMismatchException at runtime

// 3. Pin the old behavior explicitly when you need it:
_ = ((IEnumerable<int>)array).Contains(x); // force the Enumerable overload
_ = array.AsEnumerable().Contains(x); // same, more readable

And the nastiest case: span-based methods now bind inside Expression<Func<...>> lambdas, where they compile fine but throw at runtime when the expression is interpreted rather than compiled. If you build LINQ expression trees (ORMs, query providers), audit them. The practical guidance: after upgrading, review span-heavy code and expression trees, lean on analyzer/ReSharper inspections that flag the change, and pin the old binding with an explicit IEnumerable<T> cast where behavior matters. This is the price of the performance win — worth it, but not free.

Null-conditional assignment

A clean ergonomic win. The null-conditional operators ?. and ?[] can now appear on the left side of an assignment or compound assignment — the mirror of the null-conditional read you already use:

// before
if (customer is not null)
customer.Order = GetCurrentOrder();

// C# 14
customer?.Order = GetCurrentOrder(); // assigns only if customer != null; RHS evaluated only then
orders?[i] += delta; // compound assignment works too

The right-hand side is evaluated only when the left side is non-null — so GetCurrentOrder() isn’t even called if customer is null, which matters when the RHS has side effects. One restriction: this works with assignment and compound operators (+=, -=, …) but not with increment/decrement (++, --).

User-defined compound assignment operators

Previously, x += y on your own type always meant x = x + y — the compiler synthesized the compound form from your binary + operator, allocating a new instance every time. For large value types that’s wasteful. C# 14 lets you define the compound operator directly, as an instance operator that mutates the receiver in place:

public struct BigAccumulator
{
private long[] _buffer;
// user-defined += : mutates in place instead of allocating a new BigAccumulator
public void operator +=(BigAccumulator other)
{
for (int i = 0; i < _buffer.Length; i++)
_buffer[i] += other._buffer[i]; // no new allocation
}
}

The payoff is allocation reduction for big value holders — think tensor buffers, BigInteger-style number types, or accumulators in a tight loop, where the old copy-on-+= behavior was pure GC pressure. This connects directly the allocation-free theme (and to the green-coding series’ point that efficient code is cheaper and greener). C# 14 also allows user-defined ++ and --.

Cleaner lambda parameters

Finally, a syntax polish: lambda parameters can now carry modifiers — ref, in, out, ref readonly, scopedwithout forcing you to write the parameter types:

// before: modifiers required explicit types
TryParse<int> parse = (string text, out int result) => int.TryParse(text, out result);

// C# 14: modifiers with inferred types
TryParse<int> parse = (text, out result) => int.TryParse(text, out result);

One breaking change rides along: scoped is now a reserved word in lambda parameter position, so an identifier named scoped needs @scoped.

A related code-generation feature completes the picture: partial events and partial constructors. C# 13 made properties and indexers partial; C# 14 finishes the set, so a source generator can supply the implementation of an event or constructor whose declaration you wrote by hand — the same separation-of-definition-and-implementation pattern that makes generated code clean.

An illustration discussing generics, overload resolution, and expression refinements in programming, highlighting various features such as unbound generics, enhanced overload resolution, user-defined compound assignment, null-conditional assignment, and cleaner lambdas with partial members.

A recurring goal across recent C# versions is letting you write allocation-free code — code that keeps data on the stack, avoids heap churn, and so avoids the garbage-collection pressure that shows up as latency spikes and, per the green-coding series in this library, as wasted cost and energy. C# 14 advances that goal on several fronts. But it does so on top of work that landed in C# 13, and a deep dive should be precise about which is which — so let’s draw that line clearly.

Setting the record straight: ref struct interfaces are C# 13

The headline “ref struct interfaces” capability — the ability for a ref struct (a stack-only type like Span<T>) to implement an interface, and the allows ref struct anti-constraint that lets a ref struct be used as a generic type argument — arrived in C# 13, not C# 14. It’s foundational to the allocation-free story, so it belongs in this discussion, but calling it new in C# 14 would be wrong.

Here’s what C# 13 unlocked, because C# 14’s features build directly on it:

// C# 13: a ref struct can implement an interface
public interface IProcessor { void Process(); }

public ref struct SpanProcessor : IProcessor // stack-only type, now polymorphic
{
private readonly ReadOnlySpan<byte> _data;
public SpanProcessor(ReadOnlySpan<byte> data) => _data = data;
public void Process() { /* work over _data without copying to the heap */ }
}

// C# 13: 'allows ref struct' lets a ref struct be a generic type argument
public static void Run<T>(T proc) where T : IProcessor, allows ref struct
{
proc.Process(); // no boxing, no heap allocation
}

The significance: before this, a ref struct couldn’t participate in generic or interface-based polymorphism at all — you had to abandon stack-only types or abandon abstraction. Now you can write generic, abstracted, allocation-free code over Span<T> and your own ref structs. Note the crucial constraint that keeps it safe: a ref struct implementing an interface still cannot be boxed — you can only use it through a generic parameter constrained with allows ref struct, never as the interface type directly (that would require a heap allocation, defeating the purpose).

What C# 14 actually adds to the allocation-free toolkit

C# 14’s contribution is to make the surrounding code allocation-free and natural, and there are three pieces, each seen earlier in the series and now viewed through the performance lens.

First-class span conversions. The implicit T[]Span<T>/ReadOnlySpan<T> and stringReadOnlySpan<char> conversions from earlier aren’t just ergonomics — they let you thread arrays and strings through span-based APIs without intermediate copies or allocations, and let spans act as extension receivers and inference targets. That’s the connective tissue that makes ref-struct/span code read like ordinary code.

In-place user-defined compound assignment. The operator += from earlier that mutates the receiver instead of allocating a new instance is, fundamentally, an allocation-reduction feature — it turns a hot-loop sum += v over a large value type from N allocations into zero.

Overload resolution that prefers spans. The (breaking) resolution change from above means span-based library methods now bind by default in more places — so your existing code often gets more allocation-free without you rewriting it, which is exactly why the breaking change is worth tolerating.

A visual comparison of heap and stack allocation in C#. The heap section highlights issues like boxing a value type, allocation on every assignment, and copying an array to a span API. The stack section presents alternative solutions such as using ref structs, first-class span conversions, in-place user-defined assignment, and span-preferring overload resolution, emphasising lower garbage collection pressure.

Put together, a modern allocation-free pattern looks like this — stack-only data, interface abstraction, generic dispatch, no heap:

ReadOnlySpan<byte> payload = GetBuffer();          // no copy
var processor = new SpanProcessor(payload); // ref struct on the stack
Run(processor); // generic dispatch via allows ref struct — zero allocations

The honest limits

C# 14 is a strong release, but a deep dive owes you the edges — and there are several worth planning around.

Infographic titled 'Adopting C# 14: the honest limits' outlining five key changes and considerations regarding C# 14, including overload-resolution, contextual-keyword breaks, extension members, struct constraints, and framework compatibility.
  • The overload-resolution change is the real risk. As previously detailed, span-preferring binding can silently rebind calls, break compilation where return types differ, throw ArrayTypeMismatchException on covariant arrays, and — worst — throw at runtime inside interpreted Expression trees. This is the one thing to actively test after upgrading; the rest of C# 14 is additive.
  • Contextual-keyword breaks. field, extension, and scoped all became contextual/reserved in their positions. Existing symbols with those names need @-escaping or renaming. Cheap to fix, easy to miss.
  • Extension members are incomplete. Methods, properties, and operators only. Events, fields, indexers, nested types, and constructors are not supported yet — so don’t design an API assuming extension events exist (they don’t; that’s the separate partial events feature).
  • ref struct constraints still apply. Implementing an interface doesn’t make a ref struct heap-friendly: no boxing, no using it as the interface type, and the usual lifetime/scoped rules. The allows ref struct generic path is the only way to abstract over them.
  • C# 14 is tied to .NET 10. The language version travels with the target framework, so you generally can’t sprinkle C# 14 features onto a project still targeting net8.0. In practice, adopting C# 14 means adopting .NET 10 — which, per the previous series, is a forced move this year anyway.

The adoption playbook

  1. Adopt .NET 10 first — C# 14 rides with it, and the November 2026 support cliff (previous series) already forces that move.
  2. Turn on the analyzers. Let Roslyn and your IDE flag the span overload-resolution change; treat those inspections as a migration checklist, not noise.
  3. Audit span-heavy code and Expression trees specifically — this is where the behavioral break hides. Pin the old binding with IEnumerable<T> casts where behavior matters.
  4. Escape or rename any field, extension, or scoped identifiers.
  5. Reach for the ergonomic wins immediatelyfield in properties, nameof(List<>), null-conditional assignment — they’re zero-risk and cut boilerplate today.
  6. Introduce extension members where they read better than method-only helpers (extension properties on interfaces especially), staying inside the methods/properties/operators boundary.
  7. Apply the allocation-free tools where they pay — in-place compound assignment and span APIs in hot paths and high-throughput services; don’t micro-optimize cold code.