Wenn ein Wert in Go menschenlesbar werden soll, fragt das fmt-Paket den Wert höflich, ob er sich nicht selbst beschreiben möchte. Diese Frage ist das fmt.Stringer-Interface — ein Ein-Methoden-Vertrag mit der Signatur String() string. Jeder Typ, der diese Methode trägt, übernimmt damit die Kontrolle über seine eigene Darstellung in %s, %v, %q und der gesamten Print/Sprint-Familie.
Stringer ist deshalb das wichtigste Interface des fmt-Pakets: Es ist die idiomatische Stelle, an der Logs lesbar werden, Enums Namen bekommen, Fehlermeldungen Kontext erhalten und Test-Ausgaben Sinn ergeben. Gleichzeitig versteckt das Interface eine berühmte Falle — ein einziger fmt.Sprintf("%v", t) im eigenen String() reicht, um einen Stack-Overflow zu produzieren. Referenz: pkg.go.dev/fmt#Stringer.
Ein Methode, ein Vertrag
Das Interface ist im fmt-Paket exakt eine Zeile lang. Es definiert genau eine Methode, und Go folgt seinem üblichen Prinzip des strukturellen Typings: Es gibt kein implements-Schlüsselwort, keine Registrierung, keine Annotation. Sobald ein Typ eine Methode String() string besitzt, ist er automatisch fmt.Stringer — egal ob du das Interface jemals importierst. Diese Implizitheit ist Absicht. Sie erlaubt, Interfaces nachträglich für fremde Typen zu erfüllen (solange du sie selbst definierst) und entkoppelt Implementierung von Vertrag radikaler als jedes nominale Typsystem.
package main
import "fmt"
// Definition im fmt-Paket:
// type Stringer interface { String() string }
type Ampel int
const (
Rot Ampel = iota
Gelb
Gruen
)
func (a Ampel) String() string {
switch a {
case Rot:
return "rot"
case Gelb:
return "gelb"
case Gruen:
return "grün"
}
return "unbekannt"
}
func main() {
var s fmt.Stringer = Gruen
fmt.Println(s)
fmt.Println(Rot)
}grün
rotBemerkenswert ist die zweite Zeile in main: Gruen wird einer fmt.Stringer-Variablen zugewiesen — ohne Cast, ohne Deklaration. Der Compiler prüft die Methodenmenge zur Compile-Zeit und akzeptiert die Zuweisung, weil sie passt. Genau diese Eigenschaft macht Stringer in der Praxis so unauffällig nützlich.
Wann fmt String() tatsächlich aufruft
fmt ruft String() nicht immer auf. Die Methode greift nur bei Verben, die eine Wert-Beschreibung erwarten — also %s (String-Repräsentation), %v (Default-Wert), %q (gequotete Variante) und in der Print/Sprint/Fprint-Familie. Bei typ-spezifischen Verben wie %d, %x, %T oder %p wird String() ignoriert — diese Verben adressieren die rohen Bytes oder Metadaten des Wertes.
package main
import "fmt"
type ID int
func (i ID) String() string {
return fmt.Sprintf("ID#%04d", int(i)) // int(i) — nicht i selbst!
}
func main() {
id := ID(42)
fmt.Printf("%s\n", id)
fmt.Printf("%v\n", id)
fmt.Printf("%q\n", id)
fmt.Println(id)
fmt.Printf("%d\n", id)
fmt.Printf("%x\n", id)
fmt.Printf("%T\n", id)
}ID#0042
ID#0042
"ID#0042"
ID#0042
42
2a
main.IDBeachte die Konvertierung int(i) im String()-Body: Ohne sie würde %04d versuchen, eine ID zu formatieren, und fmt würde erneut String() aufrufen — die berüchtigte Rekursion. Dazu gleich mehr.
Pointer-Receiver vs. Value-Receiver
Welcher Typ am Ende tatsächlich Stringer ist, hängt vom Receiver der String()-Methode ab. Bei einem Value-Receiver erfüllen sowohl T als auch *T das Interface. Bei einem Pointer-Receiver ist nur *T ein Stringer.
Die gute Nachricht: fmt ist freundlich. Wenn du einen addressable T-Wert übergibst, nimmt fmt intern via Reflection die Adresse und ruft String() auf *T auf. Bei nicht-adressierbaren Werten — etwa Map-Elementen oder Funktions-Rückgaben direkt im Argument — funktioniert das nicht.
package main
import "fmt"
type Vec struct{ X, Y int }
func (v *Vec) String() string {
return fmt.Sprintf("(%d, %d)", v.X, v.Y)
}
func main() {
v := Vec{3, 4}
fmt.Println(v)
fmt.Println(&v)
makeVec := func() Vec { return Vec{7, 8} }
fmt.Println(makeVec())
m := map[string]Vec{"a": {1, 2}}
fmt.Println(m["a"])
}(3, 4)
(3, 4)
{7 8}
{1 2}Faustregel: Wenn String() nur liest und der Typ klein ist, ist ein Value-Receiver fast immer die bessere Wahl — er funktioniert in allen Kontexten.
Die Rekursions-Falle
Die meistgemachte Anfängerfehler mit Stringer ist ein Einzeiler, der so harmlos aussieht, dass er regelmäßig übersehen wird: return fmt.Sprintf("%v", t) im String()-Body, wo t der Receiver ist. Was passiert? Sprintf sieht den %v-Verb und einen Stringer-Wert — also ruft es brav String() auf. Diese Methode ruft erneut Sprintf("%v", t). Stack wächst. Stack-Overflow. Programm stirbt.
package main
import "fmt"
type Buggy struct {
Name string
Age int
}
// BUG: %v auf den Receiver → fmt ruft wieder String() auf.
func (b Buggy) String() string {
return fmt.Sprintf("Buggy %v", b)
}
func main() {
fmt.Println(Buggy{"Ada", 30})
}Die saubere Lösung: Greife im String()-Body niemals den ganzen Receiver mit einem Verb ab, das Stringer triggert. Formatiere stattdessen explizit auf den Feldern des Typs (deren Typen keine eigene String()-Methode haben), oder nutze den Type-Alias-Trick.
package main
import "fmt"
type Clean struct {
Name string
Age int
}
func (c Clean) String() string {
return fmt.Sprintf("Clean{Name: %q, Age: %d}", c.Name, c.Age)
}
func main() {
fmt.Println(Clean{"Ada", 30})
}Clean{Name: "Ada", Age: 30}Der Type-Alias-Trick
Manchmal willst du in String() fast die Default-Repräsentation eines Structs — nur mit etwas Prefix oder Suffix — und es wäre lästig, jedes Feld einzeln aufzulisten. Hier hilft ein Type-Alias ohne Methoden: Ein lokal definierter Typ, der dieselben Felder hat, aber keine String()-Methode trägt. Castest du den Receiver auf diesen Alias, sieht fmt keinen Stringer mehr und fällt auf die Default-Struct-Formatierung zurück.
package main
import "fmt"
type User struct {
Name string
Age int
}
func (u User) String() string {
type alias User
return fmt.Sprintf("User%+v", alias(u))
}
func main() {
fmt.Println(User{"Ada", 30})
fmt.Println(User{"Linus", 55})
}User{Name:Ada Age:30}
User{Name:Linus Age:55}Der Trick funktioniert, weil type alias User einen neuen Named Type mit identischem Underlying-Type erzeugt — und Methoden in Go an Named Types hängen, nicht an Strukturen. alias erbt also keine Methoden von User. fmt sieht einen ganz normalen Struct und formatiert ihn per Reflection.
Beziehung zum error-Interface
Das error-Interface hat eine strukturell identische Signatur wie Stringer: eine Methode, die einen string zurückgibt — nur heißt sie Error() statt String(). Trotz der Ähnlichkeit sind die beiden Interfaces getrennt: Ein Typ mit Error() string ist nicht automatisch Stringer, und umgekehrt. Wenn du beide brauchst, musst du beide Methoden definieren.
package main
import "fmt"
type NotFound struct {
Resource string
ID int
}
func (n NotFound) Error() string {
return fmt.Sprintf("%s #%d nicht gefunden", n.Resource, n.ID)
}
func (n NotFound) String() string {
return fmt.Sprintf("NotFound(resource=%q, id=%d)", n.Resource, n.ID)
}
func main() {
err := NotFound{"User", 42}
var e error = err
fmt.Println("err:", e)
var s fmt.Stringer = err
fmt.Println("str:", s)
}err: User #42 nicht gefunden
str: NotFound(resource="User", id=42)Die Subtilität: fmt priorisiert Error() vor String(), wenn der Wert als Interface vom Typ error daherkommt. Wird derselbe Wert aber als konkreter Typ übergeben, gewinnt String().
GoStringer für %#v, Formatter für volle Kontrolle
Das Verb %#v erzeugt eine Go-Syntax-Repräsentation eines Werts. Wer diese Darstellung anpassen will, implementiert das fmt.GoStringer-Interface mit der Methode GoString() string. In der Praxis ist das selten — die meisten Typen kommen mit der Default-Reflection-Ausgabe gut aus.
Wer pro Verb (%v vs. %+v vs. %s vs. eigene Flags) unterschiedlich formatieren möchte, braucht das fmt.Formatter-Interface mit der Methode Format(f fmt.State, verb rune). Diese Methode bekommt das aktuelle Verb und den Format-State als Argument und schreibt das Ergebnis selbst in den State. Formatter ist mächtiger, aber auch deutlich aufwändiger.
package main
import "fmt"
type Color struct {
R, G, B uint8
}
func (c Color) String() string {
return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)
}
func (c Color) GoString() string {
return fmt.Sprintf("Color{R: 0x%02x, G: 0x%02x, B: 0x%02x}", c.R, c.G, c.B)
}
func main() {
c := Color{255, 128, 0}
fmt.Printf("%s\n", c)
fmt.Printf("%v\n", c)
fmt.Printf("%#v\n", c)
}#ff8000
#ff8000
Color{R: 0xff, G: 0x80, B: 0x00}Lesbare Statuscodes für Logs und Tests
Go hat keine eigenen Enums; idiomatisch ersetzt man sie durch einen int-basierten Typ und Konstanten via iota. Das Problem: Ohne Stringer taucht der Status in Logs als nackte Zahl auf. Ein 10-Zeilen-Stringer macht aus dem Enum-Pattern eine echte Verbesserung.
package main
import "fmt"
type OrderStatus int
const (
OrderPending OrderStatus = iota
OrderConfirmed
OrderProcessing
OrderShipped
OrderDelivered
OrderCancelled
)
func (s OrderStatus) String() string {
switch s {
case OrderPending:
return "Pending"
case OrderConfirmed:
return "Confirmed"
case OrderProcessing:
return "Processing"
case OrderShipped:
return "Shipped"
case OrderDelivered:
return "Delivered"
case OrderCancelled:
return "Cancelled"
default:
return fmt.Sprintf("Unknown(%d)", int(s))
}
}
type Order struct {
ID int
Status OrderStatus
}
func main() {
orders := []Order{
{ID: 1001, Status: OrderConfirmed},
{ID: 1002, Status: OrderShipped},
{ID: 1003, Status: OrderCancelled},
}
for _, o := range orders {
fmt.Printf("Order #%d → %s\n", o.ID, o.Status)
}
fmt.Println("Numeric:", int(OrderProcessing))
}Order #1001 → Confirmed
Order #1002 → Shipped
Order #1003 → Cancelled
Numeric: 2Die default-Klausel mit int(s) ist wichtig: Sie bricht die potenzielle Rekursion, falls jemand jemals einen Status-Wert außerhalb des definierten Bereichs konstruiert.
Money-Typ — Geldbeträge sauber darstellen
Geld in float64 zu speichern ist ein klassischer Anti-Pattern (Rundungsfehler). Idiomatisch arbeitet man mit einem Money-Typ, der den Betrag in der kleinsten Einheit der Währung als int64 hält und die Währung separat trägt. Stringer macht aus diesem Typ einen lesbaren Wert in Logs, Rechnungen und Test-Output.
package main
import "fmt"
type Money struct {
Cents int64
Currency string
}
// Formatiert auf den Feldern, nicht auf dem Receiver — keine Rekursion.
func (m Money) String() string {
major := m.Cents / 100
minor := m.Cents % 100
if minor < 0 {
minor = -minor
}
return fmt.Sprintf("%d.%02d %s", major, minor, m.Currency)
}
func (m Money) Add(other Money) Money {
if m.Currency != other.Currency {
panic("currency mismatch")
}
return Money{Cents: m.Cents + other.Cents, Currency: m.Currency}
}
func main() {
preis := Money{Cents: 1234, Currency: "EUR"}
versand := Money{Cents: 499, Currency: "EUR"}
gesamt := preis.Add(versand)
fmt.Println("Preis: ", preis)
fmt.Println("Versand: ", versand)
fmt.Println("Gesamt: ", gesamt)
fmt.Printf("Rechnung: %s + %s = %s\n", preis, versand, gesamt)
}Preis: 12.34 EUR
Versand: 4.99 EUR
Gesamt: 17.33 EUR
Rechnung: 12.34 EUR + 4.99 EUR = 17.33 EURDer Sprintf-Aufruf in String() formatiert auf den Feldern m.Cents (int64) und m.Currency (string) — beides Typen ohne eigenes String(). Damit ist Rekursion ausgeschlossen, und der Code bleibt lesbar.
Häufige Stolperfallen
Rekursion durch %v auf den Receiver
fmt.Sprintf("%v", t) im eigenen String() ist die klassische Falle: fmt ruft String() erneut auf, Stack-Overflow garantiert. Immer auf Feldern formatieren oder Type-Alias verwenden.
Type-Alias als Default-Repr-Workaround
Wenn die Default-Struct-Darstellung gebraucht wird, lokalen Alias type alias T definieren und auf ihn casten — der Alias erbt keine Methoden und wird per Reflection formatiert.
Pointer-Receiver: Nur *T ist Stringer
func (t *T) String() string macht *T zum Stringer, nicht T. Bei nicht-adressierbaren Werten (Map-Elemente, Funktions-Rückgaben) wird String() nicht aufgerufen — Value-Receiver ist im Zweifel sicherer.
%s, %v, %q nutzen Stringer — %d, %x, %T nicht
Stringer greift nur bei Verben, die eine Wert-Beschreibung erwarten. %d, %x, %T, %p adressieren die rohen Daten und ignorieren String().
String() und Error() sind separate Interfaces
Strukturell identisch, aber nicht austauschbar. Wer beides will, implementiert beide Methoden — Error() für Fehler-Pfade, String() für reine Repräsentation.
fmt nimmt Adresse bei addressable Values
Bei String() mit Pointer-Receiver und einer adressierbaren Variable nimmt fmt intern via Reflection die Adresse. Bei Map-Elementen und Funktions-Rückgaben funktioniert das nicht.
GoStringer für %#v, Formatter für volle Kontrolle
GoString() string für die Go-Syntax-Repräsentation (%#v). Format(f State, verb rune) für verb-abhängige Ausgabe. Stringer reicht für 95 % der Fälle.
Stringer macht Enums lesbar
Ein int-Typ mit iota-Konstanten und einer String()-Switch-Methode ist das idiomatische Enum-Pattern in Go — Logs und Test-Output werden sofort menschenlesbar.