When a client asked why a simple Point struct was suddenly blowing up the memory of a high-traffic API, I realized most developers still carry the textbook belief that structs always live on the stack. The reality is messier, the CLR decides based on where the struct is used. Below is what I learned fixing that bug and how you can avoid the same trap.

The myth of "structs are always on the stack"

The old C/C++ teaching says: value types go on the stack, reference types on the heap. In C# the line is blurred. A struct is a value type, but the CLR can store the value anywhere, stack, heap, or even inside another object’s field. The only guarantee is that the value is copied on assignment, not that its backing memory is on a particular region.

Where the CLR actually puts a struct

1. As a local variable

If you declare a struct inside a method and never box it, the JIT will usually allocate it on the stack. Example:

public void Process()
{
    Point p = new Point(10, 20); // likely stack allocated
    // use p
}
  • Point lives in the stack frame of Process
  • The allocation is cheap, no GC involvement
  • The lifetime ends when the method returns

2. As a field of a reference type

When a struct is a field of a class, the whole struct lives inside the object’s heap allocation.

class Rectangle
{
    public Point TopLeft; // stored on the heap as part of Rectangle
    public Point BottomRight;
}
  • Rectangle is a reference type, allocated on the heap
  • Its Point fields are stored contiguously inside that heap block
  • Changing TopLeft modifies the heap object, not a copy

3. As an element of an array

Arrays are reference types, so each element, even if it’s a struct, resides on the heap.

Point[] points = new Point[1000]; // entire array on the heap
  • Accessing points[i] copies the struct out of the heap into a temporary
  • Mutating the temporary does not affect the array unless you write back

4. When boxed

Boxing a struct wraps it in an object, moving it to the heap.

object o = p; // boxes p, new heap allocation
  • The boxed copy lives on the heap until the GC frees it
  • Frequent boxing defeats the purpose of using a struct

Why the placement matters for performance

  • Stack allocation is cheap and automatically reclaimed. Use it for short-lived, small structs.
  • Heap allocation incurs GC cost. If you embed large structs in many objects, you may create hidden memory pressure.
  • Copying a struct copies all its fields. A 64-byte struct copied thousands of times can be more expensive than a reference to a 64-byte class.

Common pitfalls I’ve hit

Unexpected copies in LINQ

var points = list.Select(p => p.Location).ToList();

Location is a struct property. Each Select iteration copies the struct, then ToList copies again. If the struct contains large arrays, the hidden allocations explode.

Mutating structs through interface references

IShape shape = new Circle(); // Circle implements IShape, struct inside
shape.Move(5, 5); // compiles, but moves a copy, original unchanged

When a struct is accessed via an interface, the CLR boxes it, so Move works on a copy. The original stays put, leading to subtle bugs.

Guidelines for safe struct design

  1. Keep them small, aim for 16 bytes or less. Larger structs defeat the copy-cheap premise.
  2. Make them immutable, avoid methods that mutate this. This eliminates accidental copies that never propagate changes.
  3. Never expose a mutable struct through an interface or as a property, it forces boxing.
  4. Prefer classes for aggregates that need identity or large internal data, a class avoids repeated copying.
  5. Profile, use dotTrace or Visual Studio diagnostics to see if structs are causing heap churn.

Example: Refactoring a problematic struct

The client’s Point was fine, but the Polygon class stored an array of Point structs and exposed a mutable property:

class Polygon
{
    public Point[] Vertices { get; set; }
    public void Translate(int dx, int dy)
    {
        for (int i = 0; i < Vertices.Length; i++)
            Vertices[i] = new Point(Vertices[i].X + dx, Vertices[i].Y + dy);
    }
}

The loop created a new struct for every vertex on every request, generating a lot of short-lived heap allocations (the array itself is on the heap, each assignment copies the struct). I switched to a mutable class for Point and changed the array to a List<Point>:

class Point
{
    public int X; public int Y;
    public void Offset(int dx, int dy) { X += dx; Y += dy; }
}

class Polygon
{
    public List<Point> Vertices { get; } = new List<Point>();
    public void Translate(int dx, int dy)
    {
        foreach (var v in Vertices) v.Offset(dx, dy);
    }
}
  • No per-iteration allocation, we mutate the same objects.
  • Memory usage dropped dramatically and the API regained its performance.

Practical warning: don’t assume default(T) is free

When you write new T[1000] where T is a struct, the CLR zero-fills the entire array on the heap. If the struct contains reference-type fields, those references are also zeroed, which may trigger hidden allocations later. Initializing large struct arrays lazily can avoid the upfront cost.

FAQ

Why does a struct sometimes end up on the heap even if I never box it?

If the struct is a field of a class or an element of an array, the containing object lives on the heap, so the struct lives there too. The CLR never moves it to the stack in that scenario.

Is it ever worth making a 32-byte struct mutable?

Only if you have a very tight inner loop where the cost of copying outweighs the cost of a heap allocation. In most web or UI code, immutability is safer and the performance gain is negligible.

How can I detect accidental boxing in my code?

Enable the Boxing Allocation diagnostic in Visual Studio’s Performance Profiler or add a Roslyn analyzer like Microsoft.CodeAnalysis.NetAnalyzers which warns when a struct is cast to object or an interface.

Wrapping up

Next time you reach for a struct, remember: the CLR decides the storage location based on context, not on a hard rule about the stack. Keep structs tiny, immutable, and avoid exposing them through interfaces or mutable properties. When you see unexpected memory spikes, ask yourself whether a hidden copy or boxing is the culprit, often the fix is as simple as swapping a struct for a small class.