Last Updated: May 22, 2026
C# gives you two ways to define a custom type that bundles fields together: struct and class. They look almost identical when you write them, but they behave very differently at runtime, and the choice between them shows up in interviews more often than almost any other C# topic. This lesson pulls the prior lessons on structs and enums together into a side-by-side comparison, a decision framework, and a tour of the common pitfalls.
Here are two types that, on the page, look nearly the same:
Swap the keyword, and the source compiles either way. Run a few lines through each and the behavior splits hard:
Same line of code, opposite result. With the struct, s2 = s1 copied the fields, so s1 and s2 are independent. With the class, c2 = c1 copied a reference, so both variables point at the same object on the heap, and the write through c2 shows up through c1. That single distinction (value type copy vs reference type alias) is the root cause of most other differences covered here.
A complete picture of how the two types differ, on every axis that affects real code:
| Axis | struct (value type) | class (reference type) |
|---|---|---|
| Memory location | Inline where declared: stack frame for locals, inside the containing object for fields | Always on the managed heap; the variable holds a reference |
| Allocation cost | No GC allocation. Stack push or inline write. | Heap allocation, contributes to GC pressure |
| Assignment | Field-by-field copy of all the data | Copy of the reference; both variables alias the same object |
| Method parameters (default) | Passed by value: callee gets its own copy | Reference copied; callee can mutate the caller's object |
| Method parameters (opt-in) | Use in, ref, out, or ref readonly to avoid copy | ref and out available but rarely needed |
| Equality (default) | Equals does field-by-field comparison (via reflection, slow) | Equals and == compare references unless overridden |
| Inheritance | Cannot inherit from another struct or class. Implicitly sealed. | Can inherit from one class; supports virtual / override / abstract / sealed |
| Interfaces | Can implement, but casting to the interface boxes (heap allocation) | Can implement with no boxing |
null | Cannot be null. Use Nullable<T> (T?) when needed. | Can be null by default in older C#; opt-in non-null via nullable reference types in C# 8+ |
| Default value | All fields set to their zero value (no constructor runs) | null |
new keyword | Optional. Runs a constructor; without new, fields start at zero. | Required to create an instance |
| Constructors | Allowed. Parameterless constructor allowed since C# 10. | Allowed; default parameterless is generated if you don't write one |
| Boxing | Casting to object or an interface allocates a heap copy | No boxing; classes already live on the heap |
| Identity | Two structs with the same field values are interchangeable | Two class instances with the same data are still distinct objects |
| Mutability | Mutable by default; readonly struct enforces immutability | Mutable by default; immutability is achieved via init accessors or record types |
| Finalizers | Not allowed | Allowed (~ClassName()), though rarely needed |
static members | Allowed | Allowed |
| Use as generic constraint | where T : struct | where T : class |
Almost every behavior on that table traces back to one design decision: structs are values, and classes are references. The runtime treats them differently because they answer different questions. A struct answers "what is this thing made of?" A class answers "which thing is this, exactly?"
The default Equals on a struct uses reflection to walk every field unless you override it. For any struct used in hash-based collections or hot loops, override Equals and GetHashCode yourself.
The comparison table puts "stack" next to struct and "heap" next to class, but that's a simplification. The accurate rule is:
A picture that captures all three common cases:
Local struct s is right there on the stack. Local class reference c is on the stack too, but it's just an arrow pointing at a heap object. The heap object itself has a struct field (ShipTo) sitting inline inside it, not as a separate allocation. The boxed values in the object[] array each have their own small heap allocation.
That layout matters in two practical ways. First, an array of a million Point structs is one big contiguous block of bytes, which is friendly to the CPU cache and avoids a million GC allocations. An array of a million Point class instances is one array of references plus a million separate small heap objects, which is slower to walk and harder on the GC. Second, when a class contains a struct field, the struct lives inside the class on the heap; it isn't allocated separately. This is how DateTime works inside an Order class without doubling the allocation count.
A List<Point> where Point is a struct stores the points inline in the backing array. A List<Point> where Point is a class stores references and pays for an extra heap object per point. For large collections of small data, the struct version is typically 2 to 4 times smaller in memory.
A quick demonstration of inline layout vs reference layout in a class:
There is one heap allocation here, not two. The Order object lives on the heap, and the Address struct sits inside it as part of the same allocation. If Address were a class, you'd have two heap objects: an Order and a separately allocated Address reached through a reference. Both layouts work, but the struct version is denser and friendlier to the GC.
The default rule is short: structs are passed by value, classes pass the reference by value. The consequences are anything but short.
Both Bump methods look identical. The struct version receives a copy and increments the copy, which then disappears when the method returns. The class version receives a copy of a reference, follows it back to the same object the caller is holding, and increments that object's field. One mutates the caller, one doesn't. The keyword on the type is the only thing that decides which.
When you want to avoid the copy of a large struct but still pass by value semantically, use in:
in passes the struct by reference (no copy) but forbids mutation through the parameter. For a struct over 16 bytes that you pass into a hot method, in is the typical choice. For small structs, the JIT often eliminates the copy anyway and plain by-value is fine.
When you want the method to write back into the caller's struct, use ref:
Both the parameter and the call site need the ref keyword. That visibility at the call site is deliberate: anyone reading DoubleIt(ref c) can see at a glance that the method might modify c. Classes don't need this ceremony, because the reference is already what's being copied.
Structs have a handful of behaviors that surface when developers pick struct because "structs are fast." Knowing the pitfalls is half the reason to read this lesson.
A classic case. The compiler accepts the code, the runtime accepts the code, and the field doesn't change.
That doesn't compile. The error is CS1612: Cannot modify the return value of 'List<CartLine>.this[int]' because it is not a variable. The reason: list[0] for a struct returns a copy of the element, not the element itself. Assigning to a field of that copy would change a temporary that's about to be thrown away. The compiler stops you. If CartLine were a class, list[0] would return a reference and the assignment would work.
The fix for structs is to read out, modify, write back:
This pattern (and the friction it creates) is one of the strongest arguments for making structs immutable in the first place. A readonly struct sidesteps the whole question: you build a new value and replace the old one, which is exactly what the indexer's copy semantics already force you to do.
A struct turns into a heap allocation the moment you assign it to object or to any interface type. The CLR has to put the value somewhere with an identity, and the only place it can do that is the heap. The struct gets wrapped in a boxed object.
Two separate values now: the original struct (mutated to zero) and the boxed copy on the heap (still 19.99). Assigning a struct to object is the moment the heap allocation happens. Every cast through an interface does the same thing if the receiver expects the interface type:
That IPriceable handle = item; boxes. If you call item.GetPrice() directly on the struct, no boxing happens. If you put 100,000 such items into a List<IPriceable>, you've made 100,000 boxes. Generics avoid this: List<Item> stores the structs inline, and a method void Process<T>(T x) where T : IPriceable calls x.GetPrice() without boxing because the JIT specializes the generic for Item.
Boxing turns a stack-friendly value into a heap allocation, plus a copy of every field. Repeated boxing in a loop is a common cause of unexpected GC pressure in otherwise idiomatic C#.
A class constructor is the only way to get a class instance (you have to use new, which runs a constructor). A struct can come into being two ways: through new and a constructor, or as a default value with all fields set to zero. The second path skips your constructor entirely.
The constructor's check never ran for defaultOne or for the array elements. Percent = 0 happens to be a legal value here, so it looks harmless. If you'd written the struct so that "0 percent" was an invalid state (say, a Currency struct where empty string is meaningless), default(Currency) would still produce that invalid state and your code couldn't refuse.
The lesson: design structs so that the all-zero state is a valid, meaningful value. If you can't, a class might be a better fit instead, because a class can guarantee that every instance went through a constructor.
The previous pitfall showed boxing through object. The subtler version is boxing through an interface, because the cast doesn't look like a heap operation.
The list looks innocuous. But because List<IPriceable> stores references, every LineItem struct that goes into it must be wrapped in a box, and that's a thousand small heap allocations. The fix is to declare the list as List<LineItem> (storing the structs inline) and let any generic algorithm use where T : IPriceable to call through the interface without boxing. Generics specialize: a method Process<T>(T item) where T : IPriceable compiled with T = LineItem calls item.Price directly on the struct, no box involved.
Equals and GetHashCodeThe runtime's default ValueType.Equals compares two structs field by field using reflection. That's correct, but it's also significantly slower than a hand-written version, and it allocates as it walks reference-type fields. The default GetHashCode has similar issues. Any struct that ends up in a HashSet<T>, a Dictionary<TKey, TValue> key, or any equality-heavy code path should override both.
Implement IEquatable<ProductId>, because generic collections look for it and call the typed Equals(ProductId) directly, which avoids boxing the parameter. The record struct form generates all four members for you, which is one of the strongest reasons to prefer record structs for small value-like types.
Microsoft's official guidance is restrictive on purpose. The default choice in C# is class. Use struct only when a specific set of conditions is met. Paraphrased from the official type design guidelines, consider a struct only when all of these are true:
readonly fields. The boxes-vs-original confusion shown above is a symptom of mutability.object, an interface type, or puts it into non-generic collections, the boxing cost will erase the savings of avoiding heap allocation in the first place.When all four hold, a struct is usually the appropriate call. When any one fails, default to a class. The flowchart:
The defaults push toward classes for good reasons. Classes are the more forgiving choice: they support inheritance, they don't box, their copy behavior is predictable, and the heap allocation is rarely the bottleneck in everyday code. Structs are an optimization for a specific shape of problem. Pick them when the shape fits, not because "value types are faster" as a general rule.
One more axis to consider: choose record class or record struct over plain class or struct when the type is data-centric and benefits from generated equality, ToString, and with expressions. That decision is orthogonal to the struct-vs-class question but often comes up at the same time.
Looking at how the .NET base class library itself draws the line is the fastest way to learn when each makes sense. A few representative types:
| Type | Kind | Why |
|---|---|---|
Int32, Double, Boolean, Char | struct | The textbook value types: small, immutable, behave like math values, no identity |
DateTime, TimeSpan, DateOnly, TimeOnly | struct | 8 bytes (one long of ticks), immutable, value semantics. Two dates with the same ticks are interchangeable. |
Guid | struct | 16 bytes exactly, immutable, represents a single ID value, compared by value |
Decimal | struct | 16 bytes, immutable, behaves like a number with high precision |
KeyValuePair<TKey, TValue> | struct | A small pair of values returned from dictionary enumeration; staying a struct avoids heap allocation per entry |
Nullable<T> | struct | Wraps a value type plus a HasValue flag; itself a value to preserve T's allocation behavior |
Span<T>, ReadOnlySpan<T> | ref struct | Stack-only by design; represent a window into existing memory and never live on the heap |
String | class | Variable length (potentially huge), shared widely via the intern pool, treated as immutable by convention |
Array, List<T>, Dictionary<TKey, TValue> | class | Variable size, share state across many references, mutable, lives long enough to benefit from heap management |
Exception | class | Polymorphic (lots of derived types), thrown and caught across stack frames, identity-based |
Stream, Task, HttpClient | class | Hold external resources and identity, sometimes need inheritance, typically long-lived |
DateTime is the canonical example of a "should be a struct" type. It represents a single value (a moment in time). It's immutable. It fits in 8 bytes. It's compared by value. Every time you store a date in an Order class, the date sits inline in the order object on the heap, with no extra allocation.
String is the interesting outlier. By every value-type criterion, a string sounds like it should be a struct: immutable, compared by value, behaves like a value. But strings are variable length (could be 5 characters or 5 million) and are passed around constantly. Copying the entire character buffer on every assignment would be unworkable. So string is a reference type with value-like equality semantics: == compares characters, not references. That model fits variable-size immutable data.
Span<T> is the modern outlier in the other direction. It's a ref struct, a special kind of struct that's restricted to the stack and can't be boxed, stored in fields of regular classes, or captured by async methods. That restriction lets it safely point into stack memory and managed arrays without breaking GC assumptions. The _ref Struct_ lesson covers the details.
A concrete BCL pairing makes the contrast vivid:
DateTime is a struct, so t1 == t2 compares the underlying tick count. The values are equal. string is a class, but its == operator is overridden to compare characters, and the compiler also interns the string literal so both names happen to point at the same object (ReferenceEquals returns True). These two types sit at opposite ends of the value-vs-reference spectrum while both behaving like values at the call site, which is a useful pattern: a class can give you value-like equality, but the storage and lifetime are still reference-typed.
Walking through one small domain in both forms makes the trade-offs concrete. Consider modeling a 2D Point for a geometry library, where the program builds millions of them during a single calculation.
The struct version:
Every new PointS(i, i) lives on the stack for the duration of one loop iteration, then gets overwritten by the next one. There are zero heap allocations across all 10 million iterations. The GC has nothing to do.
The class version of the same code:
Now there are 10 million heap allocations across the loop. The GC will run several times during the loop just to reclaim the dead objects. The throughput drops noticeably, and the working set grows. Same logical code, different runtime behavior.
This is the common case for using a struct: small, immutable, value-like, used in tight loops where allocation pressure shows up. The four criteria from the previous section all hold (single value, 16 bytes, immutable, no interface boxing in the loop). When all of them line up like this, structs are a clear win. When any of them fails, the win can flip into a loss, which is why "use the four criteria" is more reliable than "structs are faster."
A heap allocation in .NET is fast (under 10 nanoseconds typically), but allocations are not free, and they put pressure on the GC. In a hot loop that creates millions of short-lived objects, switching from a class to a struct often pays for itself many times over. Outside such loops, the difference is rarely measurable.
The choice between struct and class is rarely a "performance" decision in the way that benchmark-minded engineers first assume. It's a semantic decision about whether the thing has identity or is a value, and the runtime behavior follows from that semantic answer.
Two questions to ask first, before any thought of stack vs heap:
After answering that, check the four criteria (single value, small, immutable, rarely boxed) for the structs and verify nothing fails. If anything does, fall back to class.
In a sense, this whole section of the curriculum has been building toward that decision. The _Struct Basics_ lesson taught you the syntax. The _readonly Struct_ lesson taught you how to make immutability enforceable. The _ref Struct_ lesson taught you the most restricted, fastest variant for memory-window types. The _Record Struct_ lesson taught you the equality-and-ToString shortcut. The _Enum Basics_ lesson showed how to give a fixed set of integer values a name. The _[Flags] Attribute_ lesson extended enums into bit-set territory. This lesson is the framework that decides which of those tools to pick up for a given job.