# Decompiling C# string interpolation and interpolated string handlers


An interpolated string is one of the most innocent-looking pieces of C# you can write — `$"Hello {name}, you have {count} messages"` — and one of the least predictable once it reaches IL. There is no interpolation instruction. The compiler rewrites every `$"..."` into ordinary method calls, and it has *three* completely different ways of doing so: a `string.Concat`, a `string.Format` with a positional template, or — on modern targets — a construct-and-append sequence against `DefaultInterpolatedStringHandler`. Which one it picks depends on your target framework, the types in the holes, and whether the receiving method opted into a handler of its own. Walking each lowering explains what a decompiler such as [Glass.NET](https://delta1labs.com/products/glass) can fold back into `$"..."`, and where it can only show you the machinery the compiler actually emitted.

## Three lowerings, one syntax

The single most useful fact about `$"..."` is that the syntax tells you almost nothing about the IL. The compiler looks at *where the string is going* and *what is in the holes* and chooses:

<figure class="my-8"><svg viewBox="0 0 680 262" role="img" aria-label="An interpolated string lowers to one of three shapes: String.Concat when every hole is already a string with no format; String.Format with a positional template and a boxed object array on older targets; and a DefaultInterpolatedStringHandler construct-append-ToStringAndClear sequence on .NET 6 and later. A modern decompiler folds the recognizable shapes back into interpolation syntax." style="width:100%;height:auto;font-family:system-ui,sans-serif"><defs><style>.b{fill:#eef4ff;stroke:#8eb4ff}.g{fill:#e6fffb;stroke:#2dd4bf}.o{fill:#fff6e9;stroke:#f0b866}.t{font:600 12px system-ui;fill:#1547e0}.m{font:11px ui-monospace,monospace;fill:#334155}.s{font:11px system-ui;fill:#475569}.a{stroke:#9aa7bd;stroke-width:1.5;fill:none}</style><marker id="hh" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0 0l6 3-6 3z" fill="#9aa7bd"/></marker></defs><rect class="b" x="8" y="110" width="120" height="44" rx="6"/><text class="m" x="20" y="130">$"a{x}b"</text><text class="s" x="20" y="146">no opcode</text><path class="a" d="M130 132 h24" marker-end="url(#hh)"/><text class="s" x="132" y="100">target + holes decide</text><rect class="g" x="156" y="12" width="214" height="42" rx="5"/><text class="t" x="166" y="30">string holes, no format</text><text class="m" x="166" y="46">String.Concat(a, x, b)</text><rect class="g" x="156" y="64" width="214" height="42" rx="5"/><text class="t" x="166" y="82">older target / boxed</text><text class="m" x="166" y="98">String.Format("a{0}b", x)</text><rect class="g" x="156" y="116" width="214" height="42" rx="5"/><text class="t" x="166" y="134">.NET 6+ string assign</text><text class="m" x="166" y="150">handler: Append… ToString</text><rect class="g" x="156" y="168" width="214" height="42" rx="5"/><text class="t" x="166" y="186">handler-typed parameter</text><text class="m" x="166" y="202">append into target, no string</text><path class="a" d="M370 33 h26 v99 h20" marker-end="url(#hh)"/><path class="a" d="M370 85 h16 v47 h30" marker-end="url(#hh)"/><path class="a" d="M370 137 h46" marker-end="url(#hh)"/><path class="a" d="M370 189 h16 v-47 h30" marker-end="url(#hh)"/><rect class="o" x="420" y="110" width="120" height="44" rx="6"/><text class="m" x="432" y="130">lowered calls</text><text class="s" x="432" y="146">per case</text><path class="a" d="M540 132 h24" marker-end="url(#hh)"/><text class="s" x="548" y="100">fold back</text><rect class="b" x="566" y="110" width="106" height="44" rx="6"/><text class="m" x="578" y="130">$"a{x}b"</text><text class="s" x="578" y="146">recognized</text></svg></figure>

The rest of this post is those four boxes, in order, with the IL each one produces and the fingerprint a decompiler uses to recognize it.

## The concat shortcut

If every hole in the string is *already* a `string`, and no hole has an alignment or format specifier, the compiler does the cheapest possible thing: it calls `String.Concat`. Given

```csharp
string Greet(string first, string last) => $"{first} {last}";
```

the body is simply a three-argument concat — the literal space is just another operand:

```
ldarg.1              // first
ldstr      " "
ldarg.2              // last
call       string System.String::Concat(string, string, string)
ret
```

This is byte-for-byte identical to what `first + " " + last` compiles to, and identical to a hand-written `string.Concat(first, " ", last)`. There is no marker that says "this was interpolation." A decompiler is free to render it as `$"{first} {last}"`, as `first + " " + last`, or as the explicit concat — all three are faithful readings of the same IL. The interpolation, as a distinct idea, did not survive compilation.

## The format template

The moment a hole is *not* a string — a number, a `DateTime`, anything that needs formatting — the classic lowering is `String.Format` with a positional template. This is what you get on `netstandard2.0`, on .NET Framework, and on any target that predates the handler type. For

```csharp
string Status(int count, double rate) => $"{count} items at {rate:P1}";
```

the compiler builds the template string, boxes the value-type arguments into an `object[]`, and calls `Format`:

```
ldstr      "{0} items at {1:P1}"
ldc.i4.2
newarr     [System.Runtime]System.Object
dup
ldc.i4.0
ldarg.1              // count
box        [System.Runtime]System.Int32      // int -> object
stelem.ref
dup
ldc.i4.1
ldarg.2              // rate
box        [System.Runtime]System.Double     // double -> object
stelem.ref
call       string System.String::Format(string, object[])
ret
```

Two things are worth noticing here, because they are exactly what you see in the decompiled output. First, your format specifier `:P1` is preserved verbatim inside the template as `{1:P1}` — the decompiler can read it straight out of the `ldstr`. Second, the value types are *boxed*; the `object[]` is the allocation cost this lowering carries, and it is the reason the newer handler form exists.

Because this is a literal `String.Format` call, it is indistinguishable from one you wrote by hand. A decompiler will show one of the two. Many print the `string.Format("{0} items at {1:P1}", count, rate)` call as-is, because it is the lower-level, more portable reading; a C#-aware decompiler may re-sugar it to `$"{count} items at {rate:P1}"` by parsing the template and mapping `{0}`,`{1}` back onto the argument order. Both are correct.

## The handler dance

On .NET 6 and later, a plain assignment of an interpolated string to a `string` no longer goes through `String.Format`. The compiler lowers it against [`DefaultInterpolatedStringHandler`](https://delta1labs.com/products/glass), a `ref struct` in `System.Runtime.CompilerServices` that accumulates the result into an internal buffer — no template parsing, no `object[]`, no boxing of the arguments into the array. The same `Status` method on a modern target decompiles to this shape:

```csharp
string Status(int count, double rate)
{
    DefaultInterpolatedStringHandler handler =
        new DefaultInterpolatedStringHandler(9, 2); // literalLength=9, formattedCount=2
    handler.AppendFormatted(count);
    handler.AppendLiteral(" items at ");
    handler.AppendFormatted(rate, "P1");
    return handler.ToStringAndClear();
}
```

Read the constructor arguments, because they are the first half of the fingerprint. The handler is created with `(int literalLength, int formattedCount)` — the total number of literal characters (`" items at "` is nine, plus any other literal runs) and the number of holes. The handler uses those to size its buffer up front. Then the body is a strict interleaving: one `AppendLiteral` per run of literal text, one `AppendFormatted` per hole, in source order, finished by exactly one `ToStringAndClear()` that produces the `string` and returns the buffer. In IL it is a sequence of `call`/`callvirt` against a local of the handler type:

```
ldloca.s   handler
ldc.i4.s   9
ldc.i4.2
call       instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
ldloca.s   handler
ldarg.1
call       instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
ldloca.s   handler
ldstr      " items at "
call       instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
...
ldloca.s   handler
call       instance string DefaultInterpolatedStringHandler::ToStringAndClear()
ret
```

That literal-length-and-count constructor, the alternating `AppendLiteral`/`AppendFormatted<T>` calls on a single handler local, and the terminating `ToStringAndClear` are a shape nothing else produces. A modern decompiler recognizes it and folds the whole block back to `$"{count} items at {rate:P1}"`. A decompiler that does not — or that is reading output where the optimizer hoisted or reordered some of the appends — shows the handler calls literally. That is not a failure: it is the honest reading, and it is often *more* informative than the sugar, because it shows you exactly where the buffer allocation and each `AppendFormatted<T>` generic instantiation happen.

## Alignment, format, and the culture trap

The `AppendFormatted` family has several overloads, and which one the compiler selects tells you what was in the braces. A bare hole `{x}` becomes `AppendFormatted<T>(T value)`. A format specifier `{x:N2}` becomes `AppendFormatted<T>(T value, string? format)` with the format as a literal string argument. An alignment `{x,10}` becomes `AppendFormatted<T>(T value, int alignment)`, and both together `{x,10:N2}` becomes `AppendFormatted<T>(T value, int alignment, string? format)`. So you can reconstruct the exact brace contents from the overload and its constant arguments — the format text is right there in the call.

There is one behavioral subtlety that the decompiled view makes visible and that bites people who refactor between the forms. `DefaultInterpolatedStringHandler` formats using the *current culture* by default, exactly like `string.Format`. If you need invariant or a specific culture, the interpolated string must be passed to an API that threads an `IFormatProvider` into the handler's constructor — the compiler then emits a constructor overload taking the provider. If you see a `DefaultInterpolatedStringHandler` constructed *without* a provider argument, the result is culture-sensitive; that is a real semantic detail hiding in the lowered IL, not noise.

## Custom handlers: when the string is never built

The handler pattern is not limited to building a `string`. Any method can accept an interpolated string directly by taking a parameter whose type is marked `[InterpolatedStringHandler]`. The compiler then constructs *that* handler and appends into it, so the interpolated string is formatted straight into the destination with no intermediate `string` at all. The clearest example is `StringBuilder.Append`:

```csharp
sb.Append($"id={id} name={name}");
```

does not build a string and append it. It lowers to the construction of a `StringBuilder.AppendInterpolatedStringHandler` that writes directly into `sb`:

```csharp
var handler = new StringBuilder.AppendInterpolatedStringHandler(8, 2, sb); // note: sb passed in
handler.AppendLiteral("id=");
handler.AppendFormatted(id);
handler.AppendLiteral(" name=");
handler.AppendFormatted(name);
sb.Append(ref handler);
```

The giveaway in the decompiled code is that the handler's constructor takes a *third* argument — the `StringBuilder` itself — and the final call is `Append(ref handler)` rather than `Append(string)`. That extra constructor argument comes from an `[InterpolatedStringHandlerArgument("sb")]` attribute on the parameter, which tells the compiler to forward the receiver into the handler.

The most striking case is a handler that can decide *not to build the string at all*. `Debug.Assert(condition, $"expensive {Describe()} message")` must not pay for the message when the assertion passes. Its handler constructor has a trailing `out bool` parameter:

```csharp
var handler = new AssertInterpolatedStringHandler(17, 1, condition, out bool shouldAppend);
if (shouldAppend)
{
    handler.AppendLiteral("expensive ");
    handler.AppendFormatted(Describe());
    handler.AppendLiteral(" message");
}
Debug.Assert(condition, ref handler);
```

When `condition` is true the constructor sets `shouldAppend = false`, the whole `if` block is skipped, and `Describe()` is never called. This is the conditional-evaluation behavior people rely on without realizing it, and it is *only* visible in the lowered form — there is no way to express "skip the interpolation" in the `$"..."` syntax itself. A decompiler that folds this back to `Debug.Assert(condition, $"...")` is correct about the result but is hiding the `shouldAppend` guard; switching to the IL or lowered-C# view in [Glass.NET](https://delta1labs.com/products/glass) is how you confirm the message really is skipped on the hot path.

## Constant interpolation disappears entirely

The last case is the one with nothing to recover. If every part of an interpolated string is a compile-time constant, the whole thing is folded at compile time:

```csharp
const string Version = "2";
const string Banner = $"Acme v{Version}";   // becomes the constant "Acme v2"
```

`Banner` is emitted as the literal string `"Acme v2"`. There is no handler, no `Format`, no `Concat` — the metadata holds a constant, and any reference to `Banner` is just `ldstr "Acme v2"`. A decompiler shows a plain string constant, because that is all that exists. The same is true of a no-hole interpolated string like `$"ready"`, which is just the literal `"ready"`. Interpolation that the compiler could resolve statically leaves no trace of having been interpolation.

## What to take from the decompiled view

String interpolation is the cleanest illustration of a theme that runs through this whole series: the surface syntax is a *request*, and the compiler answers it differently depending on context. The same `$"..."` can be a `String.Concat`, a `String.Format` with a boxed `object[]`, a `DefaultInterpolatedStringHandler` sequence, a direct append into a `StringBuilder`, or — when the inputs are constant — nothing but a baked-in literal. Only some of those carry a fingerprint strong enough to rebuild the `$"..."`: the handler dance and the custom-handler forms are recognizable; the concat and format forms are indistinguishable from hand-written calls; the constant form is already gone. A good decompiler folds back what it can prove and shows the lowered calls where it cannot — and the lowered calls are often the point, because that is where you see the boxing, the buffer allocation, the culture dependence, and the `shouldAppend` guard that the sugar quietly hides. Open the assembly in [Glass.NET](https://delta1labs.com/products/glass), switch a method to IL, and watch a one-line `$"..."` unfold into the calls the runtime actually makes. For the full workflow, see [how to decompile a .NET DLL to C#](https://delta1labs.com/blog/how-to-decompile-dotnet-dll-to-csharp).

