C# structs in depth: copying, default(T), parameterless constructors, readonly structs and defensive copies, ref structs, C# 13 interface support, boxing and mutable-struct bugs.
A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.
A struct is a value type: a variable contains the data itself, and assignment, argument passing and returning copy it. Structs are for small, immutable values where avoiding heap allocation matters. I'd cover copying, constructors and defaults, readonly structs and members, ref struct and Span<T>, record structs, boxing, and the bugs mutable structs cause.
public struct Point { public int X; public int Y; }
var a = new Point { X = 1, Y = 2 };
var b = a; // full copy
b.X = 99; // a.X still 1
default(Point)), and the runtime creates it without running any constructor — for array elements, uninitialised fields and default.new Point(), not for default or array elements.IsDefault), e.g. Money with Currency == null.readonly structs and memberspublic readonly struct Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
public Money Add(Money other) => new(Amount + other.Amount, Currency); // returns a new value
}
readonly struct makes all fields readonly, guarantees immutability, and lets the compiler avoid defensive copies when the struct is accessed via in parameters, readonly fields or ref readonly returns.readonly members (public readonly double Length() => ...) mark individual methods as non-mutating in a mutable struct.ref struct and Span<T>ref struct types (e.g. Span<T>, ReadOnlySpan<T>, Utf8JsonReader) must stay on the stack:
ref fields (C# 11), which is how Span<T> holds a managed pointer to arrays, strings, stack memory or native memory.ref structs implement interfaces and be used as generic arguments with allows ref struct, still without boxing.This enables zero-allocation, memory-safe slicing of buffers.
readonly record struct OrderId(Guid Value) generates fast value equality (IEquatable<T>, GetHashCode), ==, ToString, deconstruction and with. Ideal for strongly typed IDs and small value objects; much better than ValueType's default reflection-based Equals.
Converting a struct to object, ValueType, or an interface allocates a box on the heap and copies the value. Hidden boxing appears with non-generic collections, object parameters (string.Format), interface parameters, Equals(object) on structs without IEquatable<T>, and calling non-overridden ToString/GetHashCode. Generics with constraints avoid boxing.
var points = new List<Point> { new() { X = 1 } };
points[0].X = 5; // CS1612: can't modify the return value (it's a copy)
foreach (var p in points) p.X = 5; // CS1654 / or silently modifies a copy in other patterns
readonly Point _origin;
_origin.Move(1, 1); // mutating method runs on a defensive copy - no effect!
IMovable m = point; m.Move(1, 1); // boxes - mutates the box, not 'point'
Lost updates are silent and confusing. Microsoft's guidance: structs should be immutable ("mutable structs are evil" — Eric Lippert). Make them readonly and return new values.
A telemetry ingestion pipeline modelled readings as a 24-byte readonly record struct Reading(DateTime At, float Value, int SensorId) stored in arrays and spans; switching from a class saved millions of allocations per minute and roughly halved GC time. In the same codebase, a mutable struct Counter stored in a Dictionary<string, Counter> never incremented because dict[key].Increment() modified a copy — we changed it to CollectionsMarshal.GetValueRefOrAddDefault to mutate in place, and later to an immutable struct with replace-on-update.
Probes deep understanding of value types and modern struct features.
Green flags: copy semantics; struct location depends on container; default versus constructor; readonly structs and defensive copies; ref struct rules and C# 13 changes; boxing and ValueType.Equals; mutable struct bugs; size guideline; measurement.
Red flags: "structs are on the stack, classes on the heap" as the whole story; mutable structs; "structs are always faster"; unaware of defensive copies.
[StructLayout(LayoutKind.Sequential)]), with padding for alignment. A struct with byte, long, byte occupies 24 bytes; reordering to long, byte, byte gives 16. (For structs containing references, the runtime may use auto layout.)[StructLayout(LayoutKind.Explicit)] with [FieldOffset] enables unions for interop.Unsafe.SizeOf<T>() reports the size.string field) must be scanned by the GC when it's in a heap object or array.in, ref| Mechanism | Copy? | Callee can mutate caller's copy? | Notes |
|---|---|---|---|
| By value | Yes | No | Best for small structs |
in / ref readonly |
No | No | Use with readonly struct to avoid defensive copies |
ref |
No | Yes | For intentional in-place updates |
out |
No | Must assign | Try-pattern |
The JIT can promote small struct locals into registers (struct promotion) when they aren't address-exposed, making them effectively free.
When calling a member that might mutate on a struct stored in a read-only location (readonly field, in parameter, ref readonly), the compiler copies the struct first, so the original can't change. For non-readonly structs, every such call copies — silently costly for large structs and confusing when the method mutates. readonly struct and readonly members remove the copies.
ref structs are tracked by the compiler's ref safety rules: each has a safe-to-escape scope (where it can be returned), and the compiler rejects code that would let a span over stack memory escape its frame. scoped (C# 11) restricts a parameter or local to the current method, enabling more flexible APIs.
ValueType.Equals uses a bitwise compare when the struct has no references and no padding issues, otherwise reflection over fields (slow, boxing). ValueType.GetHashCode may use only the first field. Always implement IEquatable<T> + GetHashCode (or use record structs) for structs used in collections or comparisons.
Framework Design Guidelines: consider a struct if the type
Performance-oriented code bends the size rule (e.g. 24–32-byte structs in arrays) after benchmarking.
List<T> and a Dictionary<,> and try to mutate it in each.readonly field of a non-readonly struct and observe no change.Unsafe.SizeOf<T>() for different field orders.Dictionary<OrderId, T> with a plain struct key vs a readonly record struct key.// Good: small, immutable value type with fast equality
public readonly record struct Coordinate(double Lat, double Lon)
{
public double DistanceKm(Coordinate other)
{
const double R = 6371;
double dLat = double.DegreesToRadians(other.Lat - Lat);
double dLon = double.DegreesToRadians(other.Lon - Lon);
double a = Math.Pow(Math.Sin(dLat / 2), 2) +
Math.Cos(double.DegreesToRadians(Lat)) * Math.Cos(double.DegreesToRadians(other.Lat)) *
Math.Pow(Math.Sin(dLon / 2), 2);
return 2 * R * Math.Asin(Math.Sqrt(a));
}
}
// C# 10 parameterless constructor: runs for new(), NOT for default
public struct RetryPolicy
{
public int MaxAttempts { get; init; } = 3;
public RetryPolicy() { }
}
Console.WriteLine(new RetryPolicy().MaxAttempts); // 3
Console.WriteLine(default(RetryPolicy).MaxAttempts); // 0
// ref struct: stack-only parser over a span, no allocations
public ref struct CsvLineReader(ReadOnlySpan<char> line)
{
private ReadOnlySpan<char> _rest = line;
public bool TryRead(out ReadOnlySpan<char> field)
{
if (_rest.IsEmpty) { field = default; return false; }
int i = _rest.IndexOf(',');
field = i < 0 ? _rest : _rest[..i];
_rest = i < 0 ? [] : _rest[(i + 1)..];
return true;
}
}
// Mutable struct bug
var points = new List<MutablePoint> { new() };
// points[0].X = 5; // CS1612: cannot modify the return value (it is a copy)
var p = points[0]; p.X = 5; points[0] = p; // the only way: copy, change, write back
public struct MutablePoint { public int X; }
What happens: structs with settable fields modified through collections, properties or interfaces.
Why it is wrong: updates apply to copies and are silently lost.
Do instead: make structs readonly and return new values.
What happens: structs with many fields used as DTOs "to avoid GC".
Why it is wrong: copy costs on every assignment and call can exceed allocation costs.
Do instead: keep structs small; use classes for larger data or pass by in after benchmarking.
What happens: struct constructors validate or initialise fields.
Why it is wrong: default and arrays bypass constructors.
Do instead: make the zero state valid or check for it.
What happens: structs used as dictionary keys or compared often rely on ValueType.Equals.
Why it is wrong: reflection-based, boxing, poor hashing.
Do instead: implement IEquatable<T> or use record structs.
What happens: structs passed as object or interfaces, stored in non-generic collections.
Why it is wrong: allocations and copies negate the benefit of structs.
Do instead: use generics with constraints.
in with non-readonly structsWhat happens: passing large structs by in for performance.
Why it is wrong: defensive copies on every member call.
Do instead: mark the struct or members readonly.
What happens: compile errors lead to unsafe workarounds.
Why it is wrong: spans may point to stack memory.
Do instead: use Memory<T> for storage and async; spans in synchronous code.
What happens: classes converted to structs for assumed performance. Why it is wrong: boxing and copying can make it slower. Do instead: benchmark allocation and throughput with BenchmarkDotNet before and after.
Seniors pick structs for measured reasons and design them to be safe.
| Situation | Choice |
|---|---|
| Millions of small values in arrays | readonly struct or readonly record struct |
| Strongly typed ID | readonly record struct |
| Stack-only view over memory | ref struct (with Span<T>) |
| Entity, or type with identity | Class |
| Large data copied often | Class, or struct passed by in / ref readonly |
| Generic API that should accept spans | where T : allows ref struct (C# 13) |
They verify with BenchmarkDotNet and allocation profiles, because converting to structs can make code slower when values are large, boxed or copied a lot.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.
A record is a type whose identity is its data. public record Money(decimal Amount, string Currency); makes the compiler generate init-only properties, a constructor, Deconstruct, value-based Equals/GetHashCode/==, a readable ToString, and a hidden clone method used by with expressions for non-destructive mutation. record (or record class, C# 9) is a reference type; record struct (C# 10) is a value type whose positional properties are mutable unless you write readonly record struct. Equality includes an EqualityContract so a base and derived record are never equal. Watch out: with is a shallow copy, collection members compare by reference, and records are a poor fit for EF Core entities, which need identity-based equality.
How to tell it (STAR)
**Situation:** our telemetry ingestion allocated about [1.2 GB/s] because each measurement was a small class. **Task:** reduce GC pressure without a rewrite. **Action:** I converted `Measurement` to a small `readonly record struct`, batched values in pooled arrays and processed them with spans. I kept the struct immutable to avoid copy bugs and added an allocation benchmark to CI. **Result:** allocations dropped by about [90%], gen-0 GCs fell sharply and p99 latency during bursts improved by [roughly 40%].
Last updated 30 Oct 2026.