Skip to content

Value vs Reference Types

Key Points

  • Value types (struct, enum, primitives) are stored by value: assignments copy the bytes; equality is structural by default for struct (with caveats).
  • Reference types (class, interface, delegate, record class) are stored by reference: assignments copy a pointer; equality is reference-based unless you override.
  • record is a syntactic feature that gives value-equality and with expressions to either a class (default — record class) or a struct (record struct).
  • Primary constructors (C# 12+) work on class/struct/record. On record, parameters become public init-only properties; on class/struct, parameters are captured but not auto-promoted to fields/properties.
  • readonly struct prevents internal mutation; the compiler can elide defensive copies. Use for value-types you want to behave purely.
  • in parameters pass a struct by reference but disallow mutation — best of both worlds for large structs (no copy, no mutation risk).
  • Boxing turns a value into a heap allocation when a struct is treated as object or an interface (without where T : struct, IInterface).

Concepts (deep dive)

Storage location

   ┌──────────────────────────────────────────────────────┐
   │                   Method frame on stack               │
   │  ┌────────────┐   ┌────────────┐   ┌──────────────┐  │
   │  │ int x = 5  │   │ Point p =  │   │ Customer c = │  │
   │  │ [bytes: 5] │   │   (3, 4)   │   │   ──────────►│──┼─► heap [Customer instance]
   │  │            │   │ [bytes:    │   │   (reference)│  │
   │  │            │   │  3, 4]     │   │              │  │
   │  └────────────┘   └────────────┘   └──────────────┘  │
   └──────────────────────────────────────────────────────┘
       value type        value type         reference type
       (in-place)        (in-place)         (pointer; data on heap)

This is the mental model. A struct field of a class lives inline in the heap-allocated object, not as a separate allocation.

struct semantics

public struct Point { public int X; public int Y; }

var p1 = new Point { X = 1, Y = 2 };
var p2 = p1;          // copy
p2.X = 99;
Console.WriteLine(p1.X);   // 1 — p1 unaffected

That's the whole game. Assignment copies. Pass by value to a method copies. Putting a struct in a List<T> and reading it back: you get a copy, modifying it does not modify the list's element.

var list = new List<Point> { new Point() };
list[0].X = 10;       // ❌ compile error in newer C#:
                      //   "Cannot modify the return value because it is not a variable"

The error is the compiler protecting you from modifying a copy. List's indexer returns by value, not by ref.

class semantics

public class Customer { public string Name = ""; }

var c1 = new Customer { Name = "Alice" };
var c2 = c1;          // copy of REFERENCE; both point to the same object
c2.Name = "Bob";
Console.WriteLine(c1.Name);   // "Bob" — same object

record (= record class)

public record Customer(string Name, string Email);

var a = new Customer("Alice", "a@x");
var b = a with { Email = "alice@x" };   // shallow copy with one change
Console.WriteLine(a == b);              // False (Email differs)
Console.WriteLine(a.Name == b.Name);    // True

Records are reference types but with structural (value-based) equality, generated GetHashCode, generated ToString, and the with expression. The primary constructor's parameters become public init-only properties.

record struct

public readonly record struct Point(int X, int Y);

var p = new Point(3, 4);
var q = p with { X = 5 };

Value type with the same record perks (value equality, ToString, with). Add readonly and you get a fully immutable value type. Often the right answer for small geometric/data values.

Primary constructors on plain class/struct (C# 12+)

public class Logger(ILogger<Logger> logger, IClock clock)
{
    public void LogNow(string msg) => logger.LogInformation("{T}: {Msg}", clock.UtcNow, msg);
}

The parameters logger and clock are captured in the synthetic primary constructor but are NOT auto-promoted to properties or fields. They're accessible inside the class body. The compiler creates an implicit field per captured parameter only when the parameter is referenced from a non-constructor method.

⚠️ This is different from record primary constructors, where parameters become public init-only properties. The same syntax has different semantics by type kind.

Equality semantics summary

Type == default Equals default Generates GetHashCode?
class Reference equality Reference equality Object's identity hash
struct Compile error if == not defined Reflection-based field-by-field Reflection-based — slow!
record class Value equality Value equality Generated, value-based
record struct Value equality Value equality Generated, value-based

💡 Senior insight: plain struct with default Equals uses reflection on every call — measurably slow. Always implement IEquatable<T> and ==/!= on a custom struct, or use readonly record struct.

readonly struct

public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }
    public Money(decimal a, string c) { Amount = a; Currency = c; }
    public Money WithAmount(decimal newAmount) => new(newAmount, Currency);
}

