fmt.Errorf ist seit Go 1.13 das idiomatische Werkzeug, um Fehler zu wrappen — also einen neuen Fehler zu erzeugen, der einen darunterliegenden Fehler kontextuell anreichert, ohne die Information über die Ursache zu verlieren. Möglich wird das durch ein einziges spezielles Format-Verb: %w. Es unterscheidet sich von allen anderen Verben darin, dass es nicht nur eine Stringrepräsentation einbettet, sondern die ursprüngliche Error-Instanz in einer Kette verfügbar hält.

Diese Kette ist der Grund, weshalb errors.Is und errors.As aus dem Paket errors so mächtig sind: Sie traversieren die Kette automatisch und finden Sentinel-Errors oder konkrete Typen, egal wie tief sie verschachtelt liegen. Hintergrund und Designentscheidungen sind im Go Blog: Working with Errors in Go 1.13 ausführlich dokumentiert.

Mit Go 1.20 kam ein wichtiges Update: fmt.Errorf akzeptiert seither mehrere %w-Verben in einem Format-String und erzeugt damit einen Multi-Wrap, der semantisch zu errors.Join äquivalent ist.

Signatur und Semantik

Die Signatur von fmt.Errorf ist denkbar schlank: func Errorf(format string, a ...any) error. Errorf akzeptiert jedes Format-Verb, das fmt.Sprintf versteht — %s, %d, %v, %q, %T, alle Flags, alle Breitenangaben. Das einzige Verb mit Sonderstatus ist %w. Es nimmt zwingend einen Wert vom Typ error (oder nil) entgegen und sorgt dafür, dass der zurückgegebene Fehler eine Unwrap-Methode bekommt, die genau auf diesen übergebenen Fehler verweist.

Konkret: fmt.Errorf gibt einen error zurück, dessen dynamischer Typ ein interner Typ des fmt-Pakets ist. Dieser Typ implementiert Error() string (für die Stringrepräsentation) und Unwrap() error (für die Kette). Bei einem Multi-Wrap (Go 1.20+) implementiert er stattdessen Unwrap() []error. Vor Go 1.20 war mehrfaches %w schlicht ein Bug — nur das erste %w wurde tatsächlich gewrappt; alle weiteren gaben ein Warnsuffix in der Fehlermeldung aus. Seit Go 1.20 ist das offiziell unterstütztes Verhalten.

Was passiert ohne %w

Der wichtigste Vergleich zum Verständnis: %v (oder %s) versus %w. Beide produzieren auf den ersten Blick identische Strings — der entscheidende Unterschied liegt in der Identität der Fehlerkette.

Go ohne_w.go
package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func main() {
	wrappedV := fmt.Errorf("lookup user=42: %v", ErrNotFound)
	wrappedW := fmt.Errorf("lookup user=42: %w", ErrNotFound)

	fmt.Println("A:", wrappedV)
	fmt.Println("B:", wrappedW)
	fmt.Println("A errors.Is ErrNotFound:", errors.Is(wrappedV, ErrNotFound))
	fmt.Println("B errors.Is ErrNotFound:", errors.Is(wrappedW, ErrNotFound))
}
Output
A: lookup user=42: not found
B: lookup user=42: not found
A errors.Is ErrNotFound: false
B errors.Is ErrNotFound: true

Die Strings sind identisch — und genau deshalb verleitet %v so oft zu schlecht debugbarem Code. Erst der errors.Is-Aufruf zeigt den Unterschied: Bei Variante A ist die Verbindung zum Original-Error gekappt, bei Variante B nicht. In einer typischen Web-API würde Variante A dazu führen, dass der Handler ErrNotFound nicht mehr als solchen erkennt und stattdessen einen generischen 500 statt eines 404 zurückliefert.

%v ist nicht falsch — es ist eine bewusste Entscheidung, die Chain zu kappen. Sinnvoll etwa, wenn man interne Implementierungsdetails nicht an die API-Grenze leaken will. In den allermeisten Fällen will man aber %w.

Der Wrap-Mechanismus

Der gewrappte Error implementiert Unwrap() error. errors.Is und errors.As rufen diese Methode in einer Schleife auf, bis die Kette endet (Unwrap gibt nil zurück) oder ein Treffer gefunden wird.

Go chain.go
package main

import (
	"errors"
	"fmt"
)

var ErrDB = errors.New("db: connection refused")

func driver() error    { return ErrDB }

