Mit Go 1.19 hat das fmt-Paket eine neue Funktionsfamilie bekommen: Append, Appendln und Appendf. Sie sind die Hot-Path-Variante der klassischen Print- und Sprint-Funktionen — statt einen neuen String zu erzeugen, hängen sie ihren formatierten Output an einen bestehenden []byte-Slice an und liefern den vergrößerten Slice zurück. Damit verschwindet die Allokation des Result-Strings, die bei Sprint und Verwandten zwingend anfällt.
fmt.Append selbst ist dabei die positional formatierende Variante: keine Format-Verben, kein Newline, Whitespace nur zwischen aufeinanderfolgenden Nicht-Strings — genau die Semantik von fmt.Print/fmt.Sprint, nur eben in einen Buffer geschrieben.
Signatur und Grundverhalten
Die Signatur ist denkbar schlank und folgt dem Append-Idiom aus dem Standard-builtin-Vokabular: func Append(b []byte, a ...any) []byte.
Das erste Argument b ist der Ziel-Slice: ein bereits existierender []byte, an den angehängt wird. Die variadischen a ...any sind die zu formatierenden Werte — exakt wie bei fmt.Print. Der Rückgabewert ist der gewachsene Slice. Genau wie bei append aus dem Sprachkern gilt: das Ergebnis muss zugewiesen werden, sonst ist der neue Header verloren.
package main
import "fmt"
func main() {
buf := []byte("LOG: ")
buf = fmt.Append(buf, "user=", 42, " ok=", true)
fmt.Println(string(buf))
}LOG: user=42 ok= trueAuffällig: zwischen 42 und " ok=" steht kein Leerzeichen (String folgt auf Nicht-String), zwischen ok= und true ebenfalls nicht (String vor Nicht-String). Nur zwischen den Nicht-Strings entstehen Spaces. Diese Whitespace-Regel ist der Kern und gleich zu fmt.Print/fmt.Sprint.
Allokations-Vorteil gegenüber Sprint
fmt.Sprint(a...) baut intern einen Buffer auf, formatiert hinein und konvertiert am Ende mit string(buf) in einen unveränderlichen String — diese Konvertierung alloziert immer neu, weil Go-Strings unveränderlich sind und nicht denselben Backing-Array wie ein veränderbarer []byte teilen dürfen. Bei fmt.Append entfällt diese Schluss-Allokation komplett: der gewachsene []byte ist das Ergebnis.
package main
import "fmt"
func main() {
buf := make([]byte, 0, 64)
buf = fmt.Append(buf, "x=", 1, " y=", 2)
fmt.Printf("len=%d cap=%d content=%q\n", len(buf), cap(buf), buf)
}len=12 cap=64 content="x=1 y=2"Das cap=64 bleibt erhalten — der vorallozierte Backing-Array war groß genug, kein Wachstum nötig. Bei fmt.Sprint wäre an dieser Stelle in jedem Fall ein neuer String entstanden.
Whitespace-Regel — wie Print und Sprint
Die Regel ist identisch zur Print-Familie: zwischen zwei aufeinanderfolgenden Argumenten wird genau dann ein Leerzeichen eingefügt, wenn keines der beiden ein String ist.
package main
import "fmt"
func main() {
a := fmt.Append(nil, 1, 2, 3)
fmt.Printf("%q\n", a)
b := fmt.Append(nil, 1, "+", 2, "=", 3)
fmt.Printf("%q\n", b)
c := fmt.Append(nil, "Hello", "World")
fmt.Printf("%q\n", c)
}"1 2 3"
"1+2=3"
"HelloWorld"Kein Newline am Ende
Wie fmt.Sprint hängt fmt.Append kein Newline an. Wer eine Log-Zeile baut, muss das \n selbst beisteuern — oder direkt zu fmt.Appendln greifen, das einen abschließenden Zeilenumbruch garantiert.
Vergleich zu Appendln und Appendf
| Funktion | Format-String | Whitespace zwischen Args | Newline am Ende |
|---|---|---|---|
fmt.Append | nein | nur zwischen Nicht-Strings | nein |
fmt.Appendln | nein | immer zwischen allen Args | ja |
fmt.Appendf | ja | komplett vom Format-String gesteuert | nein |
Idiomatic Pattern — wiederverwendeter Buffer
Der eigentliche Performance-Gewinn entsteht erst, wenn derselbe Buffer über viele Aufrufe hinweg wiederverwendet wird. Das Reset-Idiom buf = buf[:0] setzt die Länge auf null zurück, behält aber die Kapazität.
package main
import (
"fmt"
"os"
)
func main() {
buf := make([]byte, 0, 256)
for i := 0; i < 3; i++ {
buf = buf[:0]
buf = fmt.Append(buf, "iter=", i, " ok=", true)
buf = append(buf, '\n')
os.Stdout.Write(buf)
}
fmt.Printf("final cap=%d\n", cap(buf))
}iter=0 ok= true
iter=1 ok= true
iter=2 ok= true
final cap=256Drei Log-Zeilen, ein einziger Buffer, keine wachstumsbedingte Reallokation. Die Kapazität von 256 bleibt durchgehend stabil.
Performance-Hinweis — keine Magie
fmt.Append ist nicht magisch allokationsfrei. Was es spart, ist exakt eine Allokation: die finale String-Konvertierung. Was es nicht spart: Reflection-Kosten, Boxing der Werte (besonders bei großen Structs), interne Buffer einzelner Formatter. Was Append zuverlässig liefert, ist der Wegfall einer Allokation pro Aufruf. Bei Millionen Aufrufen pro Sekunde ist das relevant. Bei einem einzelnen Debug-Print ist es egal — dann ist Sprint lesbarer.
High-Performance-Logger mit gehaltenem Buffer
Ein produktionsnaher Logger hält seinen Output-Buffer als Feld, hängt jede Zeile per fmt.Append an, schreibt nach Schwellwert in den io.Writer und resettet den Buffer.
package main
import (
"fmt"
"io"
"os"
)
type Logger struct {
w io.Writer
buf []byte
}
func New(w io.Writer) *Logger {
return &Logger{w: w, buf: make([]byte, 0, 1024)}
}
func (l *Logger) Log(level string, args ...any) {
l.buf = l.buf[:0]
l.buf = fmt.Append(l.buf, "[", level, "] ")
l.buf = fmt.Append(l.buf, args...)
l.buf = append(l.buf, '\n')
l.w.Write(l.buf)
}
func main() {
log := New(os.Stdout)
log.Log("INFO", "started, pid=", os.Getpid())
log.Log("WARN", "slow query took ", 42, "ms")
log.Log("INFO", "shutdown")
}[INFO] started, pid= 4711
[WARN] slow query took 42ms
[INFO] shutdownDer Logger besitzt seinen Buffer. Jeder Log-Aufruf macht in der Regel null Heap-Allokationen — vorausgesetzt, die Argumente entkommen der Escape-Analyse nicht selbst.
Custom-Serializer ohne Sprint-Concat
Wer ein eigenes Serialisierungsformat baut — sei es ein NDJSON-Writer, ein Telegraf-Line-Protocol-Encoder oder ein Prometheus-Textformat-Producer — möchte einen Buffer Stück für Stück füllen, ohne in jeder Komponente einen Zwischen-String zu allozieren.
package main
import "fmt"
type Metric struct {
Name string
Value float64
Tag string
}
func encodeNaive(m Metric) string {
return fmt.Sprint(m.Name) + "{tag=" + fmt.Sprint(m.Tag) + "} " + fmt.Sprint(m.Value)
}
func encodeFast(buf []byte, m Metric) []byte {
buf = fmt.Append(buf, m.Name, "{tag=", m.Tag, "} ", m.Value)
return buf
}
func main() {
m := Metric{Name: "req_latency_ms", Value: 12.5, Tag: "api"}
fmt.Println(encodeNaive(m))
buf := make([]byte, 0, 128)
buf = encodeFast(buf, m)
fmt.Println(string(buf))
}req_latency_ms{tag=api} 12.5
req_latency_ms{tag=api} 12.5Beide Funktionen liefern identische Strings. Die encodeFast-Variante macht in einem Hot-Loop allerdings nur eine Allokation für den Buffer (einmalig, vor der Schleife). Bei zehntausenden Metriken pro Sekunde ist das der Unterschied zwischen GC-Druck und stabilen Latenzen.
Interessantes
Go 1.19+ Feature
Die Append-Familie wurde in Go 1.19 eingeführt — vor dieser Version gab es nur Sprint/Sprintln/Sprintf mit zwangsläufiger Result-Allokation.
Signatur
func Append(b []byte, a ...any) []byte — Ziel-Slice zuerst, variadische Werte, gewachsener Slice als Rückgabe, Zuweisung wie bei append Pflicht.
Whitespace-Regel wie Print
Leerzeichen nur zwischen zwei aufeinanderfolgenden Nicht-Strings, sobald ein String beteiligt ist, kein Trenner.
Append-Semantik statt String-Allokation
Der Buffer wächst nach append-Regeln. Reicht die Kapazität, entsteht keine neue Allokation.
Hot-Path-Pattern: Reset mit buf[:0]
Buffer einmal vorallokieren, in jeder Iteration mit buf = buf[:0] zurücksetzen — Kapazität bleibt erhalten.
Reflection-Kosten bleiben
fmt inspiziert jedes any weiterhin per Reflection. Append spart die finale Result-Allokation, nicht den Formatierungs-Overhead.
Zielgruppe: Logger und Serializer
Sinnvoll überall dort, wo dieselbe Formatierungs-Operation millionenfach pro Sekunde läuft.
Vergleich zu Sprintf/Sprint
Sprint alloziert immer den Result-String. Append schreibt in einen vorhandenen Slice und spart genau diese eine Allokation pro Aufruf.