Structs and Value Types

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A struct is a value type: assigning one copies its fields. Used well, structs eliminate allocations and indirection; used carelessly they copy more data than the reference they replaced. This page is about telling the two cases apart.

Struct Semantics

public struct Vector2
{
    public double X;
    public double Y;

    public Vector2(double x, double y) => (X, Y) = (x, y);

    public readonly double Length => Math.Sqrt((X * X) + (Y * Y));
}

public static class StructSemantics
{
    public static void Demo()
    {
        var a = new Vector2(3, 4);
        Vector2 b = a;            // a full copy of both doubles
        b.X = 99;

        Console.WriteLine(a.X);   // 3  -- independent
        Console.WriteLine(b.X);   // 99
        Console.WriteLine(a.Length);

        // `default` gives an all-zero instance -- always valid, never null.
        Vector2 zero = default;
        Console.WriteLine($"{zero.X} {zero.Y}");
    }
}

Key consequences:

  • A struct can never be null (though Vector2? can be).

  • default always produces a usable instance with every field zeroed — you cannot prevent it, so a struct must be valid when zero.

  • A struct cannot inherit from another struct or class; it implicitly derives from System.ValueType and may implement interfaces.

  • A struct cannot have a finalizer.

Constructors and Field Initializers

Since C# 10, a struct may declare a parameterless constructor and field initialisers — but default and new T[10] bypass them:

public struct Settings
{
    public int Retries = 3;              // field initialiser (C# 10)
    public bool Verbose;

    public Settings() => Verbose = true; // parameterless constructor (C# 10)

    public Settings(int retries) : this() => Retries = retries;
}

public static class ConstructorTraps
{
    public static void Demo()
    {
        var explicitNew = new Settings();
        Console.WriteLine($"{explicitNew.Retries} {explicitNew.Verbose}");   // 3 True

        Settings viaDefault = default;                    // constructor NOT run
        Console.WriteLine($"{viaDefault.Retries} {viaDefault.Verbose}");     // 0 False

        var array = new Settings[2];                      // constructor NOT run per element
        Console.WriteLine($"{array[0].Retries}");                            // 0
    }
}

This is why "valid when zero" matters: design so that the all-zero state is a sensible value, or use a class.

readonly Structs and readonly Members

Marking the whole struct readonly makes every field read-only and lets the compiler skip defensive copies:

public readonly struct Money
{
    public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);

    public decimal Amount { get; }       // implicitly readonly
    public string Currency { get; }

    public Money Add(in Money other)
    {
        if (other.Currency != Currency)
        {
            throw new InvalidOperationException("Currency mismatch.");
        }

        return new Money(Amount + other.Amount, Currency);   // return a new value
    }

    public override string ToString() => $"{Amount:N2} {Currency}";
}

If the whole struct cannot be readonly, mark the individual members that do not mutate:

public struct Counter
{
    private int _value;

    public readonly int Value => _value;             // readonly member: no defensive copy
    public readonly override string ToString() => _value.ToString(CultureInfo.InvariantCulture);

    public void Increment() => _value++;             // genuinely mutating
}

Defensive Copies

When a non-readonly struct is accessed through a readonly field, an in parameter or a ref readonly, the compiler must assume any member call could mutate it — so it silently copies the struct first. On a large struct in a hot loop, that copy is the performance bug:

public struct MutableBig
{
    public double A, B, C, D, E, F, G, H;
    public double Sum() => A + B + C + D + E + F + G + H;          // not readonly!
}

public readonly struct ImmutableBig
{
    public readonly double A, B, C, D, E, F, G, H;
    public double Sum() => A + B + C + D + E + F + G + H;          // no copy needed
}

public static class DefensiveCopies
{
    // Every call to value.Sum() copies 64 bytes first.
    public static double SlowSum(in MutableBig value) => value.Sum();

    // No copy: the compiler knows Sum() cannot mutate.
    public static double FastSum(in ImmutableBig value) => value.Sum();
}

The rule: make structs readonly unless you have a specific reason not to. Analyzer CA1815 and the IDE’s "struct can be made readonly" suggestion will tell you where.

