C# Structs: Copies, Defensive Copies and Boxing Costs
C# structs on .NET 10: value vs reference copies, readonly/record/ref struct, the silent defensive-copy trap, and measured boxing allocations.
Revised November 2026. This tutorial first appeared in Korean in 2021. Its example still compiles and prints the same output on .NET 10.0.14 (C# 14), but three statements in the text are wrong today: "structs are created on the stack" (inaccurate even then), "structs cannot declare a parameterless constructor" and "struct members cannot be initialized" (both changed in C# 10). This version adds what value copies look like in real output,
readonly,recordandref struct, the defensive-copy trap that silently drops your changes, and measured boxing allocations when a struct is used through an interface.
This article is for developers who were told that structs are "lighter, so faster than classes", and for anyone who switched a type to a struct and then saw changes vanish or memory use go up. The short version: a struct is a value that gets copied in full every time you assign it or pass it, and it only pays off for small data that does not change. The moment you treat a struct as an interface or object, boxing allocates on the heap; a generic constraint (where T : IShape) brought that to zero bytes in our measurement.
The 2021 example, and what the text got wrong
The student-score struct from the original article still works. Both student1, created with new, and student2, declared without new and filled field by field, print correctly (names and subjects adapted for this version):
struct Test
{
public string name;
public int reading, math, social, science;
public Test(string name, int reading, int math, int social, int science)
{ this.name = name; this.reading = reading; this.math = math; this.social = social; this.science = science; }
public void Show()
{
Console.WriteLine(name + "'s test scores:");
Console.WriteLine("Reading: " + reading + ", Math: " + math + ", Social: " + social + ", Science: " + science);
}
}
// Main
Test student1 = new Test("Alex", 94, 96, 84, 92);
student1.Show();
Test student2;
student2.name = "Sam"; student2.reading = 100; student2.math = 84; student2.social = 96; student2.science = 86;
student2.Show();
Alex's test scores:
Reading: 94, Math: 96, Social: 84, Science: 92
Sam's test scores:
Reading: 100, Math: 84, Social: 96, Science: 86
Skipping new only works after every field has been assigned. Delete the science line and the call to Show() fails:
error CS0165: Use of unassigned local variable 'student2'
Here is what needed correcting in the original text:
| 2021 statement | What .NET 10 / C# 14 actually does |
|---|---|
| Structs are created on the stack | Stored inline where declared. Locals usually on the stack; class fields and array elements on the heap, inside their owner. Boxing creates a new heap object |
| They use less memory, so some developers prefer them | Fewer allocations, but every assignment and call copies the whole value. Large structs get slower |
| You cannot declare a parameterless constructor | Allowed since C# 10, but default and array elements skip it (see below) |
| Struct members cannot be initialized | Field initializers allowed since C# 10 |
| Structs cannot declare destructors | Correct (CS0575: Only class types can contain destructors) |
| Structs do not support inheritance | Correct (CS0527: Type 'PointS' in interface list is not an interface). They can implement interfaces |
Value copies vs reference copies, with real output
We assigned, passed and stored a struct and a class of the same shape:
var ps = new PointS { X = 1 };
var ps2 = ps; ps2.X = 99; // copies the whole value
var pc = new PointC { X = 1 };
var pc2 = pc; pc2.X = 99; // copies only the reference
Console.WriteLine($"struct: ps.X={ps.X} ps2.X={ps2.X}");
Console.WriteLine($"class : pc.X={pc.X} pc2.X={pc2.X}");
MoveS(ps); MoveC(pc);
Console.WriteLine($"after MoveS(ps), MoveC(pc): ps.X={ps.X} pc.X={pc.X}");
MoveRef(ref ps);
Console.WriteLine($"after MoveRef(ref ps): ps.X={ps.X}");
static void MoveS(PointS p) => p.X += 10;
static void MoveC(PointC p) => p.X += 10;
static void MoveRef(ref PointS p) => p.X += 10;
struct PointS { public int X; }
class PointC { public int X; }
struct: ps.X=1 ps2.X=99
class : pc.X=99 pc2.X=99
after MoveS(ps), MoveC(pc): ps.X=1 pc.X=109
after MoveRef(ref ps): ps.X=11
For the struct, the method changed its own copy and the original stayed at 1. Only passing it by ref changed the original (see methods for how ref works). Collections make the difference even more visible:
var list = new List<PointS> { new() { X = 1 } };
list[0].X = 5; // compile error
var tmp = list[0]; tmp.X = 5; list[0] = tmp; // copy out, modify, write back
PointS[] arr = [new() { X = 1 }];
arr[0].X = 5; // fine: arrays expose the element itself
error CS1612: Cannot modify the return value of 'List<PointS>.this[int]' because it is not a variable
The List<T> indexer returns a copy, so the compiler refuses code that would modify that copy and throw it away. An array element is a variable, so it changes in place. After the fix, both printed X=5.
Parameterless constructors (C# 10) and the default trap
Since C# 10, structs can have field initializers and a parameterless constructor. But default and array elements never run that constructor:
struct Score
{
public string Name = "(none)";
public int Math = 50;
public Score() { }
public Score(string name) { Name = name; }
public override string ToString() => $"Name={Name ?? "null"}, Math={Math}";
}
// prints new Score(), default, new Score("Kim"), new Score[1][0]
new Score() -> Name=(none), Math=50
default -> Name=null, Math=0
new Score("Kim") -> Name=Kim, Math=50
new Score[1][0] -> Name=null, Math=0
Code that assumes Name is never null will break on a value taken from an array. The rule for struct design is that the all-zero, all-null state must also be a valid value. Compiling with LangVersion=9 produced error CS8773: Feature 'parameterless struct constructors' is not available in C# 9.0. and the same error for 'struct field initializers', which confirms both are C# 10 features.
readonly struct, record struct and ref struct
Structs have gained several modifiers since 2021.
A readonly struct (C# 7.2) requires every field to be readonly, and its values cannot change after construction:
error CS8340: Instance fields of readonly structs must be readonly.
error CS0200: Property or indexer 'Range3.X' cannot be assigned to -- it is read only
A record struct (C# 10) gets value equality (==), ToString() and with expressions generated by the compiler:
readonly record struct Money(decimal Amount, string Currency);
var m1 = new Money(100, "JPY");
var m2 = m1 with { Amount = 250 };
Console.WriteLine($"{m1} | {m2} | equal? {m1 == new Money(100, "JPY")}");
Money { Amount = 100, Currency = JPY } | Money { Amount = 250, Currency = JPY } | equal? True
A ref struct (C# 7.2) can only live on the stack. Span<T> is the best-known example, and every path to the heap is blocked:
error CS0029: Cannot implicitly convert type 'Buf' to 'object'
error CS8345: Field or auto-implemented property cannot be of type 'Buf' unless it is an instance member of a ref struct.
error CS9244: The type 'Buf' may not be a ref struct or a type parameter allowing ref structs in order to use it as parameter 'T' in the generic type or method 'List<T>'
Since C# 13 a ref struct can implement interfaces and be passed to generics constrained with where T : ILen, allows ref struct. Our test program printed 3, and with LangVersion=12 it failed with error CS9202: Feature 'ref struct interfaces' is not available in C# 12.0. You still cannot convert a ref struct to an interface-typed variable, because that would require boxing.
The defensive-copy trap: no warning, no change
If a struct has a method that mutates it, and you call that method on a readonly field or an in parameter, the compiler protects the original by running the method on a hidden copy:
var h = new Holder();
h.ReadOnlyCounter.Increment(); h.ReadOnlyCounter.Increment();
h.MutableCounter.Increment(); h.MutableCounter.Increment();
Console.WriteLine($"readonly field: {h.ReadOnlyCounter.Value}, normal field: {h.MutableCounter.Value}");
var c = new Counter();
Bump(in c);
Console.WriteLine($"after Bump(in c): {c.Value}");
static void Bump(in Counter x)
{
x.Increment(); // runs on a hidden copy
Console.WriteLine($"inside Bump: {x.Value}");
}
class Holder { public readonly Counter ReadOnlyCounter; public Counter MutableCounter; }
struct Counter { public int Value; public void Increment() => Value++; }
readonly field: 0, normal field: 2
inside Bump: 0
after Bump(in c): 0
The build produced no warnings at all. We called Increment() five times and only the normal field changed. There are two ways to prevent this, and both turn a silent bug into a compile error:
- Declare the type as a
readonly struct. ThenValue++fails witherror CS0191: A readonly field cannot be assigned to (except in a constructor or init-only setter of the type in which the field is defined or a variable initializer). - Mark non-mutating members
readonly(C# 8). Areadonlymethod that tries to mutate fails witherror CS1604: Cannot assign to 'Value' because it is read-only.
Passing a large, non-readonly struct with in causes a copy on every method or property call, which defeats the purpose of in. Use in parameters together with readonly struct.
Boxing when a struct becomes an interface: measured allocations
Converting a struct to object or an interface creates a box object on the heap and copies the value into it. We measured how much with GC.GetAllocatedBytesForCurrentThread(). The test sums Area() over 1,000,000 instances of an 8-byte readonly struct Square : IShape (size confirmed with Unsafe.SizeOf) in five different ways. Each variant is a [MethodImpl(NoInlining)] method, warmed up 400 times, followed by GC.Collect() and five measured runs.
static double SumGeneric<T>(T[] items) where T : IShape // generic constraint
{ double t = 0; for (int i = 0; i < items.Length; i++) t += items[i].Area(); return t; }
static double SumLocalCast(Square[] items) // interface-typed local
{ double t = 0; for (int i = 0; i < items.Length; i++) { IShape s = items[i]; t += s.Area(); } return t; }
static double AreaOf(IShape s) => s.Area(); // interface-typed parameter
// SumIfaceArray: fill an IShape[] then sum / SumObjectList: List<object>.Add then unbox
interface IShape { double Area(); }
readonly struct Square(double side) : IShape { public double Side { get; } = side; public double Area() => Side * Side; }
| Variant (1M items) | Allocated | Time (runs 2–5) |
|---|---|---|
Generic where T : IShape |
0 bytes | 0.95–1.31 ms |
IShape s = items[i] local |
24,000,000 bytes | 3.22–3.26 ms |
AreaOf(IShape) parameter |
24,000,000 bytes | 3.81–4.33 ms |
Store in IShape[] |
32,000,024 bytes | 18.24–19.12 ms |
List<object>.Add |
32,000,056 bytes | 19.45–20.69 ms |
Each box is 24 bytes: the 8-byte value plus 16 bytes of object header and type pointer. The 32 MB for the array variants is 24 MB of boxes plus an 8 MB array of references. Even the short-lived local cast was not optimized away by the .NET 10 JIT in this test. With the generic constraint, the JIT compiles a version specialized for Square and calls the interface method directly, so nothing is allocated. Boxing in object-based collections such as ArrayList is measured separately in our generics article.
The same mechanism hurts structs used as Dictionary keys without their own Equals. We looked up a key 1,000,000 times (1,000 keys, last three runs after warm-up), comparing a plain struct holding a string and an int with a readonly record struct of the same shape:
| Key type | Time | Allocated |
|---|---|---|
Plain struct (default ValueType.Equals / GetHashCode) |
118.8 / 125.4 / 121.3 ms | 184,021,600 bytes |
readonly record struct |
12.3 / 14.8 / 12.6 ms | 0 bytes |
That is roughly 184 bytes allocated per lookup and about nine times slower, because the default Equals for a struct with reference-type fields takes a reflection-based path. If a struct will be a key, make it a record struct or implement IEquatable<T>.
When to use a struct
The Microsoft Learn guideline "Choosing Between Class and Struct" limits structs to types whose instances are smaller than 16 bytes, short-lived, immutable, and not boxed frequently. Our measurements point the same way.
| Situation | Recommendation |
|---|---|
| Small values that never change: coordinates, colors, money | readonly struct / readonly record struct |
Compared by value or used as a Dictionary key |
readonly record struct, or implement IEquatable<T> |
| Large data (tens of bytes or more) passed around often | class (or in with a readonly struct) |
Values that keep changing, edited inside a List<T> |
class |
| Mostly handled through interfaces | class, or generic constraints to avoid boxing |
| Working on part of a buffer without copying | a ref struct such as Span<T> |
The original student-score struct holds one string and four ints (24 bytes on 64-bit), which is over the guideline, and its fields are mutated. If we wrote it today it would be a record class. Inheritance and virtual rules on the class side are in class inheritance, and the newer interface features that structs can implement are in interfaces.
Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14 (the net10.0 default), Release builds, macOS on an Apple Silicon Mac. Compiler messages are the English SDK output.
FAQ
Are structs always allocated on the stack?
No. A struct is stored inline wherever it is declared. A local variable usually lives on the stack, but a struct field of a class or an array element lives on the heap inside that object. Casting a struct to object or an interface creates a new box on the heap; an 8-byte struct cost 24 bytes per box in our test (.NET 10, Apple Silicon Mac).
How much boxing happens when I pass a struct as an interface?
Summing 1 million 8-byte structs through an IShape local or an IShape parameter allocated 24,000,000 bytes, while a generic method constrained with where T : IShape allocated 0 bytes. The generic version took about 1 ms versus about 3 to 4 ms through the interface (.NET 10, Release).
I called a method on a struct stored in a readonly field and the value did not change. Why?
That is a defensive copy. When you call a mutating method on a struct held in a readonly field or an in parameter, the compiler runs it on a hidden copy. Calling Increment() twice left the value at 0, with no warning. Declaring the type as a readonly struct, or marking non-mutating methods readonly, turns the mistake into a compile error.