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.

Go basic.go
package main

import "fmt"

func main() {
	buf := []byte("LOG: ")
	buf = fmt.Append(buf, "user=", 42, " ok=", true)
	fmt.Println(string(buf))
}
Output
LOG: user=42 ok= true

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

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

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

FunktionFormat-StringWhitespace zwischen ArgsNewline am Ende
fmt.Appendneinnur zwischen Nicht-Stringsnein
fmt.Appendlnneinimmer zwischen allen Argsja
fmt.Appendfjakomplett vom Format-String gesteuertnein

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.

Go reuse.go
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))
}
Output
iter=0 ok= true
iter=1 ok= true
iter=2 ok= true
final cap=256

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

Go logger.go
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")
}
Output
[INFO] started, pid= 4711
[WARN] slow query took 42ms
[INFO] shutdown

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

Go serializer.go
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))
}
Output
req_latency_ms{tag=api} 12.5
req_latency_ms{tag=api} 12.5

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

Weiterführende Ressourcen

Externe Quellen

/ Weiter

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

Zur Übersicht