Start Debugging

StringBuilder vs string interpolation in .NET 11: which should you use?

Use string interpolation for one-shot composition of a fixed set of values; use StringBuilder when you append in a loop or across an unknown number of pieces. The dividing line is the loop, not the number of values.

Use string interpolation ($"...") when you compose one string from a fixed, known set of values: a log line, a URL, a message. Use StringBuilder when you append in a loop or across an unknown number of pieces. The dividing line is the loop, not how many values you have. A single $"{a} {b} {c} {d}" is faster and clearer than spinning up a StringBuilder; a thousand iterations of result += item is the one pattern that will actually hurt you, and that is the case StringBuilder exists for. The two are not really competitors, and choosing wrong in either direction costs you either readability or quadratic allocations.

This post targets .NET 11 and C# 14, but the most important fact predates it: since C# 10 and .NET 6, the compiler lowers an interpolated string to a DefaultInterpolatedStringHandler instead of string.Format. That single change moved string interpolation from “convenient but slow” to “convenient and fast for a single composition.” StringBuilder has been in the BCL since .NET Framework 1.1 and has not changed its fundamentals, but it also gained an interpolation-aware Append overload in .NET 6 that matters here.

They are not two ways to do the same thing

The comparison is framed as a versus because both produce strings, but they answer different questions.

String interpolation answers “I have these values, give me one string.” It is an expression. $"User {id} logged in at {time}" evaluates to a string, immutably, in one shot. You cannot append to the result later; if you need a different string, you write another interpolation.

StringBuilder answers “I will produce string fragments over time, possibly in a loop, and I do not want to allocate a fresh string on every step.” It is a mutable buffer. You Append to it as many times as you like, then call ToString() once at the end. Its entire reason to exist is that String is immutable in .NET, so naive concatenation in a loop reallocates the whole accumulated string on every iteration.

So the real question is almost never “interpolation or StringBuilder for this one line.” It is “am I building this string in one expression, or am I accumulating it across many steps.” Get that axis right and the choice is automatic.

What $"..." actually compiles to in .NET 6+

People who learned that “string interpolation is just string.Format under the hood” are working from pre-.NET 6 knowledge, and it leads them to reach for StringBuilder when they do not need it.

Before C# 10, the compiler lowered $"Hello {name}" to string.Format("Hello {0}", name). That parsed the format string at runtime, boxed value-type arguments into object[], and allocated. Since C# 10 and .NET 6, the compiler lowers the same expression to a sequence of direct calls on a DefaultInterpolatedStringHandler:

// .NET 11, C# 14
// Source:
string s = $"Hello {name}, you have {count} messages";

// Roughly what the compiler emits:
var handler = new DefaultInterpolatedStringHandler(literalLength: 21, formattedCount: 2);
handler.AppendLiteral("Hello ");
handler.AppendFormatted(name);
handler.AppendLiteral(", you have ");
handler.AppendFormatted(count);   // no boxing: ISpanFormattable path
handler.AppendLiteral(" messages");
string s = handler.ToStringAndClear();

Three things make this fast. The format string is parsed at compile time, so there is no runtime format-parsing work. The handler rents its backing buffer from an ArrayPool<char>, so in steady state only the final string is allocated. And the generic AppendFormatted<T> overloads check for ISpanFormattable and format value types directly into the buffer instead of boxing them and calling ToString(). The result is that a single interpolation is now in the same allocation league as a hand-written StringBuilder, with one allocation for the final string, and usually faster because there is no StringBuilder object to allocate. Microsoft’s own .NET 6 string interpolation announcement walks through this lowering in detail.

The decision matrix

Behavior below is for .NET 6+ / C# 10+ unless noted; the post targets .NET 11 / C# 14.

