C# & .NET (C# 14, .NET 10 LTS) Module 4 – Strings and text
StringBuilder: building long text efficiently
Why += in a C# loop copies quadratically more text, how StringBuilder, string.Join and string.Concat build long strings, and what each allocates on .NET 10.
What you will learn
- Explain why += in a loop copies quadratically more text as the result grows
- Build long text with StringBuilder, string.Join and string.Concat
- Measure allocations with GC.GetAllocatedBytesForCurrentThread instead of guessing
- Decide when a few + in one expression are the right tool
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why += in a loop copies quadratically more text as the result grows
- Build long text with StringBuilder, string.Join and string.Concat
- Measure allocations with GC.GetAllocatedBytesForCurrentThread instead of guessing
- Decide when a few + in one expression are the right tool
Before you start
On this page
A CSV report of 8,000 rows built with += allocated 1,152.9 MB for a text of 144,000 chars, and a StringBuilder
built the same text with 1.09 MB. The gap is not a constant: it grows with the report, because every += copies
everything built so far.
You will see why that copying grows with the square of the length, build long text with a StringBuilder and its
relatives, and count allocations yourself instead of guessing.
Adding to a string in a loop copies everything built so far
A string never changes, so csv += Row(i) cannot add a row to the end of csv. It makes a new string, copies the old
text into it, adds the row, and leaves the old string to the garbage collector. This program builds the same report
at four sizes and counts the bytes each one allocated:
// A CSV report of n rows built with += : every pass copies the whole report so far.
foreach (int rows in new[] { 1_000, 2_000, 4_000, 8_000 })
{
long before = GC.GetAllocatedBytesForCurrentThread();
string csv = "";
for (int i = 0; i < rows; i++)
{
csv += Row(i);
}
long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"{rows,5} rows: {csv.Length,7} chars, {bytes / 1_000_000.0,7:F1} MB allocated with +=");
}
// Each row is 18 chars: an id, a seat, a fare and a line break.
static string Row(int i) => $"{100000 + i},S{i % 72:00},{450 + i % 9}.00\n"; Output
1000 rows: 18000 chars, 18.1 MB allocated with += 2000 rows: 36000 chars, 72.2 MB allocated with += 4000 rows: 72000 chars, 288.4 MB allocated with += 8000 rows: 144000 chars, 1152.9 MB allocated with +=
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run concat.cs
Each time the rows doubled, the bytes grew four times: 18.1, 72.2, 288.4 and 1,152.9 MB. Step through the passes to see where that comes from:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first row. csv starts empty. The first += makes a new string of 18 chars, the first row, and csv refers to it.
- Copy and add. A string cannot grow, so the second += makes a new string of 36 chars: the 18 chars of the old csv copied, then the new row. The old string is garbage now.
- Again. The third pass copies the 36 chars built so far and adds 18 more: 54. Every pass copies everything the passes before it built.
- The last pass. Pass 8,000 copies 7,999 rows and adds one: 144,000 chars, the whole report, for one more row of 18.
- The total. The passes copy 18 × (1 + 2 + … + 8,000) = 576,072,000 chars, 2 bytes each: about 1,152 MB. The run counted 1,152.9 MB, and twice the rows would cost four times as much.
Pass k copies about k rows, so n passes copy about n²/2 rows. That is quadratic growth: Cost that grows with the square of the input: twice the input, four times the work. Building a string with += in a loop grows this way, because each pass copies everything built so far.: work that a 1,000-row report hardly notices takes over a gigabyte at 8,000 rows and would take 4.6 GB at 16,000. All of it is garbage the moment the next pass runs, so the program also keeps the garbage collector busy.
Checkpoint
Check: Why += in a loop is quadratic
A StringBuilder appends into room it already has
A StringBuilder: System.Text.StringBuilder: a mutable buffer of chars with spare room. Append writes into the room, the buffer grows when it is full, and ToString() makes the finished string once. keeps its text in a buffer with spare room. Append writes into that
room, and only when it is full does the builder allocate more, about doubling its capacity, without copying the text
it already holds into a new string each time. ToString() makes the one final string at the end. The same report,
built this way:
using System.Text;
// The same CSV report built with a StringBuilder, which appends into spare room.
foreach (int rows in new[] { 1_000, 2_000, 4_000, 8_000 })
{
long before = GC.GetAllocatedBytesForCurrentThread();
var sb = new StringBuilder();
for (int i = 0; i < rows; i++)
{
sb.Append(Row(i));
}
string csv = sb.ToString();
long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"{rows,5} rows: {csv.Length,7} chars, {bytes / 1_000_000.0,7:F2} MB allocated with StringBuilder");
}
// How the room grows: the capacity after each of the first appends of one row.
var watch = new StringBuilder();
Console.Write("Capacity after each append of 18 chars:");
for (int i = 0; i < 6; i++)
{
watch.Append(Row(i));
Console.Write($" {watch.Capacity}");
}
Console.WriteLine();
// Several + in one expression become one call to string.Concat.
string id = "100042", seat = "S07", fare = "457.00";
long start = GC.GetAllocatedBytesForCurrentThread();
string pair = id + "," + seat;
long three = GC.GetAllocatedBytesForCurrentThread() - start;
start = GC.GetAllocatedBytesForCurrentThread();
string row = id + "," + seat + "," + fare;
long five = GC.GetAllocatedBytesForCurrentThread() - start;
Console.WriteLine($"id + \",\" + seat \"{pair}\": {three} bytes, the new string alone");
Console.WriteLine($"id + \",\" + seat + \",\" + fare \"{row}\": {five} bytes, the string and an array of the 5 parts");
static string Row(int i) => $"{100000 + i},S{i % 72:00},{450 + i % 9}.00\n"; Output
1000 rows: 18000 chars, 0.15 MB allocated with StringBuilder 2000 rows: 36000 chars, 0.28 MB allocated with StringBuilder 4000 rows: 72000 chars, 0.55 MB allocated with StringBuilder 8000 rows: 144000 chars, 1.09 MB allocated with StringBuilder Capacity after each append of 18 chars: 32 64 64 128 128 128 id + "," + seat "100042,S07": 48 bytes, the new string alone id + "," + seat + "," + fare "100042,S07,457.00": 120 bytes, the string and an array of the 5 parts
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run builder.cs
Now the bytes doubled when the rows doubled, 0.15, 0.28, 0.55 and 1.09 MB, and about half of that is the row strings
that Row(i) makes for each line. The capacity line shows the room growing as the first rows went in: 32, 64, 64, 128,
128, 128. For an array or a list that already exists, string.Join(",", parts) and string.Concat(parts) do the same
job in one call, measuring the parts first and copying each of them once.
Building a report with += in a loop
foreach (var line in lines) report += line + "\n"; is quadratic. Write
var sb = new StringBuilder(); foreach (var line in lines) sb.Append(line).Append('\n'); string report = sb.ToString();
or, when lines is a collection, string.Join("\n", lines).
Checkpoint
Check: How a StringBuilder grows
One expression with a few parts costs one string
StringBuilder is not a better +. When one expression joins a few parts, as id + "," + seat does, the compiler
turns it into a single call to string.Concat, which measures the parts and makes the result once, and a short
interpolated string is likewise built in one buffer. The last two lines of builder.cs counted it: three parts allocated 48 bytes, the new string alone, and five
parts 120 bytes, the string and an array of the five parts that Concat was given.
| Job | Tool |
|---|---|
| a few parts, known when the code is written | + or an interpolated string |
| the parts of a collection, with a separator | string.Join |
| many appends, in a loop or across methods | StringBuilder |
| a string whose length is known in advance | string.Create, which fills the string’s own chars |
A StringBuilder for two or three parts adds an object and its buffer for nothing. The tool to reach for depends on
whether the number of pieces is fixed or grows with the data.
Key idea
Use + for a fixed handful of parts, string.Join for a collection, and a StringBuilder whenever text grows in a
loop: the cost of += grows with the square of the length.
Checkpoint
Check: When + is the right tool
Five ways to build one CSV, measured Pro: opens with a Pro, Premium or Ultimate pass
The same 10,000-row report can be built five ways, and their costs differ by three orders of magnitude. The run below times each one and counts its bytes, so you can see what each tool buys and where the remaining bytes go.
In this part:
- The same report five ways, with bytes and milliseconds
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Appending formatted values without temporary strings Pro: opens with a Pro, Premium or Ultimate pass
How a value goes into a builder matters as much as the builder itself. The run below appends the same CSV row four ways and counts what each way allocates beyond the builder’s own room, from nothing to 248 bytes a row.
In this part:
- Four ways to append one row, counted
- AppendJoin, and the builder’s other parts
2 runnable examples (C#, Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Three builder mistakes that bring the cost back, reproduced Pro: opens with a Pro, Premium or Ultimate pass
A builder removes the quadratic copying only while the code uses it as a builder. The run below reproduces three common mistakes that bring the cost back, each next to its fix, with the bytes and the time of each.
In this part:
- Three mistakes and their fixes, measured
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: a multiplication table
Build a table in one string
Write a program that builds an n × n multiplication table in one string, each number right-aligned in a column 6
chars wide, with a StringBuilder and sb.Append($"{i * j,6}"). Count the bytes with
GC.GetAllocatedBytesForCurrentThread() for n = 100 and n = 200, then build the same table with += and compare.
The questions below return to the 8,000-row report, the capacity line and the two concatenations. They open on the idea your answers show you know least, and every choice is explained with the numbers of these runs:
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · Why is a StringBuilder faster than += for building a long string?
Because a string can never change, so s += part makes a new string and copies all the text built so far into it,
every time. Over n appends that copying adds up to about n²/2 parts: twice the parts, four times the work. A
StringBuilder keeps its text in a buffer with spare room and appends into it, growing the buffer only when it is full,
so its work grows in step with the text, and ToString() makes the finished string once. Measured on .NET 10, a report
of 8,000 rows took 1,152.9 MB of allocations with += and 1.09 MB with a builder. For a fixed handful of parts in one
expression, + or an interpolated string is just as good, because the compiler makes one string.Concat call.
-
Fresher to mid
A nightly export builds its report with report += line, one line per order. The orders grew ten times in a year, and the export now takes about a hundred times longer. Explain the hundredfold, fix the code, and say how you would prove the fix.
-
Mid to senior
A team's coding checklist says: always use StringBuilder instead of + for strings. When is the rule right, when does it make code worse, and what would you write in each case?
-
Mid to senior
A support tool keeps a chat transcript in a StringBuilder and puts each new message at the top with sb.Insert(0, message). After a few thousand messages, adding one is visibly slow. Why does a StringBuilder not help here, and how do you fix it?
Key takeaways
+=in a loop copies everything built so far on every pass, so its work grows with the square of the length: four times the bytes when the rows double.- A
StringBuilderappends into spare room and makes one string withToString(), so its cost grows in step with the length. string.Joinandstring.Concatbuild text from a collection in one call, and a few+in one expression are a singlestring.Concat.- Count allocations with
GC.GetAllocatedBytesForCurrentThread()before and after a change, instead of guessing.
Terms in this lesson
- quadratic growth
- Cost that grows with the square of the input: twice the input, four times the work. Building a string with += in a loop grows this way, because each pass copies everything built so far.
- StringBuilder
- System.Text.StringBuilder: a mutable buffer of chars with spare room. Append writes into the room, the buffer grows when it is full, and ToString() makes the finished string once.
References
- Using the StringBuilder class in .NET (Microsoft Learn)
- StringBuilder class (Microsoft Learn)
- StringBuilder.Chars property (performance and chunks) (Microsoft Learn)
- StringBuilder.GetChunks method (Microsoft Learn)
- Strings (C# programming guide): immutability and StringBuilder (Microsoft Learn)
- String.Create method (Microsoft Learn)
- String.Join method (Microsoft Learn)
- StringBuilder.AppendInterpolatedStringHandler struct (Microsoft Learn)
- Tutorial: write a custom string interpolation handler (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
- Runtime configuration options for compilation (tiered compilation) (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress