TL;DR

  • Boxing occurs with struct dictionary keys if you don’t implement IEquatable<T>, causing hidden heap allocations.
  • Always implement IEquatable<T>, Equals, and GetHashCode for struct keys to avoid boxing and improve performance.
  • Use readonly struct or record struct for immutable, allocation-free dictionary keys.
  • Profilers, not the compiler, will reveal hidden boxing allocations in hot paths.
  • Proper struct equality means faster lookups, zero GC pressure, and safer code.

Boxing with struct dictionary keys creates hidden heap allocations that can kill performance in hot paths. When your custom struct gets boxed during equality comparisons, you’re paying a GC tax you didn’t sign up for.

The Hidden Problem

Here’s a common scenario that looks innocent but allocates memory:

public struct ProductKey
{
    public int CategoryId { get; init; }
    public string ProductCode { get; init; }
}

var productCache = new Dictionary<ProductKey, Product>();
var key = new ProductKey { CategoryId = 1, ProductCode = "ABC123" };

// This boxes the struct during equality comparison
var product = productCache[key]; // Hidden allocation!

The issue? Dictionary<TKey, TValue> needs to compare keys for equality. Without proper implementation, your struct gets boxed to object for the default equality comparison.

Why Boxing Happens

When you don’t implement IEquatable<T>, the dictionary falls back to object.Equals(). This forces your value type onto the heap as a boxed reference type. Every lookup, insertion, or removal triggers this allocation.

The fix is straightforward, implement proper equality:

public readonly struct ProductKey : IEquatable<ProductKey>
{
    public required int CategoryId { get; init; }
    public required string ProductCode { get; init; }

    public bool Equals(ProductKey other) =>
        CategoryId == other.CategoryId && 
        ProductCode == other.ProductCode;

    public override bool Equals(object? obj) =>
        obj is ProductKey other && Equals(other);

    public override int GetHashCode() =>
        HashCode.Combine(CategoryId, ProductCode);
}

Now dictionary operations stay on the stack, no boxing, no allocations.

Modern C# Makes It Easier

With C# 12, record struct handles equality automatically:

public readonly record struct ProductKey(int CategoryId, string ProductCode);

var productCache = new Dictionary<ProductKey, Product>();
var key = new ProductKey(1, "ABC123");
var product = productCache[key]; // No boxing - record struct implements IEquatable<T>

The compiler generates optimized Equals() and GetHashCode() methods for you.

Performance Impact

Here’s a benchmark comparing the performance of dictionary lookups with and without IEquatable<T>:

using BenchmarkDotNet.Attributes;

public struct ProductKeyNoEquatable
{
    public int Id { get; set; }
}

public readonly struct ProductKeyWithEquatable : IEquatable<ProductKeyWithEquatable>
{
    public int Id { get; init; }
    public bool Equals(ProductKeyWithEquatable other) => Id == other.Id;
    public override bool Equals(object? obj) => obj is ProductKeyWithEquatable other && Equals(other);
    public override int GetHashCode() => Id.GetHashCode();
}

public class StructDictionaryKeyBenchmark
{
    private Dictionary<ProductKeyNoEquatable, string> dictNoEquatable;
    private Dictionary<ProductKeyWithEquatable, string> dictWithEquatable;
    private ProductKeyNoEquatable keyNoEquatable;
    private ProductKeyWithEquatable keyWithEquatable;

    [GlobalSetup]
    public void Setup()
    {
        dictNoEquatable = new Dictionary<ProductKeyNoEquatable, string>();
        dictWithEquatable = new Dictionary<ProductKeyWithEquatable, string>();
        keyNoEquatable = new ProductKeyNoEquatable { Id = 42 };
        keyWithEquatable = new ProductKeyWithEquatable { Id = 42 };
        dictNoEquatable[keyNoEquatable] = "value";
        dictWithEquatable[keyWithEquatable] = "value";
    }

    [Benchmark]
    public string Lookup_NoEquatable()
    {
        return dictNoEquatable[keyNoEquatable];
    }

    [Benchmark]
    public string Lookup_WithEquatable()
    {
        return dictWithEquatable[keyWithEquatable];
    }
}

In our benchmarks, the difference is dramatic:

| Method               | Mean      | Error     | StdDev    | Median    |
|--------------------- |----------:|----------:|----------:|----------:|
| Lookup_NoEquatable   | 34.064 ns | 1.5828 ns | 4.4901 ns | 32.553 ns |
| Lookup_WithEquatable |  2.583 ns | 0.3142 ns | 0.9116 ns |  2.869 ns |
  • ProductKeyNoEquatable lookups are >~10x slower and cause heap allocations due to boxing.
  • ProductKeyNoEquatable lookups are much faster and allocation-free.

Best Practices

Always prefer readonly struct for dictionary keys to prevent accidental mutations:

public readonly struct CacheKey : IEquatable<CacheKey>
{
    // Implementation here
}

Common mistake: assuming structs “just work” as dictionary keys. They don’t, at least not efficiently.

The Mental Model

If your struct goes into a Dictionary, make it cheap to compare and hash, or pay the heap tax. Implement IEquatable<T> explicitly, or use record struct to get it for free.

The compiler won’t warn you about boxing, but your profiler will show the allocations. Save yourself the debugging session and implement proper equality from the start.

FAQ

Why does boxing occur when using structs as dictionary keys in C#?

Boxing happens when a struct does not implement IEquatable<T>, causing the dictionary to use object.Equals() for comparisons. This converts the struct to a reference type, resulting in heap allocations and increased GC pressure.

How can you prevent boxing with struct dictionary keys?

Implement IEquatable<T> and override Equals and GetHashCode in your struct. This allows the dictionary to compare keys without boxing, keeping operations on the stack and avoiding allocations.

What is the performance impact of boxing in dictionary lookups?

Boxing can increase lookup times by 10x and cause memory allocations on every operation. Benchmarks show ~50ns per lookup with boxing versus ~5ns without, plus zero GC pressure when boxing is avoided.

What is the benefit of using readonly struct or record struct for dictionary keys?

readonly struct prevents accidental mutations, ensuring keys remain immutable and safe for use in dictionaries. record struct in C# 12+ automatically implements IEquatable<T>, Equals, and GetHashCode, making it easy to create efficient, immutable keys with minimal code and no boxing.

What is a common mistake when using structs as dictionary keys?

A common mistake is assuming structs “just work” as keys. Without proper equality and hash code implementation, they cause boxing and degrade performance.

How do you implement equality for a struct used as a dictionary key?

Implement IEquatable<T>, override Equals(object?), and provide a robust GetHashCode method. For example:

public readonly struct ProductKey : IEquatable<ProductKey>
{
    public int Id { get; init; }
    public bool Equals(ProductKey other) => Id == other.Id;
    public override int GetHashCode() => Id.GetHashCode();
}

Can the compiler warn you about boxing with struct keys?

No, the compiler does not warn about boxing in this scenario. Profiling tools are needed to detect hidden allocations during dictionary operations.

Why is IEquatable<T> important for value types in collections?

IEquatable<T> enables efficient, type-safe equality checks for value types, preventing boxing and ensuring high performance in collections like dictionaries and hash sets.

What is the mental model for using structs as dictionary keys?

Always make structs used as dictionary keys cheap to compare and hash. Implement IEquatable<T> or use record struct to avoid hidden allocations and maximize performance.

Related Posts