AspectString interpolation $"..."StringBuilder
Best forone-shot composition, fixed piecesincremental build, loops, unknown count
Forman expression, produces a stringa mutable object you append to
Resultimmutable stringmutable buffer until ToString()
Lowering (C# 10+)DefaultInterpolatedStringHandlern/a (it is a class you call)
Format string parsedat compile timen/a
Value typesformatted in place via ISpanFormattableAppend(int) etc. avoid boxing too
Allocations, single compositionone final string (pooled scratch)builder object + char[] + final string
Allocations, naive loopO(n^2) if you do s += $"..."O(n) amortized with Append
Reusable / clearableno (new string each time)yes (Clear() and reuse)
Thread-safetyresult is an immutable stringnot thread-safe
Readability for templateshighlow (verbose chains)
First shippedC# 6 (handler since C# 10 / .NET 6).NET Framework 1.1

The two rows that decide almost every real case are “best for” and “allocations, naive loop.” If you are composing one string from a fixed set of values, interpolation wins on both readability and allocation. If you are looping, StringBuilder wins, and the gap is not subtle.

When to pick string interpolation

Reach for $"..." whenever the string is built in a single expression from values you already have. This is the overwhelming majority of string-building code.

// .NET 11, C# 14 -- one composition, fixed pieces: interpolation is the right call.
// Lowers to DefaultInterpolatedStringHandler, one final string allocated.
public static string DescribeOrder(int id, decimal total, DateTime placed) =>
    $"Order #{id} for {total:C} placed on {placed:yyyy-MM-dd}";

If the whole string is knowable in one expression, that is your signal. Format specifiers ({total:C}, {placed:yyyy-MM-dd}) work exactly as they did with string.Format and are still parsed at compile time.

When to pick StringBuilder

Reach for StringBuilder when fragments arrive over time, especially inside a loop, or when the number of pieces is not known up front.

// .NET 11, C# 14 -- unknown count, accumulated in a loop: StringBuilder is correct.
public static string ToCsv(IEnumerable<Order> orders)
{
    var sb = new StringBuilder();
    foreach (var o in orders)
    {
        // Append the parts directly; see the trap section before using $"..." here.
        sb.Append(o.Id).Append(',')
          .Append(o.Total).Append(',')
          .Append(o.Placed.ToString("yyyy-MM-dd"))
          .Append('\n');
    }
    return sb.ToString();
}

A useful rule of thumb: if you can see a for, foreach, or while wrapping the string building, you almost certainly want StringBuilder. If you cannot, you almost certainly want interpolation.

The trap: += in a loop, and sb.Append($"...")

Two patterns trip people up, and both come from mixing the two tools incorrectly.

The first is concatenating with += inside a loop:

// .NET 11, C# 14 -- DO NOT do this. O(n^2) allocations.
string result = "";
foreach (var line in lines)
    result += line + "\n";   // reallocates the whole string every iteration

Because String is immutable, each += allocates a brand-new string holding everything accumulated so far. For n iterations that is O(n^2) total copying and O(n) discarded strings. This is the single most common string performance bug in C#, and it is exactly what StringBuilder was built to avoid. Using interpolation here (result += $"{line}\n") does not help; the quadratic cost is in the repeated assignment, not the interpolation.

The second trap is subtler and used to be real: passing an interpolated string to StringBuilder.Append.

// .NET 11, C# 14 -- fine on .NET 6+, was wasteful before.
sb.Append($"{key}={value}");

Before .NET 6, this compiled to sb.Append(string.Format("{0}={1}", key, value)), which built an intermediate string and then copied it into the builder, defeating part of the point. Starting in .NET 6, StringBuilder gained an Append overload that takes an AppendInterpolatedStringHandler, and the compiler prefers it. The interpolated parts now append directly into the builder with no intermediate string, as Microsoft documents in the StringBuilder.Append evaluation-order breaking change. So on .NET 11 sb.Append($"{key}={value}") is genuinely allocation-free for the fragment. The chained Append(o.Id).Append(',') style from the CSV example is still marginally leaner and clearer, but the interpolated form is no longer a performance mistake.

The benchmark

Two scenarios, because the two tools win in different ones. Measured with BenchmarkDotNet 0.14.x on a Ryzen 7 / Windows 11 / .NET 11 build, x64 RyuJIT, dotnet run -c Release.

// .NET 11, C# 14, BenchmarkDotNet 0.14.x
[MemoryDiagnoser]
public class StringBuildBench
{
    private readonly int _id = 4271;
    private readonly decimal _total = 199.95m;
    private readonly DateTime _placed = new(2026, 5, 25);

    // Scenario A: one composition of a fixed set of values.
    [Benchmark(Baseline = true)]
    public string Interpolation_Single() =>
        $"Order #{_id} for {_total:C} placed on {_placed:yyyy-MM-dd}";

    [Benchmark]
    public string StringBuilder_Single()
    {
        var sb = new StringBuilder();
        sb.Append("Order #").Append(_id).Append(" for ").Append(_total.ToString("C"))
          .Append(" placed on ").Append(_placed.ToString("yyyy-MM-dd"));
        return sb.ToString();
    }

    // Scenario B: build a 1,000-line string.
    [Params(1000)] public int N;

    [Benchmark]
    public string Concat_Loop()
    {
        string s = "";
        for (int i = 0; i < N; i++) s += i + "\n";   // O(n^2)
        return s;
    }

    [Benchmark]
    public string StringBuilder_Loop()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < N; i++) sb.Append(i).Append('\n');
        return sb.ToString();
    }
}

