Difference Between String and StringBuilder in C# (With Benchmarks)

Use string for text you build once or glue together from a few pieces, and use StringBuilder when you append inside a loop. That’s the practical difference between string and StringBuilder in C#: a string can’t change, so every += makes a full new copy.

I built the same 20,000-line CSV of US orders both ways on .NET 10. The += loop took 724 ms and allocated 5,460.1 MB; StringBuilder took 2 ms. Below are the comparison table, each test, and the cases where plain string still wins.

String vs StringBuilder in C# at a Glance

string (System.String) is an immutable reference type. StringBuilder lives in System.Text and is a mutable buffer you turn into a string at the end.

FeaturestringStringBuilder
NamespaceSystemSystem.Text
Can it change after creation?No (immutable)Yes (mutable)
20,000 appends in a loop (my run)724 ms2 ms
Memory allocated for that loop5,460.1 MB2.2 MB
Few joins, interpolation, string.JoinBest choiceExtra object, no gain
Default capacityn/a (exact size)16 characters
Share across threadsSafe (it never changes)Not thread-safe; lock it

I run every test below on the same orders CSV, one line per order (number, US state, USD amount), like 1001,CA,20.99. For where string sits among the built-in types, see the C# data types guide.

Immutability: Why Does += Create a New String?

Here’s one order line, extended once with += and once with Append. ReferenceEquals tells you whether the variable still points at the same object:

using System.Text;

string row = "1001,Jenna Morales";
string before = row;
row += ",Austin,TX";
Console.WriteLine($"string same object:  {ReferenceEquals(before, row)}");
Console.WriteLine($"before: {before}");
Console.WriteLine($"after:  {row}");

var sb = new StringBuilder("1001,Jenna Morales");
StringBuilder same = sb;
sb.Append(",Austin,TX");
Console.WriteLine($"builder same object: {ReferenceEquals(same, sb)}");
Console.WriteLine($"builder: {sb}");

Output:

string same object:  False
before: 1001,Jenna Morales
after:  1001,Jenna Morales,Austin,TX
builder same object: True
builder: 1001,Jenna Morales,Austin,TX
VS Code running Immutability.cs: ReferenceEquals returns False after string += and True after StringBuilder.Append, showing string is immutable and StringBuilder is mutable
string += gives you a new object; Append changes the same builder

The string check printed False: += copied both parts into a brand-new string, and before still holds the old text. The builder check printed True, because Append wrote into the same object.

Note: immutability is also why == on strings compares text, not references. That’s how comparing a string with multiple values works safely.

StringBuilder Methods: Append, Insert, Replace, Remove and Clear

Same order line, now edited in place. Each call changes the builder itself and returns it, so you could also chain them:

using System.Text;

var sb = new StringBuilder("1001,Jenna Morales,Austin,TX");
sb.Append(",149.99");             // amount at the end
sb.Insert(0, "2026-11-01,");      // order date first
sb.Replace("TX", "Texas");
Console.WriteLine(sb);

sb.Remove(0, 11);                 // drop the date again
Console.WriteLine(sb);
Console.WriteLine($"Length {sb.Length}, Capacity {sb.Capacity}");

sb.Clear();
Console.WriteLine($"After Clear: Length {sb.Length}, Capacity {sb.Capacity}");

Output:

2026-11-01,1001,Jenna Morales,Austin,Texas,149.99
1001,Jenna Morales,Austin,Texas,149.99
Length 38, Capacity 59
After Clear: Length 0, Capacity 45

Insert(0, ...) put the date first, Replace swapped every TX, and Remove(0, 11) cut the 11-character date back out. Doing the same on a string means calling string.Remove or Substring and storing a new copy each time.

Clear() set the length to 0 but kept 45 characters of capacity (it was 59), so the next round of appends reuses that buffer.

Performance: Timing string += vs StringBuilder with Stopwatch

Now the full orders CSV: 20,000 lines added one at a time in a for loop. Stopwatch times each version and GC.GetAllocatedBytesForCurrentThread counts the bytes it allocated:

using System.Diagnostics;
using System.Globalization;
using System.Text;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
string[] states = ["TX", "CA", "NY", "FL", "WA"];
string Row(int i) => $"{1000 + i},{states[i % 5]},{19.99m + i % 50}\n";
double Mb() => GC.GetAllocatedBytesForCurrentThread() / 1048576.0;

var sw = Stopwatch.StartNew(); double mb = Mb();
string csv = "";
for (int i = 0; i < 20_000; i++) csv += Row(i);
Console.WriteLine($"string +=     {sw.ElapsedMilliseconds,5:N0} ms {Mb() - mb,8:N1} MB");

sw.Restart(); mb = Mb();
var sb = new StringBuilder();
for (int i = 0; i < 20_000; i++) sb.Append(Row(i));
string csv2 = sb.ToString();
Console.WriteLine($"StringBuilder {sw.ElapsedMilliseconds,5:N0} ms {Mb() - mb,8:N1} MB");
Console.WriteLine($"Same CSV: {csv == csv2}, {csv.Length:N0} chars");

Output:

string +=       724 ms  5,460.1 MB
StringBuilder     2 ms      2.2 MB
Same CSV: True, 291,000 chars
VS Code running CsvBenchmark.cs: building a 20,000-line CSV with string += took 724 ms while StringBuilder.Append took 2 ms on .NET 10
A separate run in VS Code: the milliseconds shift between runs, the gap doesn’t

On my machine, += took 724 ms and StringBuilder took 2 ms for the same 291,000 characters. You’ll get different numbers on your hardware, but you won’t see the gap close.

The reason is copying. Line 20,000 copies the 19,999 lines before it, so the work grows with the square of the line count. StringBuilder only copies when it needs a new chunk of buffer.

Memory: How Much Does string += Allocate vs StringBuilder?

Look at the MB column of the same run. The finished CSV is only 0.6 MB (char is 2 bytes), yet += allocated 5,460.1 MB of temporary strings that the garbage collector had to clean up.

StringBuilder allocated 2.2 MB, and that includes the row strings both loops create. If your app builds reports or exports on a web server, that difference shows up as GC pauses under load.

StringBuilder Capacity and How It Grows

Capacity is how many characters the builder can hold before it needs more memory. Same 14-character order line, appended 20,000 times:

using System.Globalization;
using System.Text;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
var sb = new StringBuilder();
Console.WriteLine($"Default capacity: {sb.Capacity}");
int grows = 0, last = sb.Capacity;
for (int i = 0; i < 20_000; i++)
{
    sb.Append("1001,TX,19.99\n");
    if (sb.Capacity != last) { grows++; last = sb.Capacity; }
}
Console.WriteLine($"Grew {grows:N0} times to {sb.Capacity:N0}");

var sized = new StringBuilder(20_000 * 14);
for (int i = 0; i < 20_000; i++) sized.Append("1001,TX,19.99\n");
Console.WriteLine($"Presized: {sized.Capacity:N0}, length {sized.Length:N0}");

Output:

Default capacity: 16
Grew 43 times to 280,192
Presized: 280,000, length 280,000
VS Code running BuilderCapacity.cs: a new StringBuilder starts with capacity 16, grows in chunks to hold 280,000 characters, and a presized builder never grows
Default capacity 16; a presized builder never grows

A new builder starts at 16 characters and grew 43 times to 280,192. When I know the row count, I pass the expected size to the constructor and it never grows at all.

MaxCapacity is int.MaxValue by default and is read-only. Whatever the capacity, call ToString() in C# once at the end to get the finished string.

When Plain string Beats StringBuilder

StringBuilder isn’t free: it’s an extra object plus a final copy. When you already have every piece, string.Join measures the exact length and copies once. Same orders, now as an array:

using System.Globalization;
using System.Text;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
string[] states = ["TX", "CA", "NY", "FL", "WA"];
string[] rows = Enumerable.Range(0, 20_000)
    .Select(i => $"{1000 + i},{states[i % 5]},{19.99m + i % 50}").ToArray();

