Boxing and unboxing in .NET 10: what the runtime does, the exact-type rule for unboxing, the common hidden boxing sites, the cost in allocations and GC, and the tools and fixes to remove it.
Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.
Boxing converts a value type to a reference type (object, ValueType, an interface, or Enum) by allocating an object on the heap and copying the value into it. Unboxing checks the object's type and copies the value back out. The cost is not the copy — it is the heap allocation and GC pressure, which in hot paths becomes measurable latency.
int n = 42;
object o = n; // boxing: allocate + copy
int m = (int)o; // unboxing: type check + copy
long l = (long)o; // InvalidCastException - must unbox to the EXACT type
Hidden boxing is the real problem. Common sources:
| Source | Example |
|---|---|
| Non-generic collections | ArrayList.Add(5), Hashtable, DataTable rows |
object parameters |
string.Format("{0}", count), Console.WriteLine("{0}", x), older logging APIs |
| Logging message templates | logger.LogInformation("Processed {Count}", count) boxes count into params object[] |
| Interfaces on structs | IComparable c = 5;, a struct passed as IEquatable<T> or IDisposable |
| Struct calling base members | GetHashCode(), ToString() or Equals(object) not overridden on a struct |
Equals(object) on structs |
ValueType.Equals takes object — boxes the argument |
Enum.HasFlag (old runtimes) |
Fixed by the JIT in .NET Core 2.1+ |
| Generic code with non-generic paths | if (value is IFormattable f) on a T that is a struct |
foreach over a struct collection through an interface |
IEnumerable<T> boxes the struct enumerator |
LINQ over value types with Cast<object>(), dynamic |
Every element |
String concatenation with + on old compilers |
"x" + 5 called Concat(object, object) before C# 10 |
int allocates 24 bytes on 64-bit (header + MethodTable pointer + 4 bytes padded).InvalidCastException on a wrong type is not.dotnet-counters monitor --process-id <pid> System.Runtime — watch gc-heap-alloc-rate and gen-0-gc-count.dotnet-trace with the gc-verbose profile, then look for System.Int32, System.Guid, your structs, or enumerator types in the allocation list.[MemoryDiagnoser] on suspect code: any non-zero "Allocated" for code that should be allocation-free is a clue.box opcode in SharpLab or ILSpy.List<int>, Dictionary<Guid, Order>) and generic methods.IEquatable<T>, override GetHashCode and ToString on structs — or use record struct, which generates them.void Process<T>(T item) where T : IComparable<T> calls without boxing, because the JIT specialises code for each value type.[LoggerMessage]) so arguments are strongly typed, and check IsEnabled before expensive logging.$"{count}" uses DefaultInterpolatedStringHandler with generic AppendFormatted<T>, which formats value types without boxing.EqualityComparer<T>.Default in generic code — the JIT devirtualises it for value types.A high-volume pricing API had Gen0 GCs several times per second under load. A dotnet-trace allocation profile showed the top allocated type was System.Decimal — boxed. The cause was logger.LogDebug("Price {Price} for {Sku} at {Qty}", price, sku, qty) in a hot loop: even with Debug logging disabled, the params object[] array and boxes were created before the level check. Converting to [LoggerMessage] source-generated methods removed both the array and the boxing, and allocation rate dropped by more than half. A second hit was a struct key ProductKey without IEquatable<ProductKey>, used in a Dictionary, which boxed on every lookup through ValueType.Equals(object); making it a readonly record struct fixed it.
The interviewer checks understanding of the type system's runtime cost and practical profiling skill.
Green flags
Red flags
ArrayList as the example.boxThe IL box T instruction:
T's boxed MethodTable (every value type has one).Stack value: [42]
Heap box: [header 8B][MethodTable* Int32 8B][42 4B][pad 4B] = 24 bytes
unbox.any T:
NullReferenceException).T (or T's enum underlying type for enums — you can unbox a boxed enum to its underlying integer and vice versa).That exact-type check is why (long)(object)42 throws: the box contains an Int32. You need (long)(int)o, or Convert.ToInt64(o).
For value-type type arguments the JIT generates separate specialised native code per type: List<int> and List<double> each get their own code where T is the real type. A call like item.CompareTo(other) with where T : IComparable<T> is compiled as a direct (often inlined) call on the struct — no box.
For reference types, the JIT shares one implementation (__Canon) across all reference type arguments, using a dictionary lookup for type-specific information.
In IL, generic code uses the constrained. prefix before callvirt on a type parameter:
constrained. !!T
callvirt instance int32 [System.Runtime]System.IComparable`1<!!T>::CompareTo(!0)
The JIT resolves this per instantiation: for a struct that implements the method, it emits a direct call; only if the struct does not override a method inherited from object (e.g. ToString) does it need to box.
public struct Sku { public int Id; } // no overrides
public readonly record struct SkuR(int Id); // generated overrides
Calling sku.ToString() or sku.GetHashCode() on Sku invokes ValueType's implementation, which needs this as an object — a box. ValueType.Equals(object) also boxes the argument and may use reflection. The record struct generates strongly typed Equals(SkuR), GetHashCode and ToString overrides, so no boxing.
Modern .NET removes many boxes automatically:
Enum.HasFlag is an intrinsic since .NET Core 2.1 — no box.box immediately followed by unbox or isinst in generic code is folded.typeof(T) == typeof(int) checks in generics are compile-time constants per instantiation, enabling branch elimination.object o = 5; return o.Equals(5); may allocate nothing.AppendFormatted<T>(T value) and check for ISpanFormattable without boxing.So always measure: old "avoid X because it boxes" advice may be obsolete.
Because a box is a copy, mutating through an interface operates on the box, not the original:
struct Counter : IIncrement { public int Value; public void Increment() => Value++; }
var c = new Counter();
IIncrement i = c; // box (copy)
i.Increment(); // increments the box
Console.WriteLine(c.Value); // 0
This is another reason structs should be immutable.
async and closuresAsync state machines for methods that complete synchronously stay on the stack as structs. On the first real suspension, the state machine (a struct) is boxed to the heap (AsyncStateMachineBox<TStateMachine>). That box is expected and unavoidable for truly asynchronous work; ValueTask plus PoolingAsyncValueTaskMethodBuilder (.NET 6+) can pool those boxes on extremely hot paths.
[MemoryDiagnoser]
public class BoxingBench
{
private readonly ArrayList _arrayList = new(Enumerable.Range(0, 1000).ToArray());
private readonly List<int> _list = Enumerable.Range(0, 1000).ToList();
[Benchmark] public int ArrayList() { int s = 0; foreach (object o in _arrayList) s += (int)o; return s; }
[Benchmark] public int GenericList() { int s = 0; foreach (int x in _list) s += x; return s; }
}
The allocation column shows the enumerator allocation for ArrayList (its enumerator is a class) and zero for List<int>; adding to an ArrayList in a benchmark shows 24 bytes per element.
object o = 5; int x = (int)o; into SharpLab and find box and unbox.any.Dictionary<MyStruct, int> lookup with and without IEquatable<MyStruct>.logger.LogInformation("{X}", x) vs a [LoggerMessage] method.using System.Collections;
using Microsoft.Extensions.Logging;
namespace Types;
// Struct key with proper equality: no boxing in Dictionary lookups
public readonly struct SkuKey(int storeId, int productId) : IEquatable<SkuKey>
{
public int StoreId { get; } = storeId;
public int ProductId { get; } = productId;
public bool Equals(SkuKey other) => StoreId == other.StoreId && ProductId == other.ProductId;
public override bool Equals(object? obj) => obj is SkuKey k && Equals(k);
public override int GetHashCode() => HashCode.Combine(StoreId, ProductId);
}
public static partial class Log
{
// Source-generated logging: no params object[] and no boxing
[LoggerMessage(Level = LogLevel.Debug, Message = "Request finished with {Status} in {ElapsedMs} ms")]
public static partial void RequestFinished(ILogger logger, int status, double elapsedMs);
}
public static class Boxing
{
public static void Main()
{
// Explicit box / unbox
int n = 42;
object o = n; // box
int back = (int)o; // unbox
long widened = (long)(int)o; // unbox exact type, then convert
Console.WriteLine($"{back} {widened}");
// Hidden boxing with non-generic collections
var legacy = new ArrayList { 1, 2, 3 }; // three boxes
var modern = new List<int> { 1, 2, 3 }; // none
Console.WriteLine(legacy.Count + modern.Count);
// Generic constraint avoids boxing
Console.WriteLine(Max(3, 7));
// Struct keys with IEquatable<T>
var stock = new Dictionary<SkuKey, int> { [new SkuKey(1, 100)] = 5 };
Console.WriteLine(stock[new SkuKey(1, 100)]);
}
private static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
}
What happens: ArrayList, Hashtable or object[] used to store numbers or structs.
Why it is wrong: every add boxes, every read unboxes and casts, and type errors appear at run time.
Do instead: use List<T>, Dictionary<TKey,TValue> and other generic collections.
IEquatable<T>, GetHashCode and ToStringWhat happens: a custom struct is used as a dictionary key or in HashSet<T>.
Why it is wrong: ValueType.Equals(object) boxes and may use reflection; the default hash can be poor.
Do instead: implement IEquatable<T> and override GetHashCode, or use readonly record struct.
What happens: methods accept IComparable, IFormattable or custom interfaces, and callers pass structs.
Why it is wrong: each call boxes the struct and uses interface dispatch.
Do instead: use generic methods with interface constraints: void Sort<T>(Span<T> items) where T : IComparable<T>.
What happens: _logger.LogDebug("Value {V}", value) inside tight loops.
Why it is wrong: creates a params object[] and boxes value-type arguments even when the level is disabled.
Do instead: use [LoggerMessage] source generators or guard with if (_logger.IsEnabled(LogLevel.Debug)).
What happens: large refactors to remove theoretical boxing in cold code. Why it is wrong: a few boxes per request are irrelevant; the JIT may already remove them; readability suffers. Do instead: profile allocation rate and hot paths first; fix what the data shows.
What happens: long id = (long)row["Id"]; where the database returned an int, or (int)(object)myEnum patterns with mismatched types.
Why it is wrong: unboxing requires the exact type, so this throws InvalidCastException.
Do instead: unbox to the stored type then convert ((long)(int)value), or use Convert.ToInt64 / typed readers (reader.GetInt64).
What happens: a struct implementing IDisposable or a custom interface is mutated via the interface reference.
Why it is wrong: the mutation applies to the boxed copy; the original is unchanged.
Do instead: keep structs immutable, or use classes for objects with mutable state.
IEnumerable<T>What happens: APIs take IEnumerable<T> and are called with List<T> in hot loops.
Why it is wrong: the struct enumerator is boxed and every MoveNext is an interface call.
Do instead: accept concrete types or spans in hot internal paths (ReadOnlySpan<T>, List<T>), keep IEnumerable<T> for general APIs.
object or dynamic as a general containerWhat happens: Dictionary<string, object> property bags or dynamic models for numeric data.
Why it is wrong: boxing on every write, casting on every read, and no compile-time checking.
Do instead: use typed models or generic containers; reserve property bags for genuinely dynamic data.
==What happens: object a = 5; object b = 5; if (a == b) ...
Why it is wrong: == on object compares references, and two boxes are different objects — always false.
Do instead: use a.Equals(b) or object.Equals(a, b), or avoid boxing altogether.
Seniors treat boxing as a measured hot-path concern, not a code-review reflex.
| Context | Does boxing matter? |
|---|---|
| Request handlers doing I/O | rarely; database time dominates |
| High-volume logging and metrics | yes; use [LoggerMessage] and generic APIs |
| Serialisers, parsers, protocol code | yes; generic and span-based APIs |
| Collections of structs with custom keys | yes; implement IEquatable<T> |
| Game loops, trading, real-time telemetry | yes; aim for zero allocations per operation |
IEquatable<T>, IComparer<T>, interpolated string handlers, ISpanFormattable.System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
A value type holds its data directly, so assigning or passing it copies the data; a reference type variable holds a reference to an object on the managed heap, so copying the variable makes two references to the same object. Value types are stored inline where they are declared: a local usually sits on the stack or in a register, but a struct field of a class lives inside that object on the heap, and captured or boxed values also end up on the heap. By default classes compare by identity and structs by their fields. I use structs for small, immutable values like Money or coordinates, and classes for everything with identity or behaviour.
How to tell it (STAR)
**S:** After a logging change, our API [at company] used noticeably more CPU under the same load. **T:** I had to find out why without losing the diagnostics. **A:** I traced allocations with dotnet-trace and PerfView, found boxed values and params arrays from debug logging that was not even enabled, and moved the hot calls to source-generated LoggerMessage methods. **R:** Gen-0 GCs dropped by about [x%] and CPU returned to baseline, and CA1848 now flags new cases.
Last updated 30 Oct 2026.