Das Paket fmt kennt drei Output-Interfaces, mit denen ein eigener Typ steuert, wie er in Strings auftaucht: Stringer, GoStringer und Formatter. Sie sehen auf den ersten Blick ähnlich aus, decken aber sehr unterschiedliche Bedürfnisse ab — von der schlichten Anzeige für Endnutzer bis hin zu verb-abhängigem Verhalten wie bei math/big. Für die meisten Domain-Typen reicht ein gut geschriebenes String() vollkommen, die anderen beiden sind bewusste Spezialfälle. Diese Seite ordnet ein, welches Interface zu welchem Problem passt, in welcher Reihenfolge fmt die Hooks anwendet und wo die häufigsten Stolperfallen liegen.
Bevor wir in die Auswahl einsteigen, lohnt sich ein Blick auf die nackten Signaturen und ihre primäre Zielgruppe. Jedes Interface besitzt genau eine Methode, und der Unterschied liegt nicht in der Komplexität der API, sondern darin, für wen der erzeugte String gedacht ist.
| Interface | Methode | Greift bei Verben | Zielgruppe |
|---|---|---|---|
Stringer | String() string | %s, %v, %q* | Menschliche Leser, Logs, UI |
GoStringer | GoString() string | %#v | Debugger, Snapshot-Tests, Codegen |
Formatter | Format(f State, verb rune) | jedes Verb | Library-Autoren mit Verb-Semantik |
*%q ruft String() nicht direkt auf, quoted aber dessen Ergebnis, sobald über %v ein Stringer durchläuft.
Die Tabelle macht das eigentliche Gefälle sichtbar: Stringer ist die Default-Antwort, GoStringer ein gezielter Hebel für Tooling, und Formatter die einzige Variante mit echter Kontrolle über das Verb. Wer das verinnerlicht, trifft 95 Prozent aller Entscheidungen ohne weiteres Nachdenken.
fmt prüft die Interfaces in einer festen Reihenfolge — wer das nicht kennt, schreibt schnell einen Stringer, der von einem vergessenen Format überschattet wird. Der wichtigste Punkt: Formatter schlägt alles andere, weil es die volle Kontrolle übernimmt und selbst entscheidet, ob es intern auf String() zurückfällt.
1. Implementiert der Typ Formatter? → Format(state, verb) wird gerufen, ENDE.
2. Verb ist %v und Typ implementiert error? → Error() wird gerufen.
3. Verb ist %v und Typ implementiert Stringer? → String() wird gerufen.
4. Verb ist %#v und Typ implementiert GoStringer? → GoString() wird gerufen.
5. Sonst: Default-Reflection-Pfad in fmt.Wichtig: Das error-Interface läuft auf einem eigenen Pfad und steht vor Stringer, falls beide existieren. Wer einen Typ baut, der sowohl Fehler ist als auch sinnvoll als String darstellbar, sollte sich für eines entscheiden, sonst entstehen schwer auffindbare Diskrepanzen zwischen log.Print(err) und fmt.Printf("%v", err).
Für etwa 95 Prozent aller eigenen Typen ist Stringer die richtige und einzige Wahl. Eine Methode, ein Rückgabewert, keine Verb-Logik — genau das, was Reviews und Tests gerne sehen. Erst wenn ein Typ tatsächlich verschiedene Darstellungen je nach Kontext braucht, kommen die anderen Interfaces in Frage.
package main
import "fmt"
type UserID uint64
func (u UserID) String() string {
return fmt.Sprintf("user-%08d", uint64(u))
}
func main() {
id := UserID(42)
fmt.Println(id) // %v → String()
fmt.Printf("%s\n", id) // %s → String()
fmt.Printf("%q\n", id) // %q → "user-00000042"
}user-00000042
user-00000042
"user-00000042"Beachten Sie den Cast uint64(u) im Sprintf-Aufruf — ohne ihn würde fmt erneut die String()-Methode aufrufen und in eine Endlosrekursion laufen. Diese kleine Disziplin ist der ganze Preis, den Stringer kostet.
GoString() ist immer dann interessant, wenn die Ausgabe nicht für Menschen, sondern für anderen Go-Code gedacht ist. Das klassische Einsatzgebiet sind Snapshot-Tests, in denen %#v reproduzierbare, kompilierbare Literale erzeugen soll, sowie Codegeneratoren, die struct-Werte als Quelltext ausspucken.
package main
import "fmt"
type Color struct{ R, G, B uint8 }
func (c Color) GoString() string {
return fmt.Sprintf("paint.Color{R: 0x%02x, G: 0x%02x, B: 0x%02x}", c.R, c.G, c.B)
}
func main() {
c := Color{R: 0xff, G: 0x80, B: 0x10}
fmt.Printf("%#v\n", c)
}paint.Color{R: 0xff, G: 0x80, B: 0x10}Der entscheidende Punkt ist der Paket-Qualifier paint. vor dem Typnamen — ohne ihn ist die Ausgabe nicht kopierbar, sobald sie aus dem eigenen Paket ausgeschnitten wird. GoStringer ohne diesen Präfix verfehlt seinen Zweck, weil die zentrale Eigenschaft — gültiger Go-Quelltext — nicht mehr gegeben ist.
Formatter ist das schwerste Geschütz und gleichzeitig die einzige Option, wenn ein Typ je nach Verb unterschiedlich aussehen soll — wie math/big.Int, das %d, %x, %o und %b jeweils sauber unterstützt. Wer Width, Precision oder Flags wie +, #, 0 korrekt umsetzen will, kommt um dieses Interface nicht herum.
package main
import (
"fmt"
"io"
)
type Decimal struct {
Units int64
Scale uint8 // Nachkommastellen
}
func (d Decimal) Format(f fmt.State, verb rune) {
switch verb {
case 'd':
fmt.Fprintf(f, "%d", d.Units)
case 'f', 'v':
prec := int(d.Scale)
if p, ok := f.Precision(); ok {
prec = p
}
div := pow10(d.Scale)
whole := d.Units / div
frac := abs(d.Units % div)
fmt.Fprintf(f, "%d.%0*d", whole, prec, frac)
case 's':
io.WriteString(f, d.stringForm())
default:
// Pflicht: unbekannte Verben sichtbar machen.
fmt.Fprintf(f, "%%!%c(Decimal=%s)", verb, d.stringForm())
}
}
func (d Decimal) stringForm() string { /* … */ return "" }
func pow10(n uint8) int64 { /* … */ return 1 }
func abs(x int64) int64 { /* … */ return x }Der default-Zweig ist hier nicht kosmetisch, sondern Pflicht: Ohne ihn produziert ein versehentliches %q lautlosen Leerstring statt der vertrauten %!q(Decimal=…)-Diagnose, die fmt sonst automatisch erzeugt. Auch die Auswertung von f.Precision() ist Teil der Übung — ein Formatter, der Width und Precision ignoriert, frustriert jeden, der ihn in log.Printf einsetzt.
Die folgende Hierarchie deckt nahezu jede reale Frage ab, wenn man sie von oben nach unten durchgeht:
- Brauchst du eine sinnvolle Darstellung für Logs und Println? →
Stringer. Punkt. - Soll der Typ in
%#v-Snapshots als gültiges Go-Literal erscheinen? → zusätzlichGoStringer. - Hängt die Darstellung vom Verb ab (
%dvs.%xvs.%e)? →Formatter, und nur dieser. - Müssen Width oder Precision respektiert werden? →
Formatter(Stringer kennt diese Flags nicht). - Ist der Typ ein Fehler? →
error.Error()stattStringer;Format/GoStringnur, wenn wirklich nötig. - Bist du dir unsicher? →
Stringer, und warte, bis ein konkreter Anwender mehr verlangt.
Die Reihenfolge ist Absicht: Wer von unten nach oben einsteigt, baut zu komplexe Lösungen für Probleme, die er noch nicht hat. Stringer zuerst, alles andere bei nachweisbarem Bedarf.
Die meisten Bugs rund um diese Interfaces wiederholen sich. Drei Muster decken den Großteil ab.
1. Rekursion im Stringer — der häufigste Anfängerfehler, der erst beim ersten Aufruf knallt.
type IP [4]byte
// FALSCH: %v ruft IP.String() rekursiv → Stack-Overflow.
func (ip IP) String() string {
return fmt.Sprintf("%v.%v.%v.%v", ip[0], ip[1], ip[2], ip[3])
}
// RICHTIG: explizit auf Basistyp casten.
func (ip IP) StringOK() string {
return fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3])
}Der Trick ist, im Sprintf-Aufruf entweder einen Verb zu wählen, der den Typ nicht erneut durch den Stringer schickt (%d statt %v), oder den Wert per Cast auf den Basistyp zu reduzieren. Beides bricht den Kreis.
2. Formatter ohne default-case — frisst stillschweigend jedes unbekannte Verb und produziert mysteriöse Lücken im Log.
func (d Decimal) Format(f fmt.State, verb rune) {
if verb == 'd' {
fmt.Fprintf(f, "%d", d.Units)
}
// Kein default → %x liefert leeren String, keine Fehlermeldung.
}Ein expliziter default-Zweig, der ein %!<verb>(Type=…) formuliert, ist die einzig faire Lösung. So bleibt das Verhalten konsistent mit Standard-Typen wie int oder time.Time.
3. GoString ohne Paket-Präfix — Snapshot-Tests scheinen zu funktionieren, scheitern aber, sobald jemand den Output zurück in den Test kopiert.
// FALSCH: kompiliert nur im selben Paket.
func (c Color) GoString() string {
return fmt.Sprintf("Color{%d, %d, %d}", c.R, c.G, c.B)
}
// RICHTIG: voll qualifiziert.
func (c Color) GoString() string {
return fmt.Sprintf("paint.Color{R: %d, G: %d, B: %d}", c.R, c.G, c.B)
}Der Paket-Präfix ist nicht optional — er ist das, was GoStringer von einem zweiten String() unterscheidet.
Ein realistisches Bild bekommt man, wenn man zwei Typen aus derselben Anwendung gegeneinanderstellt: eine Latency, die nur ein einziges, kompaktes Format braucht, und eine Money-Klasse, deren Darstellung sich je nach Verb deutlich unterscheidet.
package main
import (
"fmt"
"time"
)
type Latency time.Duration
func (l Latency) String() string {
d := time.Duration(l)
switch {
case d < time.Microsecond:
return fmt.Sprintf("%dns", d.Nanoseconds())
case d < time.Millisecond:
return fmt.Sprintf("%.1fµs", float64(d)/float64(time.Microsecond))
case d < time.Second:
return fmt.Sprintf("%.1fms", float64(d)/float64(time.Millisecond))
default:
return fmt.Sprintf("%.2fs", d.Seconds())
}
}
func main() {
fmt.Println(Latency(450 * time.Microsecond))
fmt.Println(Latency(2300 * time.Millisecond))
}450.0µs
2.30sLatency braucht keinen Formatter — niemand wird die Ausgabe je mit %x oder %o brauchen. Ein gut geschriebener String() deckt jeden realistischen Einsatzfall in Logs, Metriken-Dashboards und Fehlermeldungen ab. Genau das ist der Default-Fall.
package main
import (
"fmt"
)
type Money struct {
Cents int64
Currency string
}
func (m Money) Format(f fmt.State, verb rune) {
switch verb {
case 'd':
// Reine Cents — für interne Logs.
fmt.Fprintf(f, "%d", m.Cents)
case 'v', 's':
// Lesbare Darstellung — für UI und Reports.
whole := m.Cents / 100
cents := m.Cents % 100
if cents < 0 {
cents = -cents
}
fmt.Fprintf(f, "%s %d,%02d", m.Currency, whole, cents)
case 'q':
// Maschinenlesbares Tupel — für CSV/Audit.
fmt.Fprintf(f, `"%s:%d"`, m.Currency, m.Cents)
default:
fmt.Fprintf(f, "%%!%c(Money=%s %d)", verb, m.Currency, m.Cents)
}
}
func main() {
m := Money{Cents: 12345, Currency: "EUR"}
fmt.Printf("%d\n", m)
fmt.Printf("%v\n", m)
fmt.Printf("%q\n", m)
fmt.Printf("%b\n", m)
}12345
EUR 123,45
"EUR:12345"
%!b(Money=EUR 12345)Money rechtfertigt Formatter, weil interne Logs (%d als Cents), UI (%v als Komma-Notation) und Audit-Pipelines (%q als Tupel) tatsächlich auseinanderfallen. Wer hier mit drei separaten Methoden wie Cents(), Pretty() und AuditTag() arbeitet, verlagert die Auswahl auf jeden Aufrufer — Formatter zentralisiert sie genau dort, wo sie hingehört: am Typ selbst.
Stringer deckt rund 95 Prozent
Für die allermeisten Domain-Typen ist String() die einzige Methode, die je benötigt wird; alles weitere ist Spezialfall.
Formatter schlägt alle anderen Hooks
Sobald ein Typ Format(State, rune) besitzt, ignoriert fmt String(), GoString() und Error() vollständig.
Rekursionsgefahr in String()
fmt.Sprintf("%v", x) innerhalb von x.String() führt zur Endlosrekursion; Casts auf den Basistyp oder typsichere Verben brechen den Kreis.
error-Pfad ist eigenständig
Bei %v wird Error() vor String() aufgerufen, falls beide existieren; Typen mit Doppelrolle erzeugen leicht inkonsistente Logs.
GoString muss Go-Literal sein
Die Ausgabe von GoString() soll als Quelltext wieder kompilieren — inklusive Paket-Qualifier wie pkg.Type{…}.
default-Zweig im Formatter ist Pflicht
Ohne explizite Behandlung unbekannter Verben verschwindet ein versehentliches %x lautlos statt als sichtbarer %!x(…)-Hinweis.
%v ruft Stringer auf
Das universelle Verb leitet bei eigenen Typen an String() durch, weshalb Println und %v denselben Output liefern.
Pointer- vs. Value-Receiver beeinflusst Sichtbarkeit
func (t *T) String() greift nur für Zeiger; ein T-Wert im Interface fällt durchs Raster und landet im Reflection-Pfad.