readonly on the struct means no member can mutate this. The big perf win: passing a readonly struct by in (or returning a ref readonly) doesn't require the compiler to make defensive copies. Modern C# is full of these — Span<T>, ImmutableArray<T>, etc. are all readonly struct.

in parameters

public static void Print(in Point p) => Console.WriteLine($"{p.X},{p.Y}");

Point p = new(1, 2);
Print(in p);      // pass by reference, read-only

in says: pass by reference, but I won't modify. For large structs (≥ 32 bytes), in is significantly cheaper than by-value because it avoids copying. For small structs, often a wash.

ref struct (special, important)

A ref struct (e.g., Span<T>, Utf8JsonReader) is a struct that must live on the stack. Cannot be: - A field of a class or non-ref struct - Boxed (used as object / interface) - Captured by a lambda - Used across await/yield

It's the "stack only" guarantee that makes Span<T> zero-allocation by design. C# 13 added allows ref struct constraint and C# 14 added first-class language support. See Span, Memory & ref structs.

Boxing

int x = 42;
object o = x;       // ⚠️ box: heap allocation, x copied to box
int y = (int)o;     // unbox: copy back, type-check

Boxing happens any time a value type goes into: - object - A non-generic collection (ArrayList, untyped Hashtable, etc.) - An interface variable (without generic constraint) - A method param of type object / interface

Cost: heap allocation per boxing. In a hot loop, can be the dominant allocator.

// ❌ boxes every iteration
IEnumerable list = new ArrayList();
foreach (object o in list) { /* ... */ }

// ✅ no boxing — generic collection
List<int> list = new();
foreach (int x in list) { /* ... */ }

Field layout and size

[StructLayout(LayoutKind.Sequential)]
public struct A { public byte B; public long L; }   // 16 bytes (7 bytes padding)

[StructLayout(LayoutKind.Sequential)]
public struct B { public long L; public byte B; }   // 16 bytes (7 trailing padding)

[StructLayout(LayoutKind.Auto)]                      // default for value types in some scenarios
public struct C { public byte B; public long L; }   // CLR may reorder

For most code, Sequential is the default for struct. Match field order to size (largest first) to avoid padding when it matters. Use [StructLayout(LayoutKind.Explicit)] only for interop.


How it works under the hood

The CLR distinguishes value types and reference types in the type system itself:

  • Value type instances are stored inline wherever they're declared — on the stack as a local, in registers, as part of an array element, as a field within an object. The runtime tracks just the type's value layout.
  • Reference type instances live on the GC heap. Every reference is a pointer (8 bytes on x64) plus an object header (method-table pointer + sync block index — 16 bytes total).

Boxing creates a small heap object: object header + the value. Unboxing reads the value back out, with a type check. The IL instructions are literally box and unbox — very explicit at the IL level.

Records are not a runtime concept; they're compiler-generated. record class Foo(int X) lowers to a class Foo with auto-generated Equals/GetHashCode/ToString/Deconstruct and a synthesized clone method backing with.

readonly struct and in parameters are largely a verifier/compiler concept. The runtime mostly doesn't care; the compiler ensures no mutation and skips defensive copies.


Code: correct vs wrong

❌ Wrong: a mutable struct in a list

public struct Counter { public int Count; public void Inc() => Count++; }

var counters = new List<Counter> { new Counter() };
counters[0].Inc();   // ❌ ERROR: indexer returns a copy; can't call mutating method on it

✅ Correct: value-immutable struct + replace pattern

public readonly struct Counter
{
    public int Count { get; }
    public Counter(int count) => Count = count;
    public Counter Inc() => new(Count + 1);
}

var counters = new List<Counter> { new Counter(0) };
counters[0] = counters[0].Inc();   // explicit replacement

❌ Wrong: relying on default Equals for a struct

public struct Point2 { public int X, Y; }

var set = new HashSet<Point2>();   // uses default Equals/GetHashCode (reflection)
for (int i = 0; i < 1_000_000; i++)
    set.Add(new Point2 { X = i, Y = i });
// Slow because reflection-based GetHashCode runs per Add

✅ Correct: implement IEquatable<T> (or use record struct)

