Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: Adding to a string in a loop copies everything built so far · A StringBuilder appends into room it already has · One expression with a few parts costs one string. With a pass: 3 in-depth parts and 3 interview answers, about 10 minutes.

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

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:

concat.cs: a CSV report built with += at four sizes C# · concat/concat.cs
// 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:

Interactive · stage by stage What csv += row copies on every pass Step through the passes of the loop in concat.cs, and watch the number of chars copied grow with the report.
What csv += row copies on every pass, step 5 of 5. Showing Pass 1, Pass 2 (copies 36 chars), Pass 3 (copies 54 chars), Pass 8,000 (copies 144,000 chars), All 8,000 passes (about 1,152 MB allocated). In focus: All 8,000 passes.Pass 1copies 18 charsPass 2copies 36 charsPass 3copies 54 charsPass 8,000copies 144,000 charsAll 8,000 passesabout 1,152 MB allocated

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The first row. csv starts empty. The first += makes a new string of 18 chars, the first row, and csv refers to it.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 1,000 rows built with += allocated 18.1 MB. About how many MB would 3,000 rows allocate?

    Type a number.

    A hint

    Three times the rows means three times as many passes, each copying about three times as much.

    Show the answer: Question 1 of 2

    Answer: 163 MB (anything from 148 to 178 counts)

    The copying grows with n², so three times the rows is nine times the bytes: 9 × 18.1 is about 163 MB. The run's own sizes show the same rule, four times the bytes each time the rows doubled, from 18.1 MB to 72.2 MB.

  2. Question 2 of 2 On pass 100 of csv += Row(i), with rows of 18 chars, what does the += copy?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The 99 rows built so far, 1,782 chars, and then the new row

    Pass k copies the k − 1 rows built so far. Summed over all passes, that is about n²/2 rows.

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:

builder.cs: the same report with a StringBuilder, and two concatenations C# · builder/builder.cs
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

  1. Question 1 of 2 When a StringBuilder's buffer is full and Append is called, what happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It allocates more room, about doubling its capacity, and keeps appending

    Spare room is what makes a builder cheap: most appends just write into it, and growth happens rarely.

  2. Question 2 of 2 You have a List<string> of 50,000 names and need them in one string, one per line. Which is simplest and cheap?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: string.Join("\n", names)

    When the parts already exist as a collection, Join or Concat does the whole job in one call.

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

  1. One question A teammate asks you to replace string label = first + " " + last; with a StringBuilder. What does the run say?

    Choose one answer.

    Show the answer: One question

    Answer: Keep the +: one expression is one string.Concat call that makes just the result

    A StringBuilder pays off when the number of pieces grows with the data, not for a fixed handful of parts.

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.

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.

  1. Question 1 of 7 Why does report += line make a new string instead of extending report?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: Strings are immutable, so the old one cannot grow

    Immutability is what makes strings safe to share, and also what makes repeated += quadratic.

  2. Question 2 of 7 A loop with += takes 2 seconds for 20,000 lines. About how many seconds would 60,000 lines take, if time grows like the bytes did?

    Type a number.

    A hint

    Three times the lines, and the work grows with the square.

    Show the answer: Question 2 of 7

    Answer: 18 s (anything from 16 to 20 counts)

    The work grows with n², so 3 times the lines is 9 times the work: about 2 × 9 = 18 seconds.

  3. Question 3 of 7 When is the final string made in a StringBuilder loop?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: When ToString() is called, once, at the end

    Keep the builder until the text is finished, then call ToString() once.

  4. Question 4 of 7 How did the lesson's programs count the bytes a piece of code allocated?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: GC.GetAllocatedBytesForCurrentThread() read before and after the code, and subtracted

    Measure instead of guessing: read the thread's allocation counter around the code, and run the code more than once so one-time work does not distort the count.

  5. Question 5 of 7 The StringBuilder version allocated 0.55 MB for 4,000 rows. About how many MB would 6,000 rows allocate?

    Type a number.

    Show the answer: Question 5 of 7

    Answer: 0.82 MB (anything from 0.72 to 0.92 counts)

    A builder's bytes grow in step with the text: one and a half times the rows, about one and a half times the bytes, 1.5 × 0.55 ≈ 0.82 MB. The run showed the same straight line, 0.55 MB for 4,000 rows and 1.09 MB for 8,000.

  6. Question 6 of 7 id + "," + seat + "," + fare allocated 120 bytes, and three parts allocated 48. What was the extra?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: An array holding the five parts, which string.Concat was given

    One expression is still one Concat call and one result string, plus, for many parts, the array of references.

  7. Question 7 of 7 Which pairing of job and tool is right?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: A greeting from a title and a name: an interpolated string

    Choose by whether the number of pieces is fixed (+, interpolation) or grows with the data (StringBuilder, Join).

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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 StringBuilder appends into spare room and makes one string with ToString(), so its cost grows in step with the length.
  • string.Join and string.Concat build text from a collection in one call, and a few + in one expression are a single string.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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.