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.