record struct

A record struct adds synthesised value equality, ToString, Deconstruct and with support to a value type:

// Positional record struct: mutable properties by default.
public record struct Temperature(double Celsius, string Scale);

// readonly record struct: init-only properties -- the usual choice.
public readonly record struct Coordinate(double Latitude, double Longitude)
{
    public double DistanceTo(Coordinate other)
        => Math.Sqrt(Math.Pow(Latitude - other.Latitude, 2)
                   + Math.Pow(Longitude - other.Longitude, 2));
}

public static class RecordStructDemo
{
    public static void Run()
    {
        var a = new Coordinate(40.4168, -3.7038);
        var b = a with { Longitude = 0 };            // non-destructive mutation

        Console.WriteLine(a);                        // Coordinate { Latitude = 40.4168, ... }
        Console.WriteLine(a == new Coordinate(40.4168, -3.7038));   // True -- value equality
        Console.WriteLine(a.DistanceTo(b) > 0);      // True

        var (lat, lon) = a;                          // synthesised Deconstruct
        Console.WriteLine($"{lat} {lon}");
    }
}

Prefer readonly record struct for small immutable data. It gives you correct, fast, hand-written-quality Equals and GetHashCode for free — the plain struct versions use reflection-based comparison and are much slower. See Records.

ref struct

A ref struct is a value type that may live only on the stack. Span<T> and ReadOnlySpan<T> are the canonical examples:

public ref struct LineReader
{
    private ReadOnlySpan<char> _remaining;

    public LineReader(ReadOnlySpan<char> text) => _remaining = text;

    public bool TryReadLine(out ReadOnlySpan<char> line)
    {
        if (_remaining.IsEmpty)
        {
            line = default;
            return false;
        }

        int newline = _remaining.IndexOf('\n');
        if (newline < 0)
        {
            line = _remaining;
            _remaining = default;
        }
        else
        {
            line = _remaining[..newline];
            _remaining = _remaining[(newline + 1)..];
        }

        return true;
    }
}

The restrictions follow from "stack only": a ref struct cannot be boxed, cannot be a field of a class or of a non-ref struct, cannot be captured by a lambda or local function, and — until C# 13 — could not be used as a generic type argument. C# 13 relaxed that restriction with the allows ref struct anti-constraint. A separate C# 13 change permits ref struct locals inside iterators and async methods, but such a local still cannot live across an await or yield return:

public static class SpanAlgorithms
{
    // C# 13: T may be a ref struct such as Span<int>.
    public static bool IsDefaultLike<T>(T value) where T : allows ref struct
        => value is null;

    // Common shape: a generic method that accepts spans.
    public static int CountMatches<T>(ReadOnlySpan<T> values, T target)
        where T : IEquatable<T>
    {
        int count = 0;
        foreach (T value in values)
        {
            if (value.Equals(target))
            {
                count++;
            }
        }
        return count;
    }
}

