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.
| Feature | string | StringBuilder |
|---|---|---|
| Namespace | System | System.Text |
| Can it change after creation? | No (immutable) | Yes (mutable) |
| 20,000 appends in a loop (my run) | 724 ms | 2 ms |
| Memory allocated for that loop | 5,460.1 MB | 2.2 MB |
| Few joins, interpolation, string.Join | Best choice | Extra object, no gain |
| Default capacity | n/a (exact size) | 16 characters |
| Share across threads | Safe (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

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

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

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

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.Joinorstring.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:
- Reverse a string in C# (char array, LINQ and StringBuilder)
- Check that a string is not null or empty
- Convert a string to a byte array
- Get the first character of each word
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.
Bijay Kumar is a renowned software engineer, accomplished author, and distinguished Microsoft Most Valuable Professional (MVP) specializing in SharePoint. With a rich professional background spanning over 15 years, Bijay has established himself as an authority in the field of information technology. He possesses unparalleled expertise in multiple programming languages and technologies such as ASP.NET, ASP.NET MVC, C#.NET, and SharePoint, which has enabled him to develop innovative and cutting-edge solutions for clients across the globe. Read more…