Fehlerbehandlung in Go: errors.Is, errors.As und Error Wrapping

1. Übersicht

In Go sind Fehler normale Werte. Funktionen geben Fehler an die Aufrufer zurück, und der Aufrufer entscheidet selbst, wie damit umzugehen ist. Wenn ein Fehler durch mehrere Schichten wie Repository, Service und Handler wandert, möchtest du normalerweise Kontext hinzufügen, aber dennoch den ursprünglichen Fehler erkennen können.

Dieser Artikel zeigt dir, wie du Fehler mit fmt.Errorf und %w wrappst, Fehler mit errors.Is überprüfst, Custom Errors mit errors.As extrahierst und mehrere Fehler mit errors.Join kombinierst. Am Ende folgen Tests und häufige Fallstricke. Alle Beispiele verwenden nur die Standard-Library und laufen mit Go 1.20 und später. Nur errors.AsType benötigt Go 1.26.

2. Sentinel Error und Grenzen des ==-Vergleichs

In Go ist error ein sehr kleines Interface:

type error interface {
	Error() string
}

Der einfachste Weg, einen “benannten” Fehler zu definieren, ist die Erstellung eines Sentinel Error. Das ist eine Variable auf Package-Ebene, die der Aufrufer direkt vergleichen kann:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("Bestellung nicht gefunden")
	}
}
Bestellung nicht gefunden

errors.New erstellt einen neuen Pointer. Daher ist err == ErrOrderNotFound nur wahr, wenn err genau diese Variable ist. Dies funktioniert gut, bis du anfängst, Kontext zu Fehlern hinzuzufügen.

3. Fehler wrappen mit fmt.Errorf und %w

3.1. Unterschied zwischen %w und %v

Angenommen, der Service möchte die Bestellungs-ID zur Fehlermeldung hinzufügen:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

Erklärung Zeile für Zeile:

  • fmt.Errorf(... %w ...) erstellt einen neuen Fehler. Dieser Fehler behält einen Verweis auf den ursprünglichen err durch die Methode Unwrap() error.
  • err == ErrOrderNotFound gibt false zurück, da err jetzt ein anderer Wert ist, genannt ein „gewrappter” Fehler.
  • errors.Is traversiert die Wrapper-Kette und findet ErrOrderNotFound darin.
  • errors.Unwrap entfernt genau eine Ebene und gibt den ursprünglichen Fehler zurück.

Wenn du %w durch %v ersetzt, ist die gedruckte Meldung immer noch gleich. Aber errors.Is gibt false zurück, da %v nur den Text kopiert und nicht den ursprünglichen Fehler behält.

3.2. Mehrschichtiges Error Wrapping

Jede Schicht kann eine weitere Ebene wrappen. errors.Is kann den ursprünglichen Fehler immer noch finden, egal wie tief er liegt:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

Die Fehlermeldung liest sich jetzt wie ein komprimierter Stack Trace: die äußerste Schicht liegt auf der linken Seite, die Grundursache auf der rechten Seite.

4. errors.Is: Überprüfung „ist dieser Fehler X”

errors.Is(err, target) gibt true zurück, wenn ein beliebiger Fehler in der Kette eine der beiden Bedingungen erfüllt:

  1. Gleich target beim ==-Vergleich, oder
  2. Hat eine Methode Is(error) bool und diese Methode gibt true für target zurück.

Die zweite Bedingung ermöglicht es einem Error-Typ, zu deklarieren, dass er „äquivalent” zu einem Sentinel ist. Zum Beispiel hast du einen allgemeinen ErrNotFound-Fehler und einen spezifischeren Error-Typ für Kunden:

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

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

Der HTTP-Handler braucht nur errors.Is(err, ErrNotFound) zu überprüfen, um 404 zurückzugeben. Er braucht nicht zu wissen, ob der Fehler von einer Bestellung, einem Kunden oder einem Produkt stammt.

