strings.Builder ist die idiomatische Antwort auf eine der häufigsten Performance-Fallen in Go: das wiederholte Konkatenieren von Strings in einer Schleife. Weil Strings in Go unveränderlich sind, erzeugt jeder s += t-Schritt einen frisch allokierten Buffer und kopiert den bisherigen Inhalt — bei n Iterationen summiert sich das zu O(n²) Bytes Kopierarbeit.
Der Builder hält stattdessen einen wachsenden []byte-Puffer, an dessen Ende Write*-Aufrufe einfach anhängen. Erst der finale Aufruf von String() materialisiert das Ergebnis — und zwar ohne zusätzlichen Kopiervorgang, weil der Builder garantiert, dass der zurückgegebene String denselben Speicher referenziert wie sein interner Puffer.
Die Definition selbst ist bewusst minimal gehalten. Das interne Feld ist nicht exportiert, weil die No-Copy-Garantie nur dann hält, wenn niemand von außen am Puffer manipuliert. Der Zero-Value — also var b strings.Builder — ist sofort einsatzbereit und benötigt keinen Konstruktor.
// strings.Builder — wachsender []byte-Puffer mit String-Output.
// Zero-Value ist nutzbar; muss nach erstem Write NICHT per Wert kopiert werden.
type Builder struct {
addr *Builder // erkannt von Copy-Checker (go vet copylocks)
buf []byte
}Das addr-Feld ist ein Selbst-Zeiger, den der Builder beim ersten Write setzt. Wird das Struct danach per Wert kopiert, weicht addr von der eigenen Adresse ab — und go vet schlägt Alarm. So wird ein klassischer Aliasing-Bug schon zur Compile-Zeit-Diagnose.
Die folgende naive Variante hängt zehntausendmal einen kurzen Suffix an. Jeder Schritt allokiert einen neuen Backing-Array und kopiert den bisherigen Inhalt — quadratischer Aufwand in der Zahl der Iterationen. Bei realer Anwendung mit dynamischem Input ist das einer der Hauptgründe für unerklärlich langsamen Code.
package main
import (
"fmt"
"strings"
"time"
)
func naiv(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "x"
}
return s
}
func mitBuilder(n int) string {
var b strings.Builder
b.Grow(n)
for i := 0; i < n; i++ {
b.WriteByte('x')
}
return b.String()
}
func main() {
const n = 50_000
t0 := time.Now()
_ = naiv(n)
fmt.Printf("naiv: %v\n", time.Since(t0))
t1 := time.Now()
_ = mitBuilder(n)
fmt.Printf("Builder: %v\n", time.Since(t1))
}naiv: 312.4ms
Builder: 187µsDie Größenordnung spricht für sich — der Builder ist hier rund drei Größenordnungen schneller. Bei größeren n öffnet sich die Schere weiter, weil das n²-Wachstum gegenüber dem linearen Builder-Pfad immer dominanter wird.
Der Methodensatz ist kompakt und an io.Writer orientiert. Alle Write*-Methoden geben aus Schnittstellengründen (int, error) zurück, der Fehler ist bei Builder aber immer nil — das Schreiben kann nicht scheitern.
| Methode | Signatur | Zweck |
|---|---|---|
WriteString | (s string) (int, error) | Hängt einen String an |
WriteByte | (c byte) error | Hängt ein einzelnes ASCII-Byte an |
WriteRune | (r rune) (int, error) | Hängt einen Unicode-Codepoint (UTF-8) an |
Write | (p []byte) (int, error) | Hängt einen []byte-Slice an |
Grow | (n int) | Reserviert mindestens n weitere Bytes |
Reset | () | Setzt Puffer auf leer zurück |
String | () string | Liefert den aufgebauten String (no-copy) |
Len | () int | Bisher geschriebene Bytes |
Cap | () int | Aktuelle Pufferkapazität |
Welche Write-Variante passt, hängt vom Input-Typ ab. WriteString ist der häufigste Pfad, weil er einen String direkt entgegennimmt — ohne den Umweg über []byte(s), der eine Allokation erzwingen würde. WriteByte ist optimal für einzelne ASCII-Zeichen wie Trennzeichen, WriteRune für beliebige Unicode-Codepoints (encodiert intern in UTF-8), und Write füllt die io.Writer-Pflicht und erlaubt direkte []byte-Anhänge.
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
b.WriteString("Name: ") // String direkt
b.WriteString("Hannelore")
b.WriteByte('\n') // ein ASCII-Byte
b.WriteString("Ort: ")
b.WriteRune('M') // einzelner Codepoint
b.WriteString("ünchen") // Umlaut als String
b.WriteByte('\n')
raw := []byte("Status: OK\n")
b.Write(raw) // []byte-Anhang
fmt.Print(b.String())
}Name: Hannelore
Ort: München
Status: OKIn der Praxis ist WriteString für Literale und Variablen die Standardwahl, WriteByte für einzelne Trenner wie '\n', ',' oder '\t'. Ein häufiges Anti-Pattern ist b.WriteString(string(r)) für ein Rune — das umgeht den effizienten UTF-8-Pfad von WriteRune.
Wenn die Zielgröße — auch nur grob — bekannt ist, lohnt sich Grow(n). Der Builder verdoppelt sonst seinen internen Puffer in mehreren Stufen, was bei großen Outputs überflüssige Kopiervorgänge verursacht. Ein einziger Grow-Aufruf vor der Schleife ersetzt diese Reallocations durch eine einzige große Allokation.
package main
import (
"fmt"
"strings"
)
func main() {
eintraege := []string{"alpha", "beta", "gamma", "delta", "epsilon"}
// Geschätzte Zielgröße: Summe der Längen plus Trenner.
size := 0
for _, s := range eintraege {
size += len(s) + 1 // +1 fuer '\n'
}
var b strings.Builder
b.Grow(size)
for _, s := range eintraege {
b.WriteString(s)
b.WriteByte('\n')
}
fmt.Printf("Len=%d Cap=%d\n", b.Len(), b.Cap())
fmt.Print(b.String())
}Len=33 Cap=33
alpha
beta
gamma
delta
epsilonBeachtenswert ist hier, dass Cap exakt der reservierten Größe entspricht — der Builder allokiert nur ein einziges Mal. Ohne Grow wäre Cap eine Zweierpotenz größer als nötig, und es wären unterwegs mehrere Kopiervorgänge passiert.
String() ist die zentrale Optimierung des Typs. Normalerweise erzeugt ein Cast string(buf) von []byte zu string eine vollständige Kopie, weil Strings unveränderlich sein müssen und das Laufzeitsystem keinen Zugriff Dritter auf den Backing-Array zulassen darf. Der Builder umgeht das per unsafe-Konstruktion: er weiß, dass nach String() niemand mehr an seinem Puffer schreibt, und gibt deshalb dieselben Bytes direkt als String-Header zurück.
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
b.WriteString("foo")
b.WriteString("bar")
s1 := b.String()
fmt.Println(s1, "Len=", b.Len(), "Cap=", b.Cap())
// Weiter schreiben ist erlaubt — Builder legt intern neuen Header an,
// s1 bleibt stabil und gehört konzeptionell zum vorherigen Puffer.
b.WriteString("-baz")
s2 := b.String()
fmt.Println(s1) // stabil
fmt.Println(s2)
}foobar Len= 6 Cap= 8
foobar
foobar-bazDie Builder-Implementierung sorgt also für die seltene Kombination: man bekommt einen sofort nutzbaren string, ohne dass jemals ein extra Kopiervorgang stattgefunden hat. Schreibt man nach String() weiter, baut der Builder seine internen Strukturen so um, dass die alten String-Werte unangetastet bleiben — die Identität ist stabil.
Reset() setzt Länge und Kapazität auf null und löst den Selbst-Zeiger, damit der Builder wieder kopierbar ist. In einer normalen Schleife ist das selten nötig, weil man typischerweise pro Ausgabe einen frischen Builder erzeugt. Sinnvoll wird Reset in Wiederverwendungs-Szenarien — etwa beim Recycling von Buildern aus einem sync.Pool, wo die bereits allokierte Kapazität erhalten bleiben soll.
package main
import (
"fmt"
"strings"
"sync"
)
var pool = sync.Pool{
New: func() any { return new(strings.Builder) },
}
func render(name string) string {
b := pool.Get().(*strings.Builder)
b.Reset() // Cap bleibt, Len = 0
defer pool.Put(b)
b.WriteString("Hallo, ")
b.WriteString(name)
b.WriteByte('!')
return b.String()
}
func main() {
fmt.Println(render("Welt"))
fmt.Println(render("Hannelore"))
}Hallo, Welt!
Hallo, Hannelore!Wichtig dabei: Reset löscht zwar die Selbst-Adresse, sodass der Builder wieder kopierbar ist — aber das zurückgegebene s := b.String() lebt mit dem alten Buffer weiter. Würde man den Builder nach Reset neu beschreiben und das alte s parallel lesen, blieben beide stabil, weil der Builder bei Bedarf einen neuen Backing-Array allokiert.
Weil Builder die Methode Write([]byte) (int, error) mit error == nil implementiert, erfüllt er io.Writer. Damit lässt sich fmt.Fprintf direkt gegen einen Builder schreiben — ohne Umweg über Sprintf und einen separaten String-Konkat-Schritt.
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
name, alter := "Anneliese", 42
fmt.Fprintf(&b, "Name: %s\n", name)
fmt.Fprintf(&b, "Alter: %d\n", alter)
fmt.Fprintln(&b, "Status: aktiv")
fmt.Print(b.String())
}Name: Anneliese
Alter: 42
Status: aktivBeachte den &b-Adressoperator — Fprintf erwartet ein io.Writer-Interface, und die Methoden hängen am Pointer-Receiver. Ein versehentlicher Wert-Cast fmt.Fprintf(b, ...) würde nicht kompilieren, weil Builder als Wert kein Write hat.
Der häufigste Bug mit Builder ist das versehentliche Kopieren per Wert — etwa durch Übergabe an eine Funktion mit Wert-Receiver. Beide Kopien würden sich denselben internen []byte-Slice teilen, was zu klassischem Aliasing-Chaos führt. Der Selbst-Zeiger im Struct erkennt das, und go vet meldet Builder passed by value.
package main
import (
"fmt"
"strings"
)
// FALSCH — Wert-Parameter kopiert den Builder.
func anhaengen(b strings.Builder, s string) strings.Builder {
b.WriteString(s)
return b
}
// RICHTIG — Pointer-Parameter.
func anhaengenP(b *strings.Builder, s string) {
b.WriteString(s)
}
func main() {
var b strings.Builder
b.WriteString("start ")
// go vet meldet hier: strings.Builder copied
_ = anhaengen(b, "X")
anhaengenP(&b, "ende")
fmt.Println(b.String())
}start endeFaustregel: *strings.Builder als Parameter, niemals strings.Builder. Wer den Builder als Feld in einem Struct hält, sollte das Struct ebenfalls per Pointer herumreichen — sonst zieht die Kopier-Falle einfach eine Ebene tiefer.
In einem Exportskript wird häufig CSV oder TSV zeilenweise generiert. Ein Builder fasst die komplette Datei im Speicher zusammen, wenn die Größe handhabbar ist. Für Streaming auf Disk wäre bufio.Writer der richtige Typ, aber für In-Memory-Snippets ist der Builder unschlagbar.
package main
import (
"fmt"
"strings"
)
type Mitarbeiter struct {
ID int
Name string
Rolle string
}
func renderTSV(daten []Mitarbeiter) string {
var b strings.Builder
// Grobe Vorab-Reservierung: 32 Bytes pro Zeile.
b.Grow(len(daten)*32 + 32)
b.WriteString("id\tname\trolle\n")
for _, m := range daten {
fmt.Fprintf(&b, "%d\t%s\t%s\n", m.ID, m.Name, m.Rolle)
}
return b.String()
}
func main() {
rows := []Mitarbeiter{
{1, "Hannelore Schubert", "Lead"},
{2, "Friedrich Wagner", "Backend"},
{3, "Annegret Bauer", "Ops"},
}
fmt.Print(renderTSV(rows))
}id name rolle
1 Hannelore Schubert Lead
2 Friedrich Wagner Backend
3 Annegret Bauer OpsDas Pattern aus Grow + Fprintf(&b, ...) ist für solche Exporte Standard. Wer noch eine Spur mehr herauskitzeln will, ersetzt das Fprintf durch explizite WriteString-Aufrufe und strconv.Itoa — Fprintf zahlt mit Reflektion-Overhead.
Für serverseitig gerenderte HTML-Schnipsel ist ein Builder ebenfalls der direkteste Pfad. Tag-Klammern und Attribute kommen als einzelne Bytes, Inhalte und Klassennamen als Strings — das gibt eine saubere Mischung aus WriteByte und WriteString.
package main
import (
"fmt"
"strings"
)
func renderListe(klasse string, items []string) string {
var b strings.Builder
b.Grow(64 + len(items)*24)
b.WriteString(`<ul class="`)
b.WriteString(klasse)
b.WriteString(`">`)
b.WriteByte('\n')
for _, it := range items {
b.WriteString(" <li>")
b.WriteString(it)
b.WriteString("</li>")
b.WriteByte('\n')
}
b.WriteString("</ul>\n")
return b.String()
}
func main() {
fmt.Print(renderListe("zutaten", []string{"Mehl", "Eier", "Zucker"}))
}<ul class="zutaten">
<li>Mehl</li>
<li>Eier</li>
<li>Zucker</li>
</ul>Für echte HTML-Ausgabe ist natürlich html/template mit automatischem Escaping die sicherere Wahl. Aber wenn der Input bereits validiert vorliegt — etwa als interne Enum-Werte — ist der Builder schneller und transparenter als jede Template-Engine.
Zero-Value einsatzbereit
var b strings.Builder ist sofort nutzbar — kein Konstruktor, kein New-Helper, keine Init-Pflicht.
Grow vor Loop bei bekannter Zielgröße
Mit b.Grow(n) vor der Schleife sinkt die Reallocation-Zahl auf eine einzige Allokation.
String() ist no-copy
Der finale String()-Aufruf gibt den internen Puffer ohne zusätzliche Kopie als Wert zurück.
Niemals per Wert kopieren nach erstem Write
Ein Selbst-Zeiger im Struct schützt vor versehentlichem Aliasing — go vet meldet den Verstoß.
Implementiert io.Writer
Damit funktionieren fmt.Fprintf(&b, ...) und alle anderen io.Writer-konsumierenden Helfer direkt.
Reset für Wiederverwendung in Pools
Reset() setzt Länge zurück, ohne die bereits allokierte Kapazität zu verlieren — ideal für sync.Pool-Recycling.
Cap zeigt aktuelle Pufferkapazität
Len() liefert die geschriebene Bytemenge, Cap() die Größe des dahinterliegenden Slices.
Builder ist nicht bytes.Buffer
Builder ist string-spezialisiert und kennt kein Read — wer bidirektional Bytes braucht, nimmt bytes.Buffer.