AlgoMaster Logo

Struct vs Class

Last Updated: May 22, 2026

High Priority
13 min read

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.

The Surface Looks Similar, the Runtime Doesn't

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.

The Side-by-Side Comparison

A complete picture of how the two types differ, on every axis that affects real code:

Axisstruct (value type)class (reference type)
Memory locationInline where declared: stack frame for locals, inside the containing object for fieldsAlways on the managed heap; the variable holds a reference
Allocation costNo GC allocation. Stack push or inline write.Heap allocation, contributes to GC pressure
AssignmentField-by-field copy of all the dataCopy of the reference; both variables alias the same object
Method parameters (default)Passed by value: callee gets its own copyReference copied; callee can mutate the caller's object
Method parameters (opt-in)Use in, ref, out, or ref readonly to avoid copyref and out available but rarely needed
Equality (default)Equals does field-by-field comparison (via reflection, slow)Equals and == compare references unless overridden
InheritanceCannot inherit from another struct or class. Implicitly sealed.Can inherit from one class; supports virtual / override / abstract / sealed
InterfacesCan implement, but casting to the interface boxes (heap allocation)Can implement with no boxing
nullCannot 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 valueAll fields set to their zero value (no constructor runs)null
new keywordOptional. Runs a constructor; without new, fields start at zero.Required to create an instance
ConstructorsAllowed. Parameterless constructor allowed since C# 10.Allowed; default parameterless is generated if you don't write one
BoxingCasting to object or an interface allocates a heap copyNo boxing; classes already live on the heap
IdentityTwo structs with the same field values are interchangeableTwo class instances with the same data are still distinct objects
MutabilityMutable by default; readonly struct enforces immutabilityMutable by default; immutability is achieved via init accessors or record types
FinalizersNot allowedAllowed (~ClassName()), though rarely needed
static membersAllowedAllowed
Use as generic constraintwhere T : structwhere 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.

Memory: Where Each One Actually Lives

The comparison table puts "stack" next to struct and "heap" next to class, but that's a simplification. The accurate rule is:

  • A struct lives wherever its declaration puts it. A local variable struct sits in the stack frame. A struct field of a class sits inline inside that class on the heap. A struct element of an array sits inline inside the array on the heap. A boxed struct sits on the heap in a wrapper.
  • A class instance always lives on the managed heap. A variable of a class type lives wherever it's declared (stack, heap, register), but it only holds a reference to the heap object.

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.

Method Parameters: Copy vs Reference

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.

Common Pitfalls

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.

Mutating a Struct Through a Collection Indexer

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.

Boxing When You Don't Notice

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#.

Default Values That Aren't Valid

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.

Implementing an Interface Without Realizing It Boxes

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.

Slow Default Equals and GetHashCode

The 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.

When to Pick Which: The Decision Framework

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:

  1. The type logically represents a single value. Numbers, points, coordinates, durations, currency amounts, identifiers. If the type is more like an "entity" with identity (a customer, an order, a session) than a "value" (a price, a date, a position), it's a class.
  2. The instance size is small, roughly 16 bytes or less. This is a rule of thumb, not a hard line. Larger structs pay copy costs that often outweigh the heap-allocation savings, and they don't fit in registers as cleanly.
  3. The type is immutable. Mutable structs interact badly with copy semantics, collection indexers, and readonly fields. The boxes-vs-original confusion shown above is a symptom of mutability.
  4. The type will not be boxed frequently. If your usage pattern routinely casts the value to 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.

Real Examples from the Base Class Library

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:

TypeKindWhy
Int32, Double, Boolean, CharstructThe textbook value types: small, immutable, behave like math values, no identity
DateTime, TimeSpan, DateOnly, TimeOnlystruct8 bytes (one long of ticks), immutable, value semantics. Two dates with the same ticks are interchangeable.
Guidstruct16 bytes exactly, immutable, represents a single ID value, compared by value
Decimalstruct16 bytes, immutable, behaves like a number with high precision
KeyValuePair<TKey, TValue>structA small pair of values returned from dictionary enumeration; staying a struct avoids heap allocation per entry
Nullable<T>structWraps a value type plus a HasValue flag; itself a value to preserve T's allocation behavior
Span<T>, ReadOnlySpan<T>ref structStack-only by design; represent a window into existing memory and never live on the heap
StringclassVariable length (potentially huge), shared widely via the intern pool, treated as immutable by convention
Array, List<T>, Dictionary<TKey, TValue>classVariable size, share state across many references, mutable, lives long enough to benefit from heap management
ExceptionclassPolymorphic (lots of derived types), thrown and caught across stack frames, identity-based
Stream, Task, HttpClientclassHold 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.

A Side-by-Side Worked Example

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.

Putting It All Together

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:

  1. "Is this thing a value, like the number 7 or the date 2026-05-15, where two with the same contents are interchangeable?" If yes, lean struct.
  2. "Is this thing an entity with identity, like a particular customer or a particular order, where two with the same contents are still different things?" If yes, lean class.

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.