Hinweis: Rufe Unwrap nicht in der Methode Is auf, da errors.Is bereits die Kette traversiert. Die Methode Is sollte nur auf die aktuelle Error-Schicht selbst antworten.

5. errors.As: Extrahieren eines Fehlers mit spezifischem Typ

Sentinel sagt dir nur, was der Fehler ist. Wenn du zusätzliche Daten wie einen Fehlercode oder eine ID benötigst, verwendest du einen Custom-Error-Typ und extrahierst ihn mit errors.As.

5.1. Custom Error-Typ mit Unwrap

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

Der Aufrufer behandelt es wie folgt:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

Wichtige Punkte:

  • errors.As traversiert die Wrapper-Kette. Wenn es auf den ersten Fehler trifft, der in den Typ *PaymentError passt, weist es diesen Fehler pe zu und gibt true zurück.
  • Da PaymentError Unwrap() hat, kann errors.Is(err, ErrInsufficientFunds) immer noch in das Feld Err eindringen.
  • Das zweite Argument muss ein Zeiger auf die Zielvariable sein. Hier ist es &pe, das den Typ **PaymentError hat.

5.2. Pointer-Receiver-Fallstricke

Die Methode Error() ist auf *PaymentError deklariert, also implementiert nur *PaymentError das Interface error. So zu schreiben ist falsch:

var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error

go vet fängt dies zur Build-Zeit auf. Führe go vet ./... in deinem CI aus. Die einfache Regel: Der Typ der Zielvariable muss genau dem Typ entsprechen, den du zurückgibst. Wenn du &PaymentError{...} zurückgibst, muss die Zielvariable *PaymentError sein.

5.3. errors.AsType (Go 1.26+)

Ab Go 1.26 bietet die Standard-Library eine kompaktere generische Variante. Du brauchst die Variable nicht vorab zu deklarieren:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

Dies ist typsicher zur Compile-Zeit und kann nicht wie in Abschnitt 5.2 zu einem Panic führen. Wenn dein Projekt Go 1.26 nutzt, solltest du es verwenden.

6. Mehrere Fehler mit errors.Join kombinieren

Ab Go 1.20 kann ein Fehler mehrere Sub-Fehler wrappen. Ein typischer Fall ist die Datenvalidierung, wenn du alle Fehler auf einmal berichten möchtest, anstatt beim ersten Fehler zu stoppen:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

Vergiss nicht, "strings" zu den Imports hinzuzufügen. Das Ergebnis zeigt drei Dinge:

  • errors.Join verbindet Nachrichten mit Zeilenumbrüchen.
  • errors.Is und errors.As können jeden Sub-Fehler überprüfen.
  • errors.Join gibt nil zurück, wenn alle Argumente nil sind oder die Liste leer ist. Daher musst du nicht extra if len(errs) == 0 schreiben.

fmt.Errorf kann auch mehrere %w in derselben Format-Zeichenfolge akzeptieren, z.B. fmt.Errorf("sync order: %w; %w", errDB, errCache). Der resultierende Fehler wird beide Fehler wrappen.

7. Tests für Error-Verhalten schreiben

Beim Testen solltest du die Identität des Fehlers mit errors.Is oder errors.As überprüfen. Vergleiche keine Fehlermeldungs-Strings. Hier ist ein table-driven Test für checkout in der Datei checkout_test.go:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "genug Geld", balance: 1000, amount: 500},
		{name: "nicht genug Geld", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("Fehler nicht erwartet, erhalten: %v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("Fehler enthält kein *PaymentError: %v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q, möchte %q", pe.Code, tc.wantCode)
			}
		})
	}
}

Test ausführen:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/genug_Geld
=== RUN   TestCheckout/nicht_genug_Geld
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/genug_Geld (0.00s)
    --- PASS: TestCheckout/nicht_genug_Geld (0.00s)
PASS

