Working with StringBuilder for Efficient String Manipulation in C#
String Concatenation Performance
Concatenating hundreds of strings in a loop creates a new string object for each append. Strings are immutable, so "abc" + "def" allocates a new object, copies "abc", appends "def", then discards the original. Do this 10,000 times and you've allocated 10,000 temporary strings, triggering garbage collection and slowing your app.
StringBuilder solves this by maintaining a mutable buffer that grows as needed. Multiple appends reuse the same buffer, eliminating intermediate allocations. This turns O(n²) concatenation into O(n), dramatically improving performance for string-heavy operations like report generation or CSV building.
You'll learn StringBuilder's core methods (Append, Insert, Replace, Remove), when StringBuilder beats regular concatenation, capacity management for optimal performance, and real-world patterns for building strings efficiently. By the end, you'll know exactly when to reach for StringBuilder.
Appending with Append and AppendLine
Append adds content to the end of the buffer. It handles all primitive types, strings, and objects (calling ToString). Each append modifies the same buffer instead of creating new strings. This is the primary method you'll use for building strings incrementally.
AppendLine adds content followed by Environment.NewLine. Use it for line-based output like logs or reports. Both methods return the StringBuilder instance, enabling fluent chaining.
var sb = new StringBuilder();
// Append various types
sb.Append("Order ID: ");
sb.Append(12345);
sb.Append(", Total: $");
sb.Append(99.99m);
Console.WriteLine(sb.ToString());
// Fluent chaining
var report = new StringBuilder();
report.AppendLine("Sales Report")
.AppendLine("============")
.Append("Date: ").AppendLine(DateTime.Now.ToShortDateString())
.Append("Total: $").Append(1500.50m).AppendLine()
.AppendLine("End of Report");
Console.WriteLine(report.ToString());
// Loop concatenation (where StringBuilder shines)
var csv = new StringBuilder();
csv.AppendLine("Name,Age,City");
for (int i = 1; i <= 5; i++)
{
csv.Append($"User{i},");
csv.Append(20 + i);
csv.Append(',');
csv.AppendLine($"City{i}");
}
Console.WriteLine(csv.ToString());
// Output:
// Order ID: 12345, Total: $99.99
// Sales Report
// ============
// Date: 11/4/2025
// Total: $1500.50
// End of Report
// Name,Age,City
// User1,21,City1
// User2,22,City2
// ...
Chaining makes code compact and readable. Each Append returns the StringBuilder, so you can stack operations without repeating the variable name. This pattern feels natural when building multi-line text.
Modifying with Insert, Replace, and Remove
Insert adds content at a specific index without overwriting existing text. Replace swaps all occurrences of a substring with another. Remove deletes characters in a range. These methods modify the buffer in place, maintaining StringBuilder's performance advantage.
Use these when building complex strings where simple appending isn't enough. Templates, text transformations, and dynamic content generation benefit from these mutation methods.
var sb = new StringBuilder("Hello World");
// Insert at position 6
sb.Insert(6, "Beautiful ");
Console.WriteLine(sb); // Hello Beautiful World
// Replace text
sb.Replace("World", "C#");
Console.WriteLine(sb); // Hello Beautiful C#
// Remove characters (start index, length)
sb.Remove(6, 10); // Remove "Beautiful "
Console.WriteLine(sb); // Hello C#
// Chained modifications
var template = new StringBuilder("Dear [NAME], your order [ORDER_ID] is ready.");
template.Replace("[NAME]", "Alice")
.Replace("[ORDER_ID]", "#12345");
Console.WriteLine(template);
// Build and modify HTML
var html = new StringBuilder();
html.AppendLine("")
.AppendLine(" Title
")
.AppendLine(" Content
")
.AppendLine("");
html.Replace("Title", "Welcome");
html.Replace("Content", "Hello from StringBuilder");
Console.WriteLine(html);
// Output:
// Dear Alice, your order #12345 is ready.
Replace searches the entire buffer, so it's efficient for template substitution patterns. Insert and Remove work with indices, giving you precise control over content placement and removal.
Managing Capacity and Length
Capacity is the buffer size. StringBuilder automatically doubles capacity when you exceed it, which triggers allocation and copying. Length is the current content size. You can set Length to truncate content or set Capacity upfront to avoid resizes.
Setting initial capacity when you know approximate final size eliminates resize overhead. This optimization matters when building very large strings or in tight loops.
// Default capacity (usually 16)
var sb1 = new StringBuilder();
Console.WriteLine($"Default capacity: {sb1.Capacity}");
// Set initial capacity
var sb2 = new StringBuilder(100);
Console.WriteLine($"Initial capacity: {sb2.Capacity}");
// Capacity grows as needed
var sb3 = new StringBuilder();
for (int i = 0; i < 100; i++)
{
sb3.Append("x");
if (i % 20 == 0)
Console.WriteLine($"Length: {sb3.Length}, Capacity: {sb3.Capacity}");
}
// Set length to truncate
var sb4 = new StringBuilder("Hello World");
Console.WriteLine($"Original: '{sb4}', Length: {sb4.Length}");
sb4.Length = 5;
Console.WriteLine($"Truncated: '{sb4}', Length: {sb4.Length}");
// Pre-allocate for known size
var largeReport = new StringBuilder(10000); // Avoid resizes
for (int i = 0; i < 1000; i++)
{
largeReport.AppendLine($"Line {i}: Some data here");
}
Console.WriteLine($"Final length: {largeReport.Length}, Capacity: {largeReport.Capacity}");
// Output shows capacity doubling:
// Default capacity: 16
// Initial capacity: 100
// Length: 0, Capacity: 16
// Length: 21, Capacity: 32
// Length: 41, Capacity: 64
// Length: 61, Capacity: 128
// ...
Notice how capacity doubles (16, 32, 64, 128) as content grows. Each doubling allocates a new buffer and copies existing content. Pre-sizing with StringBuilder(capacity) eliminates these resizes for predictable workloads.
When StringBuilder Beats String Concatenation
Use StringBuilder when concatenating in loops or building strings incrementally over multiple operations. For a few concatenations (under 5-10), regular string concatenation with + or string.Concat is simpler and often faster due to compiler optimizations.
String interpolation and string.Join are also competitive for small cases. The crossover point where StringBuilder wins depends on string count and size, but generally happens around 10+ operations or when building strings larger than a few KB.
// GOOD: StringBuilder for loops
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Append("Item ").Append(i).AppendLine();
}
var result1 = sb.ToString();
// BAD: String concatenation in loops (creates 1000 intermediate strings)
var result2 = "";
for (int i = 0; i < 1000; i++)
{
result2 += $"Item {i}\n"; // Allocates new string each iteration
}
// GOOD: Simple concatenation for a few strings
var name = "John";
var age = 30;
var simple = $"{name} is {age} years old"; // Fine for small cases
// GOOD: string.Join for collections
var items = new[] { "Apple", "Banana", "Orange" };
var joined = string.Join(", ", items); // Better than StringBuilder for this
// GOOD: StringBuilder for building HTML/XML/JSON
var json = new StringBuilder();
json.AppendLine("{");
json.AppendLine(" \"users\": [");
for (int i = 1; i <= 5; i++)
{
json.Append(" { \"id\": ").Append(i)
.Append(", \"name\": \"User").Append(i).Append("\" }");
if (i < 5) json.Append(',');
json.AppendLine();
}
json.AppendLine(" ]");
json.AppendLine("}");
Console.WriteLine(json);
The bad example creates 1000 temporary strings. Each += allocates a new string, copies the old content, appends new text, and discards the original. StringBuilder reuses one buffer, making it vastly more efficient for loops.
Try It Yourself
Build a console app that generates a formatted table using StringBuilder's methods.
Steps
- Init:
dotnet new console -n StringBuilderDemo - Move:
cd StringBuilderDemo - Edit Program.cs with code below
- Execute:
dotnet run
using System.Text;
var table = new StringBuilder(500); // Pre-allocate
table.AppendLine("Product Sales Report");
table.AppendLine("===================");
table.AppendLine();
// Header
table.AppendLine("| Product | Units | Revenue |");
table.AppendLine("|-----------|-------|---------|");
// Data rows
var products = new[]
{
("Laptop", 15, 14985.00m),
("Mouse", 120, 3060.00m),
("Keyboard", 85, 6797.50m)
};
foreach (var (name, units, revenue) in products)
{
table.Append("| ")
.Append(name.PadRight(9))
.Append(" | ")
.Append(units.ToString().PadLeft(5))
.Append(" | $")
.Append(revenue.ToString("N2").PadLeft(6))
.AppendLine(" |");
}
table.AppendLine();
table.AppendLine($"Total Capacity Used: {table.Length} chars");
Console.WriteLine(table.ToString());
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console
Product Sales Report
===================
| Product | Units | Revenue |
|-----------|-------|---------|
| Laptop | 15 | $14,985.00 |
| Mouse | 120 | $ 3,060.00 |
| Keyboard | 85 | $ 6,797.50 |
Total Capacity Used: 234 chars