func repository() error {
	if err := driver(); err != nil {
		return fmt.Errorf("repository.loadUser: %w", err)
	}
	return nil
}

func service() error {
	if err := repository(); err != nil {
		return fmt.Errorf("service.GetUser(42): %w", err)
	}
	return nil
}

func main() {
	err := service()
	fmt.Println("message:", err)

	for e := err; e != nil; e = errors.Unwrap(e) {
		fmt.Printf("  level: %v\n", e)
	}

	fmt.Println("Is ErrDB:", errors.Is(err, ErrDB))
}
Output
message: service.GetUser(42): repository.loadUser: db: connection refused
  level: service.GetUser(42): repository.loadUser: db: connection refused
  level: repository.loadUser: db: connection refused
  level: db: connection refused
Is ErrDB: true

Jeder Aufruf von fmt.Errorf mit %w hängt ein neues Glied an die Kette. Die Stringrepräsentation entsteht durch Konkatenation der einzelnen Glieder mit : — daher die Konvention, keinen Doppelpunkt am Ende des Format-Strings zu setzen, denn er entsteht automatisch durch die nächste Schicht. Den manuellen errors.Unwrap-Loop oben braucht man im Alltag praktisch nie — errors.Is und errors.As machen das intern.

errors.Is vs errors.As

errors.Is und errors.As sind zwei unterschiedliche Werkzeuge mit klarer Aufgabenteilung. errors.Is beantwortet die Frage „ist irgendwo in der Kette dieser bestimmte Sentinel-Error?" — analog zu ==. errors.As beantwortet „gibt es in der Kette einen Error eines bestimmten Typs, und wenn ja, gib ihn mir als Pointer?".

Go is_as.go
package main

import (
	"errors"
	"fmt"
	"os"
)

type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation: %s: %s", e.Field, e.Msg)
}

func loadConfig() error {
	_, err := os.Open("/tmp/does-not-exist-zzz")
	if err != nil {
		return fmt.Errorf("loadConfig: %w", err)
	}
	return nil
}

func validate() error {
	return fmt.Errorf("validate user: %w", &ValidationError{Field: "email", Msg: "missing @"})
}

func main() {
	err1 := loadConfig()
	fmt.Println("Is ErrNotExist:", errors.Is(err1, os.ErrNotExist))

	err2 := validate()
	var vErr *ValidationError
	if errors.As(err2, &vErr) {
		fmt.Printf("As: Field=%s Msg=%s\n", vErr.Field, vErr.Msg)
	}
}
Output
Is ErrNotExist: true
As: Field=email Msg=missing @

Faustregel: Sentinel → errors.Is. Typ mit Daten → errors.As. errors.As braucht einen Pointer auf eine Variable des erwarteten Typs als zweites Argument; die Funktion füllt diese Variable, wenn ein passendes Glied gefunden wird, und gibt true zurück.

Mehrfaches %w — Multi-Wrap

Seit Go 1.20 darf fmt.Errorf mehrere %w-Verben enthalten. Der resultierende Error hat dann eine Unwrap() []error-Methode (statt der klassischen Unwrap() error), und errors.Is/errors.As traversieren alle Zweige. Das ist semantisch äquivalent zu errors.Join, nur mit zusätzlicher Format-Kontrolle.

Go multi_wrap.go
package main

import (
	"errors"
	"fmt"
)

var (
	ErrPrimary = errors.New("write failed")
	ErrCleanup = errors.New("rollback failed")
)

func main() {
	combined := fmt.Errorf("transaction: %w; cleanup: %w", ErrPrimary, ErrCleanup)

	fmt.Println("message:", combined)
	fmt.Println("Is ErrPrimary:", errors.Is(combined, ErrPrimary))
	fmt.Println("Is ErrCleanup:", errors.Is(combined, ErrCleanup))

	joined := errors.Join(ErrPrimary, ErrCleanup)
	fmt.Println("joined Is ErrPrimary:", errors.Is(joined, ErrPrimary))
}
Output
message: transaction: write failed; cleanup: rollback failed
Is ErrPrimary: true
Is ErrCleanup: true
joined Is ErrPrimary: true

Multi-Wrap ist besonders nützlich, wenn man mehrere unabhängige Fehler bündeln möchte — z. B. den eigentlichen Fehler einer Datenbank-Transaktion und den Folgefehler eines fehlgeschlagenen Rollbacks. errors.Join ist die generischere Variante ohne Format-String; fmt.Errorf mit mehreren %w ist sinnvoll, wenn man zusätzlich Kontext-Text formatieren möchte.

