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.

Text muster.go
// 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.

Go print_family.go
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")
}
Output
ohne Newline und weiter
mit Spaces und Newline
formatiert: 42 Antwort

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

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

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

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

Text vergleich.txt
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       | ja

Auffallend 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* mit io.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.

Go logger_sprint.go
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)
}
Output
[INFO] login id=7

Die Append-Variante derselben Funktion nutzt einen wiederverwendbaren Puffer (in der Praxis aus einem sync.Pool) und vermeidet die String-Allokation:

Go logger_append.go
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
}
Output
[INFO] login id=7
[WARN] retry id=7

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

Go testbarkeit.go
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)
}
Output
Bericht: umsatz Anzahl: 42
captured: "Bericht: umsatz Anzahl: 42\n"
Bericht: umsatz Anzahl: 42

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

Weiterführende Ressourcen

Externe Quellen

/ Weiter

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

Zur Übersicht