StringBuilder in .NET 10: when it beats concatenation and when it does not, its chunked internal buffer, capacity, interpolated Append, pooling, thread safety, and the alternatives for each text-building scenario.
I use StringBuilder when I'm appending an unknown or large number of pieces, usually in a loop, because += creates a new string and copies everything on every iteration. For a fixed number of pieces, a + b + c or interpolation compiles to a single string.Concat or handler call, which is just as fast. Internally StringBuilder keeps a chunked mutable buffer, so it grows without recopying old text, and ToString() allocates the final string once. Alternatives are string.Join for collections, string.Create when I know the exact length, and streaming to a writer for very large output.
The rule of thumb: use StringBuilder when you build a string in a loop or from an unknown number of pieces; use concatenation or interpolation when the number of pieces is small and fixed. Then I explain why — how StringBuilder avoids the O(n²) copying of repeated concatenation — and the alternatives that are often better than both.
Strings are immutable, so result += part; allocates a new string and copies everything built so far. In a loop of n iterations, that is O(n²) character copies and n garbage strings. For small n it does not matter; for thousands of items it dominates CPU and GC.
The compiler already optimises a single expression: a + b + c + d becomes one string.Concat(a, b, c, d) call, which computes the final length and copies once. The problem is concatenation across statements or loop iterations.
StringBuilder workschar[] buffer (default capacity 16) plus a length.Append copies the new characters into the free space. When the buffer is full it does not reallocate and copy everything (as List<T> does); since .NET 4 it links a new chunk (roughly doubling total capacity, up to 8,000 characters per chunk) to a chain of previous chunks.ToString() allocates the final string once and copies all chunks into it.Performance tips:
new StringBuilder(rows.Count * 80) avoids growing.Append(int), Append(decimal), Append(ReadOnlySpan<char>) — they format directly into the buffer with no boxing or temporary strings.AppendLine, AppendJoin, Append(char, repeatCount).sb.Append($"{x} {y}") uses an interpolated string handler that formats directly into the builder — no temporary string.Clear(), or use the internal-style pattern of ObjectPool<StringBuilder> from Microsoft.Extensions.ObjectPool.| Scenario | Best tool | Why |
|---|---|---|
| 2–5 known parts | + or $"..." |
Compiled to one Concat or a handler; one allocation |
| Joining a collection with a separator | string.Join(", ", items) |
Computes length, allocates once, clearer than a loop |
| Exact-length output (IDs, formatting fixed patterns) | string.Create(length, state, (span, s) => ...) |
Writes directly into the new string's memory — zero intermediate allocations |
| Small temporary formatting on hot paths | stackalloc char[...] + TryFormat, or DefaultInterpolatedStringHandler |
No heap allocation until the final string (or none at all) |
| Unknown number of pieces in a loop | StringBuilder |
Amortised O(n) |
| Very large output (exports, reports, HTTP responses) | Stream to TextWriter, StreamWriter, PipeWriter |
Never builds the full string in memory |
| Repeated character | new string('-', 40) |
Single allocation |
Since C# 10, $"Order {id} total {total:C}" compiles to a DefaultInterpolatedStringHandler that rents a buffer from ArrayPool<char>, formats each value with ISpanFormattable.TryFormat (no boxing), and creates one final string. So interpolation is now as fast as or faster than string.Format and manual Concat for most cases — the old advice "never use interpolation for performance" is obsolete.
An email-templating service built HTML with html += $"<tr><td>{item.Name}</td>...</tr>"; for every line item. For large B2B orders (thousands of lines) rendering took seconds and allocated hundreds of MB. Switching to a StringBuilder with a capacity estimate cut rendering to milliseconds. Later we went further: the HTML was written directly to the response stream in the API that served previews, and the email path used a templating library (Razor/Fluid) that writes to a TextWriter. The general principle: if the output is going to a stream anyway, don't build the string at all.
Checks practical performance judgement on one of the most common C# tasks.
Green flags
+= in loops and why builders avoid it.+ of fixed pieces is a single Concat.Append, pooling and thread safety.Red flags
string.Join.StringBuilder internals (since .NET Framework 4)public sealed class StringBuilder
{
internal char[] m_ChunkChars; // this chunk's buffer
internal StringBuilder? m_ChunkPrevious; // linked list of previous chunks
internal int m_ChunkLength; // used chars in this chunk
internal int m_ChunkOffset; // total chars in previous chunks
internal int m_MaxCapacity;
}
The StringBuilder object you hold is the last chunk; earlier chunks are linked backwards. Appending to the end is O(1) amortised; when the current chunk fills, a new chunk is allocated with size max(minBlockSize, min(totalLength, 8000)), and the old state is moved into a "previous" node. Chunks are capped at 8,000 chars so no chunk lands on the Large Object Heap.
Trade-offs of the chunk design:
Append never copies existing data — great for building.Insert, Remove, Replace and the indexer sb[i] are slower, because they may need to walk the chunk list (indexing from the start is O(chunks)). Iterating sb[i] in a loop over a large builder is accidentally quadratic. Use GetChunks() (.NET Core 3.0+) to enumerate ReadOnlyMemory<char> chunks efficiently.ToString() allocates a string of exact length and copies each chunk.With geometric growth, the total number of characters allocated across all chunks is at most ~2× the final length, and each character is copied exactly twice: once on Append, once in ToString(). Compare with +=: character k is copied n − k times.
| Items (100 chars each) | += copies |
StringBuilder copies |
|---|---|---|
| 100 | ~0.5 million chars | 20,000 chars |
| 10,000 | ~5 billion chars | 2 million chars |
string s = $"{name} owes {amount:C}";
is lowered to:
var h = new DefaultInterpolatedStringHandler(literalLength: 6, formattedCount: 2);
h.AppendFormatted(name);
h.AppendLiteral(" owes ");
h.AppendFormatted(amount, "C");
string s = h.ToStringAndClear();
ArrayPool<char> rental.AppendFormatted<T>(T value) is generic, so value types are not boxed; if T implements ISpanFormattable, it formats straight into the buffer.StringBuilder.Append(ref AppendInterpolatedStringHandler) uses the same idea to write into the builder directly.string.Create(CultureInfo.InvariantCulture, stackalloc char[64], $"...").string.Createpublic static string MaskCard(string pan) =>
string.Create(pan.Length, pan, static (span, src) =>
{
span.Fill('*');
src.AsSpan(src.Length - 4).CopyTo(span[^4..]);
});
The runtime allocates the string once and gives you a writable Span<char> over its memory before it is published, so you can fill it without any intermediate buffer. The static lambda plus state argument avoids closure allocation. This is what high-performance BCL code (e.g. Guid.ToString, Convert.ToHexString) does internally.
string.Join and string.Concatstring.Join(separator, IEnumerable<string>) has special paths: for arrays and lists it computes the total length in one pass and copies once; for arbitrary enumerables it uses a ValueStringBuilder internally. string.Concat(ReadOnlySpan<string?>) (.NET 9 params ReadOnlySpan) avoids allocating an array for the arguments.
Allocating a new StringBuilder per request is usually fine. In very hot paths you can pool:
private static readonly ObjectPool<StringBuilder> Pool =
new DefaultObjectPoolProvider().CreateStringBuilderPool(initialCapacity: 256, maximumRetainedCapacity: 4096);
var sb = Pool.Get();
try { /* build */ return sb.ToString(); }
finally { Pool.Return(sb); }
The maximumRetainedCapacity stops a single huge builder from being kept in the pool forever.
For HTTP responses, files and exports, write to the destination incrementally:
await using var writer = new StreamWriter(response.Body, Encoding.UTF8, bufferSize: 16 * 1024);
foreach (var row in rows)
{
await writer.WriteLineAsync($"{row.Id},{row.Total.ToString(CultureInfo.InvariantCulture)}");
}
Memory stays constant regardless of row count, and the client starts receiving data sooner.
+=, StringBuilder (with and without capacity), string.Join and string.Create for 10, 1,000 and 100,000 items with [MemoryDiagnoser].StringBuilder with sb[i] in a loop and compare with GetChunks().MaskCard with string.Create and measure allocations.using System.Text;
using Microsoft.Extensions.ObjectPool;
namespace Strings;
public sealed record OrderLine(string Sku, int Qty, decimal Price);
public static class Builders
{
private static readonly ObjectPool<StringBuilder> Pool =
new DefaultObjectPoolProvider().CreateStringBuilderPool();
// Loop with unknown size: StringBuilder with an estimated capacity
public static string RenderLines(IReadOnlyList<OrderLine> lines)
{
var sb = new StringBuilder(capacity: lines.Count * 32);
foreach (var line in lines)
{
sb.Append($"{line.Sku},{line.Qty},{line.Price:F2}").AppendLine(); // handler: no temp string
}
return sb.ToString();
}
// Hot path: reuse builders from a pool
public static string RenderPooled(IReadOnlyList<OrderLine> lines)
{
var sb = Pool.Get();
try
{
sb.AppendJoin('|', lines.Select(l => l.Sku));
return sb.ToString();
}
finally
{
Pool.Return(sb); // policy clears it
}
}
// Fixed number of parts: plain interpolation is best
public static string Label(OrderLine l) => $"{l.Sku} x{l.Qty}";
// Known length: string.Create writes once
public static string OrderId(int number) => string.Create(10, number, static (span, n) =>
{
span.Fill('0');
Span<char> digits = stackalloc char[10];
n.TryFormat(digits, out int written);
digits[..written].CopyTo(span[^written..]);
});
public static void Main()
{
var lines = new List<OrderLine> { new("A1", 2, 9.5m), new("B2", 1, 120m) };
Console.Write(RenderLines(lines));
Console.WriteLine(RenderPooled(lines));
Console.WriteLine(Label(lines[0]));
Console.WriteLine(OrderId(4521)); // 0000004521
}
}
+= in loopsWhat happens: strings built in foreach loops with += for reports, emails or SQL.
Why it is wrong: O(n²) copying and many garbage strings; large outputs end up on the LOH.
Do instead: StringBuilder, string.Join, or stream to a writer.
StringBuilder for a handful of fixed partsWhat happens: new StringBuilder().Append(a).Append(" ").Append(b).ToString().
Why it is wrong: more code and an extra allocation compared to interpolation, which compiles to a single efficient call.
Do instead: use $"{a} {b}" or string.Concat.
StringBuilder with concatenation inside AppendWhat happens: sb.Append(name + ", " + city); or sb.Append(string.Format(...)).
Why it is wrong: creates a temporary string first, then copies it into the builder.
Do instead: chain appends (sb.Append(name).Append(", ").Append(city)) or use sb.Append($"{name}, {city}"), which formats directly into the builder.
What happens: new StringBuilder() used for a 2 MB output.
Why it is wrong: many chunk allocations as it grows from 16 characters.
Do instead: estimate capacity (new StringBuilder(rowCount * avgRowLength)).
ToString() repeatedlyWhat happens: sb.ToString() inside a loop to check length or contents.
Why it is wrong: each call allocates and copies the whole content.
Do instead: use sb.Length, sb.Equals(ReadOnlySpan<char>), or GetChunks().
Insert/Remove on large buildersWhat happens: for (int i = 0; i < sb.Length; i++) if (sb[i] == ',') ... or frequent Insert(0, ...).
Why it is wrong: chunked storage makes indexing and inserts walk the chain — accidentally quadratic.
Do instead: build in order; process with GetChunks(); prepend by building in reverse or using a different structure.
What happens: return Content(sb.ToString()); for a 200 MB export.
Why it is wrong: peaks memory at several times the output size and risks OutOfMemoryException.
Do instead: write directly to Response.Body with a StreamWriter or PipeWriter.
StringBuilder across threadsWhat happens: a static or singleton builder used by concurrent requests.
Why it is wrong: StringBuilder is not thread-safe; output is corrupted or exceptions are thrown.
Do instead: create per operation or use an ObjectPool<StringBuilder>.
What happens: user input appended into SQL commands or HTML markup.
Why it is wrong: SQL injection and XSS — the building technique doesn't matter, the missing encoding/parameterisation does.
Do instead: parameterised queries (EF Core, Dapper parameters) and HTML encoding (Razor, HtmlEncoder).
What happens: sb.Append(total) in machine-readable output like CSV or JSON.
Why it is wrong: uses the current culture — a server with a different culture writes 1.234,56 or different date formats.
Do instead: use sb.Append(CultureInfo.InvariantCulture, $"{total}") (or total.ToString(CultureInfo.InvariantCulture)) for machine formats.
Seniors pick the text-building tool by size, shape and destination, and prove it with measurements.
| Factor | Implication |
|---|---|
| Number of pieces known | + / interpolation |
| Destination is a stream | write directly, never build the whole string |
| Hot path, fixed width | string.Create, TryFormat into spans |
| High throughput service | pooled builders, [LoggerMessage] for logs |
| Very large builder | watch LOH and peak memory; consider chunked output |
StringBuilder is a classic interview answer; the senior addition is knowing when not to use it and that streaming beats both.$"Hello {name}" is compiled, not parsed at run time. With a string target, C# 10+ uses DefaultInterpolatedStringHandler, which formats each hole straight into a pooled buffer without boxing, then allocates the final string once. Holes take alignment and format ({total,10:C}), and formatting uses the current culture, so for machine-readable text I use string.Create(CultureInfo.InvariantCulture, $"..."). Assigned to FormattableString, it keeps the format and arguments separate, which is how EF Core's FromSql($"...") turns holes into SQL parameters. Raw $$""" strings help with JSON. Two traps: interpolating into raw SQL causes SQL injection, and interpolating into log templates destroys structured logging.
ReadOnlySpan<char> is a view over contiguous characters, which can be a string, an array or a stack buffer, made of a reference plus a length. Slicing it is free (no new string), and almost every parsing API now takes spans, so I can split a CSV line, trim fields and parse numbers and dates with zero allocations until I really need a string. It's a ref struct, so it can't be stored in a class field, boxed, captured by a lambda or held across an await. For those cases I use ReadOnlyMemory<char> and call .Span when processing. I reach for it in measured hot paths such as parsers, protocol code and high-volume ingestion.
Last updated 30 Oct 2026.