C# Struct Usage: Value Types and When to Use Them
c# struct usage: Understand C# struct usage: value type semantics, allocation behavior, performance tradeoffs, and when structs beat classes in real code.
C# structs are value types, which means they behave differently from classes at the assignment, argument-passing, and allocation levels. A struct variable holds its data directly, while a class variable holds a reference to an object on the managed heap. That distinction drives most practical decisions about c# struct usage. When you assign one struct variable to another, the entire value is copied. When you pass a struct to a method, a copy is made unless you pass it by ref or in. This copying is cheap for small, simple data but becomes a hidden cost as the struct grows.
Value Type Semantics: Assignment and Copy Behavior
Consider this struct:
public struct Point { public int X; public int Y; }
If you write Point a = new Point { X = 1, Y = 2 }; Point b = a; b.X = 10;, the value of a.X remains 1. The assignment b = a copies the entire struct, so b and a are independent. This is fundamentally different from a class, where b = a would copy the reference, making both variables point to the same object.
The same copy behavior applies when a struct is passed to a method without a modifier. Inside the method, any changes to the parameter do not affect the caller's original variable. If you need the method to modify the caller's struct, you must pass it with ref or out. For read-only access without copying, the in parameter modifier passes the struct by reference but prevents modification.
Where Structs Are Allocated: Stack vs. Heap
A struct that is a local variable is typically allocated on the stack, not on the managed heap. This avoids the allocation and garbage collection overhead that comes with reference types. However, the allocation location is not a strict guarantee. If a struct is a field of a class, it lives inside that class's object on the heap. If a struct is boxed, it is copied into a heap-allocated box. The practical effect is that structs reduce heap pressure when they are used as local variables, method arguments, or fields within other structs.
Because structs are copied by value, they do not require a separate heap allocation when stored in an array. An array of structs stores the struct instances contiguously in memory. An array of classes stores references, and each object is allocated separately. This makes struct arrays more memory-efficient and improves cache locality when iterating over them.
Choosing Between Struct and Class
Microsoft's design guidance recommends using a struct when the type is small, immutable, and represents a single value. A common rule of thumb is to keep the total size under 16 or 24 bytes, but that is not a hard limit. More important is the semantic fit: if the type should behave like a number, a coordinate, or a key, a struct is often appropriate. If the type has identity, mutable state, or a large amount of data, a class is usually better.
Here is a practical decision matrix:
| Criterion | Prefer struct | Prefer class |
|---|---|---|
| Size | Small (typically under 24 bytes) | Larger or variable |
| Immutability | Immutable or rarely mutated | Mutable state expected |
| Identity | Value equality (all fields) | Reference identity |
| Allocation frequency | High (avoid heap churn) | Low or moderate |
| Boxing exposure | Rarely boxed | Boxing not a concern |
A struct that is used as a dictionary key or a hash set element avoids a separate allocation for each entry. The struct is stored inline in the collection's internal array. A class key would require a reference plus a heap object. For high-throughput code that creates many temporary values, structs can reduce garbage collection pressure.
Common Performance Traps
A struct that is too large causes expensive copying. Every assignment, method call, and collection operation copies the entire struct. If a struct contains several large fields, the copy cost can exceed the cost of a heap allocation. For example, a struct with 100 bytes will be copied in full when passed to a method by value. Passing it by in or ref avoids that copy but introduces a different constraint: the compiler may need to create a defensive copy if the method calls another method that could modify the struct.
Boxing is another trap. When you assign a struct to an object, interface, or a non-generic collection like ArrayList, the struct is boxed. Boxing allocates a heap object and copies the struct into it. Unboxing copies it back. This defeats the allocation advantage of structs. Generic collections such as List<T> and Dictionary<TKey, TValue> avoid boxing because the type parameter is known at compile time. Prefer generic collections when storing structs.
readonly struct and ref struct
Two modern modifiers make struct usage safer and more efficient. A readonly struct declares that all fields are readonly, which prevents accidental mutation and lets the compiler avoid defensive copies in many scenarios. For example:
public readonly struct Money { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount = amount; Currency = currency; } }
Because Money is readonly, the compiler knows it cannot change after construction. This allows the runtime to pass it by reference without worrying about the callee modifying it, which eliminates some hidden copy operations.
A ref struct is a struct that is restricted to the stack. It cannot be boxed, cannot be a field of a class, and cannot be used in async methods or iterators. ref struct types like Span<T> enable high-performance, allocation-free operations on memory buffers. If you are working with low-level data processing, ref struct gives you the performance of a struct without the risk of heap allocation.
When Structs Break Down: Mutable Structs and Defensive Copies
Mutable structs are a frequent source of bugs. If a struct is exposed as a property, calling a method that mutates the struct can fail silently because the compiler copies the struct before the method call. For example:
public struct Counter { public int Value; public void Increment() => Value++; } public class Container { public Counter Counter { get; set; } } var container = new Container(); container.Counter.Increment(); // This does NOT modify container.Counter
The property getter returns a copy of Counter, and Increment() mutates that copy. The original property value remains unchanged. This behavior surprises many developers. The fix is to make the struct immutable or to avoid exposing mutable structs as properties. If you need mutable state, a class is usually the correct choice.
Another issue arises with readonly fields. If you declare a readonly struct field and call a method on it, the compiler creates a defensive copy to ensure the method cannot modify the field. This copy can happen even for methods that only read the struct. Using readonly struct eliminates this defensive copy because the compiler knows the struct is immutable.
Practical Struct Usage Patterns
A common practical use for structs is representing simple value objects that are frequently created and discarded. For example, a Vector3 in a physics engine, a DateTime-like custom type, or a Range with start and end indices. These types benefit from value equality and reduced allocation.
When you implement IEquatable<T> on a struct, you avoid boxing during equality checks. The generic interface lets the runtime call the strongly typed method directly. Without it, comparing two structs with Equals(object) boxes both operands. For collections that rely on equality, such as HashSet<T> or Dictionary<TKey, TValue>, implementing IEquatable<T> improves performance and correctness.
A struct is also a good fit for a small, immutable configuration value that is passed through many layers. Because it is copied, there is no risk of a callee mutating the original. This makes the code easier to reason about in multithreaded scenarios. Since each thread receives its own copy, there is no shared mutable state.
Finally, consider using in parameters for large readonly structs. The in modifier passes the struct by reference without allowing modification. This avoids copying the entire struct while preserving the caller's original value. However, in parameters can cause defensive copies if the method calls another method that takes the struct by value. In practice, in is most useful for large structs that are truly readonly and are passed to methods that do not forward them to other methods that expect a value copy.