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:

Go builtin-error.go
// 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:

  • error ist 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.
  • nil heißt „kein Fehler". Der Zero Value eines Interface-Typs ist nil. Eine Funktion, die (T, error) zurückgibt, signalisiert Erfolg mit err == 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.
Go error-ist-wert.go
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)
    }
}
Output
err = <nil>
err = Datei nicht gefunden
Error() = Datei nicht gefunden
[0] <nil>
[1] Datei nicht gefunden
[2] zweiter Fehler

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

Go signatur-konvention.go
// 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) error

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

AspektException-Sprachen (Java, Python, …)Go
SichtbarkeitImplizit — Aufrufer muss in Doku nachschlagen, was geworfen wirdExplizit in der Signatur — (T, error)
Kontroll-FlussSprung zur nächsten catch-Klausel, beliebig weit oben im StackNormaler Return — keine versteckten Sprünge
Vergessen möglich?Ja, ungeprüfte Exceptions propagieren bis zum Top-Level-Crashgo vet und Linter erkennen ignorierte Errors
Werte oder Events?Events mit eigenem LifecycleWerte wie alle anderen
Cost beim WerfenStack-Unwinding + Stack-Trace-AllokationEin 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:

Go if-err-nil.go
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 error würde syntaktisch funktionieren, aber niemand schreibt das.
  • Bei err != nil ist result typischerweise 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:

Go readall-pattern.go
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)
}
Output
Inhalt (12 Byte): myhostname

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

Go if-init.go
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: return und panic — und panic ist 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.

Go errors-als-werte.go
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)
    }
}
Output
step-2: kaputt
step-3: auch kaputt

Das 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 java-exception.java
// 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 python-exception.py
# 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 go-error.go
// 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 != nil ist sofort sichtbar (und meist vom Compiler verhindert, weil die result-Variable sonst ungenutzt bleibt). Ein vergessenes try/catch ist 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:

Go errors-new.go
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"))
}
Output
Name darf nicht leer sein
<nil>

Intern ist errors.New ein winziger Wrapper:

Go errors-new-impl.go
// 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:

Go fmt-errorf.go
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"))
}
Output
Konfiguration "/nicht/existent/datei.conf" konnte nicht geöffnet werden: open /nicht/existent/datei.conf: no such file or directory

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

Go stdlib-error-quellen.go
// 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)          // time

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

Go propagieren.go
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):

Go behandeln.go
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:

Go ignorieren.go
// 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:

Go wrapping-vorgriff.go
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)")
        }
    }
}
Output
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.

Go zero-value-konvention.go
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)
    }
}
Output
"": FEHLER: leerer Input
"abc": FEHLER: keine Zahl
"42": Alter = 42

Es gibt zwei begründete Ausnahmen von der Regel:

  • Partial Read. io.Reader.Read darf gleichzeitig n > 0 und err != nil zurückgeben, weil tatsächlich Bytes gelesen wurden, bevor der Fehler auftrat. Aufrufer müssen erst n verarbeiten, dann err prü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:

Go praxis-config-laden.go
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")
        }
    }
}
Output
loadServerConfig: lesen "/nicht/da.json": open /nicht/da.json: no such file or directory
-> Ursache: Datei fehlt

Beobachtungen:

  • 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 %w gewickelt — der Aufrufer kann errors.Is(err, os.ErrNotExist) machen.
  • Validierungs-Fehler nutzen fmt.Errorf ohne %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:

Go praxis-http-handler.go
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()
    }
}
Output
id="42"  -> 200 OK
id=""    -> 400 Bad Request
id="7"   -> 404 Not Found
id="999" -> 403 Forbidden

Was 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.Is läuft die Wrapping-Kette ab. Selbst wenn der Service den Sentinel hinter fmt.Errorf("...: %w", ErrUserNotFound) versteckt, findet errors.Is ihn.

Anti-Patterns

Drei klassische Fehler-Behandlungs-Sünden, die in jedem Code-Review aufschlagen:

(1) Errors stillschweigend ignorieren.

Go antipattern-ignorieren.go
// 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.

Go antipattern-invertiert.go
// 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.

Go antipattern-generisch.go
// 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 nilerr == 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

/ Weiter

Zurück zu Error-Handling

Zur Übersicht