Das fmt-Paket exponiert vier Output-Familien — Print*, Fprint*, Sprint* und Append* — die jeweils in drei Suffix-Varianten (-, ln, f) existieren. Aus diesem orthogonalen Muster entstehen zwölf Funktionen, die sich ausschließlich in einer einzigen Achse unterscheiden: der Output-Senke. Wer die Senke versteht — os.Stdout, beliebiger io.Writer, string-Rückgabe oder ein wachsender []byte-Puffer — wählt für jedes Szenario die richtige Familie und vermeidet überflüssige Allokationen.
Alle zwölf Funktionen folgen demselben Schema: ein Stamm legt die Output-Senke fest, ein Suffix bestimmt das Formatierungsverhalten. Der Stamm Print schreibt nach os.Stdout, Fprint nach einem io.Writer, Sprint liefert einen string, und Append hängt an einen []byte-Slice an. Die Suffixe sind über alle Familien hinweg konsistent: ohne Suffix verwendet die Funktion Default-Formatierung, ln ergänzt einen Zeilenumbruch mit Leerzeichen zwischen Operanden, f erwartet einen Format-String mit Verben.
// Stamm | - | ln | f
// ---------|------------|-------------|------------
// Print | Print | Println | Printf -> os.Stdout
// Fprint | Fprint | Fprintln | Fprintf -> io.Writer
// Sprint | Sprint | Sprintln | Sprintf -> string
// Append | Append | Appendln | Appendf -> []byte (Go 1.19+)Die Tabelle ist nicht nur didaktisch hilfreich, sondern auch ein praktischer Merkhelfer: Wer eine Printf-Variante für eine Datei sucht, ersetzt mental P durch Fp und landet bei Fprintf. Wer einen String statt os.Stdout will, ersetzt durch Sp. Wer in einen vorhandenen Puffer schreiben möchte, nimmt Append.
Die Print-Familie ist der schnellste Weg vom Quelltext zur sichtbaren Ausgabe: Stamm Print schreibt fest verdrahtet nach os.Stdout. Es gibt keine Konfigurations-Möglichkeit, keinen alternativen Writer, keine Umlenkung auf Test-Buffer.
package main
import "fmt"
func main() {
fmt.Print("ohne Newline")
fmt.Print(" und weiter\n")
fmt.Println("mit", "Spaces", "und", "Newline")
fmt.Printf("formatiert: %d %s\n", 42, "Antwort")
}ohne Newline und weiter
mit Spaces und Newline
formatiert: 42 AntwortGeeignet ist diese Familie für kleine CLIs, schnelle Demos, Übungs-Programme und main-Skripte, in denen Ausgabe-Umlenkung keine Rolle spielt. Sobald eine Funktion testbar werden soll oder ihre Ausgabe nach os.Stderr, in eine Datei oder über das Netzwerk gehen könnte, ist Print die falsche Wahl. Test-Frameworks können os.Stdout zwar mit Workarounds umlenken, aber jeder solche Workaround signalisiert, dass die Funktion eigentlich Fprint hätte nutzen sollen.
Fprint* schreibt in einen beliebigen io.Writer — das ist das Idiom für jede Code-Stelle, die mehr leisten soll als ein Throwaway-Skript. Der erste Parameter ist der Writer, der Rest entspricht der Print-Familie. Da *os.File, *bytes.Buffer, *strings.Builder, net.Conn, gzip.Writer und unzählige weitere Typen das Interface erfüllen, deckt eine einzige Funktion alle realistischen Output-Ziele ab.
package main
import (
"bytes"
"fmt"
"io"
"os"
)
func greet(w io.Writer, name string) {
fmt.Fprintf(w, "Hallo %s\n", name)
}
func main() {
// Produktiv: Stdout
greet(os.Stdout, "Welt")
// Im Test: bytes.Buffer als Writer
var buf bytes.Buffer
greet(&buf, "Test")
fmt.Printf("captured: %q\n", buf.String())
}Hallo Welt
captured: "Hallo Test\n"Der entscheidende Vorteil ist die Testbarkeit: greet lässt sich mit einem bytes.Buffer aufrufen, dessen Inhalt anschließend mit buf.String() abgefragt und gegen die Erwartung verglichen wird — kein Stdout-Hijacking, keine globale Mutation, keine Race-Conditions zwischen parallelen Tests. Die Faustregel: Jede Funktion in einem Library- oder Service-Paket, die formatiert ausgibt, nimmt einen io.Writer als Parameter.
Sprint* formatiert nicht in eine Senke, sondern liefert das Ergebnis als string zurück. Diese Familie ist die richtige Wahl, wenn der formatierte Text als Wert benötigt wird: als Argument für eine Logger-API, als Schlüssel für eine Map, als Feldinhalt einer Struktur, als Element einer []string-Liste.
package main
import (
"fmt"
"log"
)
func main() {
user := "alice"
id := 7
// Logger-API erwartet einen string
msg := fmt.Sprintf("user=%s id=%d", user, id)
log.Println(msg)
// Map-Key
cache := map[string]int{}
key := fmt.Sprint("v1:", id)
cache[key] = 100
fmt.Println(cache)
}2026/05/22 12:00:00 user=alice id=7
map[v1:7:100]Der Preis ist eine Allokation: Jeder Sprint-Aufruf erzeugt einen neuen string auf dem Heap. Für gelegentliche Aufrufe ist das irrelevant, im Hot-Path eines Loggers oder Serializers summieren sich diese Allokationen jedoch zu spürbarem GC-Druck. Wenn am Ende ohnehin ein []byte-Puffer oder ein io.Writer befüllt wird, ist der Umweg über Sprint und anschließendes Write([]byte(s)) reine Verschwendung — dann ist Fprint oder Append die bessere Wahl.
Seit Go 1.19 existiert die Append-Familie: Append, Appendln und Appendf hängen formatierte Bytes direkt an einen vorhandenen []byte-Slice an und geben den potentiell vergrößerten Slice zurück. Das Signaturmuster ist func Appendf(b []byte, format string, a ...any) []byte — identisch zur Konvention der Standardbibliothek (append, strconv.AppendInt, time.AppendFormat).
package main
import (
"fmt"
"os"
)
func main() {
buf := make([]byte, 0, 256) // Puffer mit Reserve
buf = fmt.Appendf(buf, "{\"user\":%q,", "alice")
buf = fmt.Appendf(buf, "\"id\":%d,", 7)
buf = fmt.Appendf(buf, "\"active\":%t}\n", true)
os.Stdout.Write(buf)
}{"user":"alice","id":7,"active":true}Der Charme der Familie liegt im Verzicht auf den Zwischen-String: Sprintf würde einen string allozieren, der anschließend in den Puffer kopiert und vom GC wieder aufgeräumt werden müsste. Appendf schreibt direkt in die Backing-Array des vorhandenen Slices. Wenn der Puffer mit make([]byte, 0, N) ausreichend vor-allokiert wurde, fällt für die Formatierung selbst keine zusätzliche Heap-Allokation an. Dieses Muster ist die Standardform für strukturiertes Logging, NDJSON-Serialisierung und alle Pipelines, die viele Records pro Sekunde formatieren.
Die folgende Tabelle fasst die vier Familien entlang der vier relevanten Achsen zusammen — Senke, Allokations-Profil, Testbarkeit und Eignung für Hot-Paths:
Familie | Senke | Allokation | Testbar | Hot-Path
---------|-----------------|-------------------|----------|----------
Print | os.Stdout (fix) | keine zusaetzlich | nein | nein
Fprint | io.Writer | abhaengig vom Writer | ja | ja
Sprint | string | string-Allok. | n/a | nein
Append | []byte | nur bei Wachstum | ja | jaAuffallend ist die Symmetrie: Print und Sprint sind die bequemen, aber wenig flexiblen Optionen, Fprint und Append die produktionsreifen, parametrisierbaren Gegenstücke. Fprint skaliert über alle Senken hinweg, Append ist die spezialisierte Hot-Path-Variante für []byte-Puffer.
Die Familienwahl lässt sich mit einer kurzen Entscheidungskaskade treffen:
- Stdin/Stdout in einem Throwaway-Skript oder
main.go? →fmt.Print* - Datei, Network, Logger, testbares Library-Code? →
fmt.Fprint*mitio.Writer-Parameter - String als Resultat-Wert benötigt (Map-Key, Logger-Message, Struct-Feld)? →
fmt.Sprint* - Bestehender
[]byte-Puffer, Hot-Path, viele Records pro Sekunde? →fmt.Append*(Go 1.19+)
Die einzige Konstellation, in der zwei Familien semantisch gleichwertig sind: fmt.Println(x) und fmt.Fprintln(os.Stdout, x) liefern identische Bytes. Der Unterschied ist die Parametrisierbarkeit — die Fprintln-Variante lässt sich im Test mit einem Buffer aufrufen, die Println-Variante nicht.
Ein strukturierter Logger formatiert viele Log-Records pro Sekunde und schreibt sie in eine Senke. Die naive Implementierung baut pro Record einen string mit Sprintf und schreibt ihn anschließend in den Writer — das alloziert für jeden Record einen wegwerfbaren String.
package main
import (
"fmt"
"os"
)
// Variante A: Sprint -> Write (eine string-Allokation pro Record)
func logSprint(level, msg string, id int) {
line := fmt.Sprintf("[%s] %s id=%d\n", level, msg, id)
os.Stdout.WriteString(line)
}
func main() {
logSprint("INFO", "login", 7)
}[INFO] login id=7Die Append-Variante derselben Funktion nutzt einen wiederverwendbaren Puffer (in der Praxis aus einem sync.Pool) und vermeidet die String-Allokation:
package main
import (
"fmt"
"os"
)
// Variante B: Append in vorhandenen Puffer, dann Write
func logAppend(buf []byte, level, msg string, id int) []byte {
buf = buf[:0] // Reset, Kapazitaet bleibt
buf = fmt.Appendf(buf, "[%s] %s id=%d\n", level, msg, id)
os.Stdout.Write(buf)
return buf
}
func main() {
buf := make([]byte, 0, 256)
buf = logAppend(buf, "INFO", "login", 7)
buf = logAppend(buf, "WARN", "retry", 7)
_ = buf
}[INFO] login id=7
[WARN] retry id=7Bei wenigen Aufrufen pro Sekunde ist der Unterschied unsichtbar, bei einem Logger im Heißlauf eines Webservices spart die Append-Variante eine Allokation pro Log-Zeile und reduziert damit den GC-Druck spürbar. Das Muster buf = buf[:0]; buf = fmt.AppendXxx(buf, ...) ist die kanonische Form für solche Pfade.
Ein subtiler, aber wichtiger Punkt: fmt.Println(args...) und fmt.Fprintln(os.Stdout, args...) produzieren exakt dieselbe Ausgabe. Trotzdem ist die zweite Form in Library-Code besser, weil sie testbar ist.
package main
import (
"bytes"
"fmt"
"io"
"os"
)
// Schlecht: nicht testbar, Stdout fest verdrahtet
func reportBad(name string, n int) {
fmt.Println("Bericht:", name, "Anzahl:", n)
}
// Gut: testbar, Senke ist Parameter
func reportGood(w io.Writer, name string, n int) {
fmt.Fprintln(w, "Bericht:", name, "Anzahl:", n)
}
func main() {
// Produktion: Stdout
reportGood(os.Stdout, "umsatz", 42)
// Test: Buffer
var buf bytes.Buffer
reportGood(&buf, "umsatz", 42)
fmt.Printf("captured: %q\n", buf.String())
// Zum Vergleich: identische Bytes wie reportBad
reportBad("umsatz", 42)
}Bericht: umsatz Anzahl: 42
captured: "Bericht: umsatz Anzahl: 42\n"
Bericht: umsatz Anzahl: 42reportGood schreibt in Produktion nach os.Stdout und im Test in einen bytes.Buffer, dessen Inhalt sich gegen eine Erwartung prüfen lässt. reportBad zwingt jeden Test zu Stdout-Capture-Workarounds. Die Mehr-Tipperei Fprintln(os.Stdout, ...) gegenüber Println(...) zahlt sich beim ersten Unit-Test aus.
Print-Familie: os.Stdout fest verdrahtet
fmt.Print* schreibt unveränderlich nach os.Stdout und bietet keinen Parameter für eine alternative Senke; das macht die Familie bequem für Skripte, aber ungeeignet für Library- und Test-Code.
Fprint-Familie: io.Writer = testbar
fmt.Fprint* nimmt einen io.Writer als ersten Parameter und schreibt damit in Dateien, Netzwerk-Verbindungen, bytes.Buffer oder beliebige andere Senken — derselbe Aufruf funktioniert in Produktion und im Unit-Test.
Sprint alloziert immer einen string
fmt.Sprint* liefert einen frischen string zurück, der auf dem Heap landet; im Hot-Path eines Loggers oder Serializers summiert sich das zu unnötigem GC-Druck.
Append schreibt ohne Zwischen-String
fmt.Append* schreibt formatierte Bytes direkt in einen []byte-Slice und überspringt damit die Allokation, die Sprintf für den Zwischen-String benötigen würde.
Append existiert erst seit Go 1.19
Die Append-Familie ist mit Go 1.19 hinzugekommen; Code, der ältere Toolchains unterstützen muss, muss weiterhin auf Sprintf plus Write oder auf Fprintf zurückgreifen.
Suffix-Logik bleibt über alle Familien gleich
Die Suffixe -, ln und f haben in jeder Familie dieselbe Bedeutung: Default-Format, Format-mit-Newline-und-Spaces, Format-String mit Verben.
Println-Spaces unterscheiden sich von Print
Println fügt Leerzeichen zwischen allen Operanden ein, Print nur zwischen zwei aufeinanderfolgenden Nicht-String-Operanden — dieselbe Asymmetrie gilt für Fprintln/Fprint, Sprintln/Sprint und Appendln/Append.
Hot-Path-Pattern: buf = fmt.AppendXxx(buf, ...)
Der kanonische Aufruf ist buf = fmt.AppendXxx(buf, ...) — der Slice-Header wird durch jeden Append-Aufruf neu zugewiesen, weil das Backing-Array beim Wachstum die Adresse wechseln kann.