Das fmt-Paket ist die komfortabelste Schnittstelle für formatierte Ausgabe in Go — und genau diese Bequemlichkeit hat einen Preis. Jeder Sprintf-Aufruf alloziert mindestens den Ergebnis-String, jeder %v zwingt die Laufzeit zu Reflection auf den übergebenen Wert, und der Format-String wird bei jedem Aufruf neu geparst. In normalen Anwendungspfaden ist das vernachlässigbar; in Hot-Paths — Loggern, Hot-Loops, Serializern — summieren sich diese Mikro-Kosten zu messbaren GC-Drücken und Latenz-Spitzen.

Diese Seite zeigt, wo Allokationen tatsächlich entstehen, welche Familien (Sprintf, Fprintf, Appendf) welche Profile haben, wann ein Wechsel zu strconv lohnt und wie ein Hot-Path-tauglicher Formatierungs-Pfad aufgebaut wird. Alle Benchmark-Zahlen sind typische Größenordnungen auf modernem amd64 — die exakten Werte schwanken, das Verhältnis bleibt stabil.

Intern unterhält fmt einen Pool von pp-Strukturen (printer state) und wiederverwendbaren Byte-Buffern. Dieser Pool dämpft sehr viele Allokationen weg — der temporäre Arbeitsbuffer für die Formatierung kostet praktisch nichts. Trotzdem bleiben drei Quellen, die nicht im Pool versteckt werden können.

Die erste ist das Ergebnis selbst: Sobald eine Funktion einen string zurückgibt (Sprintf, Sprint, Errorf), muss dieser String auf den Heap. Die zweite ist das Boxing in any: Jeder Wert, der als Interface-Parameter übergeben wird, braucht eine Interface-Repräsentation — Werttypen werden dazu in viele Fälle heap-alloziert. Die dritte sind Reflection-Pfade: Komplexe %v-Formate auf Structs, Slices oder Maps können zusätzliche Zwischen-Allokationen verursachen.

Wer Allokationen messen will, nutzt go test -bench=. -benchmem. Die Spalte allocs/op ist der ehrliche Indikator, nicht ns/op allein.

Die drei großen Formatierungs-Familien haben unterschiedliche Allokations-Profile, weil sie unterschiedliche Senken bedienen. Die folgende Tabelle zeigt typische Größenordnungen für fmt.Xprintf("user=%s id=%d", "alice", 42):

Variantens/opallocs/opSenke
fmt.Sprintf~150 ns1 alloc (das Ergebnis)string
fmt.Fprintf(&buf, …) mit bytes.Buffer~120 ns0 allocs (Buffer wiederverwendet)io.Writer
fmt.Appendf(buf, …) mit buf = buf[:0]~110 ns0 allocs (Slice wiederverwendet)[]byte

Die Faustregel: Wenn das Ergebnis sowieso in einen Buffer/Writer wandert, ist Sprintf der teuerste Weg, weil die string-Zwischenstufe sinnlos alloziert wird.

Der folgende Benchmark stellt die drei Varianten direkt gegenüber. Wichtig ist, dass Buffer und Slice wiederverwendet werden — der erste Lauf alloziert immer, danach ist es Pool-Recycling.

Go bench_fmt_test.go
package fmtbench

import (
    "bytes"
    "fmt"
    "testing"
)

func BenchmarkSprintf(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("user=%s id=%d", "alice", 42)
    }
}

func BenchmarkFprintfBuffer(b *testing.B) {
    var buf bytes.Buffer
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf.Reset()
        fmt.Fprintf(&buf, "user=%s id=%d", "alice", 42)
    }
}

func BenchmarkAppendf(b *testing.B) {
    buf := make([]byte, 0, 64)
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf = buf[:0]
        buf = fmt.Appendf(buf, "user=%s id=%d", "alice", 42)
    }
}
Output
BenchmarkSprintf-8        7821534    152 ns/op    32 B/op    1 allocs/op
BenchmarkFprintfBuffer-8  9842117    121 ns/op     0 B/op    0 allocs/op
BenchmarkAppendf-8       10923441    109 ns/op     0 B/op    0 allocs/op

Appendf ist konsistent vorne, weil keine io.Writer-Indirektion über Write([]byte) läuft — der Code schreibt direkt in den Slice. Bei wenigen Verben ist der Vorsprung klein, bei vielen Aufrufen pro Sekunde wird er sichtbar.

