📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are boxing and unboxing in C#? Where does hidden boxing happen, what does it cost, and how do you find and remove it?

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.

Mid-Level Debugging Intermediate allocations boxing csharp performance types unboxing

🎯 Key points

  • Boxing copies a value type into a new heap object so it can be treated as object or an interface
  • Unboxing checks the type and copies the value out; it must be the exact type (or its Nullable), otherwise InvalidCastException
  • Each box is a heap allocation (24 bytes for a boxed int on 64-bit) that adds GC pressure
  • Hidden sites: object and interface parameters, non-generic collections, params object[], calling GetType or non-overridden Equals/GetHashCode/ToString on a struct, struct enumerators through IEnumerable<T>
  • Generics with constraints (where T : IComparable<T>) call struct methods without boxing
  • Mutating a boxed struct through an interface changes the box, not the original
  • Find boxing with BenchmarkDotNet MemoryDiagnoser, allocation profilers, dotnet-counters and the IL box instruction
  • Modern C# reduces boxing: interpolated string handlers, generic math, and the JIT removing some non-escaping boxes

⏱ 30-second answer

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>.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Explicit vs hidden boxing

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

2. What it costs

  • Each box of an int allocates 24 bytes on 64-bit (header + MethodTable pointer + 4 bytes padded).
  • Allocation itself is cheap (bump pointer, ~10–20 ns), but it drives Gen0 collections; at millions per second you get frequent GCs, more CPU in the GC, and promoted objects if they live briefly across collections.
  • Unboxing is cheap (a type check and copy), but InvalidCastException on a wrong type is not.
  • Interface calls on boxed structs are also slower (indirect dispatch, no inlining).

3. How I find it

  1. Measure first: dotnet-counters monitor --process-id <pid> System.Runtime — watch gc-heap-alloc-rate and gen-0-gc-count.
  2. Allocation profiling: Visual Studio's .NET Object Allocation tool, JetBrains dotMemory, or dotnet-trace with the gc-verbose profile, then look for System.Int32, System.Guid, your structs, or enumerator types in the allocation list.
  3. BenchmarkDotNet with [MemoryDiagnoser] on suspect code: any non-zero "Allocated" for code that should be allocation-free is a clue.
  4. Read the IL: search for the box opcode in SharpLab or ILSpy.
  5. Analysers: Roslynator and the Heap Allocation Viewer (Rider/ReSharper) highlight boxing in the editor.

4. How I remove it

  • Use generic collections (List<int>, Dictionary<Guid, Order>) and generic methods.
  • Implement IEquatable<T>, override GetHashCode and ToString on structs — or use record struct, which generates them.
  • Use generic constraints instead of interface parameters: void Process<T>(T item) where T : IComparable<T> calls without boxing, because the JIT specialises code for each value type.
  • Use source-generated logging ([LoggerMessage]) so arguments are strongly typed, and check IsEnabled before expensive logging.
  • Use interpolated strings: since C# 10, $"{count}" uses DefaultInterpolatedStringHandler with generic AppendFormatted<T>, which formats value types without boxing.
  • Use EqualityComparer<T>.Default in generic code — the JIT devirtualises it for value types.

5. A production example

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.

🔍 What the interviewer is checking

The interviewer checks understanding of the type system's runtime cost and practical profiling skill.

Green flags

  • Explains box and unbox precisely, including the exact-type rule.
  • Lists several hidden boxing sites, including interfaces and struct keys.
  • Knows how generics with constraints avoid boxing.
  • Describes how to measure (MemoryDiagnoser, allocation profiler, dotnet-counters).

Red flags

  • Only knows ArrayList as the example.
  • Suggests micro-optimising boxing without measuring.
  • Unaware that mutating a boxed struct does not affect the original.

📚 Learn in depth

1. What the runtime does on box

The IL box T instruction:

  1. Allocates an object whose MethodTable is T's boxed MethodTable (every value type has one).
  2. Copies the value's bytes into the object's field area.
  3. Pushes the object reference.
Stack value: [42]
Heap box:    [header 8B][MethodTable* Int32 8B][42 4B][pad 4B]   = 24 bytes

unbox.any T:

  1. Null check (unboxing null to a non-nullable value type throws NullReferenceException).
  2. Checks the object's MethodTable is exactly T (or T's enum underlying type for enums — you can unbox a boxed enum to its underlying integer and vice versa).
  3. Copies the value out.

That exact-type check is why (long)(object)42 throws: the box contains an Int32. You need (long)(int)o, or Convert.ToInt64(o).

2. Why generics avoid boxing

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.

3. Struct overrides matter

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.

4. JIT optimisations that remove boxing for you

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.
  • Escape analysis (.NET 9+) can stack-allocate boxes that do not escape the method, so object o = 5; return o.Equals(5); may allocate nothing.
  • Interpolated string handlers (C# 10) call AppendFormatted<T>(T value) and check for ISpanFormattable without boxing.

So always measure: old "avoid X because it boxes" advice may be obsolete.

5. Boxing and mutation surprises

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.

6. Boxing in async and closures

Async 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.

7. Measuring with BenchmarkDotNet

[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.

Try it yourself

  1. Paste object o = 5; int x = (int)o; into SharpLab and find box and unbox.any.
  2. Benchmark a Dictionary<MyStruct, int> lookup with and without IEquatable<MyStruct>.
  3. Compare allocations of logger.LogInformation("{X}", x) vs a [LoggerMessage] method.
  4. Reproduce the interface-mutation example and explain the output.

💻 Code

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;
}

⚠ Common mistakes

1. Using non-generic collections in new code

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.

2. Structs without IEquatable<T>, GetHashCode and ToString

What 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.

3. Passing structs as interfaces

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>.

4. Logging with message templates in hot paths

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)).

5. Optimising boxing without measuring

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.

6. Unboxing to the wrong type

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).

7. Mutating a struct through an interface

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.

8. Iterating struct collections through 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.

9. Using object or dynamic as a general container

What 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.

10. Comparing boxed values with ==

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.

🏛 Senior insight: trade-offs

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
  • Make allocation budgets visible: BenchmarkDotNet in CI with an allocation threshold for critical paths.
  • Prefer APIs designed to avoid boxing: generic math, IEquatable<T>, IComparer<T>, interpolated string handlers, ISpanFormattable.
  • The modern runtime removes some boxing automatically, so always measure on the target runtime (.NET 10) before optimising.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions ValueType.Equals or struct keys. Why: Struct dictionary keys without IEquatable<T> are a classic hidden boxing source.
    How to answer

    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.

  2. When: The candidate explains that boxing copies the value. Why: Confirms the candidate understands copy semantics behind the boxing copy.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Debug logging allocations caused GC pressure even with Debug disabled
Situation
A high-traffic API (about 3,000 requests per second on AKS) logged several values per request at Debug level using the ILogger extension methods.
Signals
dotnet-counters showed a high allocation rate and many gen-0 GCs; CPU was 15% higher than a load-test baseline from before a logging change.
Investigation
Recorded a trace with dotnet-trace (gc-verbose) and opened it in PerfView; top allocated types were object[] and boxed Int32 and Double from LoggerExtensions.LogDebug calls, even though Debug was filtered out.
Resolution
Replaced the hot log calls with [LoggerMessage] source-generated methods, which check IsEnabled first and take strongly typed parameters.
Prevention
Analyser rule CA1848 (use LoggerMessage delegates) set to warning in the API projects, and a BenchmarkDotNet allocation check on the request pipeline.

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.

↩ Usually asked after

More in Types

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me