Dieser Test läuft immer noch korrekt, wenn später jemand die Fehlermeldung ändert oder eine Wrapper-Ebene hinzufügt. Deshalb solltest du die Identität überprüfen, nicht die Zeichenfolge.

8. Häufige Fallstricke und wann man NICHT wrappen sollte

8.1. String-Vergleich err.Error()

if strings.Contains(err.Error(), "not found") { ... } // fragil

Nur eine Änderung der Nachricht oder ein anderer Fehler, der zufällig den Satz „not found” enthält, und die Logik ist kaputt. Verwende stattdessen Sentinels mit errors.Is.

8.2. Typed nil

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... kein Fehler ...
	return pe // NICHT nil!
}

Das Interface error enthält jetzt den Typ *PaymentError mit einem nil-Wert. Da das Interface einen Typ hat, gibt err != nil immer noch true zurück. Wenn es keinen Fehler gibt, schreibe immer explizit return nil.

8.3. Wrap macht Implementierungsdetails zur öffentlichen API

Wenn du sql.ErrNoRows mit %w wrappst, verpflichtest du dich implizit, dass Aufrufer errors.Is(err, sql.ErrNoRows) überprüfen können. Dieser Fehler wird Teil deiner öffentlichen API. Wenn du später zu MongoDB wechselst, bricht der Code des Aufrufers.

An Package-Grenzen hast du zwei bessere Optionen:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // übersetze zu Domain-Fehler
}
return fmt.Errorf("find order %d: %v", id, err) // nur Nachricht, verstecke ursprünglichen Fehler

Hinweis: %w ist eine API-Entscheidung, nicht nur eine Formatierungsentscheidung. Wrap nur, wenn Aufrufer den ursprünglichen Fehler wirklich überprüfen sollen.

8.4. Gleichzeitig loggen und zurückgeben

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

Wenn du das in jeder Schicht machst, erscheint ein Fehler mehrfach in den Logs. Die allgemeine Regel ist entweder behandeln (log) oder zurückgeben, nicht beides. Logging sollte die äußerste Schicht übernehmen, z.B. HTTP-Handler oder main.

8.5. Wiederholter Kontext, mangelnde Information

Nachrichten wie failed to load order: failed to query: failed to ... sind lang und wenig informativ. Schreibe den Operationsnamen und wichtige Daten auf, wie loadOrder id=7: .... Lass „failed” weg, denn es ist bereits ein Fehler.

8.6. Wann man errors.As NICHT verwenden sollte

Wenn du nur wissen brauchst, welchen Fehlertyp es gibt, ist errors.Is mit einem Sentinel ausreichend und einfacher. Erstelle nur einen Custom-Error-Typ, wenn Aufrufer wirklich Begleitdaten benötigen, wie Fehlercode, falsches Feld oder Wiederholungszeit.

9. Fazit

Du hast die Go-Fehlerbehandlungs-Werkzeuge durchgegangen:

  • fmt.Errorf mit %w fügt Kontext hinzu, bewahrt aber den ursprünglichen Fehler. %v behält nur den Text.
  • errors.Is überprüft, ob ein Fehler einen Sentinel erfüllt, traversiert die gesamte Wrapper-Kette. Ein Error-Typ kann die Methode Is definieren, um seine Äquivalenz zu deklarieren.
  • errors.As (oder errors.AsType ab Go 1.26) extrahiert einen Fehler eines spezifischen Typs mit seinen Daten. Der Typ der Zielvariable muss genau dem zurückgegebenen Typ entsprechen.
  • errors.Join und mehrere %w ermöglichen es einem Fehler, mehrere Sub-Fehler zu wrappen.

Letzten Endes: Sehe Wrapping als Teil des API-Designs. Wrap mit Absicht, übersetze Infrastruktur-Fehler in Domain-Fehler an Package-Grenzen, und schreibe Tests auf Basis der Error-Identität, nicht der Meldungszeichenfolge.

golangerror handlingerrorsbest practices