If you’ve been writing C# for a while, you’ve almost certainly seen struct
used beside
class
and thought, “Okay… when exactly do I pick one over the other?” That’s a sensible question.
Structs are value types, which sounds simple - and it is - until you hit the subtle places where
copying, boxing, and lifetime change how your code behaves.
In this article we’ll fit those details into practical advice you can use in real projects: when to choose a
struct, when to avoid it, and how to design them safely. We’ll keep things hands-on. Expect clear examples, short
asides like “you might notice…”, and a few of the gotchas that make engineers scratch their heads later. If you’re
implementing small domain types (think Point
, Money
, or a tiny Range
), this
is for you.
Quick Intuition: What a Struct Really Is
A struct
is a value type. Assigning one struct variable to another copies the whole value.
Contrast that with classes - assigning a class copies a reference. This difference is small in wording but large
in consequences for semantics and performance.
public struct Point
{
public int X;
public int Y;
public Point(int x, int y) { X = x; Y = y; }
}
var a = new Point(1, 2);
var b = a; // full copy
b.X = 10;
Console.WriteLine(a.X); // 1
Console.WriteLine(b.X); // 10
That copy behavior is the hallmark of value semantics. It’s great when you want independent copies; it’s confusing when you expect shared, mutable state.
When to Prefer a Struct (Real-World Rules of Thumb)
- Small, logically single values:
Point
,Color
, smallMoney
. - Immutable data where copying is cheap.
- You want inline storage (arrays of structs are stored contiguously).
- High-frequency, allocation-sensitive code (e.g., game engines, numeric libraries).
And when to avoid them:
- Large objects (copying dozens or hundreds of bytes is expensive).
- Shared mutable state - mutable structs often produce bugs.
- When inheritance is required (structs can’t be base classes).
Keep Them Small - A Practical Size Guideline
A pragmatic guideline is to prefer structs that are small (think ≤ ~16 bytes) if they’ll be used heavily, especially inside arrays or spans. This is a heuristic - not a hard rule. The real advice is: measure in your actual workload. Copying costs can outstrip allocation costs for large structs.
Immutability: Your Friend for Safe Structs
Mutability plus implicit copies is a recipe for confusion. Prefer immutable, or at least readonly
structs whenever possible. The compiler will help you avoid accidental mutation and sometimes enable
optimizations.
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public Money Add(Money other)
{
if (Currency != other.Currency) throw new InvalidOperationException("Currency mismatch");
return new Money(Amount + other.Amount, Currency);
}
}
Small aside: making a struct readonly
communicates intent clearly - you’re declaring it’s a value
with no in-place mutation.
The Pesky Default Value Problem
All value types have a default value - it’s the zeroed state. That means a freshly-defaulted struct will have all
fields set to their default values (e.g., numbers = 0, references = null). If 0
is an invalid value
for your domain, guard against it - constructors don’t run for default
.
Boxing: When the Struct Loses Its Advantage
When you treat a struct as an object
or a non-generic interface, the runtime boxes it: the struct is
copied into a heap-allocated object. That defeats the allocation advantage.
object o = new Point(1,2); // boxing occurs
IFormattable f = new Point(1,2); // boxing if using non-generic interface
Tip: prefer generic collections (List<T>
) and typed interfaces (like
IEquatable<T>
) to avoid boxing in hotspots.
Arrays, Spans, and Memory Layout - The Performance Win
An array of structs stores values inline and contiguously, which improves cache locality and can dramatically reduce allocations compared to arrays of reference types. This is why game engines and numeric code often favor structs for tiny value types.
Point[] pts = new Point[1000]; // 1000 Points stored inline - cache friendly
ref
, in
and Avoiding Heavy Copies
Passing a struct to a method copies it by default. For larger structs you can use:
ref
- pass by reference and allow mutation.in
- pass by readonly reference (no mutation allowed; avoids copying).
public void Process(in BigStruct s) // no copy, readonly reference
{
// can read fields, can't modify them
}
Use in
when you want to avoid copies but keep the callee from mutating the caller’s data.
Interfaces and Potential Boxing
When a struct implements an interface, calling the interface method through an interface-typed variable can cause
boxing unless the interface usage is generic. Implement IEquatable<T>
for efficient typed
equality checks.
public readonly struct Vector2 : IEquatable
{
public float X { get; }
public float Y { get; }
public Vector2(float x, float y) { X = x; Y = y; }
public bool Equals(Vector2 other) => X == other.X && Y == other.Y;
}
If you write IEquatable<Vector2> eq = new Vector2(...)
in non-generic contexts, beware of
boxing. Use typed comparisons when possible.
Common Gotchas (Read These Twice)
- Mutating a struct returned from a property: properties return copies - mutating the returned struct doesn’t change the original.
- Mutable struct as dictionary key: changing a struct after inserting it into a hash-based collection breaks lookups.
- Closures and captures: capturing a struct in a lambda can capture a copy, which may be surprising when mutation is involved.
- Boxing in collections: storing structs in non-generic collections (e.g.,
ArrayList
) will box them.
Equality, Operators and GetHashCode
If your struct will be used in equality checks or as keys, implement IEquatable<T>
and override
Equals(object)
and GetHashCode()
. Prefer immutability to avoid hash-code changes after
insertion.
public readonly struct Vector2 : IEquatable
{
public readonly float X, Y;
public Vector2(float x, float y) { X = x; Y = y; }
public bool Equals(Vector2 other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) => obj is Vector2 v && Equals(v);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(Vector2 a, Vector2 b) => a.Equals(b);
public static bool operator !=(Vector2 a, Vector2 b) => !a.Equals(b);
}
Interop: Struct Layout for Native Calls
Structs are the natural shape for P/Invoke. You can control layout using [StructLayout]
. Use
LayoutKind.Sequential
most of the time; use LayoutKind.Explicit
and
[FieldOffset]
only when you need precise control.
[StructLayout(LayoutKind.Sequential)]
public struct NativePoint
{
public int X;
public int Y;
}
ref struct
and Stack-Only Types
C# has ref struct
(e.g., Span<T>
) which can’t be boxed or captured by the heap.
These are for low-level memory scenarios - useful, but advanced.
Record Structs and Modern Conveniences
Newer C# supports record struct
, which reduces boilerplate while providing value semantics and
structural equality. Handy when you want quick, immutable value types.
public readonly record struct ImmutablePoint(int X, int Y);
A Practical Example: Range
Here’s a concise, practical struct: small, immutable, and conceptually a single value.
public readonly struct Range
{
public int Start { get; }
public int Length { get; }
public int End => Start + Length;
public Range(int start, int length)
{
if (start < 0 || length < 0) throw new ArgumentOutOfRangeException();
Start = start;
Length = length;
}
public bool Contains(int index) => index >= Start && index < End;
public override string ToString() => $"{Start}..{End - 1}";
}
This is the kind of struct that makes sense: tiny, immutable, and clearly a value concept.
A Short Checklist Before Making a Type a Struct
- Is it small (cheap to copy)?
- Is it logically a single value?
- Can it be made immutable?
- Will it be used in arrays or spans a lot?
Performance: Measure, Don’t Guess
Structs can offer big wins in allocation-heavy scenarios, but they can also slow things down if you copy large structs frequently. Use real-world benchmarks (BenchmarkDotNet) on your workload before making sweeping changes.
Common Mistakes Even Experienced Devs Make
- Returning a mutable struct from a property and mutating the returned copy.
- Using mutable structs as dictionary keys (hash breakage).
- Boxing structs by storing them in non-generic collections or casting to
object
. - Assuming constructors run for
default(T)
- they don’t.
Practical Tips from Experience
- Prefer
readonly struct
for immutability and clearer intent. - Avoid public mutable fields; use properties or readonly fields instead.
- Implement
IEquatable<T>
for typed equality and to avoid boxing. - Use
in
parameters to avoid copying without allowing mutation. - When in doubt, prefer the option that makes the API simpler and less error-prone.
Summary
Structs are a tool, not a silver bullet. They give you value semantics and allocation advantages when used correctly - small, immutable, conceptually single-value types. Watch for implicit copies, boxing, and default-state surprises. Keep structs small, prefer immutability, implement typed equality, and always measure performance in real scenarios before optimizing.
If you remember one thing: prefer immutability and keep structs small. That will avoid most of the pitfalls and keep your code both fast and maintainable.