Choosing Between Structs and Classes for Performance in C#

Understanding the Struct vs Class Decision

Structs and classes look similar in C#, but they behave differently under the hood. Structs are value types that live on the stack, while classes are reference types allocated on the heap. This fundamental difference affects how your code performs, especially when you're creating thousands or millions of instances.

The decision between structs and classes isn't about which one is better. It's about matching the right tool to your specific needs. You'll get the best results when you understand how each type allocates memory, handles copying, and impacts garbage collection.

You'll learn how value types and reference types work differently, when structs provide real performance advantages, and practical guidelines for making the right choice in your applications.

How Structs and Classes Differ

Classes are reference types. When you create a class instance, the CLR allocates memory on the heap and gives you a reference to that memory. Passing a class to a method means passing the reference, not the actual data.

Structs are value types. They typically live on the stack when they're local variables, though they can exist on the heap when they're fields of a class or array elements. When you pass a struct to a method, C# copies the entire value by default.

Point.cs - Struct example
public struct Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public double DistanceFromOrigin()
    {
        return Math.Sqrt(X * X + Y * Y);
    }
}

// Usage shows value type behavior
Point p1 = new Point(10, 20);
Point p2 = p1;  // Copies the entire struct
p2.X = 30;      // Modifying p2 doesn't affect p1

Console.WriteLine($"p1.X: {p1.X}");  // Output: p1.X: 10
Console.WriteLine($"p2.X: {p2.X}");  // Output: p2.X: 30
Person.cs - Class example
public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

// Usage shows reference type behavior
Person person1 = new Person("Alice", 30);
Person person2 = person1;  // Copies the reference, not the data
person2.Age = 35;          // Modifying person2 affects person1

Console.WriteLine($"person1.Age: {person1.Age}");  // Output: person1.Age: 35
Console.WriteLine($"person2.Age: {person2.Age}");  // Output: person2.Age: 35

With the struct, p2 gets a complete copy of p1's data. Changes to p2 don't affect p1. With the class, person2 gets a copy of the reference to the same object, so both variables point to identical data in memory.

Memory Allocation Patterns

Where your data lives in memory has significant performance implications. Stack allocation is fast because it's just moving a stack pointer. Heap allocation requires the garbage collector to find available space, track the object's lifetime, and eventually clean it up.

MemoryExample.cs - Stack vs heap allocation
public struct Vector3
{
    public float X, Y, Z;

    public Vector3(float x, float y, float z)
    {
        X = x; Y = y; Z = z;
    }
}

public class Transform
{
    public Vector3 Position { get; set; }  // Struct stored as part of class
    public Vector3 Rotation { get; set; }
}

public void ProcessPhysics()
{
    // These vectors live on the stack (fast allocation)
    Vector3 velocity = new Vector3(1.0f, 0.0f, 0.0f);
    Vector3 acceleration = new Vector3(0.1f, 0.0f, 0.0f);

    // Calculate new velocity
    velocity.X += acceleration.X;
    velocity.Y += acceleration.Y;
    velocity.Z += acceleration.Z;

    // This transform lives on the heap
    // But its Vector3 fields are embedded in the Transform object
    Transform transform = new Transform
    {
        Position = velocity,  // Copies the struct into the transform
        Rotation = new Vector3(0, 45, 0)
    };
}

// When processing thousands of objects per frame
public void UpdateGameObjects(List<GameObject> objects)
{
    foreach (var obj in objects)
    {
        // Each temp calculation uses stack-allocated structs
        Vector3 newPos = obj.Position;
        newPos.Y -= 9.8f * Time.DeltaTime;  // Gravity
        obj.Position = newPos;
    }
}

Local struct variables like velocity and acceleration allocate instantly on the stack and disappear automatically when the method returns. The Transform class allocates on the heap, but its Vector3 fields are embedded directly in the Transform object, not stored as separate heap allocations.

Performance Trade-offs

