Go kennt keine Exceptions. Es gibt kein throw, kein try, kein catch. Stattdessen ist ein Fehler in Go ein ganz normaler Wert — ein Rückgabewert wie jeder andere, der vom Aufrufer geprüft und behandelt wird. Das eingebaute Interface error ist die kleinste sinnvolle Abstraktion dafür: eine einzige Methode, Error() string. Wer in Go programmiert, schreibt deshalb auf jeder zweiten Zeile if err != nil. Das wirkt zuerst wie viel Boilerplate — und ist in Wahrheit das, was die Sprache am stärksten formt. Dieser Artikel verankert das Konzept aus der Spec, zeigt das idiomatische Muster, erklärt Rob Pikes Begründung, vergleicht mit Exception-Sprachen und macht klar, wo Errors entstehen, wo sie hingehen und wie man sie mit Kontext anreichert.
Die Spec — error ist ein eingebautes Interface
Im Universe Block der Go-Spec ist error zusammen mit int, string, bool und Co. als vordefinierter Identifier aufgeführt. Die Definition aus dem builtin-Paket ist denkbar knapp:
// The error built-in interface type is the conventional
// interface for representing an error condition, with the
// nil value representing no error.
type error interface {
Error() string
}Das ist alles. Ein Interface mit einer einzigen Methode, die einen String zurückgibt. Jeder Typ — egal ob struct, int, string oder Pointer auf irgendwas — der eine Methode Error() string definiert, ist ein error. Mehr Sprach-Mechanik gibt es nicht.
Daraus folgen drei wichtige Eigenschaften:
errorist ein Wert. Du kannst ihn in einer Variable speichern, in eine Slice packen, über einen Channel schicken, als Map-Key benutzen (sofern er vergleichbar ist) oder über Funktions-Grenzen zurückgeben.nilheißt „kein Fehler". Der Zero Value eines Interface-Typs istnil. Eine Funktion, die(T, error)zurückgibt, signalisiert Erfolg miterr == nil.- Die Methode liefert nur einen String. Das ist die Mindest-API — wer mehr braucht (Stack-Trace, Status-Code, Wrapping), baut das über zusätzliche Methoden oder Struct-Felder eines Custom-Error-Typs.
package main
import (
"errors"
"fmt"
)
func main() {
// Ein error ist ein gewöhnlicher Wert.
var err error // Zero Value: nil
fmt.Println("err =", err)
err = errors.New("Datei nicht gefunden")
fmt.Println("err =", err)
// Aufruf der Interface-Methode.
fmt.Println("Error() =", err.Error())
// In Container speichern, wie jeden anderen Wert.
box := []error{nil, err, errors.New("zweiter Fehler")}
for i, e := range box {
fmt.Printf("[%d] %v\n", i, e)
}
}err = <nil>
err = Datei nicht gefunden
Error() = Datei nicht gefunden
[0] <nil>
[1] Datei nicht gefunden
[2] zweiter FehlerKeine Exceptions — Fehler sind reguläre Rückgabewerte
In Java, Python, C#, Ruby und vielen anderen Sprachen ist ein Fehler ein Kontroll-Fluss-Ereignis: Eine Funktion „wirft" eine Exception, die unsichtbar den normalen Return überspringt und am nächsten catch/except-Block landet. In Go gibt es das nicht. Eine Funktion, die scheitern kann, deklariert das in ihrer Signatur — typischerweise als letzten Rückgabewert vom Typ error:
// Klassische Signatur-Form: erst die Nutz-Werte, zuletzt error.
func divide(a, b float64) (float64, error)
func readFile(path string) ([]byte, error)
func parseInt(s string) (int, error)
// Funktion, die nur scheitern kann (ohne Nutz-Rückgabe): nur error.
func writeFile(path string, data []byte) errorDiese Konvention ist nirgends in der Spec festgeschrieben — sie ist aber so universell, dass jeder Go-Code sie befolgt. Wer beim Lesen einer Funktions-Signatur am Ende ein error sieht, weiß sofort: diese Operation kann fehlschlagen, und der Aufrufer muss damit umgehen.
Der Unterschied zur Exception-Welt ist fundamental:
| Aspekt | Exception-Sprachen (Java, Python, …) | Go |
|---|---|---|
| Sichtbarkeit | Implizit — Aufrufer muss in Doku nachschlagen, was geworfen wird | Explizit in der Signatur — (T, error) |
| Kontroll-Fluss | Sprung zur nächsten catch-Klausel, beliebig weit oben im Stack | Normaler Return — keine versteckten Sprünge |
| Vergessen möglich? | Ja, ungeprüfte Exceptions propagieren bis zum Top-Level-Crash | go vet und Linter erkennen ignorierte Errors |
| Werte oder Events? | Events mit eigenem Lifecycle | Werte wie alle anderen |
| Cost beim Werfen | Stack-Unwinding + Stack-Trace-Allokation | Ein zusätzlicher Return — keine Magie |
Das idiomatische Muster — if err != nil
Jeder Aufruf einer fehlbaren Funktion wird in Go mit demselben Muster behandelt. Es ist so allgegenwärtig, dass es ein eigenes Stilelement der Sprache ist:
result, err := someOperation()
if err != nil {
return err // oder: behandle hier, oder: wrap und weitergebe
}
// Ab hier ist garantiert: err == nil, result ist gültig.
useResult(result)Drei Dinge sind an diesem Muster wichtig:
- Die Prüfung kommt direkt nach dem Aufruf. Effective Go empfiehlt, den Fehler-Pfad früh zu verlassen und die Erfolgs-Logik darunter „nach unten fließen" zu lassen. Kein
else-Block, kein zusätzliches Einrücken. - Die Variable heißt
err. Konvention — kein Mechanismus.var oops errorwürde syntaktisch funktionieren, aber niemand schreibt das. - Bei
err != nilistresulttypischerweise der Zero Value. Aufrufer dürfen sich darauf nicht blind verlassen (siehe Multi-Return-Konvention weiter unten), aber die idiomatische Erwartung ist: entweder gültiger Wert oder Fehler.
Ein vollständiges Beispiel:
package main
import (
"fmt"
"os"
)
func main() {
data, err := os.ReadFile("/etc/hostname")
if err != nil {
fmt.Println("Fehler:", err)
os.Exit(1)
}
fmt.Printf("Inhalt (%d Byte): %s", len(data), data)
}Inhalt (12 Byte): myhostnameEine alternative Pattern-Form ist die Kombination von Init-Statement und if, die häufig genutzt wird, wenn err außerhalb des if-Blocks nicht mehr gebraucht wird:
if err := writeFile("/tmp/out.log", payload); err != nil {
return fmt.Errorf("Log schreiben: %w", err)
}
// err ist hier außerhalb des if-Scopes nicht mehr sichtbar.Diese Form hält den err-Scope minimal — der Wert lebt nur, solange er gebraucht wird. Bei Funktionen mit mehreren aufeinanderfolgenden Calls trennt sich dadurch die Fehler-Logik sauber von der Erfolgs-Logik.
Warum Go diesen Weg geht
Rob Pike, einer der Sprach-Designer, hat den Entwurf an mehreren Stellen begründet. Die Kernpunkte:
- Errors sind Werte. Was Werte sind, kann man programmatisch behandeln — speichern, vergleichen, transformieren, in Collections sammeln, über Funktions-Grenzen reichen. Was Exceptions sind, kann man nur „fangen" oder „weiterwerfen".
- Lokalität. In Exception-Code liegt der Fehler-Handler oft hunderte Zeilen entfernt vom Auslöser. In Go-Code steht der Handler in derselben Funktion, oft direkt unter dem Aufruf. Wer den Code liest, sieht beides auf einen Blick.
- Keine versteckten Kontroll-Sprünge. Ein Go-Programm hat genau zwei Mechanismen, die den linearen Fluss verlassen können:
returnundpanic— undpanicist ausdrücklich nicht für gewöhnliche Fehler gedacht. Alles andere ist sequentiell, vorhersagbar, debuggbar. - Explizitheit. Effective Go formuliert es kompromisslos: „Always check error returns; they are provided for a reason." Ein ignorierter Error ist in Go ein Code-Smell, der von Tools (
go vet,errcheck,staticcheck) markiert wird.
Pike fasst es in einem oft zitierten Vortrag in einen Satz: „Errors are values." Daraus folgt: man darf sie wie Werte behandeln — komponieren, in Closures kapseln, in eigenen Typen anreichern, über Channels schicken. Genau das machen idiomatische Go-Bibliotheken.
package main
import (
"errors"
"fmt"
)
// Errors lassen sich in einer Liste sammeln, weil sie Werte sind.
type batch struct {
errs []error
}
func (b *batch) do(op string, fn func() error) {
if err := fn(); err != nil {
b.errs = append(b.errs, fmt.Errorf("%s: %w", op, err))
}
}
func (b *batch) finalize() error {
if len(b.errs) == 0 {
return nil
}
return errors.Join(b.errs...)
}
func main() {
var b batch
b.do("step-1", func() error { return nil })
b.do("step-2", func() error { return errors.New("kaputt") })
b.do("step-3", func() error { return errors.New("auch kaputt") })
if err := b.finalize(); err != nil {
fmt.Println(err)
}
}step-2: kaputt
step-3: auch kaputtDas ist in einer Exception-Sprache deutlich umständlicher: man bräuchte einen Try-Block pro Operation, eine Liste, in die im Catch-Block manuell die Exception eingehängt wird, plus eine Konstruktion zum „Wieder-Werfen" am Ende.
Vergleich mit Exception-Sprachen
Ein direkter Vergleich macht die Designs-Konsequenzen sichtbar. Dieselbe Operation — eine Datei lesen und ihren Inhalt parsen — in drei Sprachen:
// Java — der Aufrufer sieht nicht, was hier scheitern kann.
// Pflicht-Information steckt in der throws-Klausel.
public Config loadConfig(String path) throws IOException, ParseException {
byte[] data = Files.readAllBytes(Path.of(path));
return Config.parse(new String(data));
}# Python — die Signatur sagt NICHTS über mögliche Exceptions.
# Aufrufer muss die Doku lesen (oder nicht — Crash zur Laufzeit).
def load_config(path: str) -> Config:
with open(path, "rb") as f:
data = f.read()
return Config.parse(data.decode())// Go — die Signatur sagt explizit: kann scheitern.
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("Lesefehler: %w", err)
}
cfg, err := parseConfig(data)
if err != nil {
return nil, fmt.Errorf("Parse-Fehler: %w", err)
}
return cfg, nil
}Drei Beobachtungen:
- Die Go-Variante ist länger an der Schreibe-Seite. Jeder fehlbare Aufruf braucht drei Zeilen statt einer.
- Die Go-Variante ist kürzer an der Lese-Seite. Es gibt keinen versteckten Kontroll-Fluss zu rekonstruieren, kein Stack-Trace-Wegrennen, keine Pflicht zum Konsultieren von externer Doku.
- Die Go-Variante ist robuster. Ein vergessenes
if err != nilist sofort sichtbar (und meist vom Compiler verhindert, weil dieresult-Variable sonst ungenutzt bleibt). Ein vergessenestry/catchist in Python erst zur Laufzeit beim Crash sichtbar.
Error-Konstruktoren — errors.New und fmt.Errorf
Das error-Interface ist offen — jeder Typ darf es implementieren. Trotzdem gibt es zwei kanonische Wege, die in 90 % der Fälle reichen:
errors.New für simple, statische Fehler-Nachrichten:
package main
import (
"errors"
"fmt"
)
func validate(name string) error {
if name == "" {
return errors.New("Name darf nicht leer sein")
}
return nil
}
func main() {
fmt.Println(validate(""))
fmt.Println(validate("Alice"))
}Name darf nicht leer sein
<nil>Intern ist errors.New ein winziger Wrapper:
// aus der Stdlib:
package errors
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string { return e.s }Der Pointer-Receiver ist Absicht — zwei errors.New("foo")-Aufrufe ergeben unterschiedliche Werte (verschiedene Heap-Adressen), die mit == nicht gleich sind. Das verhindert, dass Aufrufer-Code versehentlich „Sentinel"-Vergleiche auf identische Strings macht. Für absichtliche Sentinel-Errors (io.EOF, sql.ErrNoRows) definiert man die Variable einmalig auf Package-Level — Details dazu im Sentinel- und Typed-Errors-Artikel.
fmt.Errorf für formatierte Fehler mit Kontext:
package main
import (
"fmt"
"os"
)
func openConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("Konfiguration %q konnte nicht geöffnet werden: %w",
path, err)
}
return nil
}
func main() {
fmt.Println(openConfig("/nicht/existent/datei.conf"))
}Konfiguration "/nicht/existent/datei.conf" konnte nicht geöffnet werden: open /nicht/existent/datei.conf: no such file or directoryDer Verb-Code %w ist seit Go 1.13 das Mittel der Wahl: er „wickelt" den ursprünglichen Error in den neuen ein, sodass errors.Is und errors.As ihn später wiederfinden. Mit %v würdest du nur den String einbetten und die Information verlieren, welcher Error-Typ darunter lag. Tiefer geht es im Artikel zu errors.Is, errors.As, errors.Join und Wrapping.
Wo Errors HER kommen
Eine Operation, die scheitern kann, signalisiert das in ihrer Signatur. In der Stdlib siehst du das auf jeder Seite:
// I/O — alles, was das Betriebssystem oder das Netzwerk anfasst.
func Open(name string) (*File, error) // os
func ReadFile(name string) ([]byte, error) // os
func (c *Conn) Read(b []byte) (n int, err error) // net
// Parsing — alles, was Strings in strukturierte Werte verwandelt.
func Atoi(s string) (int, error) // strconv
func Unmarshal(data []byte, v any) error // json
// Konvertierung — Operationen, die ausserhalb ihrer Domain scheitern.
func Compile(expr string) (*Regexp, error) // regexp
func ParseDuration(s string) (Duration, error) // timeDie Faustregel: Jede Operation, deren Erfolg von etwas abhängt, das die Funktion selbst nicht kontrolliert, gibt einen Error zurück. Dateisystem-Zustand, Netzwerk-Verfügbarkeit, Eingabe-Validität, Speicher-Reservierung über bestimmte Grenzen — alles potentielle Fehler-Quellen.
Umgekehrt: Funktionen ohne error-Rückgabe versprechen, niemals zu scheitern. len(), cap(), string-Indexierung, arithmetische Operationen auf Integer-Typen — die haben keinen Error-Return, weil sie semantisch nicht scheitern können (oder per panic aussteigen, was kein normaler Fehler-Pfad ist).
Wo Errors HIN gehen
Sobald eine Funktion einen Error zurückgibt, hat der Aufrufer genau drei legitime Optionen:
(1) Propagieren — den Error an den eigenen Aufrufer weiterreichen, meist mit zusätzlichem Kontext:
func loadUser(id int) (*User, error) {
row, err := db.QueryRow("SELECT ... FROM users WHERE id = ?", id)
if err != nil {
return nil, fmt.Errorf("loadUser(%d): %w", id, err)
}
// ...
}(2) Behandeln — den Error abfangen und eine sinnvolle Reaktion auslösen (Retry, Fallback, Default-Wert, Logging):
func loadUserWithFallback(id int) *User {
user, err := loadUser(id)
if err != nil {
log.Printf("WARN loadUser(%d) fehlgeschlagen: %v, nehme Default", id, err)
return defaultUser()
}
return user
}(3) Ignorieren — nur in Ausnahmefällen, mit dokumentierter Begründung. Idiomatisch über das Blank-Identifier-Pattern:
// Best-Effort-Schreiben in den Trace-Log — wenn es scheitert, egal.
_, _ = traceLog.Write([]byte(line))
// ABER: f.Close() in einer Funktion, die das File schreibt,
// darf NIE ignoriert werden — der Close() kann den eigentlichen
// Schreib-Fehler reporten (Buffer-Flush schlägt fehl).Die Entscheidung zwischen Propagieren und Behandeln ist eine architektonische: behandelt wird typischerweise an Schicht-Grenzen (HTTP-Handler übersetzt Domain-Fehler in HTTP-Status, CLI übersetzt in Exit-Code und Stderr-Text), propagiert wird überall dazwischen.
Kontext anreichern — %w als Vorgriff auf Wrapping
Ein bloßes return err reicht selten. Wer einen Error aus os.Open weiterleitet, hat zur Laufzeit nur die Meldung open /etc/foo: no such file or directory — und keine Ahnung, welche der zwanzig Funktionen, die diesen Aufruf indirekt machen, sie ausgelöst hat. Idiomatisch wickelt man jeden propagierten Error mit Kontext ein:
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("readConfig(%s): %w", path, err)
}
return data, nil
}
func loadApp() error {
_, err := readConfig("/etc/app.conf")
if err != nil {
return fmt.Errorf("loadApp: %w", err)
}
return nil
}
func main() {
err := loadApp()
if err != nil {
fmt.Println("FEHLER:", err)
// Trotz der Wrapping-Schichten ist der Ursprung auffindbar.
if errors.Is(err, os.ErrNotExist) {
fmt.Println("(es war ein Datei-nicht-gefunden-Problem)")
}
}
}FEHLER: loadApp: readConfig(/etc/app.conf): open /etc/app.conf: no such file or directory
(es war ein Datei-nicht-gefunden-Problem)Das %w-Verb baut eine Wrapping-Kette auf — jeder Layer fügt seinen Kontext hinzu, ohne den Ursprung zu zerstören. errors.Is und errors.As laufen die Kette ab und finden den ursprünglichen Sentinel- oder Typed-Error wieder. Wer mehrere Errors gleichzeitig weitergeben muss (Multi-Error), nutzt errors.Join. Das ganze Wrapping-Vokabular hat einen eigenen Artikel — errors.Is, errors.As, errors.Join und Wrapping.
Multiple Returns — die Zero-Value-Konvention
Eine Funktion mit Signatur func f() (T, error) folgt einer impliziten, aber universellen Konvention: wenn err != nil, ist T der Zero Value und unbenutzbar. Wenn err == nil, ist T ein gültiger Wert.
package main
import (
"errors"
"fmt"
)
// Idiomatisch: bei Fehler den Zero Value mit zurückgeben.
func parseAge(s string) (int, error) {
if s == "" {
return 0, errors.New("leerer Input") // 0, nicht etwa -1
}
if s == "abc" {
return 0, errors.New("keine Zahl")
}
return 42, nil // success-pfad
}
func main() {
for _, in := range []string{"", "abc", "42"} {
age, err := parseAge(in)
if err != nil {
fmt.Printf("%q: FEHLER: %v\n", in, err)
continue
}
fmt.Printf("%q: Alter = %d\n", in, age)
}
}"": FEHLER: leerer Input
"abc": FEHLER: keine Zahl
"42": Alter = 42Es gibt zwei begründete Ausnahmen von der Regel:
- Partial Read.
io.Reader.Readdarf gleichzeitign > 0underr != nilzurückgeben, weil tatsächlich Bytes gelesen wurden, bevor der Fehler auftrat. Aufrufer müssen erstnverarbeiten, dannerrprüfen. - Strukturierte Multi-Errors. Validatoren oder Batch-Operationen können einen partiellen Erfolg mit einer Liste von Fehlern zurückgeben. Hier ist die Konvention abhängig von der API — wird explizit dokumentiert.
In allen anderen Fällen gilt: err != nil heißt, ignoriere den Nutz-Rückgabewert. Wer das nicht beachtet, baut sich subtile Bugs (eine Funktion gibt nil-Slice + Error zurück, der Aufrufer iteriert die Slice — kein Crash, kein Verhalten, schwer zu finden).
Praxis 1 — Datei-Lese-Funktion mit klassischem Pattern
Eine kleine Funktion, die JSON aus einer Datei liest und dekodiert. Realistisch, kompakt, idiomatisch — jeder Schritt mit eigenem Fehler-Kontext:
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
)
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
TLS bool `json:"tls"`
}
// Liest, parst, validiert. Jeder Schritt kann scheitern,
// jeder Fehler bekommt seinen eigenen Kontext.
func loadServerConfig(path string) (*ServerConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loadServerConfig: lesen %q: %w", path, err)
}
var cfg ServerConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("loadServerConfig: JSON parsen: %w", err)
}
if cfg.Host == "" {
return nil, fmt.Errorf("loadServerConfig: host ist leer")
}
if cfg.Port <= 0 || cfg.Port > 65535 {
return nil, fmt.Errorf("loadServerConfig: ungültiger Port %d", cfg.Port)
}
return &cfg, nil
}
func main() {
_, err := loadServerConfig("/nicht/da.json")
if err != nil {
fmt.Println(err)
if errors.Is(err, os.ErrNotExist) {
fmt.Println("-> Ursache: Datei fehlt")
}
}
}loadServerConfig: lesen "/nicht/da.json": open /nicht/da.json: no such file or directory
-> Ursache: Datei fehltBeobachtungen:
- Jeder Fehler-Pfad gibt einen
nil-Pointer für den Erfolg-Wert zurück — Zero-Value-Konvention. - Jede Fehler-Nachricht beginnt mit dem Funktions-Namen, sodass die Quelle bei verschachtelten Calls sichtbar bleibt.
- I/O- und Parse-Fehler werden mit
%wgewickelt — der Aufrufer kannerrors.Is(err, os.ErrNotExist)machen. - Validierungs-Fehler nutzen
fmt.Errorfohne%w, weil es kein Wrapper um einen anderen Error ist, sondern ein originär in dieser Funktion erzeugter Fehler.
Praxis 2 — HTTP-Handler mit Domain-Fehler-Mapping
In einer Web-Anwendung gibt es eine klare Schicht-Grenze: Domain-Code arbeitet mit error-Werten, HTTP-Handler übersetzt sie in Status-Codes. Das ist ein hervorragendes Beispiel dafür, wo Errors „landen" und behandelt werden:
package main
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
)
// Domain-Layer: Sentinel-Errors, die der Service zurückgibt.
var (
ErrUserNotFound = errors.New("user nicht gefunden")
ErrPermission = errors.New("keine Berechtigung")
ErrInvalidInput = errors.New("ungültige Eingabe")
)
// Service: arbeitet ausschließlich mit error-Werten.
func loadUser(id string) (string, error) {
switch id {
case "":
return "", ErrInvalidInput
case "42":
return "Alice", nil
case "999":
return "", ErrPermission
default:
return "", fmt.Errorf("loadUser(%s): %w", id, ErrUserNotFound)
}
}
// HTTP-Layer: hier werden Domain-Errors in HTTP-Status übersetzt.
func handleGetUser(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
name, err := loadUser(id)
if err != nil {
switch {
case errors.Is(err, ErrInvalidInput):
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrUserNotFound):
http.Error(w, "Not Found", http.StatusNotFound)
case errors.Is(err, ErrPermission):
http.Error(w, "Forbidden", http.StatusForbidden)
default:
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
fmt.Fprintf(w, "user: %s\n", name)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/user", handleGetUser)
srv := httptest.NewServer(mux)
defer srv.Close()
for _, id := range []string{"42", "", "7", "999"} {
resp, _ := http.Get(srv.URL + "/user?id=" + id)
fmt.Printf("id=%-3q -> %s\n", id, resp.Status)
resp.Body.Close()
}
}id="42" -> 200 OK
id="" -> 400 Bad Request
id="7" -> 404 Not Found
id="999" -> 403 ForbiddenWas hier passiert, ist das Kern-Pattern jeder strukturierten Go-Anwendung:
- Der Service kennt keine HTTP-Semantik. Er gibt nur abstrakte Errors zurück —
ErrUserNotFound,ErrPermission. Damit ist er testbar und in anderen Kontexten (CLI, gRPC, Queue-Worker) wiederverwendbar. - Der Handler ist der Übersetzer. Genau eine Stelle entscheidet: dieser Domain-Fehler ist ein 404, jener ein 400. Das Mapping ist explizit, lesbar, ergänzbar.
errors.Isläuft die Wrapping-Kette ab. Selbst wenn der Service den Sentinel hinterfmt.Errorf("...: %w", ErrUserNotFound)versteckt, findeterrors.Isihn.
Anti-Patterns
Drei klassische Fehler-Behandlungs-Sünden, die in jedem Code-Review aufschlagen:
(1) Errors stillschweigend ignorieren.
// FALSCH — der Schreib-Fehler wird verschluckt.
f, _ := os.Create("/tmp/out.log")
f.WriteString("wichtige Daten\n")
f.Close()
// RICHTIG — jeder Schritt wird geprüft, Close() besonders.
f, err := os.Create("/tmp/out.log")
if err != nil {
return fmt.Errorf("create: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close: %w", cerr)
}
}()(2) Invertierte Nil-Logik.
// FALSCH — passiert beim hastigen Copy-Paste-Refactoring.
result, err := doWork()
if err == nil {
return err
}
useResult(result)
// RICHTIG
result, err := doWork()
if err != nil {
return err
}
useResult(result)(3) Generische „something went wrong"-Errors ohne Kontext.
// FALSCH — bei der Diagnose keine Hilfe.
if err := db.Insert("orders", o); err != nil {
return errors.New("error saving order")
}
// RICHTIG — Kontext + Wrapping.
if err := db.Insert("orders", o); err != nil {
return fmt.Errorf("saveOrder: db insert (order=%d): %w", o.ID, err)
}Ein Error ohne Kontext ist im Log eine Bug-Quelle, die Stunden Debugging kostet. Faustregel: Jeder Error, den du erzeugst oder propagierst, sollte beim Lesen des Log-Eintrags genau eine Frage beantworten: „Was hat hier was versucht, und woran ist es gescheitert?"
Interessantes
`error` ist nur ein Interface — keine Sprach-Magie.
Das wirkt zuerst trivial, ist aber der wichtigste mentale Schritt von Exception-Sprachen weg. error ist kein Schlüsselwort, kein eingebauter Mechanismus, keine Klassenhierarchie. Es ist ein gewöhnliches Interface mit einer einzigen Methode Error() string. Daraus folgt: du kannst eigene Error-Typen mit beliebigen Feldern bauen, Errors in Slices sammeln, über Channels schicken, in Maps speichern.
`nil` ist der einzige „kein Fehler"-Wert.
Eine Funktion, die error zurückgibt, signalisiert Erfolg mit return nil. Niemals mit return errors.New("") oder einem leeren Custom-Error. Aufrufer prüfen ausschließlich gegen nil — err == nil heißt Erfolg, alles andere ist Fehler. Diese Strenge ist eine der wenigen Stellen, an der Go ganz ohne Konventions-Spielraum auskommt.
`if err != nil` ist kein Boilerplate-Lärm, sondern Lese-Hilfe.
Wer aus Exception-Sprachen kommt, empfindet if err != nil { return err } als Wiederholung. Mit der Zeit wird klar: jede dieser Zeilen markiert eine Stelle, an der etwas schiefgehen kann — und sie steht direkt am Aufruf. Beim Code-Review siehst du auf einen Blick, welche Operationen fehlbar sind.
`fmt.Errorf` mit `%w` ist der Default — `%v` verliert Information.
Bei jedem propagierten Error nutzt du %w, nicht %v. %w wickelt den Original-Error ein, sodass errors.Is und errors.As ihn später wiederfinden. %v bettet nur den String ein — die Typ-Information ist weg, Sentinel-Vergleiche scheitern. Faustregel: einziger %w pro Format-String, am Ende der Nachricht, mit Doppelpunkt davor.
`errors.New` für Sentinels, `fmt.Errorf` für Wrapping.
Bei statischen, vergleichbaren Fehler-Werten (io.EOF, sql.ErrNoRows, deine eigenen ErrXxx-Variablen) nimmst du errors.New und definierst die Variable einmal auf Package-Level. Bei dynamischen Fehlern mit Kontext oder beim Propagieren eines Sub-Errors nimmst du fmt.Errorf. Beide funktionieren — die Wahl signalisiert die Absicht.
Bei `(T, error)`-Rückgabe ist `T` bei Fehler nicht zu vertrauen.
Universelle Konvention: wenn err != nil, dann ist der Nutz-Rückgabewert der Zero Value ("", 0, nil-Slice, nil-Pointer). Aufrufer prüfen err zuerst und ignorieren T im Fehler-Fall. Die einzigen Ausnahmen — io.Reader.Read mit partial-read, manche Multi-Error-APIs — sind in der jeweiligen Funktions-Doku explizit dokumentiert.
Errors gehören in die Signatur, nicht in den Doc-Kommentar.
Wenn deine Funktion scheitern kann, deklariere das durch error im Return — niemals durch ein Doc-Kommentar wie „may panic on bad input". panic ist für unmögliche Zustände reserviert. Für alle erwartbaren Fehler ist die Signatur (T, error) der einzige idiomatische Weg.
„Errors are values" heißt: behandle sie wie Daten.
Rob Pikes berühmtes Mantra ist eine Aufforderung zum kreativen Umgang. Du kannst Errors in einer Slice sammeln und gemeinsam reporten, in einem Channel an einen Error-Handler-Worker schicken, mit errors.Join zu einem Multi-Error verbinden, in Tests mit errors.Is/errors.As strukturiert prüfen. Ein Error ist kein Sonderfall — er ist ein gewöhnlicher Wert mit besonders klarer Konvention.
Weiterführende Ressourcen
Externe Quellen
- Errors — Go Language Specification (Predeclared Identifiers)
- Effective Go: Errors
- Error handling and Go (Go Blog, Andrew Gerrand)
- Errors are values (Go Blog, Rob Pike)
- Working with Errors in Go 1.13
errorimbuiltin-Paket- Package
errors— Standard-Library
Verwandte Artikel
- Sentinel- und Typed-Errors — wiedererkennbare Fehler-Werte
- errors.Is, errors.As, errors.Join und Wrapping
- panic und recover — wann sie statt error angemessen sind
- defer/recover-Pattern für robuste Server
- Multiple Returns — Konventionen und Stolperfallen
- nil-Interface-Fallen — wenn
err != nilfalsch ist - Interfaces — Übersicht und Method-Sets