TL;DR
- Boxing occurs with struct dictionary keys if you don’t implement
IEquatable<T>
, causing hidden heap allocations. - Always implement
IEquatable<T>
,Equals
, andGetHashCode
for struct keys to avoid boxing and improve performance. - Use
readonly struct
orrecord 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#?
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?
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?
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?
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?
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?
IEquatable<T>
or use record struct
to avoid hidden allocations and maximize performance.