C# StringBuilder vs String Concatenation: Performance, Allocations, and Interpolated Handlers
C# StringBuilder is one of those APIs that every developer learns about early -- and then sometimes forgets to use when they should. Choosing between StringBuilder and string concatenation seems simple on the surface, but the full picture is more nuanced. .NET 6 added interpolated string handlers, .NET 5+ added Span<char> integration, and modern compiler optimizations change the math in ways that are not always obvious.
This guide walks through how each approach works, when each is appropriate, and how to profile and measure the difference in your own code.
The Core Problem: String Immutability and Allocations
As covered in the strings fundamentals, .NET strings are immutable. Every apparent modification creates a new string object. In a tight loop, this becomes expensive fast:
// ❌ Quadratic allocation -- each += allocates a new string
var result = "";
for (var i = 0; i < 1000; i++)
{
result += i.ToString(); // Creates a new string every iteration
}
With 1,000 iterations, this creates roughly 1,000 intermediate strings on the heap, totaling a combined size of O(N²) bytes. The garbage collector must collect all but the final one.
String Concatenation: When It Is Fine
String concatenation with + is not always bad. The C# compiler is smart about constant folding and simple cases:
// ✅ Compiler folds these into a single string literal at compile time
const string Hello = "Hello" + ", " + "World!";
// ✅ Single concatenation -- compiled as string.Concat call, one allocation
var greeting = "Hello, " + firstName + "!";
The compiler compiles a + b + c + d as string.Concat(a, b, c, d) -- a single call that allocates exactly one string. This is not slow. The problem only arises when you concatenate in a loop, or when you build up a string across many statements.
Guidelines for Concatenation
- OK for a fixed number of parts (up to ~5-6 pieces)
- OK for one-off formatting operations
- Avoid inside loops
- Avoid when the number of parts is dynamic or unbounded
String Interpolation: Modern and Compiler-Optimized
String interpolation is the preferred syntax for formatting in modern C#:
var name = "Nick";
var count = 42;
// Compiled as string.Format or string.Concat depending on content
var message = $"Hello, {name}! You have {count} messages.";
In simple cases (no format specifiers, no complex expressions), the compiler may optimize $"Hello, {name}" into string.Concat("Hello, ", name) rather than a string.Format call. This is a meaningful difference -- string.Concat avoids boxing and the parsing overhead of format strings.
StringBuilder: The Right Tool for Loop-Heavy Building
StringBuilder maintains an internal char[] buffer. Append operations write into this buffer without allocating new strings. Only the final ToString() call allocates the result string:
using System.Text;
// ✅ StringBuilder -- one final allocation
var sb = new StringBuilder(capacity: 4096); // Pre-size to avoid internal resizing
for (var i = 0; i < 1000; i++)
{
sb.Append(i);
if (i < 999)
{
sb.Append(", ");
}
}
var result = sb.ToString();
Key StringBuilder API
var sb = new StringBuilder();
sb.Append("Hello"); // Append string
sb.Append(42); // Append int (no boxing in .NET 5+)
sb.AppendLine("World"); // Append string + newline
sb.AppendFormat("{0:F2}", pi); // Formatted append
sb.Insert(0, "START: "); // Insert at position
sb.Remove(0, 7); // Remove characters
sb.Replace("old", "new"); // Replace all occurrences
int length = sb.Length; // Current content length
sb.Length = 0; // Clear without reallocating buffer (reuse trick)
var text = sb.ToString(); // Produce final string (single allocation)
var segment = sb.ToString(5, 3); // Substring of the builder
Pre-sizing the Buffer
If you have an estimate of the final string length, pre-size the StringBuilder to avoid internal array resizing:
// If you expect roughly 2000 characters, start with capacity 2048
var sb = new StringBuilder(capacity: 2048);
The default capacity is 16 characters. If you append more than 16, the internal array is doubled -- this is fine for occasional use but is an avoidable cost in performance-sensitive code.
Interpolated String Handlers (.NET 6+)
.NET 6 introduced DefaultInterpolatedStringHandler, a struct that the C# compiler can use for string interpolation in certain contexts. This is a meaningful performance improvement for allocation-heavy interpolation.
When you write $"Hello, {name}!", the compiler may convert this to a DefaultInterpolatedStringHandler usage -- a struct that builds the string incrementally, potentially using a stack-allocated buffer for small strings. The exact lowering is context-dependent: the compiler applies the optimization selectively, not on every interpolated string.
// In .NET 6+, this may use DefaultInterpolatedStringHandler internally
var message = $"Processing item {index} of {total}: {name}";
The improvement is most significant when you pass interpolated strings to APIs that accept ref DefaultInterpolatedStringHandler -- like custom loggers or formatters designed to avoid allocation entirely.
Custom Interpolated String Handlers
You can write your own interpolated string handler to intercept interpolated strings and route them directly into a StringBuilder, avoiding intermediate string allocations:
using System.Runtime.CompilerServices;
using System.Text;
namespace StringDemo;
[InterpolatedStringHandler]
public ref struct StringBuilderInterpolatedHandler
{
private readonly StringBuilder _builder;
public StringBuilderInterpolatedHandler(int literalLength, int formattedCount, StringBuilder builder)
{
_builder = builder;
}
public void AppendLiteral(string s) => _builder.Append(s);
public void AppendFormatted<T>(T value) => _builder.Append(value);
public void AppendFormatted<T>(T value, string? format)
where T : IFormattable
{
_builder.Append(value?.ToString(format, null));
}
}
public static class BuilderExtensions
{
public static void AppendInterpolated(
this StringBuilder sb,
[InterpolatedStringHandlerArgument("sb")] StringBuilderInterpolatedHandler handler)
{
// handler has already done the work via AppendLiteral/AppendFormatted
}
}
This pattern allows you to call sb.AppendInterpolated($"Hello, {name}!") and have the interpolation go directly into the StringBuilder without any intermediate string allocation.
string.Create(): Zero-Allocation String Construction
For cases where you need to build a string efficiently without StringBuilder, string.Create() is the gold standard:
namespace StringDemo;
public static class KeyBuilder
{
public static string BuildKey(int tenantId, string userId)
{
// Exact final size known upfront -- one allocation
return string.Create(
userId.Length + 10,
(tenantId, userId),
static (span, state) =>
{
var (id, user) = state;
id.TryFormat(span, out int written);
span[written] = ':';
user.AsSpan().CopyTo(span[(written + 1)..]);
});
}
}
string.Create() allocates the exact string once and gives you a Span<char> to write into. There are no intermediate strings, no buffer copies. This is ideal when you know the final size ahead of time.
Span for In-Place Manipulation
Span<char> enables you to manipulate characters in place on the stack:
// Convert to uppercase without heap allocation
Span<char> buffer = stackalloc char[64];
"hello world".AsSpan().CopyTo(buffer);
buffer[.."hello world".Length].ToUpperInvariant(buffer);
var upper = buffer[.."hello world".Length].ToString(); // One allocation
This approach avoids all intermediate string objects. The final ToString() call is the only allocation.
Performance Comparison
Here is a rough comparison of the approaches for building a string from 1,000 integer parts:
| Approach | Allocations | Notes |
|---|---|---|
+= loop |
~1,000 strings | O(N²) total bytes allocated |
string.Concat() |
1 string | Only valid for fixed, small N |
StringBuilder |
1 string + internal buffer | The standard solution |
string.Create() |
1 string exactly | Best when size is known |
Span<char> on stack |
1 string (ToString) | Best for ≤~256 chars |
For most application code, StringBuilder is the right answer. string.Create() and Span<char> are optimizations for performance-critical code paths -- parsers, formatters, serializers.
Real-World Example: Building a CSV Line
using System.Text;
namespace StringDemo;
public static class CsvBuilder
{
public static string BuildLine(IReadOnlyList<string> fields)
{
if (fields.Count == 0)
{
return string.Empty;
}
// Estimate capacity: each field + quote chars + commas
var estimatedCapacity = fields.Sum(f => f.Length + 3);
var sb = new StringBuilder(estimatedCapacity);
for (var i = 0; i < fields.Count; i++)
{
if (i > 0)
{
sb.Append(',');
}
var field = fields[i];
bool needsQuoting = field.Contains(',') || field.Contains('"') || field.Contains('
');
if (needsQuoting)
{
sb.Append('"');
sb.Append(field.Replace(""", """"));
sb.Append('"');
}
else
{
sb.Append(field);
}
}
return sb.ToString();
}
}
This is clean, readable, and efficient. Pre-sizing with estimatedCapacity prevents internal resizing in most cases.
When the Performance Difference Does Not Matter
It is worth stating plainly: for many application code paths, the difference between StringBuilder and concatenation does not matter. If you are building a short error message, a log line for a one-off operation, or a display string for a UI control, the allocation cost is negligible compared to I/O, database queries, or HTTP calls.
Optimize where it matters. Profile before you optimize. The factory method pattern best practices in C# article discusses similar pragmatism in design decisions -- the best pattern is the one that fits the context, not the theoretically optimal one.
When you do have a performance-sensitive path, look at the broader context too. Code organization matters -- feature slicing in C# can help you isolate high-performance paths and apply optimizations only where needed.
The strategy pattern real-world example in C# is a natural fit for string building strategies -- you can swap between StringBuilder-based and Span-based implementations at runtime depending on the use case.
For AI and search features, consider how string building performance affects your embeddings pipeline. The Build a Semantic Search Engine with Semantic Kernel in C# guide shows how text processing feeds into vector search -- efficient string building matters for throughput.
Summary: Decision Guide
| Scenario | Use |
|---|---|
| Fixed small number of parts | + operator or interpolation |
| Loop with many iterations | StringBuilder |
| Known exact size | string.Create() |
| Short strings, hot path | Span<char> + stackalloc |
| Dynamic format strings | StringBuilder or string.Format |
| Structured logging | Use a logging framework with structured data |
FAQ
Is string.Concat faster than StringBuilder for a small number of parts?
Yes. string.Concat with 2-5 arguments is typically faster because it allocates exactly one string with no internal buffer management overhead. StringBuilder shines when you have many concatenation steps -- especially in loops.
Does string interpolation use StringBuilder internally?
Not by default. The C# compiler converts simple interpolation to string.Concat or string.Format depending on the content. In .NET 6+, some contexts use DefaultInterpolatedStringHandler for better performance. The compiler makes this decision automatically.
What is the capacity parameter in StringBuilder?
It sets the initial size of the internal character buffer. The default is 16. If your final string will be larger, pre-sizing prevents internal array resizing (which involves allocation and copying). A good rule of thumb: estimate the maximum expected length and pass it as the initial capacity.
Can I reuse a StringBuilder instance?
Yes. Set sb.Length = 0 to reset the content without releasing the internal buffer. This is useful in tight loops where you build and flush strings repeatedly.
What is DefaultInterpolatedStringHandler?
It is a .NET 6+ struct that the compiler uses for string interpolation. It can build strings in a stack-allocated buffer without heap allocation. You can write custom interpolated string handlers that the compiler targets automatically by applying the [InterpolatedStringHandler] attribute.
When should I use string.Create instead of StringBuilder?
Use string.Create() when you know the exact final length upfront. It allocates exactly one string and gives you a Span<char> to write directly. StringBuilder is better when the final length is unknown or variable.
How does Span compare to StringBuilder for performance?
Span<char> with stackalloc is the highest-performing option for short strings (up to a few hundred characters) because it uses stack memory only. For longer strings, you need a heap allocation anyway, and StringBuilder is cleaner. Use benchmarks to verify the trade-off for your specific case.

