Last Updated: May 22, 2026
Strings in C# are immutable, so every += or + on a string produces a brand new string and throws the old one away. That's fine for a handful of concatenations, but inside a loop it gets expensive fast. StringBuilder is the standard library's answer: a mutable, internally growing character buffer that lets you assemble a string in place and pay for one allocation at the end instead of one per step.
The _String Immutability & Interning_ lesson covered why strings are immutable. A quick recap of what that means for performance: every operation that "modifies" a string actually allocates a new one. The original sticks around until the garbage collector reclaims it, and the new string copies every character from the old one.
That makes += in a loop quietly quadratic. Each iteration copies everything built so far, so the total work grows with the square of the number of iterations.
A receipt-building loop shows the problem clearly:
The exact number depends on the machine, but the shape is consistent: ten thousand short concatenations take hundreds of milliseconds because the loop allocates and copies on every step. The first iteration copies ~15 characters. The thousandth copies ~15,000. The ten thousandth copies ~150,000. Add it all up and you've moved roughly a gigabyte of memory around to produce a string that's only 160 KB.
A single expression like s1 + s2 + s3 is a different story. The C# compiler turns it into one String.Concat(s1, s2, s3) call, which allocates the result once. The problem is specifically += (or +) inside a loop, where each iteration sees only what's been built so far and has to start a fresh allocation.
Building a string with += in a loop is O(N^2) in both time and memory traffic. Below about 5-10 concatenations the difference doesn't matter; above that, switch to StringBuilder.
StringBuilder solves this by holding a char[] internally and writing into it directly. Each Append copies only the new characters, not everything before them, so the total work is linear in the size of the final string.
StringBuilder lives in the System.Text namespace, so any file using it needs a using directive (or relies on a global using if the project enables them).
Four constructors cover almost every case:
Four observations from that output. The default capacity is 16. Passing a size up front pre-allocates the buffer to that size. Passing an initial string sets Length to the string's length but doesn't bump the capacity unless the string is longer than 16. The third form (new StringBuilder("Order: ", 64)) seeds the buffer and pre-allocates at the same time.
Which one to use? Given a rough idea of the final size, pass it to the constructor. Picking a capacity that's slightly too large wastes a few hundred bytes; picking one that's far too small causes the buffer to grow several times, copying its contents on each growth. For a 5,000-character receipt, new StringBuilder(5000) avoids every reallocation. For an unknown size that's likely small, the default is fine.
Picking a starting capacity that's too low forces the buffer to double several times as it fills. Each doubling allocates a new char[] and copies the existing contents. For a known final size, pre-allocate once and skip all of that.
StringBuilder exposes a small set of methods that cover almost every assembly task. Each one mutates the buffer in place and returns the same StringBuilder instance so calls can be chained.
Append adds characters to the end. It has overloads for every primitive type plus string, char, char[], and object, so you can append a number or a boolean directly without converting first.
The int and double overloads format the number using the current culture, which matches how Console.WriteLine formats them. There's no need to call .ToString() on the value first.
AppendLine is Append plus a newline at the end. It uses Environment.NewLine, which is \n on Linux and macOS and \r\n on Windows. That last detail matters when the output is read back by a program that cares about line endings.
The empty AppendLine() call writes just a newline, which is how the blank line between the items and the total appears. The trailing blank line at the very end is the newline that followed "Total: $89.98".
AppendFormat accepts a format string with placeholders and writes the formatted result. It's the buffer-friendly version of String.Format.
The format string uses positional placeholders. Inside each one, the number after the comma is the column width (negative for left-align, positive for right-align) and the part after the colon is a format specifier.
AppendJoin writes a sequence of values separated by a delimiter. It's the in-place version of String.Join, and it avoids materializing an intermediate string.
There's no separator after the last item, which is exactly what you want for CSV-style output and saves you the awkward "is this the last one?" check.
Insert puts characters at a specific position and pushes the rest of the buffer to the right. The first argument is the zero-based index where the new content should appear.
Insert is handy for prefixing or for splicing a value into the middle of an already-built string. It's slower than Append for long buffers because every character to the right of the insertion point has to shift over. Append-only workloads stay fast; insert-heavy workloads don't.
Insert at position i shifts Length - i characters to the right. Inserting near the end of a long buffer is cheap. Inserting near the front is O(N). Prefer building in order with Append when possible.
Remove takes a starting index and a count, and deletes that slice. The characters after the slice shift left to fill the gap.
Like Insert, Remove shifts characters. Removing from the very end is cheap; removing from the middle pays for the shift.
Replace swaps every occurrence of one string (or char) with another. It returns the same StringBuilder and mutates in place.
That's a common pattern for tiny templates: an email body or message text with named placeholders, replaced one by one. For anything more complex, look at regular expressions or proper templating.
Clear resets Length to 0 without releasing the buffer, so a pre-allocated StringBuilder can be reused for the next assembly without reallocating.
The capacity stays at 1024 through the whole sequence. Reusing a StringBuilder like that fits building many similar strings in a row, such as one receipt per order in a batch.
ToString is what turns the buffer into an actual string. Until you call it, the data lives inside the StringBuilder and isn't accessible to anything that expects a string (like Console.WriteLine, file APIs, or string parameters).
ToString allocates a new string and copies the characters out. Calling it once at the end is fine; calling it in a loop to check intermediate state defeats the point of using a StringBuilder.
Every ToString() call allocates a fresh string and copies the buffer into it. Call it once when assembly is complete, not every iteration.
StringBuilder exposes a [i] indexer that reads and writes individual characters, just like an array.
This is the one obvious thing you can't do with a regular string. Strings are immutable, so s[0] = 'h' is a compile error. StringBuilder[0] = 'h' works fine and updates the buffer in place.
StringBuilder exposes two size-related properties, and confusing them is a common source of bugs.
char[] that backs the builder. It's how much room is allocated, not how much is used.A small program makes the distinction concrete:
The first two appends fit inside the existing eight-slot buffer, so Capacity stays at 8. The third append needs space for a ninth character. The StringBuilder doubles its capacity to 16, allocates a new char[], copies the existing eight characters into it, and then writes the new 'I'. Length goes to 9, capacity goes to 16.
That doubling is the buffer's growth strategy. Every time an Append would overflow the current capacity, the builder picks a new capacity at least double the current one (and at least big enough to hold the new content). Doubling keeps the total amortized cost linear: the buffer reallocates O(log N) times for an N-character string, and each reallocation copies what's already there, so the total characters copied across all growths sum to roughly 2N.
A diagram of what happens on overflow:
The blue cells are the old buffer. Orange is the work done during the grow: a new buffer is allocated and the existing characters are copied in. Green is the new buffer holding the same content plus the new character. Teal slots are the spare capacity left over for future appends.
There's also a MaxCapacity property, which is the upper bound the builder will allow itself to grow to. By default it's int.MaxValue (just over two billion characters), which is the largest array .NET supports anyway. You can set a smaller max via a constructor overload (new StringBuilder(initialCapacity, maxCapacity)), and any append that would exceed it throws an ArgumentOutOfRangeException. That's useful for guarding against runaway output, but it's not something most code needs to touch.
EnsureCapacity(int) grows the buffer to at least the requested size if it isn't already that big. It's the explicit way to pre-allocate when you didn't know the final size at construction time but learn it partway through.
The four items wrote 44 characters, but capacity is at the requested 256 because EnsureCapacity reserved that much up front. No reallocations happened during the loop.
Every mutating method on StringBuilder returns the same StringBuilder instance. That lets you chain calls instead of writing a fresh statement for each one.
Chaining is purely cosmetic. The compiled code is the same as writing each call on its own line. Use it when the assembly is short and reads better as a chain. For longer assembly logic with conditions and loops, separate statements are clearer.
A small e-commerce example that mixes a chain with a foreach:
The header is built with a chain because it's a fixed block. The per-item lines use a loop because the body varies with the input. The footer goes back to a chain. Each style is used where it reads best.
A short rule of thumb covers most cases.
a + b + c + d into a single String.Concat call, and string interpolation does something similar. Up to roughly 5-10 concatenations in straight-line code, the difference doesn't matter.A side-by-side benchmark of the two main approaches makes the difference visible:
Output (approximate, varies by machine):
The gap is in the thousands of times. += reallocates and copies on every iteration, so the total work is O(N^2). StringBuilder writes into a single buffer that doubles as it fills, so the total work is O(N). With N = 50_000, that ratio plays out in real time.
What about a single expression like this?
That's fine. The compiler rewrites it to a single String.Concat(...) call, which allocates one result string and copies each piece in once. Using a StringBuilder here would be slower, not faster, because of the builder's own overhead. The StringBuilder wins when the concatenations are spread out across statements or iterations and the compiler can't see them all at once.
A few common e-commerce patterns where StringBuilder fits:
One more thing to flag, since interviewers like to ask. StringBuilder is not thread-safe. Two threads appending to the same instance concurrently can corrupt the buffer or throw, with no guarantees about what the final string looks like. If multiple threads need to share an output buffer, each thread should build its own and a single thread should stitch the results together, or use a thread-safe alternative like a lock around the shared instance.
StringBuilder adds about 50-100 bytes of object overhead plus the buffer's own size. For a single short concat, that overhead exceeds the savings. For loops or unknown-size output, it pays for itself many times over.