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

📚 Interview question

When should you use StringBuilder instead of string concatenation in C#? How does StringBuilder work internally, and what are the alternatives such as string.Join, string.Create and interpolation?

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.

Junior Coding Intermediate allocations csharp performance string-create stringbuilder strings

🎯 Key points

  • Use StringBuilder when the number of appends is unknown or large, typically in loops
  • For a fixed number of pieces, + or interpolation compiles to one Concat or handler call and is as fast or faster
  • StringBuilder keeps a mutable buffer made of linked chunks; it grows without copying earlier text, and ToString makes one final string
  • Default capacity is 16 characters; set an initial capacity when you can estimate the size
  • Append($"...") on StringBuilder uses an interpolated string handler, so no intermediate string is allocated (.NET 6+)
  • StringBuilder is not thread-safe; reuse via Clear() or an ObjectPool<StringBuilder> in hot paths
  • Alternatives: string.Join for collections, string.Create when the length is known, spans and TryFormat for formatting, streaming for very large output
  • Measure with BenchmarkDotNet MemoryDiagnoser rather than assuming

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Why repeated concatenation is slow

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.

2. How StringBuilder works

  • It holds a mutable char[] 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.
  • So total copying is O(n) and only one final string is allocated.

Performance tips:

  • Pass a capacity if you can estimate the size: new StringBuilder(rows.Count * 80) avoids growing.
  • Use typed overloads: Append(int), Append(decimal), Append(ReadOnlySpan<char>) — they format directly into the buffer with no boxing or temporary strings.
  • Use AppendLine, AppendJoin, Append(char, repeatCount).
  • Since .NET 6, sb.Append($"{x} {y}") uses an interpolated string handler that formats directly into the builder — no temporary string.
  • For repeated building, reuse with Clear(), or use the internal-style pattern of ObjectPool<StringBuilder> from Microsoft.Extensions.ObjectPool.

3. The alternatives and when they win

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

4. Interpolation in modern C#

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.

5. A production example

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.

🔍 What the interviewer is checking

Checks practical performance judgement on one of the most common C# tasks.

Green flags

  • Explains O(n²) copying for += in loops and why builders avoid it.
  • Knows + of fixed pieces is a single Concat.
  • Mentions capacity, interpolated Append, pooling and thread safety.
  • Suggests streaming for large output.

Red flags

  • "Always use StringBuilder."
  • Unaware of string.Join.
  • Builds 100 MB strings in memory without concern.

📚 Learn in depth

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

2. Amortised analysis

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

3. Interpolated string handlers (C# 10)

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();
  • The handler starts with a stack buffer or an 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.
  • You can pass a stack buffer explicitly: string.Create(CultureInfo.InvariantCulture, stackalloc char[64], $"...").

4. string.Create

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

5. string.Join and string.Concat

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

6. Pooling builders

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.

7. When not to build strings at all

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.

Try it yourself

  1. Benchmark +=, StringBuilder (with and without capacity), string.Join and string.Create for 10, 1,000 and 100,000 items with [MemoryDiagnoser].
  2. Index into a large StringBuilder with sb[i] in a loop and compare with GetChunks().
  3. Paste an interpolated string into SharpLab and read the generated handler code.
  4. Implement MaskCard with string.Create and measure allocations.

💻 Code

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

⚠ Common mistakes

1. += in loops

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

2. Using StringBuilder for a handful of fixed parts

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

3. Defeating StringBuilder with concatenation inside Append

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

4. Not setting capacity for large known outputs

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

5. Calling ToString() repeatedly

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

6. Indexing or Insert/Remove on large builders

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

7. Building huge strings that go straight to a stream

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.

8. Sharing a StringBuilder across threads

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

9. Building SQL or HTML with string builders

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

10. Ignoring culture when appending numbers and dates

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.

🏛 Senior insight: trade-offs

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.
  • Interpolated string handlers (C# 10) changed the advice: interpolation is now allocation-efficient for many cases.
  • Add allocation benchmarks for critical formatting code so regressions are caught in CI.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions Append($"...") or interpolation. Why: Interpolated string handlers changed the performance advice for building strings.
    How to answer

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

  2. When: The candidate mentions string.Create or Span<char> buffers. Why: The next level of allocation-free text work is spans.
    How to answer

    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.

↩ Usually asked after

More in Strings

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me