Structs avoid garbage collection pressure because stack-allocated values don't need garbage collection. When you're creating millions of temporary objects in performance-critical code, this matters. However, structs get copied when passed to methods, which can hurt performance if they're large.

PerformanceComparison.cs - When structs shine
// Good use of struct - small and frequently created
public struct Color
{
    public byte R, G, B, A;  // Only 4 bytes total

    public Color(byte r, byte g, byte b, byte a = 255)
    {
        R = r; G = g; B = b; A = a;
    }

    public Color Blend(Color other, float amount)
    {
        return new Color(
            (byte)(R + (other.R - R) * amount),
            (byte)(G + (other.G - G) * amount),
            (byte)(B + (other.B - B) * amount),
            A
        );
    }
}

// Processing many colors without heap allocations
public void RenderGradient(Span<Color> pixels, Color start, Color end)
{
    for (int i = 0; i < pixels.Length; i++)
    {
        float t = i / (float)pixels.Length;
        pixels[i] = start.Blend(end, t);  // No GC allocations
    }
}

// Bad use of struct - too large, gets expensive to copy
public struct LargeData  // Don't do this
{
    public double[] Values;  // 8 bytes (reference)
    public string Description;  // 8 bytes (reference)
    public Matrix4x4 Transform;  // 64 bytes
    public Vector3[] Points;  // 8 bytes (reference)
    // Total: ~88 bytes, copied every time you pass it
}

// Better as a class
public class LargeData
{
    public double[] Values { get; set; }
    public string Description { get; set; }
    public Matrix4x4 Transform { get; set; }
    public Vector3[] Points { get; set; }
    // Only 8 bytes (reference) copied when passed
}

The Color struct is 4 bytes. Copying it is faster than following a heap reference and dealing with garbage collection. The LargeData struct would be copied in full every time you pass it to a method, making it slower than passing a class reference.

Structs and Immutability

Immutable structs avoid defensive copying and make your code safer. When a struct is immutable, the compiler can optimize better because it knows the value won't change. The readonly struct modifier enforces immutability at compile time.

ImmutableStruct.cs - Readonly structs
// Immutable struct with readonly modifier
public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency ?? throw new ArgumentNullException(nameof(currency));
    }

    // Operations return new instances instead of modifying
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");

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

    public Money Multiply(decimal factor)
    {
        return new Money(Amount * factor, Currency);
    }
}

// Usage is clear and safe
Money price = new Money(29.99m, "USD");
Money tax = price.Multiply(0.08m);
Money total = price.Add(tax);

// Modern C# 10+ with record struct (immutable by default)
public readonly record struct Temperature(double Value, string Unit)
{
    public Temperature ToFahrenheit()
    {
        if (Unit == "C")
            return new Temperature(Value * 9 / 5 + 32, "F");
        return this;
    }

    public Temperature ToCelsius()
    {
        if (Unit == "F")
            return new Temperature((Value - 32) * 5 / 9, "C");
        return this;
    }
}

// Clean usage with record structs
Temperature temp = new(20, "C");
Temperature fahrenheit = temp.ToFahrenheit();
Console.WriteLine($"{fahrenheit.Value}°{fahrenheit.Unit}");  // 68°F

The readonly modifier prevents accidental mutations and enables better compiler optimizations. Record structs in C# 10+ give you immutability with less boilerplate, making them perfect for small value types like Money or Temperature.

Avoiding Copies with Ref Parameters

When you need to pass large structs without copying them, use ref, in, or out parameters. The in modifier passes a readonly reference, preventing both copying and modifications. This gives you the performance of reference semantics with the safety of immutability.

RefParameters.cs - Efficient struct passing
public struct Matrix4x4
{
    // 16 floats = 64 bytes
    public float M11, M12, M13, M14;
    public float M21, M22, M23, M24;
    public float M31, M32, M33, M34;
    public float M41, M42, M43, M44;

