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):
| Variante | ns/op | allocs/op | Senke |
|---|---|---|---|
fmt.Sprintf | ~150 ns | 1 alloc (das Ergebnis) | string |
fmt.Fprintf(&buf, …) mit bytes.Buffer | ~120 ns | 0 allocs (Buffer wiederverwendet) | io.Writer |
fmt.Appendf(buf, …) mit buf = buf[:0] | ~110 ns | 0 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.
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)
}
}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/opAppendf 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.
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)
}
}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/opDie 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.
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.
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)
}
}BenchmarkLogNaive-8 4231876 285 ns/op 80 B/op 2 allocs/op
BenchmarkLogFast-8 6892341 175 ns/op 0 B/op 0 allocs/opBei 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.