PowerShell StringBuilder Pattern for Large Text Generation

String concatenation with += in a loop is one of the most common and costly performance mistakes in PowerShell scripts. Every iteration allocates a brand-new string in memory — the old one plus the new fragment — so a loop that builds a ten-thousand-line report silently burns exponential memory and time. The .NET StringBuilder class eliminates this overhead by maintaining a mutable internal buffer, making large text generation a linear operation instead of a quadratic one.
Quick Answer
Instantiate with [System.Text.StringBuilder]::new(), build with .AppendLine(), and retrieve the final string with .ToString(). For anything over a few hundred concatenations in a loop, StringBuilder is measurably faster than +=.
Instantiating StringBuilder with [System.Text.StringBuilder]
You can create a StringBuilder with no arguments, with an initial capacity hint, or with seed content. Providing an initial capacity avoids internal resizing when you know the approximate output size.
# Basic — no initial content or size hint
$sb = [System.Text.StringBuilder]::new()
# With estimated capacity (characters, not bytes)
$sb = [System.Text.StringBuilder]::new(65536) # 64 KB
# With initial string content
$sb = [System.Text.StringBuilder]::new("<!-- Generated report -->`n")
# Verify type
$sb.GetType().FullName # System.Text.StringBuilder
The capacity is advisory — StringBuilder will grow automatically if you exceed it. Pre-sizing prevents the hidden cost of repeated internal array doublings when generating reports from thousands of records.
Append, AppendLine, AppendFormat Methods
The three workhorse methods cover the vast majority of text-building scenarios.
$sb = [System.Text.StringBuilder]::new()
# Append — adds text with no trailing newline
$sb.Append("Server: ") | Out-Null
$sb.Append("web01.corp.local") | Out-Null
# AppendLine — adds text followed by Environment.NewLine
$sb.AppendLine("") | Out-Null # blank line
$sb.AppendLine("Status: Online") | Out-Null
# AppendFormat — printf-style substitution
$sb.AppendFormat("CPU: {0}% Memory: {1} MB`n", 42, 1024) | Out-Null
# Pipe to Out-Null because Append returns the StringBuilder itself,
# which would flow down the pipeline and clutter output.
Always pipe .Append*() calls to Out-Null or assign to $null. Each method returns the StringBuilder instance for chaining, and if you let it flow into the pipeline, PowerShell will emit a stream of StringBuilder objects to the console.
Converting to String with .ToString()
When the buffer is fully assembled, a single .ToString() call produces the final immutable string. You can also extract a substring with .ToString(startIndex, length).
$output = $sb.ToString()
Write-Host $output
# Substring extraction (no extra allocation)
$first100 = $sb.ToString(0, 100)
# Reset the buffer for reuse without reallocating
$sb.Clear() | Out-Null
Write-Host "Length after Clear: $($sb.Length)" # 0
Benchmark: StringBuilder vs += vs -join on 10,000 Iterations
Numbers vary by machine, but the relative order is consistent. -join on a pre-populated array is fastest for fixed datasets; StringBuilder wins for streamed, iterative appending.
$iterations = 10000
$line = "This is a sample log line for benchmarking purposes.`n"
# Method 1: += concatenation
$time1 = Measure-Command {
$text = ""
for ($i = 0; $i -lt $iterations; $i++) { $text += $line }
}
# Method 2: StringBuilder
$time2 = Measure-Command {
$sb = [System.Text.StringBuilder]::new($iterations * $line.Length)
for ($i = 0; $i -lt $iterations; $i++) { $sb.AppendLine($line) | Out-Null }
$result = $sb.ToString()
}
# Method 3: Collect then -join
$time3 = Measure-Command {
$lines = for ($i = 0; $i -lt $iterations; $i++) { $line }
$result = $lines -join ""
}
[PSCustomObject]@{
PluEquals_ms = [math]::Round($time1.TotalMilliseconds)
StringBuilder_ms = [math]::Round($time2.TotalMilliseconds)
JoinArray_ms = [math]::Round($time3.TotalMilliseconds)
} | Format-Table
PluEquals_ms StringBuilder_ms JoinArray_ms
------------ ---------------- ------------
4821 38 22
When Here-Strings and -join Are Better Choices
Use a here-string (@"..."@) for fixed multi-line template text that does not need dynamic insertion in a loop. Use the -join operator when you have already collected all parts into an array and simply need to assemble them once. StringBuilder shines specifically when parts are streamed iteratively — such as reading records from a file or database and appending one line per record.
Using StringBuilder in HTML Report Generation
HTML generation combines many small appends, making it an ideal StringBuilder use case.
$servers = Get-CimInstance -ClassName Win32_ComputerSystem |
Select-Object Name, TotalPhysicalMemory
$sb = [System.Text.StringBuilder]::new(8192)
$sb.AppendLine("<table><thead><tr><th>Server</th><th>RAM (GB)</th></tr></thead><tbody>") | Out-Null
foreach ($s in $servers) {
$ramGB = [math]::Round($s.TotalPhysicalMemory / 1GB, 1)
$sb.AppendFormat("<tr><td>{0}</td><td>{1}</td></tr>`n",
[System.Web.HttpUtility]::HtmlEncode($s.Name), $ramGB) | Out-Null
}
$sb.AppendLine("</tbody></table>") | Out-Null
$html = $sb.ToString()
$html | Set-Content "server-report.html" -Encoding UTF8
Common Errors
- Appending a PSCustomObject directly:
$sb.Append($object)calls.ToString()on the object, which for aPSCustomObjectreturns the type name, not the property values. Convert to a formatted string first:$sb.AppendLine("$($obj.Name): $($obj.Value)"). - Capacity set too small causing repeated resizing: If you pre-size with a capacity that is too low, the buffer doubles internally each time it fills, adding hidden allocations. When the final size is predictable, use
$iterations * $lineLengthas the initial capacity argument.
Related Cmdlets / See Also
Wrapping Up
Replace += in tight loops with StringBuilder and the performance difference becomes immediately apparent. Pre-size the buffer when you know the approximate output length, pipe .Append*() to Out-Null, and call .ToString() once at the end. Reserve here-strings and -join for simpler scenarios where data is already fully assembled.