    // Bad - copies 64 bytes on every call
    public static Matrix4x4 Multiply(Matrix4x4 left, Matrix4x4 right)
    {
        Matrix4x4 result = default;
        // Matrix multiplication logic
        return result;
    }

    // Better - passes by readonly reference (no copy)
    public static Matrix4x4 Multiply(in Matrix4x4 left, in Matrix4x4 right)
    {
        Matrix4x4 result = default;
        result.M11 = left.M11 * right.M11 + left.M12 * right.M21 +
                     left.M13 * right.M31 + left.M14 * right.M41;
        // ... rest of multiplication
        return result;
    }
}

// Using in parameters for read-only access
public readonly struct BoundingBox
{
    public Vector3 Min { get; }
    public Vector3 Max { get; }

    public BoundingBox(Vector3 min, Vector3 max)
    {
        Min = min;
        Max = max;
    }

    // in parameter avoids copying while preventing modification
    public bool Contains(in Vector3 point)
    {
        return point.X >= Min.X && point.X <= Max.X &&
               point.Y >= Min.Y && point.Y <= Max.Y &&
               point.Z >= Min.Z && point.Z <= Max.Z;
    }

    public bool Intersects(in BoundingBox other)
    {
        return !(other.Min.X > Max.X || other.Max.X < Min.X ||
                 other.Min.Y > Max.Y || other.Max.Y < Min.Y ||
                 other.Min.Z > Max.Z || other.Max.Z < Min.Z);
    }
}

// Efficient usage in collision detection
public void CheckCollisions(List<BoundingBox> boxes)
{
    for (int i = 0; i < boxes.Count; i++)
    {
        for (int j = i + 1; j < boxes.Count; j++)
        {
            // No copying, boxes[i] and boxes[j] passed by reference
            if (boxes[i].Intersects(in boxes[j]))
            {
                HandleCollision(i, j);
            }
        }
    }
}

The in modifier tells the compiler to pass a reference to the struct instead of copying it. This works great for larger structs where copying would be expensive, while still preventing the called method from modifying the original value.

Practical Guidelines for Choosing

Use structs when you're modeling simple values that make sense to copy. Good candidates include coordinates, measurements, colors, time durations, and mathematical types. The key is that copying the value should have the same meaning as copying the concept it represents.

Choose classes when you need identity semantics, where each instance is unique and modifications should be visible to all references. Classes are also better for large types, anything requiring inheritance, or types that benefit from polymorphic behavior.

DecisionExamples.cs - Real-world scenarios
// Good struct - represents a simple value
public readonly struct DateRange
{
    public DateTime Start { get; }
    public DateTime End { get; }

    public DateRange(DateTime start, DateTime end)
    {
        if (end < start)
            throw new ArgumentException("End must be after start");
        Start = start;
        End = end;
    }

    public int Days => (End - Start).Days;
    public bool Contains(DateTime date) => date >= Start && date <= End;
}

// Good class - represents an entity with identity
public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    public string Email { get; set; }
    public DateTime CreatedAt { get; set; }
    public List<string> Roles { get; set; }

    public void AddRole(string role)
    {
        Roles ??= new List<string>();
        if (!Roles.Contains(role))
            Roles.Add(role);
    }
}

// Good struct - small, immutable, frequently created
public readonly struct Percentage
{
    private readonly double value;

    public Percentage(double value)
    {
        if (value < 0 || value > 100)
            throw new ArgumentOutOfRangeException(nameof(value));
        this.value = value;
    }

    public double AsDecimal() => value / 100.0;
    public static Percentage operator +(Percentage a, Percentage b) =>
        new Percentage(Math.Min(a.value + b.value, 100));
}

// Should be a class - mutable with complex behavior
public class ShoppingCart
{
    private readonly List<CartItem> items = new();

    public decimal Total => items.Sum(i => i.Price * i.Quantity);
    public int ItemCount => items.Sum(i => i.Quantity);