public readonly record struct Point2(int X, int Y);
// equality + GetHashCode generated and fast

❌ Wrong: boxing in hot path

public void Log(string format, object arg)   // ❌ boxes every value-type arg
    => Console.WriteLine(string.Format(format, arg));

Log("{0}", 42);   // boxes 42 every call

✅ Correct: generic, no boxing

public void Log<T>(string format, T arg)   // T can be value-type without boxing
    => Console.WriteLine(string.Format(format, arg));

(Even better: use [InterpolatedStringHandler] patterns; see C# 13 / 14 Features.)

❌ Wrong: returning struct by value when struct is large

public struct Big { public long A, B, C, D, E, F, G, H; }   // 64 bytes

public Big Compute()    // 64-byte copy on return
{
    var b = new Big();
    /* fill */
    return b;
}

✅ Correct: heap-allocate via class, OR return by ref (advanced)

// Option 1: small struct — accept the copy.
// Option 2: class for large data.
public class Big2 { public long A, B, C, D, E, F, G, H; }

// Option 3: in/out via ref-readonly (advanced, usually overkill)
private static readonly Big _shared = new();
public ref readonly Big Compute() => ref _shared;

Design patterns for this topic

Pattern 1 — "Use record class for DTOs"

  • Intent: value-equality data carriers.
  • When to use it: API request/response models, cache keys, immutable config blobs.
  • When NOT to use it: entities (DDD): equality must be by ID, not by value. Use class with IEquatable<T> overriding equality on ID.
  • Code sketch: public record CustomerDto(Guid Id, string Name, string Email);
  • Trade-offs: records inherit and clone; with allocates per call. For very hot paths, hand-roll.

Pattern 2 — "Use readonly record struct for small value-like things"

  • Intent: unboxed, immutable, equatable.
  • When to use it: geometric points, IDs, money, ratios, dimensional values.
  • When NOT to use it: anything ≥ 32 bytes (struct copy cost), anything mutable.
  • Code sketch: public readonly record struct ProductId(int Value);
  • Trade-offs: structs over ~16 bytes start paying real copy cost. Profile if you cross the line.

Pattern 3 — "Wrap primitive IDs in record struct"

  • Intent: type-safety. A method that accepts ProductId cannot accidentally be passed a CustomerId.
  • When to use it: any domain with multiple ID kinds.
  • Code sketch:
public readonly record struct ProductId(Guid Value);
public readonly record struct CustomerId(Guid Value);

public Product? Get(ProductId id);   // can't accidentally pass CustomerId

Pattern 4 — "Use class with explicit equality for entities"

  • Intent: equality by ID, not by all fields.
  • When to use it: DDD entities; anything with identity that mutates over its lifetime.
  • Code sketch:
public class Customer : IEquatable<Customer>
{
    public Guid Id { get; }
    public string Name { get; set; } = "";
    public Customer(Guid id) => Id = id;
    public bool Equals(Customer? other) => other is not null && Id == other.Id;
    public override int GetHashCode() => Id.GetHashCode();
    public override bool Equals(object? obj) => Equals(obj as Customer);
}

Pattern 5 — "in for read-only struct parameters"

  • Intent: avoid copy of large struct; signal "I won't mutate".
  • Code sketch: public static double Length(in Vector3 v) => Math.Sqrt(...)
  • Trade-offs: the in keyword is required at call site if struct isn't readonly; readonly struct doesn't require it.

Pros & cons / trade-offs

Choice Pros Cons
class Reference semantics; no copy cost on pass Heap allocation; GC pressure if churning
struct No allocation; cache-friendly when used in arrays Copy cost; defensive copies; mutation traps
record class Value equality + concise syntax with allocates; structural equality not always desired
record struct Combines value semantics with record perks Same struct copy cost; equality based on all fields
readonly struct Immutability; compiler can elide copies Cannot mutate (by design)
Primary ctors (class) Concise DI wiring Different semantics from records — confuses readers

When to use / when to avoid

  • Use record class for DTOs, value containers, immutable transfer objects.
  • Use readonly record struct for small value-like primitives (Money, Point, IDs).
  • Use class with custom equality for entities (identity, not value).
  • Avoid mutable structs. Almost always a footgun. The only exception: hand-rolled hot-path types where the design is documented and tested.
  • Avoid plain struct in HashSet/Dictionary keys without implementing IEquatable<T>.
  • Avoid copying structs > 32 bytes in hot paths — pass by in or use a class.

Interview Q&A

Q1. What's the difference between record and record struct? record (or record class) is a reference type with structural equality. record struct is a value type with the same record perks. Both generate Equals, GetHashCode, ToString, Deconstruct, and the with expression. The default for record struct is mutable; usually you want readonly record struct.

Q2. Why is the default Equals on a struct slow? It uses reflection over the struct's fields to compare each — a full walk per call. Always implement IEquatable<T> (or use record struct, which generates equality at compile time).

Q3. What does readonly struct actually do? Prevents any member from mutating this. Allows the compiler to skip defensive copies when the struct is passed by in or accessed via ref readonly. Best practice for any immutable value type.

Q4. Explain primary constructors on class vs record. On record, primary-constructor parameters become public init-only properties. On class/struct, parameters are captured: they're available inside the class body but are not auto-promoted to fields or properties. The compiler creates implicit fields only when the parameters are used outside the constructor.

Q5. What's a ref struct? A struct that must live on the stack. Cannot be a field of a class, cannot be boxed, cannot be captured by lambdas, cannot survive an await or yield. Used by zero-allocation primitives like Span<T>, Utf8JsonReader, ValueStringBuilder.

Q6. What's boxing and how do you spot it in your code? Implicit conversion of a value type to object or to an interface (without a generic constraint). Allocates a heap box. Spot via BenchmarkDotNet's [MemoryDiagnoser] — boxing shows as allocations on otherwise non-allocating methods. Also via static analyzers like Roslynator or RoslynClrHeapAllocationAnalyzer.

Q7. When should you use a class over a record? When you need identity-based equality (entities), when the type is mutable and that's intentional, or when you need inheritance hierarchies that don't fit the record model. record class is for value-like reference types; class is for everything else.

Q8. What's the difference between in, ref, and out parameters? - in — pass by reference; callee cannot mutate (read-only). - ref — pass by reference; callee can mutate; caller must initialize. - out — pass by reference; callee MUST assign before returning; caller doesn't need to initialize.

Q9. Why might a Dictionary<MyStruct, V> be unexpectedly slow? If MyStruct doesn't implement IEquatable<T>, every Add/Lookup calls reflection-based Equals. Implement IEquatable<MyStruct> and override GetHashCode — order-of-magnitude speedup.

Q10. What's the layout of a class vs a struct in memory? A class instance has an 8-byte (x64) header (method table pointer) + 8-byte sync-block index, then fields, then padding to align. A struct instance has just its fields with the layout policy (Sequential by default). When a struct is a field of a class, it's inline in the class — no separate allocation.

Q11. Why might you prefer record struct over record class for a small DTO? If the DTO is genuinely small (≤16 bytes), used in arrays or hot paths, and you want allocation-free passing. For larger DTOs the struct copy cost dominates and record class is better.

Q12. What does with do on a record? Creates a shallow copy of the record with the named members changed. The compiler generates a Clone method and copy constructor; with uses them. Each with is one allocation for record class.

Q13. Explain field initialization order: primary constructor parameters, field initializers, and init setters. Field initializers run before the constructor body. Primary constructor parameters are conceptually scoped to the entire class but are evaluated at construction. init setters run during object initializer evaluation, after the constructor body. The order matters when one depends on another — usually surfaces as nullability warnings or NREs in flow.


Gotchas / common mistakes

  • ⚠️ Mutable structs — almost always wrong. The compiler protects you in some cases but not all (e.g., struct fields in a class are mutable through the parent).
  • ⚠️ Default struct equality — reflection-based, slow. Use readonly record struct or implement IEquatable<T>.
  • ⚠️ Boxing in hot paths — shows as allocations in [MemoryDiagnoser]. Use generics.
  • ⚠️ Returning a Span<T> from a method that allocates locally on the stack — compile error (good!), but the temptation exists.
  • ⚠️ Treating record as immutablerecord class { ... } allows mutating settable properties unless you use init only. record Foo(int X) properties are init-only by default.
  • ⚠️ Comparing two record instances expecting reference equality — they may compare equal by value but be different instances. Use ReferenceEquals if you really mean identity.
  • ⚠️ Primary constructor on class exposing parameters — the parameters aren't properties; you can't obj.MyParam from outside. If you want a property, declare it explicitly.
  • ⚠️ with allocates — every call. In a tight loop, build up state explicitly.

Further reading