Boxing in C# is the conversion of a value type (an int, a struct, an enum, etc.) into an object reference or an interface reference. The runtime wraps the value inside a heap-allocated object, and unboxing is the reverse operation that copies the value back out. Boxing is implicit, unboxing is explicit, and both cost more than they look: the extra heap allocation and the garbage collection pressure it creates can quietly slow down performance-critical code.
Boxing has been part of .NET since its inception two decades ago. In this post we’ll start by introducing boxing and unboxing and their performance cost, then move on to more modern and lesser-known topics: the everyday patterns that trigger boxing without you noticing, JIT escape analysis, tooling to detect boxing, ref struct and the .NET generics math library.
What Is Boxing and Unboxing in C#?
Here’s a sample program demonstrating the two main scenarios where C# performs boxing and unboxing:
- When a value is boxed into an
objectreference. - When a value is boxed by casting it to an interface that its value type implements.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// object reference scenario int i = 1; object obj = i; // Boxing i++; int j = (int)obj; // Unboxing Console.WriteLine(i); // Outputs: 2 Console.WriteLine(j); // Outputs: 1 // interface reference scenario MyStruct value1 = new MyStruct(1); IDisposable interfaceRef = value1; // Boxing value1.Value++; MyStruct value2 = (MyStruct)interfaceRef; // Unboxing Console.WriteLine(value1.Value); // Outputs: 2 Console.WriteLine(value2.Value); // Outputs: 1 // Just use IDisposable as a random interface for illustration purposes // Boxing and disposing are two different topics struct MyStruct : IDisposable { public int Value; public MyStruct(int value) { Value = value; } public void Dispose() { } } |
Two details are worth underlining. Boxing is implicit: the plain assignment is enough to trigger it, no cast required. Unboxing is explicit and needs a cast back to the exact original value type. The boxed copy is independent of the source, which is why the outputs differ: incrementing i or value1.Value after the box leaves the boxed contents untouched. And because the cast must match the boxed type exactly, unboxing to the wrong type throws an InvalidCastException at runtime rather than performing a silent numeric conversion.
Here is the decompiled IL code illustrating the use of the box and unbox.any instructions:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
IL_0000: ldc.i4.1 IL_0001: stloc.0 IL_0002: ldloc.0 IL_0003: box [System.Runtime]System.Int32 // <-- box: object scenario IL_0008: stloc.1 IL_0009: ldloc.0 IL_000a: ldc.i4.1 IL_000b: add IL_000c: stloc.0 IL_000d: ldloc.1 IL_000e: unbox.any [System.Runtime]System.Int32 // <-- unbox: object scenario IL_0013: ldloc.0 IL_0014: call void [System.Console]System.Console::WriteLine(int32) IL_0019: call void [System.Console]System.Console::WriteLine(int32) IL_001e: ldloca.s 2 IL_0020: ldc.i4.1 IL_0021: call instance void MyStruct::.ctor(int32) IL_0026: ldloc.2 IL_0027: box MyStruct // <-- box: interface scenario IL_002c: ldloca.s 2 IL_002e: ldflda int32 MyStruct::Value IL_0033: dup IL_0034: ldind.i4 IL_0035: ldc.i4.1 IL_0036: add IL_0037: stind.i4 IL_0038: unbox.any MyStruct // <-- unbox: interface scenario IL_003d: ldloc.2 IL_003e: ldfld int32 MyStruct::Value IL_0043: call void [System.Console]System.Console::WriteLine(int32) IL_0048: ldfld int32 MyStruct::Value IL_004d: call void [System.Console]System.Console::WriteLine(int32) |
At a glance, here is how the two operations compare:
| Boxing | Unboxing | |
|---|---|---|
| Direction | value type -> object / interface | object / interface -> value type |
| Conversion | implicit | explicit cast required |
| What happens | allocates an object on the managed heap and copies the value into it | verifies the type, then copies the value back to the stack |
| IL instruction | box |
unbox / unbox.any |
| Main cost | heap allocation and GC pressure | type check and copy (cheaper, but not free) |
The Performance Cost of Boxing in C#
Using BenchmarkDotNet we can assess the cost of boxing:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
using BenchmarkDotNet.Running; using BenchmarkDotNet.Attributes; using System.Runtime.CompilerServices; BenchmarkRunner.Run<BoxingPerformanceBenchmark>(); Console.ReadKey(); [MemoryDiagnoser] // Use this attribute to track heap allocation public class BoxingPerformanceBenchmark { private int _intValue = 691; // Avoid inline JIT optimisation on benchmark methods [MethodImpl(MethodImplOptions.NoInlining)] [Benchmark] public int NoBoxing() { // Returns the integer directly // The value stays on the stack, no boxing involved return _intValue; } [MethodImpl(MethodImplOptions.NoInlining)] [Benchmark] public object BoxedToHeap() { // Returns the integer boxed as an object allocated on the heap return _intValue; } [MethodImpl(MethodImplOptions.NoInlining)] [Benchmark] public int BoxedToStack() { // The integer is temporarily boxed into an object reference. // Since the boxed object doesn't escape the method, // the JIT can keep it on the stack // instead of allocating it on the heap (escape analysis optimization). object obj = _intValue; return (int)obj; } } |
Here are the results:
|
1 2 3 4 5 |
| Method | Mean | Error | StdDev | Median | Gen0 | Allocated | |------------- |----------:|----------:|----------:|----------:|-------:|----------:| | NoBoxing | 0.0134 ns | 0.0184 ns | 0.0225 ns | 0.0000 ns | - | - | | BoxedToHeap | 3.9643 ns | 0.1414 ns | 0.1736 ns | 3.9199 ns | 0.0038 | 24 B | | BoxedToStack | 0.0228 ns | 0.0130 ns | 0.0217 ns | 0.0000 ns | - | - | |
BoxedToHeap() shows that boxing isn’t free: it burns CPU cycles and allocates memory on the heap (24 bytes here, just to hold a single int).
On its own, 24 bytes and a few nanoseconds are negligible. The trouble is scale. Box a value inside a loop that runs millions of times, or once per element of a large collection, and you generate a flood of short-lived heap objects. That is what drives garbage collection frequency up and produces the GC pauses that surface as latency spikes in a hot path. Boxing rarely hurts in isolation; it hurts in aggregate.
On the other hand BoxedToStack() is almost as fast as NoBoxing() despite a boxing and an unboxing operation. This is because, since .NET 9, the JIT is smart enough to detect that the reference doesn’t escape the method scope. As a consequence it can allocate the wrapping object on the stack, as if it were a value type. This feature is named Object Stack Allocation. It results from JIT Escape Analysis Optimization and this is explained by Stephen Toub here.
Common Scenarios Where Boxing Happens in C#
Most boxing is not written on purpose. It hides behind ordinary-looking code, which is why it is so easy to ship and so hard to spot. These are the patterns that allocate boxed values most often in real projects:
- Non-generic collections. Storing value types in
ArrayList,Hashtableor the otherSystem.Collectionstypes boxes every element going in and unboxes it coming out. Replace them withList<T>,Dictionary<TKey, TValue>and friends. - Casting a struct to an interface. Assigning a struct to an interface variable, or calling an interface method on it (
IComparable,IEnumerable,IDisposable, …), boxes the struct. That is exactly the second scenario shown earlier. - Value types in string concatenation and formatting.
string.Concat,string.Formatand anyparams object[]overload box each value-type argument you pass. Interpolated strings behave better on modern runtimes, but a value handed to a parameter typed asobjectstill boxes. - Struct equality without IEquatable. Calling
EqualsorGetHashCodeon a struct that doesn’t implementIEquatable<T>falls back toobject.Equals(object), which boxes the argument. This bites hard when structs are used as dictionary or hash-set keys. - foreach over a non-generic IEnumerable. Enumerating a non-generic collection yields
object, so each value-type element is boxed on every iteration. - Enums treated as objects. Storing an enum in an
objectfield, or olderEnum.HasFlagimplementations, can box the enum value.
The single most common case is the non-generic collection. Compare the two snippets below: the generic version preserves the value type end to end and never touches the heap.
|
1 2 3 4 5 6 7 8 9 |
// ArrayList boxes every int that goes in and unboxes it coming out ArrayList list = new ArrayList(); list.Add(42); // boxing int x = (int)list[0]; // unboxing // List<int> stores the ints directly - no boxing, no unboxing List<int> typed = new List<int>(); typed.Add(42); int y = typed[0]; |
How to Detect Boxing and Unboxing in Your Code
Although boxing occurs at runtime, the C# compiler dictates it by emitting specific IL instructions. This means you don’t need to run the code to detect it: it can be identified through static analysis.
Because boxing is encoded as the box and unbox.any IL instructions you saw earlier, any IL-aware tool can surface it. Decompilers such as ILSpy and dotPeek let you read the emitted IL and search for those instructions. At runtime, allocation profilers such as JetBrains dotMemory, the Visual Studio allocation tool and PerfView reveal the boxed allocations that pile up under load. Static analysis has the edge that it flags every occurrence at once, without running the code. NDepend is a .NET static analyzer and it can detect the usage of boxing and unboxing with a code rule like this one:
|
1 2 3 4 5 |
// <Name>Boxing Detection</Name> warnif count > 0 from m in Application.Methods where m.IsUsingBoxing || m.IsUsingUnboxing select new { m, m.IsUsingBoxing, m.IsUsingUnboxing } |
You can refine such a rule to ignore cold code and only warn on boxing inside hot paths, which is where it actually matters.
How to Avoid Boxing with Generics
The good news is that boxing and unboxing can often be avoided in C# thanks to .NET generics, which preserve value types at compile time. In contrast, Java’s generics use type erasure, so value types are still boxed when stored in generic collections.
For example, in C# a List<int> stores values directly, while in Java a List<Integer> stores boxed integers. Since generics were introduced in .NET 2.0, they have given the .NET platform a performance advantage over Java in handling value types. The same idea explains why implementing IEquatable<T> and IComparable<T> on your own structs removes boxing: the runtime can call the strongly typed methods instead of the object-based ones.
A common scenario where boxing and unboxing occurs unintentionally is when passing or returning value types through an object reference as method parameters:
|
1 2 3 4 5 6 7 8 9 10 11 |
// Boxing occurs because value type is passed as object object Increment(object value) { return value switch { int i => (object)(i + 1), double d => d + 1, _ => throw new ArgumentException("Unsupported type", nameof(value)) }; } int i = (int)Increment(5); double d = (double)Increment(5.1d); |
The method above may look awkward. Unfortunately it’s not uncommon to see object used where a more specific type would be appropriate; consider, for example, Equals<object>(object other).
Thanks to the .NET Generics math library, this Increment(object):object method can be rewritten this way:
|
1 2 3 4 5 6 |
T Increment<T>(T value) where T : System.Numerics.INumber<T> { return value + T.One; // no boxing } int i = Increment(5); double d = Increment(5.1d); |
Although all primitive numeric types (int, double, byte, etc.) implement the INumber<TSelf> interface, constraining a generic type to INumber<TSelf> does not generate any boxing or unboxing IL instructions. This works because, at runtime, an optimized version of Increment<T>() is generated for each value type T it’s called with. For reference types T, the JIT can often share the same compiled code. This is a remarkable design feature of .NET that you should be aware of.
When I first explored the .NET generics math library, my reaction was: “What? Primitive types now implement plenty of interfaces. INumber<TSelf> alone extends 18 interfaces! Does this hurt performance?” Not at all: generics in .NET are carefully designed to avoid any boxing and any overhead.
Interfaces on structs matter, and using them wisely can even improve your application’s performance.
Preventing Boxing at Compile Time with ref struct
To implement Span<T>, C# introduced the concept of a ref struct. A ref struct is a struct that the compiler guarantees will never be boxed or placed in an object field. This restriction exists because Span<T> holds internally something special through a ref field: a managed pointer. A managed pointer is a high-performance reference that must remain on the stack to be safely tracked by the garbage collector. Managed pointers are powerful because they can reference any exotic memory location: a field inside an object, an element in a collection, or a character at a specific index in a string.
Managed pointers have existed since the early days of .NET with the by-ref parameter, but they gained new prominence with Span<T> and ref struct. This is why we use the ref keyword in ref struct, ref field, and ref return, even though it has nothing to do with a regular reference type. This blog post explains it all: Managed pointers, Span, ref struct, C#11 ref fields and the scoped keyword
As a result, using a ref struct forces the compiler to ensure its values are never boxed, because they cannot live on the heap.
Nevertheless keep in mind that a ref struct comes with the limitation that it cannot be stored in object fields.
C# Boxing FAQ
What is boxing in C#?
Boxing is the implicit conversion of a value type (such as int, double or a struct) into an object or interface reference. The CLR allocates an object on the managed heap and copies the value into it.
What is the difference between boxing and unboxing?
Boxing wraps a value type in a heap object and happens implicitly. Unboxing copies the value back out of that object into a value type and requires an explicit cast. Unboxing to a type that doesn’t match the boxed value throws an InvalidCastException.
Why is boxing bad for performance?
Every boxing operation allocates memory on the heap and copies the value. In loops and hot paths this produces large numbers of short-lived objects, which increases garbage collection pressure and causes latency spikes.
How do I avoid boxing in C#?
Use generics (List<int> instead of ArrayList), implement IEquatable<T> and IComparable<T> on your structs, prefer generic math (INumber<T>) over object parameters, and use ref struct or Span<T> where a type must never be boxed.
Is boxing always bad?
No. A handful of boxing operations are harmless. It only matters in performance-critical or high-throughput code, where the allocations accumulate and feed the garbage collector.
Do generics prevent boxing?
Yes. .NET reifies generics, so List<int> stores ints directly with no boxing, unlike Java, whose type erasure boxes value types in generic collections.
How can I find where boxing happens in my code?
Boxing is emitted as the box and unbox.any IL instructions, so a static analyzer such as NDepend, a decompiler such as ILSpy, or an allocation profiler such as dotMemory can pinpoint it.
Conclusion
Boxing can silently degrade the performance of critical code. Understanding when it occurs and how to avoid it lets you write more efficient .NET code. Features like generics, ref struct and modern JIT optimizations such as object stack allocation make it easier than ever to minimize unnecessary allocations and keep applications fast.
Another subtle but worthwhile modern optimization to be aware of is C# 13’s params collections.