    public void AddItem(Product product, int quantity)
    {
        var existing = items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existing != null)
            existing.Quantity += quantity;
        else
            items.Add(new CartItem(product, quantity));
    }

    public void RemoveItem(int productId)
    {
        items.RemoveAll(i => i.ProductId == productId);
    }

    public void Clear() => items.Clear();
}

DateRange and Percentage are perfect structs. They're small, immutable, and represent single values. User and ShoppingCart need to be classes because they have identity and complex mutable state that you want to modify in place.

Avoiding Common Mistakes

Don't use structs for large types. The general guideline is to keep structs under 16 bytes when possible. Beyond that size, copying becomes expensive enough that you lose the performance benefits.

Watch out for boxing. When you cast a struct to an interface or object type, the runtime creates a heap-allocated copy. This defeats the purpose of using a struct in the first place.

Be careful with mutable structs. When you modify a struct that's a property of another struct, you might not be modifying what you think. The property getter returns a copy, so your changes apply to that copy instead of the original.

CommonMistakes.cs - Pitfalls to avoid
// Pitfall 1: Boxing defeats the purpose
public struct Point
{
    public int X, Y;
}

void ProcessPoint(object obj)  // Requires boxing
{
    if (obj is Point p)  // Creates a boxed copy on the heap
    {
        Console.WriteLine($"{p.X}, {p.Y}");
    }
}

Point point = new Point { X = 10, Y = 20 };
ProcessPoint(point);  // Boxing allocates on heap, causes GC pressure

// Better: Use generics to avoid boxing
void ProcessPoint<T>(T value) where T : struct
{
    if (value is Point p)
    {
        Console.WriteLine($"{p.X}, {p.Y}");
    }
}

// Pitfall 2: Mutable struct surprises
public struct MutablePoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

public class Container
{
    public MutablePoint Position { get; set; }
}

var container = new Container { Position = new MutablePoint { X = 10, Y = 20 } };
container.Position.X = 30;  // Modifies a copy, not the stored value!
Console.WriteLine(container.Position.X);  // Still 10, not 30

// Fix: Use readonly struct or make changes explicit
var pos = container.Position;
pos.X = 30;
container.Position = pos;  // Now it works

// Pitfall 3: Struct with reference type fields
public struct DataWrapper
{
    public int Id;
    public string Name;  // Reference type
    public List<int> Values;  // Reference type
}

DataWrapper w1 = new() { Id = 1, Name = "Test", Values = new List<int> { 1, 2 } };
DataWrapper w2 = w1;  // Copies Id, but Name and Values are references

w2.Values.Add(3);  // Modifies the same list that w1 references!
Console.WriteLine(w1.Values.Count);  // 3, not 2

// This mixed behavior is confusing - use a class instead

These pitfalls show why careful consideration matters. Boxing defeats performance gains, mutable structs create confusing behavior, and mixing value and reference semantics leads to bugs. When in doubt, start with a class and switch to a struct only when you have a clear reason.

Frequently Asked Questions (FAQ)

When should I use a struct instead of a class in C#?

Use structs for small, immutable types that represent single values like coordinates, colors, or measurements. They're ideal when you need high performance with frequent allocations, typically under 16 bytes in size. If your type needs inheritance, polymorphism, or will be frequently passed by reference, use a class instead.

Are structs always faster than classes in C#?

No, structs aren't automatically faster. They avoid heap allocation and garbage collection overhead, which helps in tight loops with many allocations. However, large structs get copied entirely when passed to methods, which can be slower than passing a class reference. Choose based on size, usage patterns, and whether copying semantics make sense.

Can structs contain reference type members in C#?

Yes, structs can contain reference type members like strings or class instances. However, this creates a mixed allocation pattern where the struct lives on the stack but its reference members point to heap objects. This can reduce the performance benefits you'd normally get from using structs, so consider whether a class would be more appropriate.

Back to Articles