Skip to content
Katabench
Try free
8 min read The Katabench team

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

immutable strings

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

Repeated += copy pressure output grows →
A few values in one expression are not the same workload as rebuilding an accumulated string in a loop.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.