ref fields (C# 11) are what let a ref struct hold a reference rather than a copy; scoped limits how far such a reference may escape. See Unsafe Code, Spans and Performance.

in Parameters

in passes a struct by reference, read-only — avoiding a copy for large structs:

public readonly struct Matrix4x4Like
{
    public readonly double M11, M12, M13, M14;
    public readonly double M21, M22, M23, M24;
    public readonly double M31, M32, M33, M34;
    public readonly double M41, M42, M43, M44;

    public double Trace => M11 + M22 + M33 + M44;
}

public static class InParameters
{
    // 128 bytes passed by reference rather than copied.
    public static double TraceOf(in Matrix4x4Like matrix) => matrix.Trace;

    // `ref readonly` (C# 12): like `in`, but the caller must pass a variable, not a literal.
    public static double TraceOfVariable(ref readonly Matrix4x4Like matrix) => matrix.Trace;
}

in is worthwhile roughly above 16-24 bytes (three or four int`s). Below that, a plain by-value parameter is usually faster — the copy is cheaper than the indirection. Measure rather than guess; `in on a non-readonly struct can be slower because of the defensive copies above.

with Expressions on Structs

with works on any struct from C# 10, not just records — it produces a copy with some members changed:

public readonly record struct Window(int Width, int Height, bool Maximised);

public static class WithDemo
{
    public static void Run()
    {
        var small = new Window(800, 600, false);
        var large = small with { Width = 1920, Height = 1080 };
        var maximised = large with { Maximised = true };

        Console.WriteLine($"{small} / {large} / {maximised}");
    }
}

Inline Arrays

[InlineArray(n)] turns a struct with one field into a fixed-size buffer whose elements live inside the struct — a safe replacement for fixed buffers:

[System.Runtime.CompilerServices.InlineArray(8)]
public struct Buffer8
{
    private int _element0;      // exactly one field; the attribute supplies the rest
}

public static class InlineArrayDemo
{
    public static int Sum()
    {
        Buffer8 buffer = default;

        for (int i = 0; i < 8; i++)
        {
            buffer[i] = i * i;          // indexable
        }

        int total = 0;
        foreach (int value in buffer)   // enumerable, and sliceable as a Span<int>
        {
            total += value;
        }

        Span<int> asSpan = buffer;
        return total + asSpan[^1];
    }
}

No heap allocation, no unsafe, and it works with any element type. This is what the runtime uses internally for small fixed buffers.

Nullable<T>

int? is System.Nullable<int> — itself a struct wrapping a value and a HasValue flag:

int? maybe = null;

Console.WriteLine(maybe.HasValue);              // False
Console.WriteLine(maybe ?? -1);                 // -1
Console.WriteLine(maybe.GetValueOrDefault(7));  // 7

maybe = 42;
Console.WriteLine(maybe.Value);                 // 42
Console.WriteLine(maybe + 1);                   // 43 -- lifted operator

// Boxing a null nullable produces a null reference, not a boxed Nullable<int>.
object? boxed = (int?)null;
Console.WriteLine(boxed is null);               // True

Layout and Interop

// Sequential layout: fields in declaration order, with platform padding. Required for
// most P/Invoke structures.
[StructLayout(LayoutKind.Sequential)]
public struct NativePoint
{
    public int X;
    public int Y;
}

// Explicit layout: a C-style union.
[StructLayout(LayoutKind.Explicit)]
public struct FloatBits
{
    [FieldOffset(0)] public float AsFloat;
    [FieldOffset(0)] public uint AsBits;
}

public static class LayoutDemo
{
    public static string Bits(float value)
    {
        var bits = new FloatBits { AsFloat = value };
        return Convert.ToString(bits.AsBits, 2).PadLeft(32, '0');
    }
}

The default is LayoutKind.Auto for managed structs — the runtime may reorder fields to reduce padding. Always state Sequential (or Explicit) on anything crossing an interop boundary. See Native Interop.

Choosing Between a Class, a Struct, a Record and a Tuple

flowchart TD A{"Is the type mostly
data, with value equality?"} -->|no| B{"Does it need identity,
inheritance or mutation
shared across references?"} A -->|yes| C{"Is it small
(≈ 16 bytes or less)
and immutable?"} B -->|yes| CLASS["class
reference semantics,
inheritance, polymorphism"] B -->|no| C C -->|yes| D{"Is it used only
locally, unnamed,
never in a public API?"} C -->|no| REC["record class
value equality + with,
heap allocated"] D -->|yes| TUP["tuple (double X, double Y)
zero ceremony, no name"] D -->|no| RS["readonly record struct
value equality + with,
no allocation"] style CLASS fill:#eef4fb,stroke:#3d6fa5 style REC fill:#f3eefb,stroke:#6b4fa5 style RS fill:#eefaf4,stroke:#2f7d51 style TUP fill:#fff4e8,stroke:#b5762a

Microsoft’s own guidance for a struct: prefer one only when the type logically represents a single value, is immutable, is under about 16 bytes, and will not be boxed frequently. If any of those fails, use a class.

The hidden cost is boxing: storing a struct in a List<object>, passing it as an interface, or capturing it in a closure allocates, and you lose everything the struct bought you. Generic collections (List<T>, Dictionary<TKey, TValue>) do not box, which is why they are the right container for structs.

See Also