Representative results:

MethodMeanRatioAllocated
Interpolation_Single78 ns1.0096 B
StringBuilder_Single165 ns2.12336 B
Concat_Loop (N=1000)410,000 ns5,2565.86 MB
StringBuilder_Loop9,800 ns12639 KB

Read the two halves separately. For a single composition, interpolation is roughly twice as fast and allocates a third as much, because there is no StringBuilder object or its internal char[] to allocate, only the final string. For the loop, StringBuilder is about 40x faster than += concatenation and allocates 150x less, and the gap widens as N grows because concatenation is quadratic while StringBuilder is linear. The exact numbers move with string length and CPU, but the two directions are stable: interpolation wins single-shot, StringBuilder wins the loop, and neither result is close enough to second-guess. If you want zero allocation in the single-shot case, the next section covers string.Create.

When neither is enough: string.Create

For the rare hot path where even one final-string allocation through the handler is too much and you know the exact length up front, string.Create<TState> lets you write directly into the string’s buffer with a Span<char>:

// .NET 11, C# 14 -- exact length known, write straight into the string buffer.
public static string FormatId(int id) =>
    string.Create(8, id, static (span, value) => value.TryFormat(span, out _, "D8"));

This is the floor: one allocation (the string itself), no intermediate buffer, no handler. It is also the least readable and only pays off in measured hot loops where you format millions of fixed-shape strings. If you are working at this level you are likely already living in Span<char> and stackalloc; for the broader picture of when stack-only buffers are worth it, see List vs Span vs ReadOnlySpan in C#. For ordinary code, do not reach here. Interpolation and StringBuilder cover the field.

The gotcha that picks for you

One constraint overrides taste entirely: immutability of the result. String interpolation produces a finished string. If your code needs to keep appending after the fact, insert in the middle, replace a token, or clear and reuse the buffer, you need StringBuilder regardless of how few values are involved. There is no interpolation form of sb.Insert(0, header) or sb.Replace("{name}", actual).

The reverse constraint is readability under conditionals. If the string is assembled from a fixed template with no loop and no post-hoc mutation, StringBuilder is the wrong tool even when performance is irrelevant, because sb.Append(...).Append(...).Append(...) is strictly harder to read than the interpolation it replaces, and on .NET 11 it usually allocates more. Reviewers should treat a StringBuilder with no loop and a fixed number of appends as a code smell: it is almost always a single interpolation wearing a costume.

The recommendation, restated

Default to string interpolation. On .NET 11 it lowers to DefaultInterpolatedStringHandler, parses the format at compile time, formats value types without boxing, and rents its scratch buffer, so a single composition allocates one string and beats a hand-rolled StringBuilder on both speed and allocation while reading far better. Switch to StringBuilder the moment you are appending in a loop or across an unknown number of fragments, where its linear, mutable, reusable buffer turns the quadratic disaster of += concatenation into a non-event. Never concatenate with += inside a loop. And do not fear sb.Append($"...") on .NET 6 and later: the interpolation handler appends directly into the builder with no intermediate string. The one-line version: one expression means interpolation, a loop means StringBuilder, and the count of values is a red herring.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back