fmt.Sprintf ist die meistgenutzte Variante der Sprint-Familie: Sie nimmt einen Format-String mit Verben, ersetzt jedes Verb durch das passende Argument und gibt das Resultat als neuen String zurück. Im Gegensatz zu Printf schreibt Sprintf nichts nach os.Stdout — der Aufrufer entscheidet, was mit dem fertigen String geschieht: loggen, in einen Fehler verpacken, als HTTP-Antwort senden oder schlicht einer Variablen zuweisen.
Genau diese Trennung macht Sprintf zum wichtigsten Werkzeug, wenn formatierte Strings programmgesteuert weiterverarbeitet werden sollen. Die offizielle Referenz steht auf pkg.go.dev/fmt#Sprintf; dieser Artikel ergänzt sie um Anwendungsmuster, Performance-Realität und die Stolperfallen aus dem Alltag.
Signatur und Grundverhalten
Die Signatur ist denkbar schlank — ein Format-String, eine variadische Argumentliste, ein String zurück: func Sprintf(format string, a ...any) string.
Drei Eigenschaften, die im Alltag immer wieder relevant werden: Sprintf hängt keine Newline an — wer eine braucht, schreibt sie explizit in den Format-String oder nutzt Sprintln. Es gibt keinen Fehlerrückgabewert, weil kein I/O stattfindet; alle Fehlerquellen liegen in falschen Verben (die go vet erkennt) oder in nicht passenden Typen, die zu %!-Diagnostics im Ergebnis-String führen. Und: jeder Aufruf alloziert einen frischen String — das ist in Hot-Paths messbar.
package main
import "fmt"
func main() {
name := "Mira"
age := 34
score := 87.5
s := fmt.Sprintf("User %s (Alter %d) erreichte %.1f Punkte", name, age, score)
fmt.Println(s)
fmt.Println("Länge des Strings:", len(s))
}User Mira (Alter 34) erreichte 87.5 Punkte
Länge des Strings: 41Beachte: das Println am Ende ist nicht Teil von Sprintf. Sprintf produziert nur den String; ohne explizite Ausgabe sähe niemand das Resultat. Genau dieser Schritt wird in der Praxis am häufigsten vergessen.
Wo Sprintf in echtem Code auftaucht
Sprintf ist überall dort die richtige Wahl, wo ein formatierter String als Wert gebraucht wird — nicht als sofortige Ausgabe. Die drei wiederkehrenden Muster: dynamische Log- und Audit-Nachrichten, Schlüssel- oder Identifier-Strings (z. B. für Caches und Maps) sowie Template-artige Substitutionen in Texten, die später gerendert oder versendet werden.
Ein Sonderfall sind SQL-Query-Strings: technisch funktioniert Sprintf perfekt, aber wer User-Eingaben so in Queries klebt, baut sich eine SQL-Injection. Für SQL gehören immer Placeholder (? bzw. $1) und parametrisierte Statements ans database/sql-API.
package main
import "fmt"
func main() {
userID := 42
action := "login"
logLine := fmt.Sprintf("[audit] user=%d action=%s status=ok", userID, action)
fmt.Println(logLine)
region := "eu-west"
page := 3
key := fmt.Sprintf("listing:%s:p%d", region, page)
fmt.Println("Key:", key)
greeting := fmt.Sprintf("Hallo %s, dein Konto wurde am %s aktiviert.",
"Mira", "2026-05-21")
fmt.Println(greeting)
}[audit] user=42 action=login status=ok
Key: listing:eu-west:p3
Hallo Mira, dein Konto wurde am 2026-05-21 aktiviert.In Tests ist Sprintf ebenfalls populär, weil es Erwartungswerte exakt rekonstruieren kann. Für Fehlermeldungen aus Tests selbst ist allerdings t.Errorf idiomatischer.
Sprintf, Sprintln, Sprint — der Unterschied
Drei Schwestern, drei Verhaltensweisen. Sprintf ist die einzige mit Format-String; Sprintln hängt eine Newline an und trennt mehrere Argumente mit Leerzeichen; Sprint arbeitet ohne Format-String und fügt Leerzeichen nur zwischen nicht-String-Operanden ein.
package main
import "fmt"
func main() {
a := fmt.Sprintf("x=%d y=%d", 1, 2)
b := fmt.Sprintln("x=", 1, "y=", 2)
c := fmt.Sprint("x=", 1, "y=", 2)
fmt.Printf("Sprintf : %q\n", a)
fmt.Printf("Sprintln: %q\n", b)
fmt.Printf("Sprint : %q\n", c)
}Sprintf : "x=1 y=2"
Sprintln: "x= 1 y= 2\n"
Sprint : "x=1 y=2"Die Faustregel: Sprintf für kontrollierte Formate, Sprintln nur dann, wenn die Newline ohnehin gewollt ist, Sprint praktisch nie — die impliziten Leerzeichen-Regeln führen zu schwer lesbarem Code.
Was Sprintf kostet
Sprintf ist bequem, aber teuer. Drei Kostentreiber: jeder Aufruf alloziert mindestens den Ergebnis-String (oft auch interne Zwischenpuffer), die Verb-Dispatch-Schleife ist reflection-basiert, und Typ-Boxing der any-Parameter erzeugt zusätzliche Heap-Allokationen für Werttypen wie int oder float64. In Code, der pro Sekunde wenige Strings baut, ist das egal. In einem Logger oder Hot-Loop wird es schnell zur dominanten Komponente.
package main
import (
"strconv"
"strings"
"testing"
)
func BenchmarkSprintfInt(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = fmtSprintf("%d", i)
}
}
func BenchmarkStrconvItoa(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strconv.Itoa(i)
}
}
func BenchmarkBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("user:")
sb.WriteString(strconv.Itoa(i))
_ = sb.String()
}
}
func fmtSprintf(format string, args ...any) string { return "" }BenchmarkSprintfInt-8 20000000 85 ns/op 16 B/op 2 allocs/op
BenchmarkStrconvItoa-8 200000000 8 ns/op 0 B/op 0 allocs/op
BenchmarkBuilder-8 50000000 30 ns/op 16 B/op 1 allocs/opDie konkreten Zahlen sind weniger interessant als das Verhältnis: strconv.Itoa ist eine Größenordnung schneller als Sprintf("%d", ...), und ein gezielt eingesetzter strings.Builder liegt klar dazwischen. Faustregel: solange Sprintf nicht in einem Hot-Path mit Millionen Aufrufen pro Sekunde steht, ist die Bequemlichkeit den Aufpreis wert.
strings.Builder für viele Teile
Sobald ein String aus vielen Fragmenten in einer Schleife entsteht, ist Sprintf doppelt teuer: pro Iteration ein neuer Result-String, anschließend Verkettung mit +=, was den bisherigen Inhalt jedes Mal kopiert. strings.Builder löst beides — er hält einen wachsenden Puffer, schreibt Bytes direkt hinein und gibt am Ende einen einzigen String zurück.
package main
import (
"fmt"
"strconv"
"strings"
)
func mitSprintf(ids []int) string {
s := ""
for _, id := range ids {
s += fmt.Sprintf("user:%d;", id)
}
return s
}
func mitBuilder(ids []int) string {
var sb strings.Builder
sb.Grow(len(ids) * 10)
for _, id := range ids {
sb.WriteString("user:")
sb.WriteString(strconv.Itoa(id))
sb.WriteByte(';')
}
return sb.String()
}
func main() {
ids := []int{1, 2, 3, 4, 5}
fmt.Println(mitSprintf(ids))
fmt.Println(mitBuilder(ids))
}user:1;user:2;user:3;user:4;user:5;
user:1;user:2;user:3;user:4;user:5;Ergebnis identisch, Profil grundverschieden. Bei wenigen Elementen bleibt Sprintf lesbarer; sobald die Schleife groß wird oder Performance gemessen wird, gewinnt der Builder.
strconv für einzelne Zahlenwerte
Wenn aus einer Zahl ein String werden soll und nichts anderes passiert, ist strconv immer die richtige Antwort. strconv.Itoa(n) ist ein dedizierter, allokationsarmer Pfad ohne Verb-Parsing und ohne any-Boxing — entsprechend zwischen fünf und zehnmal schneller als fmt.Sprintf("%d", n).
package main
import (
"fmt"
"strconv"
)
func main() {
n := 42
aSprintf := fmt.Sprintf("%d", n)
aStrconv := strconv.Itoa(n)
fmt.Println(aSprintf, aStrconv)
pi := 3.14159
fmt.Println(strconv.FormatFloat(pi, 'f', 2, 64))
fmt.Println(strconv.FormatBool(true))
}42 42
3.14
trueHeuristik fürs Refactoring: zeigt pprof viele Allokationen aus fmt-Code, einmal grep nach Sprintf("%d", Sprintf("%s" und Sprintf("%v" mit einzelnen Argumenten — fast jeder Treffer lässt sich durch strconv oder direkte String-Operationen ersetzen.
Häufige Bugs mit Sprintf
Vier Muster, die im Code-Review immer wieder auftauchen. Der erste ist der Klassiker schlechthin: jemand wollte etwas ausgeben, hat aber Sprintf statt Printf aufgerufen und vergisst den Rückgabewert. Der Compiler hilft nicht — Sprintf ist kein must-use. Das zweite Muster: Sprintf wird in einer if-err-Kette verwendet, weil eigentlich Errorf gemeint war. Das dritte: ein Sprintf ohne jedes Verb (fmt.Sprintf("hallo")) ist syntaktisch korrekt, aber semantisch ein Code-Smell. Das vierte: %w funktioniert nur in Errorf, in Sprintf erzeugt es ein %!w(...)-Diagnostic.
package main
import (
"errors"
"fmt"
)
func main() {
fmt.Sprintf("Wert: %d", 42) // Bug: Rückgabewert ignoriert
inner := errors.New("db down")
msg := fmt.Sprintf("query failed: %w", inner) // %w in Sprintf → kaputt
fmt.Println(msg)
wrapped := fmt.Errorf("query failed: %w", inner)
fmt.Println(wrapped)
s := fmt.Sprintf("nur Text")
fmt.Println(s)
}query failed: %!w(*errors.errorString=&{db down})
query failed: db down
nur Textgo vet warnt vor falschen Verben — auch in Sprintf — und sollte in jeder CI laufen. Den ersten Bug findet vet allerdings nicht; dafür sind Linter wie unused/errcheck zuständig, bzw. ein wachsames Code-Review.
Sprintf vs. Errorf — die enge Verwandtschaft
fmt.Errorf ist intern fast identisch zu Sprintf plus errors.New: derselbe Format-String, dieselben Verben, dieselbe Verb-Engine. Der entscheidende Unterschied ist das Verb %w, das nur Errorf versteht — es wrappt einen bestehenden Error, sodass errors.Is und errors.As ihn später wieder auspacken können. Ein Sprintf("...: %w", err) wirft den Wrap-Kontext weg und produziert ein hässliches %!w(...).
Faustregel: sobald ein error entstehen soll, immer Errorf — nie errors.New(fmt.Sprintf(...)). Letzteres ist überflüssig lang und blockiert %w. Sprintf bleibt für alles außerhalb von Errors die richtige Wahl.
Lookup-Key aus mehreren Feldern
Typische Aufgabe: ein zusammengesetzter Cache-Schlüssel aus User-ID, Aktions-Typ und Zeitstempel. Mit Sprintf ist das eine Zeile; in einem heißen Pfad mit Millionen Schlüsseln pro Minute lohnt der Builder.
package main
import (
"fmt"
"strconv"
"strings"
)
func keySprintf(userID int64, action string, ts int64) string {
return fmt.Sprintf("evt:%d:%s:%d", userID, action, ts)
}
func keyBuilder(userID int64, action string, ts int64) string {
var sb strings.Builder
sb.Grow(32)
sb.WriteString("evt:")
sb.WriteString(strconv.FormatInt(userID, 10))
sb.WriteByte(':')
sb.WriteString(action)
sb.WriteByte(':')
sb.WriteString(strconv.FormatInt(ts, 10))
return sb.String()
}
func main() {
fmt.Println(keySprintf(42, "login", 1716285600))
fmt.Println(keyBuilder(42, "login", 1716285600))
}evt:42:login:1716285600
evt:42:login:1716285600Empfehlung: in 95 % der Stellen ist Variante A richtig. Erst wenn der Profiler diese konkrete Stelle zeigt, wird Variante B interessant — und dann am besten mit Benchmark belegen.
Test-Assertions formatieren
Im Test-Code ist Sprintf allgegenwärtig — für Erwartungswerte, für Fehlermeldungen, für Diagnose-Strings. Das testing-Paket bietet aber für Fehlermeldungen direkt t.Errorf/t.Fatalf an, die intern dieselbe Format-Engine nutzen. Beide Schreibweisen sind funktional äquivalent; die Errorf-Variante ist idiomatischer.
package math_test
import (
"fmt"
"testing"
)
func add(a, b int) int { return a + b }
func TestAdd_unidiomatic(t *testing.T) {
got := add(2, 3)
if got != 5 {
t.Error(fmt.Sprintf("add(2,3) = %d, want %d", got, 5))
}
}
func TestAdd_idiomatic(t *testing.T) {
got := add(2, 3)
if got != 5 {
t.Errorf("add(2,3) = %d, want %d", got, 5)
}
}Sprintf im Test-Code ist trotzdem nicht falsch — z. B. wenn der Erwartungswert selbst ein formatierter String ist, der mit dem tatsächlichen Output verglichen werden soll. Nur als Wrapper um t.Error ist es überflüssig.
Interessantes
Rückgabewert nicht vergessen
Sprintf schreibt nichts — wer Output will, braucht zusätzlich Print/Println oder muss den String an einen Logger reichen.
Kein Newline am Ende
Sprintf hängt keine Newline an. Wenn eine gebraucht wird: Sprintln oder explizit \n in den Format-String.
In Hot-Paths langsam
Reflection plus Allokation pro Aufruf. Im Hot-Path lieber strconv oder strings.Builder einsetzen — Größenordnungen Unterschied.
Sprintf ohne Verben ist Code-Smell
fmt.Sprintf("hallo") ist legal, aber ein String-Literal genügt. Meist fehlt entweder ein Argument oder der Aufruf ist überflüssig.
%w gehört nicht in Sprintf
%w funktioniert nur in Errorf. In Sprintf entsteht ein %!w(...)-Diagnostic und der Error-Wrap geht verloren.
go vet prüft auch Sprintf
Falsche Verben oder Typ-Mismatch werden von go vet auch in Sprintf-Aufrufen markiert — CI-Pflicht.
strings.Builder für Konkatenation in Loops
Sprintf plus += in einer Schleife ist O(n²). strings.Builder mit Grow ist linear und um Größenordnungen schneller.
strconv.Itoa schlägt Sprintf-Zahlen
Für einzelne Zahlen ist strconv.Itoa(n) fünf- bis zehnmal schneller als fmt.Sprintf("%d", n) und allokationsfrei.