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.

Go typdefinition.go
// 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.

Go naiv_vs_builder.go
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))
}
Output
naiv:    312.4ms
Builder: 187µs

Die 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 -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.

MethodeSignaturZweck
WriteString(s string) (int, error)Hängt einen String an
WriteByte(c byte) errorHä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() stringLiefert den aufgebauten String (no-copy)
Len() intBisher geschriebene Bytes
Cap() intAktuelle 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.

Go write_varianten.go
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())
}
Output
Name: Hannelore
Ort: München
Status: OK

In 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.

Go grow_reservierung.go
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())
}
Output
Len=33 Cap=33
alpha
beta
gamma
delta
epsilon

Beachtenswert 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.

Go string_nocopy.go
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)
}
Output
foobar Len= 6 Cap= 8
foobar
foobar-baz

Die 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.

Go reset_pool.go
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"))
}
Output
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.

Go iowriter.go
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())
}
Output
Name: Anneliese
Alter: 42
Status: aktiv

Beachte 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.

Go copy_antipattern.go
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())
}
Output
start ende

Faustregel: *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.

Go tsv_generator.go
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))
}
Output
id	name	rolle
1	Hannelore Schubert	Lead
2	Friedrich Wagner	Backend
3	Annegret Bauer	Ops

Das 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.ItoaFprintf 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.

Go html_snippet.go
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"}))
}
Output
<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.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Das strings-Paket — String-Manipulation

Zur Übersicht