Jeder Parameter, der an fmt.*printf geht, wird als any (also interface{}) übergeben. Das hat zwei Konsequenzen: Erstens muss der Wert in das Interface eingepackt werden (Boxing), zweitens muss fmt zur Laufzeit per Type-Switch und teils Reflection entscheiden, wie er formatiert wird. Für %d auf einem int ist das ein kurzer Type-Switch — für %v auf einem unbekannten Struct landet fmt in reflect.Value.Interface()-Pfaden, die deutlich teurer sind.

Praktisch heißt das: %d und %s sind günstig, %v auf Primitiven ist günstig, %v auf Structs/Slices/Maps ist messbar teurer und kann zusätzliche Allokationen erzeugen. Bei reinen Zahlen-Pipelines lohnt der direkte Weg über strconv, weil dort kein Interface, kein Format-String-Parse und kein Type-Switch stattfinden.

Wer nur eine Zahl in einen String verwandeln will, zahlt mit fmt.Sprintf("%d", n) den vollen Preis: Boxing in any, Format-String-Parse, Type-Switch, Ergebnis-Allokation. strconv.Itoa(n) ist ein direkter int → string-Pfad ohne Reflection — und entsprechend schneller.

Go bench_strconv_test.go
package fmtbench

import (
    "fmt"
    "strconv"
    "testing"
)

func BenchmarkSprintfInt(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("%d", 1234567)
    }
}

func BenchmarkStrconvItoa(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = strconv.Itoa(1234567)
    }
}

func BenchmarkStrconvAppendInt(b *testing.B) {
    buf := make([]byte, 0, 16)
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf = buf[:0]
        buf = strconv.AppendInt(buf, 1234567, 10)
    }
}
Output
BenchmarkSprintfInt-8        12834521    98.4 ns/op    16 B/op    2 allocs/op
BenchmarkStrconvItoa-8      131245678    9.1 ns/op     8 B/op    1 allocs/op
BenchmarkStrconvAppendInt-8 198342156    6.0 ns/op     0 B/op    0 allocs/op

Die zweite Allokation bei Sprintf ist das Boxing von 1234567 in any — bei größeren Integern, die nicht im Small-Int-Cache liegen, sichtbar als zusätzliche Allokation.

Eine häufige Annahme: fmt erkennt wiederkehrende Format-Strings und cached die Parse-Ergebnisse. Das ist nicht der Fall. Jeder Aufruf von Sprintf("user=%s id=%d", ...) durchläuft den Format-String erneut Zeichen für Zeichen, identifiziert die Verben und ruft die passenden Formatter auf. Es gibt keinen sync.Map-Cache pro Format-Konstante.

Praktisch bedeutet das zweierlei: Erstens lohnt es sich nicht, Format-Strings künstlich zu deduplizieren — der Compiler tut das bereits für String-Literale auf Daten-Ebene. Zweitens ist in extremen Hot-Paths ein hand-gerolltes append(buf, "user=") plus strconv.AppendInt schneller als jedes fmt.Appendf, eben weil der Format-String-Parse wegfällt.

Die Append*-Familie (Appendf, Append, Appendln) ist der Hot-Path-freundlichste Teil von fmt. Sie nimmt ein []byte entgegen und gibt das erweiterte Slice zurück — ohne Zwischen-string, ohne io.Writer-Indirektion. Mit einem wiederverwendeten Buffer via buf = buf[:0] ergibt das einen formatierten Datenstrom ohne Allokationen pro Iteration.

Go hot_path.go
package main

import (
    "fmt"
    "os"
)

func main() {
    // Buffer einmal allozieren, danach wiederverwenden
    buf := make([]byte, 0, 256)

    for i := 0; i < 1000; i++ {
        buf = buf[:0]                                 // Länge auf 0, Kapazität bleibt
        buf = fmt.Appendf(buf, "iter=%d ", i)
        buf = fmt.Appendf(buf, "status=%s\n", "ok")
        os.Stdout.Write(buf)                          // Schreibt direkt, keine string-Stufe
    }
}

buf[:0] ist der entscheidende Trick: Die Länge wird auf null gesetzt, die unterliegende Kapazität bleibt erhalten. Solange die nächste Iteration nicht über die Kapazität hinaus schreibt, gibt es keinen neuen Backing-Array — also keine Allokation.

Nicht jeder Code muss allokationsfrei sein. Eine CLI, die einmal beim Start eine Konfiguration formatiert ausgibt, hat keinen Hot-Path — fmt.Println ist dort die richtige Antwort, weil es lesbar und vertraut ist. Ein HTTP-Handler, der pro Request einmal eine Fehlermeldung formatiert, sieht 1 alloc/op niemals als Bottleneck.