Fehlerketten richtig formatieren

Die Go-Community hat sich auf ein recht konsistentes Schreibmuster geeinigt. Das Format ist funcname(arg): %w — klein geschrieben, ohne abschließenden Punkt, ohne Newline. Der Hintergrund: Errors werden konkateniert, und Großbuchstaben oder Satzzeichen würden in zusammengesetzten Nachrichten unschön wirken.

Go konvention.go
// Schlecht
return fmt.Errorf("Failed to load user!\n")
return fmt.Errorf("ERROR: could not parse config: %w.", err)
return fmt.Errorf("oh no: %v", err) // %v statt %w → Chain kaputt

// Gut
return fmt.Errorf("loadUser(%d): %w", id, err)
return fmt.Errorf("parseConfig(%q): %w", path, err)
return fmt.Errorf("validate: %w", err)

Der Funktionsname plus relevantes Argument macht aus einer Fehlerkette einen Mini-Stacktrace ohne den Overhead von echten Stacktraces. Beispiel: service.GetUser(42): repository.loadUser: db: connection refused ist sofort lesbar — man sieht den Aufrufpfad und die Ursache in einer Zeile. Das failed to-Präfix, das man aus vielen anderen Sprachen kennt, ist in Go überflüssig: dass es sich um einen Fehler handelt, ist durch den Kontext (Rückgabetyp error, Log-Level) schon klar.

Drei Stolperfallen

Erstens: %w außerhalb von fmt.Errorf. Das Verb existiert für Sprintf, Printf etc. nicht als Wrap-Verb — es wird stattdessen als Format-Fehler markiert.

Zweitens: doppeltes %w vor Go 1.20. Wer auf einer alten Go-Version arbeitet (oder einen alten go.mod-Toolchain-Eintrag hat), bekommt eine kaputte Chain ohne Compile-Fehler — nur ein Hinweis im Output-String. Lösung: go.mod-go-Direktive auf 1.20+ heben.

Drittens: %w mit nil. fmt.Errorf("ctx: %w", nil) ergibt einen nicht-nilen Error mit der Nachricht ctx: %!w(<nil>) und einer Kette, die mit nil endet. Wer also blind nach jedem Call wrappt, erzeugt im Erfolgsfall plötzlich einen Fehler.

Go nil_wrap.go
package main

import "fmt"

func op() error { return nil }

func main() {
	err := fmt.Errorf("op: %w", op())
	fmt.Println("err is nil?", err == nil)
	fmt.Println("message:", err)
}
Output
err is nil? false
message: op: %!w(<nil>)

Defensive Programmierung bleibt also Pflicht: Erst nil-Check, dann wrappen.

4-Schichten-Error-Chain im HTTP-Service

Ein typischer Web-Service hat vier Schichten: Driver (z. B. database/sql), Repository (Persistenz-Logik), Service (Business-Logik) und Handler (HTTP-Schicht). Jede Schicht wrappt mit Kontext, der Handler klassifiziert mit errors.Is.

Go layered.go
package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
)

var (
	ErrUserNotFound = errors.New("user not found")
	ErrForbidden    = errors.New("forbidden")
)

func dbQuery(id int) error {
	if id == 0 {
		return errors.New("sql: no rows in result set")
	}
	return nil
}

func repoLoadUser(id int) error {
	err := dbQuery(id)
	if err != nil {
		if err.Error() == "sql: no rows in result set" {
			return fmt.Errorf("repo.LoadUser(%d): %w", id, ErrUserNotFound)
		}
		return fmt.Errorf("repo.LoadUser(%d): %w", id, err)
	}
	return nil
}

func serviceGetUser(callerRole string, id int) error {
	if callerRole != "admin" && id != 1 {
		return fmt.Errorf("service.GetUser(%d): %w", id, ErrForbidden)
	}
	if err := repoLoadUser(id); err != nil {
		return fmt.Errorf("service.GetUser(%d): %w", id, err)
	}
	return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	err := serviceGetUser("guest", 0)
	if err == nil {
		w.WriteHeader(http.StatusOK)
		return
	}
	switch {
	case errors.Is(err, ErrUserNotFound):
		http.Error(w, err.Error(), http.StatusNotFound)
	case errors.Is(err, ErrForbidden):
		http.Error(w, err.Error(), http.StatusForbidden)
	default:
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(handler))
	defer srv.Close()
	resp, _ := http.Get(srv.URL + "/")
	fmt.Println("status:", resp.StatusCode)
}
Output
status: 403