long start = GC.GetAllocatedBytesForCurrentThread();
string joined = string.Join("\n", rows);
long viaJoin = GC.GetAllocatedBytesForCurrentThread() - start;

start = GC.GetAllocatedBytesForCurrentThread();
var sb = new StringBuilder();
foreach (string r in rows) sb.Append(r).Append('\n');
sb.Length--;                                  // drop the last \n
string built = sb.ToString();
long viaBuilder = GC.GetAllocatedBytesForCurrentThread() - start;

Console.WriteLine($"string.Join   {viaJoin / 1024.0,8:N0} KB");
Console.WriteLine($"StringBuilder {viaBuilder / 1024.0,8:N0} KB");
Console.WriteLine($"Same text: {joined == built}");

Output:

string.Join        568 KB
StringBuilder    1,150 KB
Same text: True

string.Join allocated 568 KB, about the size of the result. The builder allocated 1,150 KB, because it grows its buffer and then copies everything into the final string.

Short joins are the other common case. The compiler turns $"{city}, {state}" or a + b + c into one string.Concat or interpolation-handler call, so there’s nothing to save.

The same idea powers turning a list into a comma-separated string.

Is StringBuilder Thread-Safe?

No. A string never changes, so any number of threads can read the same one safely. A StringBuilder has no internal locking, so parallel Append calls can corrupt it. Same order line, 20,000 parallel appends:

using System.Globalization;
using System.Text;

CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
const string Line = "1001,TX,19.99\n";      // 14 chars, immutable
var shared = new StringBuilder();
try
{
    Parallel.For(0, 20_000, i => shared.Append(Line));
    Console.WriteLine($"No lock:   {shared.Length:N0} of {20_000 * 14:N0} chars");
}
catch (Exception ex) { Console.WriteLine($"No lock:   {ex.GetType().Name}"); }

var safe = new StringBuilder();
object gate = new();
Parallel.For(0, 20_000, i => { lock (gate) safe.Append(Line); });
Console.WriteLine($"With lock: {safe.Length:N0} of {20_000 * 14:N0} chars");

Output:

No lock:   AggregateException
With lock: 280,000 of 280,000 chars
VS Code running BuilderThreads.cs: Parallel.For appending to one shared StringBuilder fails without a lock and produces all 280,000 characters with a lock
A shared StringBuilder needs a lock

Without a lock, the run threw an AggregateException that Parallel.For collected from the threads that broke the builder. With lock, all 280,000 characters arrived. A cleaner fix is one builder per thread, or build strings per item and join them at the end.

When to Use string Instead of StringBuilder

  • A fixed number of pieces, like a full name or one log message: use string concatenation or interpolation.
  • You already have a list or array of parts: use string.Join or string.Concat.
  • You append in a loop and don’t know the count up front: use StringBuilder.
  • You edit text many times (insert, replace, remove) before you’re done: use StringBuilder, then ToString() once.
  • The text is shared across threads: keep it a string, or lock the builder.

In short: immutability makes string safe and simple, but it turns loop concatenation into thousands of copies. Reach for StringBuilder when the number of changes is large or unknown, and keep string for everything else.

A few more string tasks on the site:

Frequently Asked Questions

Is StringBuilder always faster than string in C#?

No. For a few concatenations, interpolation or string.Join, plain strings are as fast and allocate less. StringBuilder pays off when you append many times in a loop.

How many concatenations before I should use StringBuilder?

There’s no exact number. A fixed, small set of pieces is fine with + or interpolation. Switch when the count depends on data, like one line per order, or when you modify the same text many times.

Which namespace is StringBuilder in?

System.Text. Add using System.Text; at the top of your file.

What is the default capacity of StringBuilder?

16 characters, with a maximum of int.MaxValue. It grows automatically; pass a starting capacity to the constructor when you know the rough size.

Does StringBuilder.ToString() copy the text?

Yes. It creates a new string from the buffer, so call it once at the end, not inside the loop. Microsoft Learn’s Using the StringBuilder class page covers ToString and the other methods.