fmt.Appendf ist die wichtigste Funktion der Append-Familie und der eigentliche Grund, warum diese Familie in Go 1.19 überhaupt eingeführt wurde. Sie liefert die volle Sprintf-Semantik — alle Format-Verben, alle Reflection-Tricks, das ganze Komfort-Arsenal — schreibt das Ergebnis aber direkt an einen bereits existierenden []byte-Buffer, statt einen frischen string zu allozieren.
Genau dieser Unterschied entscheidet auf heißen Pfaden über Throughput und Allokations-Druck: Logger, Serializer, Template-Engines und alles, was pro Sekunde tausende formatierte Bytes produziert, profitiert direkt. Wer bisher Sprintf in einer Schleife aufgerufen und das Resultat per buf = append(buf, s...) angehängt hat, baute genau das von Hand nach, was Appendf in einem Schritt erledigt — und sparte dabei keinen einzigen String-Header.
Die Signatur im Detail
func Appendf(b []byte, format string, a ...any) []byte — drei Parameter, ein Rückgabewert, und exakt diese Form ist die Brücke zwischen der Sprintf-Welt (Format-String, variadische Argumente) und der append-Welt (Buffer rein, Buffer raus). Der erste Parameter b ist der Ziel-Buffer; er darf nil sein, leer sein oder bereits Inhalt enthalten. Der zweite Parameter ist der Format-String mit denselben Verben, die auch Sprintf versteht.
Der Rückgabewert ist — wie bei append selbst — der potentiell neu allozierte Slice-Header. Diese Konvention ist nicht optional: Wer den Return-Wert ignoriert und beim nächsten Aufruf wieder den alten b übergibt, verliert geschriebene Daten, sobald die Capacity überschritten wurde.
Wie Sprintf, nur an einen Buffer angehängt
Funktional ist Appendf ein Sprintf, das sein Ergebnis nicht in einen frischen String steckt, sondern an einen bestehenden Byte-Slice hängt. Alle Format-Verben, die du aus Sprintf kennst — %d, %s, %v, %+v, %#v, %q, %x, %t, %f, %e, %g, %p — funktionieren identisch. Auch Breite, Präzision und Flags verhalten sich exakt gleich.
package main
import "fmt"
func main() {
buf := []byte("LOG: ")
buf = fmt.Appendf(buf, "user=%q id=%d active=%t", "ada", 42, true)
buf = append(buf, '\n')
buf = fmt.Appendf(buf, "balance=%8.2f rate=%5.1f%%", 1234.5, 4.25)
fmt.Print(string(buf))
}LOG: user="ada" id=42 active=true
balance= 1234.50 rate= 4.2%Wer Sprintf beherrscht, beherrscht Appendf — die Lernkurve ist exakt null. Was sich ändert, ist ausschließlich, wohin die Bytes geschrieben werden und wer den Buffer besitzt.
Allokations-Verhalten — der eigentliche Gewinn
Bei Sprintf entsteht in jedem Aufruf ein neuer string. Diese finale Kopie ist die Allokation, die Appendf einspart — der interne Formatter schreibt direkt in den übergebenen Slice. Allokiert wird nur dann neu, wenn die cap(b) nicht ausreicht. Solange genug Capacity da ist, läuft Appendf vollständig allokations-frei.
package main
import "fmt"
func main() {
buf := make([]byte, 0, 4096)
for i := 0; i < 5; i++ {
buf = fmt.Appendf(buf, "row=%d value=%06.3f\n", i, float64(i)*1.111)
}
fmt.Printf("len=%d cap=%d\n", len(buf), cap(buf))
fmt.Print(string(buf))
}len=95 cap=4096
row=0 value=00.000
row=1 value=01.111
row=2 value=02.222
row=3 value=03.333
row=4 value=04.444Die Capacity bleibt über alle fünf Iterationen konstant — kein einziges Re-Grow, kein einziger Heap-Hit nach der initialen Reservierung.
Vergleich zu Sprintf
Beide Funktionen nutzen denselben internen Formatter und akzeptieren identische Format-Verben. Der Unterschied ist ausschließlich, wohin das Resultat geht: Sprintf produziert einen neuen, unveränderlichen string; Appendf produziert einen erweiterten []byte-Slice.
package main
import "fmt"
func main() {
s := fmt.Sprintf("name=%s age=%d", "ada", 36)
fmt.Println("Sprintf:", s)
buf := []byte("PREFIX| ")
buf = fmt.Appendf(buf, "name=%s age=%d", "ada", 36)
fmt.Println("Appendf:", string(buf))
}Sprintf: name=ada age=36
Appendf: PREFIX| name=ada age=36Inhaltlich ist die formatierte Region identisch — der Unterschied ist die Verpackung. Sprintf ist die richtige Wahl, wenn du ohnehin einen String brauchst; Appendf ist die richtige Wahl, sobald die Bytes anschließend in einen Writer fließen.
Idiomatic Pattern für Logger
Das kanonische Einsatzgebiet von Appendf ist ein selbstgebauter Logger oder ein strukturierter Log-Encoder. Der Buffer wird einmal als Member-Variable angelegt, jeder Log-Call hängt seine formatierte Zeile mit Appendf an, und am Ende des Flush-Zyklus wird der Buffer per buf[:0] zurückgesetzt.
package main
import (
"fmt"
"os"
)
type Logger struct {
buf []byte
out *os.File
}
func NewLogger(out *os.File) *Logger {
return &Logger{
buf: make([]byte, 0, 4096),
out: out,
}
}
func (l *Logger) Logf(level, format string, args ...any) {
l.buf = fmt.Appendf(l.buf, "[%s] ", level)
l.buf = fmt.Appendf(l.buf, format, args...)
l.buf = append(l.buf, '\n')
}
func (l *Logger) Flush() {
l.out.Write(l.buf)
l.buf = l.buf[:0]
}
func main() {
log := NewLogger(os.Stdout)
log.Logf("INFO", "request id=%d path=%q", 1, "/api/users")
log.Logf("WARN", "slow query took=%dms", 142)
log.Logf("INFO", "request id=%d status=%d", 1, 200)
log.Flush()
}[INFO] request id=1 path="/api/users"
[WARN] slow query took=142ms
[INFO] request id=1 status=200Drei Log-Aufrufe, ein einziger Write-Syscall, null Allokationen pro Log-Call nach dem initialen make. Das ist die Sorte Effizienz, die in Hochlast-Systemen zwischen „läuft" und „bricht ein" entscheidet.
Performance-Vergleich
Sprintf in einer Schleife alloziert pro Iteration mindestens einen String — bei N Iterationen sind das N Allokationen plus der Overhead des Garbage Collectors. Appendf mit vorab reserviertem Buffer braucht im optimalen Fall null Allokationen nach dem initialen make. Vier- bis fünffacher Throughput, null Allokationen statt vieler pro Iteration — auf einem Server, der zehntausende solcher Schleifen pro Sekunde durchläuft, ist das der Unterschied zwischen einer GC-Pause alle paar Sekunden und einer GC-Pause, die in der Praxis kaum noch messbar ist.
Verb-Kompatibilität — auch %w
Sämtliche Format-Verben funktionieren bei Appendf syntaktisch identisch zu Sprintf. Das schließt auch %w ein — das Verb für Error-Wrapping. Syntaktisch akzeptiert Appendf %w ohne zu meckern; semantisch ergibt das aber nur in fmt.Errorf Sinn, weil dort der Rückgabewert ein error ist, dessen Unwrap()-Kette das gewrappte Original wieder freigibt. Bei Appendf ist das Ergebnis ein []byte — die Wrap-Information verschwindet ins Nichts. Wer wrappen will, nimmt fmt.Errorf; wer die Fehlermeldung nur in einen Log-Buffer schreiben will, nimmt %s oder %v.
High-Performance-Logger mit strukturiertem Output
Der häufigste echte Einsatz von Appendf ist ein hauseigener Logger, der strukturierte Felder schreibt. Das folgende Beispiel zeigt einen Logger, der Key-Value-Felder in einem logfmt-ähnlichen Format ausgibt, dabei sauberes Quoting für Werte mit Sonderzeichen erledigt und alle Schreibvorgänge in einen einzigen wiederverwendeten Buffer leitet.
package main
import (
"fmt"
"os"
"strings"
)
type Field struct {
Key string
Val any
}
type FastLogger struct {
buf []byte
out *os.File
}
func NewFastLogger(out *os.File) *FastLogger {
return &FastLogger{
buf: make([]byte, 0, 8192),
out: out,
}
}
func (l *FastLogger) Log(level, msg string, fields ...Field) {
l.buf = fmt.Appendf(l.buf, "level=%s msg=%q", level, msg)
for _, f := range fields {
switch v := f.Val.(type) {
case string:
if strings.ContainsAny(v, " \"=") {
l.buf = fmt.Appendf(l.buf, " %s=%q", f.Key, v)
} else {
l.buf = fmt.Appendf(l.buf, " %s=%s", f.Key, v)
}
default:
l.buf = fmt.Appendf(l.buf, " %s=%v", f.Key, v)
}
}
l.buf = append(l.buf, '\n')
}
func (l *FastLogger) Flush() {
l.out.Write(l.buf)
l.buf = l.buf[:0]
}
func main() {
log := NewFastLogger(os.Stdout)
log.Log("INFO", "request received",
Field{"method", "GET"},
Field{"path", "/api/users/42"},
Field{"remote", "10.0.0.1"},
)
log.Log("WARN", "slow query",
Field{"query", "SELECT * FROM users WHERE id = 42"},
Field{"duration_ms", 142},
)
log.Flush()
}level=INFO msg="request received" method=GET path=/api/users/42 remote=10.0.0.1
level=WARN msg="slow query" query="SELECT * FROM users WHERE id = 42" duration_ms=142Auffällig ist die Mischung der Append-Familie im Inneren: fmt.Appendf für formatierte Segmente, das eingebaute append für einzelne Bytes wie '\n'. Genau diese Komposition ist das Idiom.
Custom-Serializer ohne Sprintf-Cascade
Der zweite klassische Einsatz ist eigenes Serialisieren — etwa ein JSON-ähnliches Format für eine spezifische Domain. Vor Go 1.19 hätte man hier Sprintf-Aufrufe verschachtelt und das Ergebnis Stück für Stück zusammengeklebt, jeder Aufruf eine eigene Allokation. Mit Appendf baut der Encoder den kompletten Output in einen einzigen Buffer.
package main
import "fmt"
type User struct {
ID int
Name string
Email string
Tags []string
}
func encodeUser(buf []byte, u User) []byte {
buf = append(buf, '{')
buf = fmt.Appendf(buf, "%q:%d,", "id", u.ID)
buf = fmt.Appendf(buf, "%q:%q,", "name", u.Name)
buf = fmt.Appendf(buf, "%q:%q,", "email", u.Email)
buf = fmt.Appendf(buf, "%q:[", "tags")
for i, t := range u.Tags {
if i > 0 {
buf = append(buf, ',')
}
buf = fmt.Appendf(buf, "%q", t)
}
buf = append(buf, ']', '}')
return buf
}
func main() {
users := []User{
{1, "Ada Lovelace", "ada@example.com", []string{"admin", "math"}},
}
out := make([]byte, 0, 1024)
for _, u := range users {
out = encodeUser(out, u)
}
fmt.Println(string(out))
}{"id":1,"name":"Ada Lovelace","email":"ada@example.com","tags":["admin","math"]}Der gesamte JSON-Output entsteht in einem einzigen Buffer — keine Zwischenstrings, kein strings.Builder, keine Sprintf-Cascade. Das %q-Verb kümmert sich nebenbei um Quoting und Escaping.
Interessantes
Go 1.19+
fmt.Appendf ist Teil des Append-Quartetts, das Go 1.19 eingeführt hat — vor 1.19 schlicht nicht vorhanden.
Signatur
func Appendf(b []byte, format string, a ...any) []byte — Buffer zuerst, Format-String als zweiter, variadische Args, gewachsener Slice als Rückgabe.
Gleiche Verben wie Sprintf
Alle Format-Verben verhalten sich identisch zu fmt.Sprintf — %d, %s, %q, %v, %+v, %#v, %f, Breite, Präzision, Flags inklusive.
Append-Semantik
Der Buffer wächst nur, wenn die Capacity überschritten wird — innerhalb der Capacity läuft Appendf allokations-frei.
Vorab-Allokation
make([]byte, 0, N) für Hot-Paths reservieren — typische Werte sind 1 KiB bis 8 KiB, je nach erwartetem Output pro Zyklus.
Reset per buf[:0]
Nach dem Flush den Buffer mit buf = buf[:0] zurücksetzen — Length geht auf null, Capacity bleibt.
%w nur in Errorf sinnvoll
%w funktioniert syntaktisch in Appendf, verliert aber seine Unwrap-Semantik im Buffer-Kontext — lieber %s oder %v.
Hot-Path-Standard
Wichtigste der Append-Familie für formatierten Output — der direkte Ersatz für jedes Sprintf in einer Schleife.