Der Handler kennt nur die Sentinels — er kennt weder Repository noch Driver. Wenn morgen eine neue Datenbank dazukommt, ändert sich am Handler nichts, solange die Repository-Schicht weiterhin ErrUserNotFound durch die Kette wrappt. Das ist die eigentliche Stärke der Error-Chain: Klassifikation an der Grenze, nicht an jeder Schicht.

typed-error-Extraktion mit errors.As

Der zweite häufige Fall: man hat einen Driver-spezifischen Error-Typ (z. B. *pq.Error von lib/pq), der wertvolle Felder wie Code, Detail, Constraint mitliefert. Diese will man im Service inspizieren, ohne dass die Logik den konkreten Driver kennen muss — errors.As ist genau dafür da.

Go errors_as.go
package main

import (
	"errors"
	"fmt"
)

type pqError struct {
	Code       string
	Constraint string
}

func (e *pqError) Error() string {
	return fmt.Sprintf("pq: code=%s constraint=%s", e.Code, e.Constraint)
}

func insertUser(email string) error {
	return &pqError{Code: "23505", Constraint: "users_email_key"}
}

func repoInsert(email string) error {
	if err := insertUser(email); err != nil {
		return fmt.Errorf("repo.Insert(%q): %w", email, err)
	}
	return nil
}

func createUser(email string) error {
	err := repoInsert(email)
	if err == nil {
		return nil
	}
	var pqe *pqError
	if errors.As(err, &pqe) {
		if pqe.Code == "23505" {
			return fmt.Errorf("createUser: duplicate %s (constraint=%s)",
				email, pqe.Constraint)
		}
	}
	return err
}

func main() {
	err := createUser("max@example.com")
	fmt.Println(err)
}
Output
createUser: duplicate max@example.com (constraint=users_email_key)

errors.As arbeitet sich durch alle Wrapping-Schichten und findet den *pqError zuverlässig, egal wie tief er verschachtelt ist. Der Service-Code muss nicht wissen, wie viele fmt.Errorf-Schichten dazwischen liegen — das macht ihn robust gegen Refactorings.

Häufige Stolperfallen

%w nur in fmt.Errorf

Das %w-Verb funktioniert ausschließlich in fmt.Errorf. In Sprintf, Printf, Fprintf etc. erzeugt es einen Format-Runtime-Hinweis wie %!w(...) und keine Wrap-Kette.

errors.Is vs errors.As: Vergleich vs Typ-Extraktion

errors.Is für Sentinel-Vergleich (analog zu ==), errors.As für die Extraktion eines konkreten Typs in einen Pointer. Beide traversieren die Kette automatisch — manuelles Unwrap ist fast nie nötig.

errors.Unwrap manuell nur in Spezialfällen

errors.Unwrap braucht man im Alltag praktisch nie. Logging-Frameworks oder Error-Inspektion-Tools verwenden es; normale Business-Logik nutzt Is/As.

Mehrfaches %w erst ab Go 1.20

Vor Go 1.20 hat nur das erste %w gewrappt, alle weiteren wurden als %v behandelt und erzeugten ein Format-Warnsuffix. Toolchain-Version in go.mod prüfen.

%v statt %w bricht die Chain

fmt.Errorf(&quot;ctx: %v&quot;, err) produziert dieselbe Stringausgabe wie %w, aber errors.Is schlägt fehl. Bewusste Entscheidung, wenn man interne Details an einer API-Grenze nicht durchreichen will.

Sentinel-Errors als Paket-Variablen

Konvention: var ErrNotFound = errors.New(&quot;user: not found&quot;) auf Paket-Ebene. So sind Sentinels stabil vergleichbar und können von Konsumenten via errors.Is klassifiziert werden.

Wrap-String-Konvention

Format: &quot;funcname(args): %w&quot;. Klein geschrieben, ohne abschließenden Punkt, ohne Newline. So ergeben verschachtelte Wraps lesbare konkatenierte Strings.

%w bei nil ergibt nicht-nilen Error

fmt.Errorf(&quot;op: %w&quot;, nil) liefert einen Error-Wert, der nicht gleich nil ist — Inhalt op: %!w(<nil>). Erst nil-Check des Underlying-Errors, dann wrappen.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

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

Zur Übersicht