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.

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

Go appendf_prealloc.go
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))
}
Output
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.444

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

Go sprintf_vs_appendf.go
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))
}
Output
Sprintf: name=ada age=36
Appendf: PREFIX| name=ada age=36

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

Go logger_pattern.go
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()
}
Output
[INFO] request id=1 path="/api/users"
[WARN] slow query took=142ms
[INFO] request id=1 status=200

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

Go praxis_logger.go
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()
}
Output
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=142

Auffä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.

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

Weiterführende Ressourcen

Externe Quellen

/ Weiter

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

Zur Übersicht