Vor Go 1.13 war jeder Fehler eine opaque Box: error ist ein Interface mit einer einzigen Methode Error() string, und wer aus einem tieferen Layer kam, war beim Aufrufer nur noch ein String. Wer den ursprünglichen Fehler trotzdem identifizieren wollte, musste auf Sentinel-Werte mit == vergleichen — und sobald irgendwo fmt.Errorf("kontext: %v", err) dazwischenstand, war die Verbindung gekappt. Go 1.13 brachte die Wrapping-API: %w in fmt.Errorf, plus errors.Is, errors.As und errors.Unwrap in der Stdlib. Go 1.20 ergänzte errors.Join. Dieser Artikel arbeitet die vier Werkzeuge gründlich durch, zeigt, wann Is und wann As, und macht explizit, wo Wrapping die falsche Entscheidung ist.
Pre-1.13 — Sentinels und String-Vergleich
In der Welt vor Go 1.13 gab es genau zwei Wege, einen Fehler zu identifizieren. Der erste: Sentinel-Vergleich mit ==. Pakete wie io exportieren Variablen wie io.EOF, und der Aufrufer prüft direkt:
package main
import (
"fmt"
"io"
"strings"
)
func main() {
r := strings.NewReader("hallo")
buf := make([]byte, 16)
_, err := r.Read(buf)
if err == io.EOF {
fmt.Println("Ende erreicht")
}
}Der zweite Weg: Type-Assertion auf einen konkreten Error-Typ, etwa *os.PathError. Beides funktionierte — solange der Fehler unverändert beim Aufrufer ankam. Sobald ein Zwischen-Layer aber Kontext anhängen wollte (fmt.Errorf("öffnen: %v", err)), entstand ein neuer Error-String, und == lieferte false. Das Wissen, dass darunter eigentlich ein io.EOF lag, war verloren.
Manche Pakete halfen sich mit Eigenbau-Wrappern (github.com/pkg/errors mit errors.Cause), aber es gab keinen Stdlib-Standard. Genau das hat Go 1.13 geändert.
fmt.Errorf mit %w — der Wrap-Operator
Das Format-Verb %w ist der einzige Eintrittspunkt, mit dem du in Stdlib-Code einen Fehler einpackst, statt seinen Text einfach in einen neuen String zu kleben. Der zurückgegebene Wert ist ein konkreter Typ *fmt.wrapError, der eine Unwrap() error-Methode hat — und genau diese Methode ist der Hebel, an dem errors.Is, errors.As und errors.Unwrap ansetzen.
package main
import (
"errors"
"fmt"
"io"
)
func readConfig() error {
// simuliert: aus einem leeren Reader gelesen -> EOF
return io.EOF
}
func loadProfile() error {
if err := readConfig(); err != nil {
return fmt.Errorf("loadProfile: %w", err)
}
return nil
}
func main() {
err := loadProfile()
fmt.Println("Text: ", err)
fmt.Println("Is(EOF): ", errors.Is(err, io.EOF))
}Text: loadProfile: EOF
Is(EOF): trueDrei Eigenschaften des %w-Verbs sind festzuhalten:
- Genau ein
%wproErrorf-Aufruf ist erlaubt (seit Go 1.20 auch mehrere, dann wird der Rückgabewert intern wieerrors.Joinbehandelt). Mehrere%wohne Multi-Wrap-Modus ergibt einen Format-Fehler. - Das Argument zu
%wmusserror(odernil) sein.fmt.Errorf("x: %w", "string")ist ein Compile-Time-Hinweis vongo vet, kein Crash, aber semantisch unsauber. %wändert die String-Repräsentation nicht. Der gewrappte Fehler erscheint im Text genauso wie bei%soder%v— die Wrap-Information sitzt nur im Typ, nicht im String.
%w vs. %v — der semantische Unterschied
Das ist die Stelle, an der die meisten Migrations-Fehler entstehen. Optisch tun %w und %v dasselbe — beide formatieren den Fehler in den umschließenden Text. Der Unterschied ist unsichtbar, aber folgenreich.
package main
import (
"errors"
"fmt"
"io"
)
func wrappedW() error {
return fmt.Errorf("layer: %w", io.EOF) // erhält Wrapper-Kette
}
func wrappedV() error {
return fmt.Errorf("layer: %v", io.EOF) // verliert Wrapper-Kette
}
func main() {
a := wrappedW()
b := wrappedV()
fmt.Println("Text a:", a)
fmt.Println("Text b:", b)
fmt.Println("Is(a, EOF):", errors.Is(a, io.EOF))
fmt.Println("Is(b, EOF):", errors.Is(b, io.EOF))
}Text a: layer: EOF
Text b: layer: EOF
Is(a, EOF): true
Is(b, EOF): falseBeide Strings sind identisch — errors.Is aber liefert nur für a true. Bei %v ruft fmt einfach err.Error() auf, klebt das Resultat in den Format-String und gibt einen frischen *errors.errorString zurück, der mit dem Original keine Verbindung mehr hat. Bei %w baut fmt stattdessen einen Wrapper, der Unwrap() auf das Original delegiert.
Faustregel: Wer in einer Library oder einem internen Layer einen Fehler weiterreicht, nimmt %w. Wer einen Fehler bewusst als String „einfriert", weil der Aufrufer die innere Ursache nicht sehen soll, nimmt %v (siehe Information-Hiding weiter unten).
errors.Unwrap — eine Schicht abrollen
errors.Unwrap(err) ist die einfachste Funktion der API: sie ruft die Unwrap() error-Methode auf dem übergebenen Fehler auf, sofern vorhanden, und gibt das Ergebnis zurück. Existiert keine Unwrap-Methode, ist das Ergebnis nil.
package main
import (
"errors"
"fmt"
"io"
)
func main() {
inner := io.EOF
mid := fmt.Errorf("midlayer: %w", inner)
outer := fmt.Errorf("outermost: %w", mid)
fmt.Println("outer :", outer)
fmt.Println("unwrap(outer):", errors.Unwrap(outer))
fmt.Println("unwrap(mid) :", errors.Unwrap(mid))
fmt.Println("unwrap(inner):", errors.Unwrap(inner))
}outer : outermost: midlayer: EOF
unwrap(outer): midlayer: EOF
unwrap(mid) : EOF
unwrap(inner): <nil>In Anwendungs-Code brauchst du errors.Unwrap praktisch nie direkt — errors.Is und errors.As laufen die Kette automatisch ab. errors.Unwrap ist nützlich, wenn du selbst Code schreibst, der die Kette inspizieren will (Logging-Middleware, Error-Reporter, Tests).
errors.Is — Identitätsvergleich entlang der Kette
errors.Is(err, target) beantwortet eine Frage: Steht in der Wrapper-Kette von err irgendwo der Sentinel-Wert target? Die Funktion läuft die Kette über Unwrap() ab und vergleicht jedes Glied mit target. Vergleichen heißt zuerst ==; zusätzlich darf jedes Glied eine eigene Is(target error) bool-Methode haben, die ein angepasstes Matching definiert.
package main
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
)
func openDeep(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("openDeep(%q): %w", path, err)
}
return nil
}
func main() {
err := openDeep("/does/not/exist")
fmt.Println("err:", err)
fmt.Println("Is(fs.ErrNotExist):", errors.Is(err, fs.ErrNotExist))
fmt.Println("Is(io.EOF): ", errors.Is(err, io.EOF))
}err: openDeep("/does/not/exist"): open /does/not/exist: no such file or directory
Is(fs.ErrNotExist): true
Is(io.EOF): falseInteressant ist hier, dass os.Open keinen direkten fs.ErrNotExist zurückgibt, sondern einen *fs.PathError. Dieser implementiert eine eigene Is(target error) bool-Methode, die für fs.ErrNotExist auch dann true liefert, wenn der konkrete Typ ein PathError ist — ein klassisches Beispiel für angepasstes Matching. Eigener Sentinel mit Is-Methode:
package main
import (
"errors"
"fmt"
)
type HTTPError struct {
Status int
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d", e.Status)
}
// ErrNotFound ist ein abstrakter Marker — alle 404er sollen ihm matchen.
var ErrNotFound = errors.New("not found")
func (e *HTTPError) Is(target error) bool {
return target == ErrNotFound && e.Status == 404
}
func main() {
err := fmt.Errorf("api call: %w", &HTTPError{Status: 404})
fmt.Println("Is(ErrNotFound):", errors.Is(err, ErrNotFound))
err2 := fmt.Errorf("api call: %w", &HTTPError{Status: 500})
fmt.Println("Is(ErrNotFound):", errors.Is(err2, ErrNotFound))
}Is(ErrNotFound): true
Is(ErrNotFound): falseEin *HTTPError mit Status 404 matcht den abstrakten ErrNotFound-Sentinel, ein 500er nicht. Aufrufer schreiben weiterhin nur errors.Is(err, ErrNotFound) — die Logik, was das im Detail bedeutet, liegt im Error-Typ selbst.
errors.As — Typ-Treffer in der Kette
errors.As(err, &target) ist das Gegenstück für typisierte Fehler. Die Funktion läuft die Kette ab und sucht das erste Glied, dessen konkreter Typ dem Typ von *target entspricht (oder ein Interface erfüllt, das target repräsentiert). Treffer? Dann schreibt As den gefundenen Fehler in *target und liefert true.
package main
import (
"errors"
"fmt"
"os"
)
func loadCfg() error {
_, err := os.Open("/no/such/file")
if err != nil {
return fmt.Errorf("loadCfg: %w", err)
}
return nil
}
func main() {
err := loadCfg()
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("Op = %q\n", pathErr.Op)
fmt.Printf("Path = %q\n", pathErr.Path)
fmt.Printf("Err = %v\n", pathErr.Err)
}
}Op = "open"
Path = "/no/such/file"
Err = no such file or directoryDer entscheidende Unterschied zu einer simplen Type-Assertion: errors.As läuft die Wrapper-Kette ab. Eine direkte Assertion pathErr, ok := err.(*os.PathError) würde scheitern, weil das äußere err ein *fmt.wrapError ist — der gesuchte *os.PathError sitzt erst eine Schicht tiefer.
Zwei Regeln zur Signatur:
targetmuss ein Pointer auf ein Interface oder auf einen Typ sein, dererrorimplementiert.errors.As(err, &"string")paniciert.targetdarf nichtnilsein. Das ist ein Panic-Fall, kein Return-false.
Wann Is, wann As
Die Frage taucht in jedem Review-Gespräch auf. Die Antwort lässt sich auf eine Zeile reduzieren: Is für Werte, As für Typen.
| Du willst wissen… | Werkzeug | Beispiel |
|---|---|---|
| …ob ein bestimmter Sentinel in der Kette steckt | errors.Is | errors.Is(err, io.EOF) |
| …ob ein bestimmter Error-Typ in der Kette steckt, und du seine Felder lesen willst | errors.As | var pe *os.PathError; errors.As(err, &pe) |
| …ob ein Error-Typ in der Kette steckt, du brauchst aber keine Felder | errors.As mit Dummy-Variable | var pe *os.PathError; if errors.As(err, &pe) { ... } |
| …ob der Fehler exakt ein Wert ist (keine Kette) | err == target | seltene Optimierung, vermeide das |
Is ist die richtige Wahl, wenn der Fehler-Wert die gesamte Information trägt — wie io.EOF, das nichts weiter sagt als „Ende erreicht". As ist die richtige Wahl, wenn der Fehler ein strukturierter Typ mit Feldern ist — *os.PathError enthält Op, Path, Err, ein eigener *ValidationError enthält vielleicht Field, Reason. Hier willst du die Felder lesen, nicht nur den Typ identifizieren.
errors.Join — mehrere Fehler bündeln (Go 1.20)
Vor Go 1.20 war es im Bibliotheks-Code üblich, eigene MultiError-Typen zu basteln, sobald eine Operation mehrere unabhängige Fehler gleichzeitig produzieren konnte — ein Batch-Job, eine Validierung mit mehreren fehlerhaften Feldern, ein paralleler Fan-Out. Mit errors.Join ist das Standard-API:
package main
import (
"errors"
"fmt"
)
var (
ErrTooShort = errors.New("zu kurz")
ErrTooLong = errors.New("zu lang")
ErrBadChar = errors.New("ungültiges Zeichen")
)
func validate(s string) error {
var errs []error
if len(s) < 3 {
errs = append(errs, ErrTooShort)
}
if len(s) > 20 {
errs = append(errs, ErrTooLong)
}
for _, r := range s {
if r == ' ' {
errs = append(errs, ErrBadChar)
break
}
}
return errors.Join(errs...)
}
func main() {
err := validate("a b")
fmt.Println("err:", err)
fmt.Println("Is(ErrTooShort):", errors.Is(err, ErrTooShort))
fmt.Println("Is(ErrBadChar):", errors.Is(err, ErrBadChar))
fmt.Println("Is(ErrTooLong):", errors.Is(err, ErrTooLong))
}err: zu kurz
ungültiges Zeichen
Is(ErrTooShort): true
Is(ErrBadChar): true
Is(ErrTooLong): falseDrei wichtige Eigenschaften:
errors.Joinfiltertnil. Wererrseinennil-Wert übergibt, kriegt ihn nicht im Resultat. Wenn alle Argumentenilsind, gibtJoinselbstnilzurück — kein leerer Container.- Der String ist die Konkatenation aller Einzel-Strings mit Newline-Trennung. Das ist die Default-Repräsentation, du kannst sie aber selten so darstellen wollen — Logging-Layer formatieren oft eigene Listen.
errors.Isunderrors.Asfinden Treffer in jedem der gebündelten Fehler. Das ist der eigentliche Wert vonJoingegenüber dem alten Trick mit String-Konkatenation — die Fehler bleiben als typisierte Werte zugreifbar.
Intern erfüllt der Rückgabewert ein erweitertes Wrap-Interface mit Unwrap() []error (statt Unwrap() error). errors.Is und errors.As kennen beide Formen seit Go 1.20.
Eigene Wrapper — Unwrap auf Custom-Typen
Wer einen eigenen Error-Typ schreibt, der einen inneren Fehler trägt, muss eine Unwrap() error-Methode anbieten, damit errors.Is und errors.As die Kette durchlaufen können. Ohne Unwrap ist der Custom-Typ ein Endpunkt — die Suche stoppt dort.
package main
import (
"errors"
"fmt"
"io"
)
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}
// Pflicht-Methode für die Wrapping-API.
func (e *QueryError) Unwrap() error {
return e.Err
}
func runQuery(q string) error {
return &QueryError{Query: q, Err: io.EOF}
}
func main() {
err := runQuery("SELECT 1")
fmt.Println("Is(io.EOF):", errors.Is(err, io.EOF))
var qe *QueryError
if errors.As(err, &qe) {
fmt.Println("Query:", qe.Query)
}
}Is(io.EOF): true
Query: SELECT 1Für gebündelte Fehler — wenn dein eigener Typ mehrere innere Fehler tragen soll — implementierst du stattdessen Unwrap() []error. Damit unterstützt dein Typ dieselbe Multi-Wrap-Semantik wie errors.Join:
type ValidationError struct {
Field string
Errs []error
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation of %q failed: %d issues", e.Field, len(e.Errs))
}
// Multi-Wrap-Form, von errors.Is/As seit Go 1.20 unterstützt.
func (e *ValidationError) Unwrap() []error {
return e.Errs
}Wann NICHT wrappen — Information-Hiding
Wrapping ist mächtig — und die Macht hat eine Schattenseite. Wenn dein Paket den inneren Fehler über %w weiterreicht, wird er Teil deiner öffentlichen API. Aufrufer können errors.As darauf machen, ihren Code daran ausrichten — und sobald du in einer späteren Version die innere Implementation austauschst (etwa von database/sql auf einen anderen Driver), brichst du den Aufrufer-Code, obwohl deine offizielle API stabil ist.
Die Standard-Empfehlung der Go-Doku (go1.13-errors-Blog): Wrappen ist eine bewusste Entscheidung, kein Default. Drei Leitfragen:
- Hat der Aufrufer ein legitimes Interesse, den inneren Fehler zu inspizieren? Wenn ja (z. B.
fs.ErrNotExistaus deinem File-Layer): wrap. - Ist der innere Fehler ein Implementations-Detail, das du gleich wieder ändern könntest? Wenn ja: kein Wrap, lieber
%voder eine eigene typisierte Fehler-Klasse, die die innere Ursache abstrahiert. - Wirfst du den Fehler eigentlich nur weiter, ohne Kontext zu ergänzen? Dann gib ihn unverändert zurück — ein wertloses
fmt.Errorf("err: %w", err)ohne neuen Kontext ist Lärm in der Stack-Trace.
// GUT — Wrapping mit Kontext, weil der Aufrufer den inneren
// os.PathError sehen darf (Teil der dokumentierten API).
func loadConfig(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("loadConfig(%q): %w", path, err)
}
defer f.Close()
return nil
}
// BEWUSST KEIN Wrap — der DB-Fehler ist Implementations-Detail.
// Außen sieht der Aufrufer einen abstrakten ErrUserNotFound,
// die DB-Connection-Strings bleiben verborgen.
var ErrUserNotFound = errors.New("user not found")
func loadUser(id int64) error {
row := db.QueryRow("SELECT ... WHERE id = ?", id)
var u User
if err := row.Scan(&u); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUserNotFound
}
// bewusst %v, nicht %w — innere SQL-Details verbergen
return fmt.Errorf("loadUser: %v", err)
}
return nil
}Faustregel: Wrappe nach unten, kapsle nach außen. In internen Layern lass die Kette intakt, damit du beim Debuggen den vollen Stack hast. An stabilen Paket-Grenzen entscheide bewusst, ob du den inneren Fehler exponierst.
Praxis 1 — mehrstufiger Service-Stack
Ein realistisches Szenario: ein User-Service ruft einen Repository-Layer, der wiederum auf einem Storage-Layer sitzt. Jeder Layer wrappt den Fehler des darunterliegenden, ergänzt seinen eigenen Kontext, und am Ende landet alles in einem HTTP-Handler.
package main
import (
"errors"
"fmt"
"io/fs"
)
// --- Storage-Layer ----------------------------------------------
type StorageError struct {
Op string
Key string
Err error
}
func (e *StorageError) Error() string {
return fmt.Sprintf("storage %s(%q): %v", e.Op, e.Key, e.Err)
}
func (e *StorageError) Unwrap() error { return e.Err }
func storageGet(key string) error {
// simulieren: Datei existiert nicht
return &StorageError{Op: "get", Key: key, Err: fs.ErrNotExist}
}
// --- Repository-Layer ------------------------------------------
func repoFindUser(id int64) error {
key := fmt.Sprintf("users/%d.json", id)
if err := storageGet(key); err != nil {
return fmt.Errorf("repo.FindUser(%d): %w", id, err)
}
return nil
}
// --- Service-Layer ---------------------------------------------
func svcGetProfile(userID int64) error {
if err := repoFindUser(userID); err != nil {
return fmt.Errorf("svc.GetProfile(%d): %w", userID, err)
}
return nil
}
// --- Handler ---------------------------------------------------
func handle(userID int64) {
err := svcGetProfile(userID)
if err == nil {
fmt.Println("200 OK")
return
}
fmt.Println("Full error:", err)
if errors.Is(err, fs.ErrNotExist) {
fmt.Println("-> HTTP 404")
}
var se *StorageError
if errors.As(err, &se) {
fmt.Printf("-> Log: op=%s key=%q\n", se.Op, se.Key)
}
}
func main() {
handle(42)
}Full error: svc.GetProfile(42): repo.FindUser(42): storage get("users/42.json"): file does not exist
-> HTTP 404
-> Log: op=get key="users/42.json"Drei Layer, drei Wrap-Schichten, und am Ende kommt der Handler trotzdem an alle relevanten Informationen heran: errors.Is findet die abstrakte Wurzel (fs.ErrNotExist), errors.As zieht den strukturierten *StorageError mit Op und Key heraus, der Fehlertext ist eine vollständige Trace für die Logs.
Praxis 2 — Batch-Job mit errors.Join
Zweites realistisches Szenario: ein Batch-Importer verarbeitet hundert Dateien. Wenn drei davon scheitern, will der Aufrufer alle drei Fehler sehen, nicht nur den ersten — gleichzeitig soll er aber programmatisch prüfen können, ob ein bestimmter Fehler-Typ darunter war.
package main
import (
"errors"
"fmt"
)
var (
ErrQuotaExceeded = errors.New("quota exceeded")
ErrCorrupt = errors.New("file corrupt")
)
type ItemError struct {
Item string
Err error
}
func (e *ItemError) Error() string {
return fmt.Sprintf("item %q: %v", e.Item, e.Err)
}
func (e *ItemError) Unwrap() error { return e.Err }
func importOne(name string) error {
switch name {
case "a.csv":
return &ItemError{Item: name, Err: ErrCorrupt}
case "b.csv":
return &ItemError{Item: name, Err: ErrQuotaExceeded}
case "c.csv":
return &ItemError{Item: name, Err: ErrCorrupt}
default:
return nil
}
}
func importAll(items []string) error {
var errs []error
for _, it := range items {
if err := importOne(it); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func main() {
err := importAll([]string{"a.csv", "ok.csv", "b.csv", "c.csv"})
if err == nil {
fmt.Println("alles importiert")
return
}
fmt.Println("Gesamtfehler:")
fmt.Println(err)
fmt.Println()
if errors.Is(err, ErrQuotaExceeded) {
fmt.Println("-> mindestens ein Quota-Fehler, Retry-Backoff verlängern")
}
if errors.Is(err, ErrCorrupt) {
fmt.Println("-> korrupte Datei(en), in Quarantäne verschieben")
}
}Gesamtfehler:
item "a.csv": file corrupt
item "b.csv": quota exceeded
item "c.csv": file corrupt
-> mindestens ein Quota-Fehler, Retry-Backoff verlängern
-> korrupte Datei(en), in Quarantäne verschiebenDas ist der eigentliche Gewinn von errors.Join gegenüber einer naiven String-Konkatenation: die einzelnen Fehler bleiben typisierte, gewrappte Werte — errors.Is(err, ErrQuotaExceeded) läuft durch alle drei Einträge des Join-Containers und schaut innerhalb jedes *ItemError weiter. Ein einziger Eintrag in der Sammlung reicht, damit Is true liefert.
Übersichts-Tabelle
| Funktion | Eingang | Zweck | Seit |
|---|---|---|---|
fmt.Errorf("...: %w", err) | error, ergänzt Kontext | wrappt einen Fehler in einen neuen *wrapError | Go 1.13 |
errors.Unwrap(err) error | beliebiger Fehler | gibt den nächsten inneren Fehler zurück (1 Schicht ab) | Go 1.13 |
errors.Is(err, target) bool | Fehler + Sentinel-Wert | sucht in der Kette nach target per == oder Is-Methode | Go 1.13 |
errors.As(err, &target) bool | Fehler + Pointer auf Typ | sucht in der Kette nach passendem Typ, schreibt Treffer in *target | Go 1.13 |
errors.Join(errs ...error) error | Liste von Fehlern | bündelt zu einem Fehler mit Unwrap() []error | Go 1.20 |
Besonderheiten
Genau ein `%w` pro Errorf — oder bewusst mehrere ab Go 1.20.
Bis Go 1.19 war exakt ein %w pro fmt.Errorf-Aufruf erlaubt; mehrere %w produzierten einen Format-Fehler. Seit Go 1.20 darfst du mehrere %w verwenden — das Ergebnis verhält sich wie errors.Join über die genannten Fehler. Wer mehrere unzusammenhängende Fehler bündeln will, sollte trotzdem direkt errors.Join nehmen.
`%w` ändert den Fehler-String nicht — der Unterschied steckt nur im Typ.
Visuell sind fmt.Errorf("x: %w", e) und fmt.Errorf("x: %v", e) ununterscheidbar. Der Unterschied: %w baut einen Wrapper-Typ mit Unwrap-Methode, %v baut einen frischen String-Fehler ohne Verbindung. errors.Is und errors.As finden den ersten, nicht den zweiten.
`errors.As` braucht einen Pointer auf einen Pointer — bei Pointer-Receiver-Errors.
Die meisten Stdlib-Error-Typen wie *os.PathError haben Pointer-Receiver. Die As-Variable muss dann selbst ein Pointer-Pointer sein: var pe *os.PathError; errors.As(err, &pe). Wer fälschlich var pe os.PathError; errors.As(err, &pe) schreibt, kriegt einen Laufzeit-Panic.
`errors.Is(err, nil)` ist nicht dasselbe wie `err == nil`.
errors.Is(nil, nil) liefert true, errors.Is(someErr, nil) liefert false. Trotzdem ist err == nil der idiomatische Nil-Check — errors.Is für diesen Zweck zu nutzen ist Code-Smell und in Reviews unbeliebt. Nutze errors.Is ausschließlich für nicht-nil-Targets.
Eigene `Is`-Methode definiert das Matching, nicht den Typ.
Wer einem eigenen Error-Typ eine Is(target error) bool-Methode mitgibt, kann frei entscheiden, welche Sentinels dazu matchen — auch über Typ-Grenzen hinweg. Klassisches Muster: ein konkreter *HTTPError matcht den abstrakten ErrNotFound-Sentinel, sobald sein Status 404 ist. Aufrufer schreiben weiterhin nur den abstrakten Vergleich, die Logik kapselt der Error-Typ.
`errors.Join(nil, nil, nil)` ist `nil` — keine leere Hülle.
errors.Join filtert alle nil-Werte aus der Liste; bleibt am Ende kein Fehler übrig, gibt es nil zurück. Das ist ein bewusster Komfort: du kannst bedingungslos sammeln (errs = append(errs, doSomething())) und am Ende return errors.Join(errs...) schreiben, ohne den leeren-Container-Fall extra zu behandeln.
`%w` weitergeben ohne neuen Kontext ist Lärm.
Code wie return fmt.Errorf("%w", err) oder return fmt.Errorf(": %w", err) ist sinnlos — die Wrapping-Schicht trägt keinen neuen Kontext, der Fehlertext wird länger, der Stack tiefer, der Nutzen null. Gib den Fehler unverändert zurück (return err) oder ergänze einen konkreten Hinweis ("loadConfig(%q): %w"). Eine Schicht ohne Mehrwert ist eine Schicht zu viel.
An stabilen API-Grenzen: bewusst nicht wrappen.
Wer in einem öffentlichen Paket den inneren Fehler über %w exponiert, macht ihn zur API-Garantie — Aufrufer matchen mit errors.As und brechen, sobald du die Implementation tauschst. An solchen Grenzen ist %v plus ein abstrakter Sentinel (eigener ErrXxx) die robustere Wahl. Innerhalb deiner internen Layer dagegen ist Wrapping fast immer richtig.
Weiterführende Ressourcen
Externe Quellen
- Working with Errors in Go 1.13 — Go Blog
errors.Is— Stdlib-Dokuerrors.As— Stdlib-Dokuerrors.Join— Stdlib-Dokuerrors.Unwrap— Stdlib-Dokufmt.Errorfund das%w-Verb — Stdlib-Doku- Go 1.20 Release Notes —
errors.Joinund Multi-%w
Verwandte Artikel
- Error als Wert — Idiomatische Fehler-Rückgabe in Go
- Sentinel- und Typed-Errors — Muster für identifizierbare Fehler
panicundrecover— wann sie angebracht sinddefer/recover-Pattern — Panics in Errors verwandeln- Type Assertion und Type Switch —
err.(*MyError)underrors.Asim Vergleich - nil-Interface-Fallen — warum
*MyError(nil)kein nil-errorist