C# String Concatenation Performance, Measured
Choose between +, interpolation, StringBuilder, and direct writes in modern C#. Fix repeated copying, then verify the result with allocation benchmarks.
Someone sees this line in a review and reaches for StringBuilder:
var label = "order-" + orderId + ".json";
Someone else sees this loop and says the + operator is fine because modern compilers optimize it:
var csv = "";
foreach (var row in rows)
{
csv += FormatRow(row) + Environment.NewLine;
}
Both reviews apply a true fact to the wrong workload. C# string concatenation performance is not a contest between two keywords. It depends on whether the runtime can size one result, whether you rebuild an accumulated prefix, whether a final string is needed at all, and whether the code is hot enough for any of this to matter.
The useful rule is narrower: keep the clearest one-shot expression; avoid repeatedly copying a growing string; measure before accepting a more complex alternative.
Same output, different work
Where string construction pays for copying
One expression
prefix + id + suffix one result
compiler lowers to a suitable concatenation path
Repeated +=
result += chunk copy old + new, every pass
the growing prefix is copied again and again
StringBuilder
builder.Append(chunk) append into reusable chunks
materialize the final string once
The expensive shape is not the
+character. It is rebuilding an accumulated result after every append, when the old prefix must be copied into the next immutable string.
Why += in a loop gets expensive
.NET strings are immutable. An operation that appears to add characters cannot expand the existing
string in place; it produces another string. Microsoft's current
C# string guide
shows that += creates a new object containing the combined text and releases the old reference for
garbage collection.
That behavior is harmless for one append. It compounds when the left side already contains every previous append. If ten equally sized chunks arrive, the loop copies one chunk into the first result, roughly two chunks into the next, then three, and so on. The final output grows linearly, but the characters copied through all intermediate results form a triangle. More rows mean more copying, more short-lived strings, and more work for the garbage collector.
This is the problematic shape:
static string BuildCsv(IEnumerable<Order> orders)
{
var result = "id,total" + Environment.NewLine;
foreach (var order in orders)
{
result += $"{order.Id},{order.Total}" + Environment.NewLine;
}
return result;
}
Current compilers may combine pieces of the expression into one construction path, so it is wrong to
count an intermediate allocation for every visible +. The assignment still has to produce a new
string containing the entire old result plus the next row on every iteration. Optimizing how the row
is formatted while leaving that accumulated copy in place misses the dominant shape.
A single + expression is different
Do not turn prefix + value + suffix into a StringBuilder ritual. The
String.Concat documentation
states that C# compilers translate the concatenation operator into an appropriate Concat call.
The runtime has overloads for several inputs and can create the combined result without exposing a
chain of user-visible partial strings.
This remains direct and readable:
static string ObjectKey(string tenantId, string fileName) =>
"tenants/" + tenantId + "/files/" + fileName;
Interpolation is often clearer when values need formatting:
static string ReceiptLine(int id, decimal total) =>
$"Order {id}: {total:C}";
Modern C# uses interpolated string handlers in supported contexts. They let an API control how an
interpolated expression is assembled and, in cases such as disabled logging, whether values need to
be formatted at all. Microsoft's
interpolated string handler guide
explains that mechanism. It does not make every interpolation allocation-free: if the expression
must return a string, a result string still has to exist.
Choose between + and interpolation for intent in ordinary one-shot construction. Benchmark only
when a profiler identifies this exact call site as meaningful.
Use StringBuilder for incremental construction
The CSV loop has a natural incremental shape, so a builder fits it:
using System.Text;
static string BuildCsv(IReadOnlyCollection<Order> orders)
{
var builder = new StringBuilder(capacity: 16 + orders.Count * 32);
builder.AppendLine("id,total");
foreach (var order in orders)
{
builder.Append(order.Id)
.Append(',')
.Append(order.Total)
.AppendLine();
}
return builder.ToString();
}
Each append extends the builder's storage instead of rebuilding the whole prefix as a new string.
ToString() creates the final immutable result once. Supplying a reasonable initial capacity can
also reduce buffer growth when the approximate output size is known. Do not spend ten lines deriving
a perfect capacity: an honest estimate captures most of the benefit, while a wild overestimate can
retain more memory than the output needs.
Notice the separate Append calls. Constructing a string before passing it to Append defeats part
of the point:
builder.Append(order.Id.ToString() + "," + order.Total.ToString());
That argument must exist as a string before Append receives it. Interpolation is subtler:
current StringBuilder.Append overloads can consume an interpolated string handler without first
materializing the combined argument, but behavior depends on the target framework and overload
resolution. Separate appends make the intended construction explicit across targets. Benchmark the
exact runtime you deploy instead of generalizing from a different framework version.
Prefer Join when the separator is the job
If the complete values already exist and the only requirement is a delimiter, string.Join says
exactly what the code means:
var header = string.Join(',', columnNames);
The String.Join API provides
overloads for arrays, spans, and enumerable values. A handwritten loop must correctly avoid a
leading or trailing separator and rarely communicates the operation better.
Be careful when the input pipeline creates strings before Join sees them:
var line = string.Join(',', orders.Select(order => $"{order.Id}:{order.Total}"));
Join can assemble the final value efficiently, but each projection still formats an intermediate
string. That may be completely acceptable. If profiling says it is not, an append loop or a direct
writer gives you control over formatting without the intermediate sequence.
The fastest giant string may be no giant string
If the destination is a response body, file, or network stream, ask why the entire payload must live
as one string. Writing rows as they become available avoids both the accumulated copies and the
large final allocation:
static async Task WriteCsvAsync(
IEnumerable<Order> orders,
TextWriter writer,
CancellationToken cancellationToken)
{
await writer.WriteLineAsync("id,total".AsMemory(), cancellationToken);
foreach (var order in orders)
{
await writer.WriteAsync(order.Id.ToString(), cancellationToken);
await writer.WriteAsync(",", cancellationToken);
await writer.WriteLineAsync(order.Total.ToString(), cancellationToken);
}
}
Real export code should also choose an explicit culture and escape CSV fields correctly; those are correctness requirements, not performance decorations. The architectural point is that streaming changes the lifetime and peak-memory problem. It can begin sending data sooner and never needs a single contiguous object for the full export. This simple version still creates a small string for each numeric value; span-based formatting can remove those too if measurement justifies it.
For highly tuned formatting where the final length is known, string.Create, Span<char>, and
TryFormat can write directly into a destination. Microsoft's
creating strings guide
positions string.Create for performance-sensitive cases that know the final length and want to
avoid intermediate character buffers. These tools earn their complexity in parsers, serializers,
and other measured hot paths, not in every label or log message.
Benchmark allocations, not just elapsed time
A stopwatch around one call is mostly noise. Use a benchmarking harness that warms up the runtime, runs enough iterations, and records managed allocations. The official BenchmarkDotNet getting-started guide covers the project setup. Keep the result observable so the JIT cannot discard the work.
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CsvBenchmarks
{
private Order[] _orders = null!;
[Params(10, 1_000)]
public int Count { get; set; }
[GlobalSetup]
public void Setup() =>
_orders = Enumerable.Range(1, Count)
.Select(id => new Order(id, id * 1.25m))
.ToArray();
[Benchmark(Baseline = true)]
public string PlusEqualsLoop() => BuildCsvWithPlusEquals(_orders);
[Benchmark]
public string PreSizedBuilder() => BuildCsvWithBuilder(_orders);
}
Run the benchmark in Release mode outside a debugger. Compare at least two input sizes. The small case catches an "optimization" whose setup costs more than it saves; the larger case reveals whether repeated copying grows badly. Read both the time columns and allocated bytes. A lower mean with surprising allocation can still lose under production concurrency because garbage collection is shared work.
Then validate the benchmark against the application. A microbenchmark cannot tell you that the database takes 40 milliseconds while formatting takes 20 microseconds, or that streaming changes a client contract. It answers one controlled question; a profiler decides whether that question matters.
A practical decision table
| Workload | Start with | Reason |
|---|---|---|
| A few values, one result | + or interpolation |
Clear, direct, and lowered to modern runtime helpers |
| Existing values with a separator | string.Join |
Expresses delimiter handling without a manual loop |
| Conditional or repeated appends | StringBuilder |
Avoids rebuilding the growing prefix |
| Response, file, or network output | Write to the destination | Avoids materializing one giant intermediate string |
| Known final length on a measured hot path | string.Create or span-based formatting |
Gives direct control over the destination buffer |
This table is a starting hypothesis, not a leaderboard. String length, input count, formatting, runtime version, and destination all change the result.
Katabench's performance exercises report managed bytes alongside correctness and execution time, so the tradeoff becomes visible on the same hidden workloads that stress the implementation. The grading model explains the signal, and the algorithm track gives you places to practice removing repeated work without trading away clarity.
The habit transfers beyond strings: identify the growing intermediate, choose a construction shape
that does not repeatedly rebuild it, and prove the improvement with allocation data. That is much
more reliable than banning the + operator in code review.