Performance-Tuning lohnt erst, wenn ein Profil (pprof) fmt-Pfade als Hotspot ausweist oder benchmem in einem Hot-Loop zweistellige Allokationen pro Iteration zeigt. Vor diesem Punkt ist Lesbarkeit das wichtigere Argument — fmt.Sprintf schlägt eine hand-gerollte append-Kette jederzeit, wenn beide nie ein Bottleneck werden. Erst messen, dann tunen.

Ein typisches Praxis-Beispiel ist ein strukturierter Logger, der pro Request eine Zeile schreibt. Die naive Variante baut einen String mit Sprintf und schreibt ihn dann. Die optimierte Variante hält pro Goroutine (oder via sync.Pool) einen Byte-Buffer vor und schreibt direkt hinein.

Go logger_bench_test.go
package fmtbench

import (
    "fmt"
    "io"
    "testing"
)

// Naive Variante: Sprintf baut string, dann Write
func logNaive(w io.Writer, method, path string, status, dur int) {
    line := fmt.Sprintf("method=%s path=%s status=%d dur=%dms\n",
        method, path, status, dur)
    io.WriteString(w, line)
}

// Optimierte Variante: Appendf in wiederverwendeten Buffer
func logFast(w io.Writer, buf []byte, method, path string, status, dur int) []byte {
    buf = buf[:0]
    buf = fmt.Appendf(buf, "method=%s path=%s status=%d dur=%dms\n",
        method, path, status, dur)
    w.Write(buf)
    return buf
}

func BenchmarkLogNaive(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        logNaive(io.Discard, "GET", "/api/users", 200, 12)
    }
}

func BenchmarkLogFast(b *testing.B) {
    buf := make([]byte, 0, 128)
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf = logFast(io.Discard, buf, "GET", "/api/users", 200, 12)
    }
}
Output
BenchmarkLogNaive-8    4231876   285 ns/op   80 B/op   2 allocs/op
BenchmarkLogFast-8     6892341   175 ns/op    0 B/op   0 allocs/op

Bei 10.000 Requests pro Sekunde sind das 20.000 Allokationen pro Sekunde weniger — und damit weniger GC-Druck, gleichmäßigere Latenzen und vorhersehbarere Tail-Latencies. Genau dieser Effekt rechtfertigt das []byte-Boilerplate.

Sprintf alloziert immer das Ergebnis

Jeder fmt.Sprintf-Aufruf gibt einen string zurück — dieser landet auf dem Heap. Mindestens 1 alloc/op, oft mehr durch Boxing der Parameter.

Fprintf in bytes.Buffer spart die String-Stufe

Wer in einen bytes.Buffer schreibt und ihn zwischen Aufrufen mit buf.Reset() wiederverwendet, kommt nach dem Warmup auf 0 allocs/op — keine string-Zwischenstufe.

Reflection-Kosten bei any-Parametern

%v auf Structs/Slices/Maps läuft durch reflect-Pfade — deutlich teurer als %d/%s auf Primitiven. Bei tiefem Logging messen, nicht raten.

strconv schlägt fmt bei reinen Zahlen

strconv.Itoa(n) ist ~10x schneller als fmt.Sprintf("%d", n), strconv.AppendInt arbeitet zudem allokationsfrei. Für Zahlen-Pipelines die erste Wahl.

fmt cached keinen Format-String

Jeder Sprintf-Aufruf parst den Format-String neu. Es gibt keinen versteckten Cache — daher in extremen Hot-Paths handgerolltes append mit strconv schneller.

Append-Familie für den Hot-Path

fmt.Appendf(buf, ...) mit buf = buf[:0] zwischen Iterationen ergibt formatierte Ausgabe ohne Allokationen. Der idiomatische Go-Hot-Path-Pfad.

Boxing in any ist real

Jeder Parameter an *printf wird in ein Interface verpackt — Werttypen können dabei heap-alloziert werden. Erklärt 2 allocs/op bei Sprintf("%d", n) statt nur 1.

benchmem vor Tuning

Erst go test -bench=. -benchmem laufen lassen und allocs/op lesen. Ohne Profil-Beleg ist fmt-Tuning verfrühte Optimierung — Lesbarkeit zählt mehr.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Das fmt-Paket — Formatierte I/O

Zur Übersicht