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:

Go sentinel-classic.go
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.

Go wrap-basics.go
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))
}
Output
Text:     loadProfile: EOF
Is(EOF):  true

Drei Eigenschaften des %w-Verbs sind festzuhalten:

  • Genau ein %w pro Errorf-Aufruf ist erlaubt (seit Go 1.20 auch mehrere, dann wird der Rückgabewert intern wie errors.Join behandelt). Mehrere %w ohne Multi-Wrap-Modus ergibt einen Format-Fehler.
  • Das Argument zu %w muss error (oder nil) sein. fmt.Errorf("x: %w", "string") ist ein Compile-Time-Hinweis von go vet, kein Crash, aber semantisch unsauber.
  • %w ändert die String-Repräsentation nicht. Der gewrappte Fehler erscheint im Text genauso wie bei %s oder %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.

Go w-vs-v.go
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))
}
Output
Text a: layer: EOF
Text b: layer: EOF
Is(a, EOF): true
Is(b, EOF): false

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

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

Go errors-is.go
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))
}
Output
err: openDeep("/does/not/exist"): open /does/not/exist: no such file or directory
Is(fs.ErrNotExist): true
Is(io.EOF):        false

Interessant 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:

Go custom-is.go
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))
}
Output
Is(ErrNotFound): true
Is(ErrNotFound): false

Ein *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.

Go errors-as.go
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)
    }
}
Output
Op   = "open"
Path = "/no/such/file"
Err  = no such file or directory

Der 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:

  • target muss ein Pointer auf ein Interface oder auf einen Typ sein, der error implementiert. errors.As(err, &"string") paniciert.
  • target darf nicht nil sein. 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…WerkzeugBeispiel
…ob ein bestimmter Sentinel in der Kette steckterrors.Iserrors.Is(err, io.EOF)
…ob ein bestimmter Error-Typ in der Kette steckt, und du seine Felder lesen willsterrors.Asvar pe *os.PathError; errors.As(err, &pe)
…ob ein Error-Typ in der Kette steckt, du brauchst aber keine Feldererrors.As mit Dummy-Variablevar pe *os.PathError; if errors.As(err, &pe) { ... }
…ob der Fehler exakt ein Wert ist (keine Kette)err == targetseltene 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:

Go errors-join.go
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))
}
Output
err: zu kurz
ungültiges Zeichen
Is(ErrTooShort): true
Is(ErrBadChar): true
Is(ErrTooLong): false

Drei wichtige Eigenschaften:

  • errors.Join filtert nil. Wer errs einen nil-Wert übergibt, kriegt ihn nicht im Resultat. Wenn alle Argumente nil sind, gibt Join selbst nil zurü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.Is und errors.As finden Treffer in jedem der gebündelten Fehler. Das ist der eigentliche Wert von Join gegenü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.

Go custom-unwrap.go
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)
    }
}
Output
Is(io.EOF): true
Query: SELECT 1

Fü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:

Go custom-multi-unwrap.go
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.ErrNotExist aus deinem File-Layer): wrap.
  • Ist der innere Fehler ein Implementations-Detail, das du gleich wieder ändern könntest? Wenn ja: kein Wrap, lieber %v oder 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.
Go information-hiding.go
// 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.

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

Go batch-join.go
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")
    }
}
Output
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 verschieben

Das ist der eigentliche Gewinn von errors.Join gegenüber einer naiven String-Konkatenation: die einzelnen Fehler bleiben typisierte, gewrappte Werteerrors.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

FunktionEingangZweckSeit
fmt.Errorf("...: %w", err)error, ergänzt Kontextwrappt einen Fehler in einen neuen *wrapErrorGo 1.13
errors.Unwrap(err) errorbeliebiger Fehlergibt den nächsten inneren Fehler zurück (1 Schicht ab)Go 1.13
errors.Is(err, target) boolFehler + Sentinel-Wertsucht in der Kette nach target per == oder Is-MethodeGo 1.13
errors.As(err, &target) boolFehler + Pointer auf Typsucht in der Kette nach passendem Typ, schreibt Treffer in *targetGo 1.13
errors.Join(errs ...error) errorListe von Fehlernbündelt zu einem Fehler mit Unwrap() []errorGo 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

/ Weiter

Zurück zu Error-Handling

Zur Übersicht