Die fundamentalste Designentscheidung im Rust-Error-Handling ist die Wahl zwischen panic! und Result<T, E>. Diese zwei Werkzeuge sind nicht austauschbar — sie modellieren grundlegend verschiedene Situationen. Panic ist für Zustände, die niemals auftreten sollten (Programmier-Fehler, verletzte Invarianten); Result ist für Zustände, die erwartet werden und vom Aufrufer behandelt werden sollen (I/O-Fehler, Parser-Fehler, Validierungs-Fehler). Wer die Trennung verinnerlicht hat, schreibt robusten Code, der weder fragil (Panic an falscher Stelle) noch unergonomisch (Result, wo es keinen Sinn ergibt) ist.

Die Trennlinie

Die Wahl zwischen panic! und Result ist nicht eine Stil-Frage — sie ist eine semantische Aussage über die Natur des Fehlers. Wenn du falsch wählst, verwirrst du Konsumenten deines Codes und produzierst entweder fragile Programme oder unhandliche APIs.

Panic signalisiert: „Hier ist ein Bug aufgetreten. Der Programm-Zustand ist inkonsistent. Es gibt keine sinnvolle Reaktion außer Programm-Abbruch." Beispiele sind ein Index-out-of-Bounds-Zugriff auf einen Vec, eine unwrap() auf einer None-Option (die laut Code-Logik nicht möglich sein sollte), oder ein assert! in deinem eigenen Code, der eine Invariante verletzt sieht. Diese Fälle sind immer Bugs, die im Code repariert werden müssen — niemals etwas, was der aufrufende Code abfangen sollte.

Result signalisiert: „Hier kann eine Operation aus regulären, erwarteten Gründen scheitern. Der Aufrufer soll entscheiden, was bei einem Fehler zu tun ist." Beispiele sind das Öffnen einer Datei (sie könnte fehlen oder nicht lesbar sein), das Parsen von Nutzer-Input (er könnte ungültig sein), eine Netzwerk-Operation (sie könnte timeouten), eine Datenbank-Abfrage (sie könnte einen Constraint verletzen). Diese Fälle sind keine Bugs — sie sind erwartete Programm-Zustände, mit denen jeder produktive Code umgehen muss.

Eine einfache Heuristik: Wenn du dir vorstellst, ein Aufrufer würde den Fehler mit if let Err(e) = ... oder ? behandeln — und das wäre sinnvoll —, dann ist Result richtig. Wenn die einzige sinnvolle Reaktion wäre „Programm abbrechen und Bug-Report schreiben", dann ist panic richtig.

panic! im Detail

Das panic!-Makro ist das Stdlib-Konstrukt, um einen Panic auszulösen.

Rust panic! mit Message
fn dividieren(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Division durch Null! a={a}, b={b}");
    }
    a / b
}

fn main() {
    // dividieren(10, 0);   // → Panic mit Stack-Trace
    let r = dividieren(10, 2);
    println!("{r}");        // 5
}

Das panic!-Makro nimmt einen Format-String wie println!, plus optionale Argumente. Bei einem Panic passiert folgendes: die Panic-Nachricht wird auf stderr ausgegeben, eine Stack-Trace wird gerendert (sofern RUST_BACKTRACE=1 gesetzt ist), und der aktuelle Thread beginnt mit dem Stack-Unwinding.

Während des Unwindings werden alle lokalen Werte im Stack-Frame gedroppt — File-Handles werden geschlossen, Mutex-Guards freigegeben, eigene Drop-Impls laufen. So bleibt der Programm-Zustand soweit möglich konsistent, auch wenn der Thread stirbt. Wenn der Unwinding-Prozess den Top-Level-Frame des Threads erreicht und der Thread der Main-Thread war, wird der gesamte Prozess beendet; sonst stirbt nur der Worker-Thread, und der Main-Thread kann ihn per JoinHandle::join als Err empfangen.

Implizite Panics in der Stdlib

Viele Stdlib-Operationen panicken bei Misbrauch:

Rust Stdlib-Panics
fn main() {
    let v = vec![1, 2, 3];
    // let x = v[10];                      // Panic: index out of bounds
    // let n: Option<i32> = None;
    // let m = n.unwrap();                 // Panic: unwrap on None
    // let r: Result<i32, &str> = Err("x");
    // let n = r.unwrap();                 // Panic: unwrap on Err

    // Sichere Alternativen:
    let x = v.get(10);                     // Option, kein Panic
    let n: Option<i32> = None;
    let m = n.unwrap_or(0);                // Default, kein Panic
}

Hier siehst du die zwei Welten nebeneinander. v[10] panickt — der direkte Index-Zugriff geht davon aus, dass du als Programmierer weißt, dass der Index gültig ist. v.get(10) ist die sichere Alternative: sie gibt Option<&T> zurück und verlangt explizite Behandlung. Ähnlich unwrap() (Panic bei None/Err) vs. unwrap_or(default) (Default bei None/Err).

Welche du wählst, hängt vom Kontext ab. Wenn du gerade selbst die Länge des Vecs geprüft hast und der Index garantiert gültig ist, ist v[i] korrekt — eine Panic wäre dann ein echter Bug. Wenn der Index aus unsicherer Quelle (User-Input, Berechnung mit möglichen Edge-Cases) kommt, ist v.get(i) die richtige Wahl.

Unwinding vs. Abort

Beim Panic gibt es zwei mögliche Modi: Unwinding (Default) oder Abort.

Unwinding (Default)

Beim Stack-Unwinding läuft die Panic durch alle Stack-Frames des Threads, dropt jeden lokalen Wert sauber, und endet entweder im main (Prozess-Ende) oder im Spawn-Punkt eines Worker-Threads.

Rust Unwinding-Verhalten
struct Cleanup(&'static str);

impl Drop for Cleanup {
    fn drop(&mut self) {
        println!("Cleanup: {}", self.0);
    }
}

fn arbeitet() {
    let _a = Cleanup("a");
    let _b = Cleanup("b");
    panic!("etwas ging schief");
}

fn main() {
    // arbeitet();
    // Ausgabe bei Panic mit Unwinding:
    //   Cleanup: b
    //   Cleanup: a
    //   (Stack-Trace)
}

Das Unwinding ist wichtig für RAII-basiertes Resource-Management. Auch bei einem Panic werden Files geschlossen, Locks freigegeben, Datenbank-Transaktionen rolled-back. Das macht Rust-Code robust gegen Panics — der Programm-Zustand bleibt konsistent, auch wenn ein Thread abstürzt.

Abort (Performance-Modus)

In Cargo.toml kannst du den Panic-Modus auf abort umstellen:

Rust Cargo.toml panic-Modus
[profile.release]
panic = "abort"

Mit panic = "abort" wird bei einem Panic kein Unwinding gemacht — der Prozess wird sofort beendet, ohne dass Drop-Implementierungen laufen. Vorteile: kleinere Binaries (kein Unwinding-Code), etwas schnellere Hot-Paths (kein Unwinding-Overhead in Funktions-Prologen). Nachteile: keine Ressourcen-Cleanup-Garantie, kein catch_unwind.

Für embedded Systeme oder sehr performance-kritische Anwendungen ist abort oft die richtige Wahl — die Unwinding-Garantie zahlt sich nur aus, wenn dein Code wirklich Cleanup-Logik braucht.

catch_unwind — die seltene Ausnahme

Es gibt einen Weg, Panics doch abzufangen: std::panic::catch_unwind.

Rust catch_unwind
use std::panic;

fn main() {
    let result = panic::catch_unwind(|| {
        println!("vor Panic");
        panic!("kontrolliert");
    });

    match result {
        Ok(_) => println!("Closure lief ohne Panic"),
        Err(_) => println!("Panic abgefangen — Programm läuft weiter"),
    }
}

catch_unwind führt eine Closure aus und fängt einen eventuellen Panic ab. Das Ergebnis ist Result<T, Box<dyn Any>> — bei normalem Return das Ergebnis der Closure, bei Panic ein Box mit dem Panic-Payload.

Wichtige Einschränkungen: erstens funktioniert das nur mit Unwinding-Modus, nicht mit Abort. Zweitens ist es nicht für „Exception-artiges" Error-Handling gedacht — es ist eine Notbremse für sehr spezielle Anwendungsfälle: FFI-Boundaries (Panics dürfen nicht über C-Code propagieren — Undefined Behavior), Plugin-Systeme (ein crashender Plugin soll nicht den Host mitreißen), Test-Frameworks (jeder Test soll isoliert sein).

Wenn du catch_unwind für normales Error-Handling nutzt, machst du etwas falsch — du solltest stattdessen Result verwenden.

assert, debug_assert, unreachable

Drei verwandte Makros für defensive Programmierung:

Rust Defensive Makros
fn berechne(n: i32) -> i32 {
    assert!(n > 0, "n muss positiv sein, war {n}");
    debug_assert!(n < 1000, "n unerwartet groß: {n}");
    match n % 4 {
        0 => 100,
        1 => 200,
        2 => 300,
        3 => 400,
        _ => unreachable!("n % 4 kann nur 0..=3 sein"),
    }
}

assert!(condition, message) läuft in jedem Build und panickt, wenn die Bedingung nicht erfüllt ist. Für harte Invarianten, die immer geprüft werden müssen — etwa Vorbedingungen, ohne die die Funktion gar nicht arbeiten kann.

debug_assert!(condition, message) läuft nur im Debug-Build (cargo build), nicht im Release-Build (cargo build --release). Für teure Checks, die in Production-Performance keinen Sinn machen, aber während der Entwicklung Bugs frühzeitig fangen.

unreachable!(message) signalisiert „diese Code-Stelle kann logisch nicht erreicht werden". Wenn sie doch erreicht wird, panickt sie. Ideal in match-Fallback-Armen, wo der Compiler die Exhaustiveness nicht selbst beweisen kann (etwa bei Guards oder bei komplexer Range-Logik).

Alle drei sind Panic-basierte Werkzeuge und damit für Bugs in deinem eigenen Code gedacht — nicht für erwartete Fehler-Situationen.

Entscheidungs-Tabelle

Situationpanic oder Result?
I/O-Operation (Datei, Netzwerk, DB)Result
Parsen von Nutzer-InputResult
Index in bekannt-gültigem Vecdirekt [i] (kein Panic erwartet)
Index aus User-Inputget() + Result/Option
Verletzte interne Invariantepanic (assert, unreachable)
Validierung von Konstruktor-Argumentenmeist Result (Library), panic nur in Prototypen
Programmier-Fehler (z. B. doppeltes init)panic
Cleanup nach erwartetem FehlerResult
Verarbeitung externer API-AntwortResult
Stdlib-Routinen mit klarer Vorbedingungpanic bei Verletzung

Eine wichtige Faustregel: in Library-Code ist Result fast immer richtig — der Library-Autor weiß nicht, wie der Konsument reagieren will, also gibt er ihm die Wahl. In Application-Code ist Panic für klare Bugs angemessen — wenn deine eigene App-Logik konsistent sein soll, ist Panic ein klares Signal.

Praxis: panic vs. Result im echten Code

Konfigurations-Parser

Rust Config-Loading
use std::fs;

// Result — der Aufrufer entscheidet
pub fn lade_config(pfad: &str) -> Result<String, std::io::Error> {
    fs::read_to_string(pfad)
}

// assert — Library garantiert Vorbedingung
pub fn neue_config(workers: u32) -> AppConfig {
    assert!(workers > 0, "workers muss > 0 sein");
    assert!(workers <= 1024, "workers > 1024 nicht unterstützt");
    AppConfig { workers }
}
# struct AppConfig { workers: u32 }

lade_config kann scheitern (Datei fehlt, keine Lese-Rechte) — das ist erwartet, also Result. neue_config mit workers = 0 wäre ein Bug im aufrufenden Code, kein erwarteter Fehler — also Panic via assert.

Diese Mischung ist typisch. Innerhalb einer Library sind beide Werkzeuge zu finden, jeweils dort eingesetzt, wo sie semantisch passen.

Vec-Zugriff mit unterschiedlicher Sicherheit

Rust Indexierung
pub fn drittes_element(v: &[i32]) -> i32 {
    // Direkter Index — Panic bei Misuse
    assert!(v.len() >= 3, "Vec muss mind. 3 Elemente haben");
    v[2]
}

pub fn element_an(v: &[i32], i: usize) -> Option<i32> {
    // Sicherer Zugriff — None bei Out-of-Bounds
    v.get(i).copied()
}

Zwei Funktionen, zwei API-Stile. drittes_element hat eine dokumentierte Vorbedingung (Vec mind. 3 Elemente) und panickt bei Verletzung — schnell, ohne Result-Overhead. element_an ist defensiv und gibt eine Option zurück — der Aufrufer kann sicher mit beliebigen Indices arbeiten.

Welche Form die richtige ist, hängt vom API-Vertrag ab. Wenn der Aufrufer eine Garantie geben kann (etwa weil er die Vec-Länge selbst kontrolliert), ist die Panic-Variante effizienter. Wenn die Indices aus unsicherer Quelle stammen, ist die Option-Variante sicherer.

Network-Operation

Rust Network
use std::io;

pub fn lese_server_status(url: &str) -> Result<u16, io::Error> {
    // ... fake-Implementation
    if url.is_empty() {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "URL leer"));
    }
    Ok(200)
}

pub fn validiere_url(url: &str) {
    // Vorbedingung — wäre Bug, wenn verletzt
    assert!(!url.is_empty(), "URL darf nicht leer sein");
    assert!(url.starts_with("http"), "URL muss mit http(s) beginnen");
}

lese_server_status ist eine I/O-Operation — sie kann aus tausenden Gründen scheitern (Netzwerk-Probleme, DNS-Fehler, ungültige URL). Result ist der richtige Rückgabe-Typ.

validiere_url ist eine reine Vorbedingungs-Prüfung — wenn der Aufrufer eine ungültige URL übergibt, ist das ein Bug, kein erwarteter Zustand. Panic ist angemessen.

Database-Transaction mit Cleanup

Rust Transaction-Pattern
struct Tx { committed: bool }
impl Drop for Tx {
    fn drop(&mut self) {
        if !self.committed {
            println!("ROLLBACK auf Drop");
        }
    }
}

pub fn execute_query(query: &str) -> Result<u64, &'static str> {
    let _tx = Tx { committed: false };
    if query.is_empty() {
        return Err("leere Query");
    }
    // Verarbeitung — bei Erfolg fortfahren
    Ok(42)
}

Bei einem Panic im Body würde der Tx::drop automatisch laufen und ein Rollback ausführen — das ist der Unwinding-Vorteil. Mit panic = "abort" würdest du dieses Cleanup verlieren. Solche Cleanup-kritischen Anwendungen sind ein guter Grund, beim Default-Unwinding-Modus zu bleiben.

Tests mit gezielten Panics

Rust Test-Pattern
# struct Counter { wert: u32 }
# impl Counter { fn neu() -> Self { Counter { wert: 0 } } fn inkrementieren(&mut self) { self.wert += 1; } }

#[test]
fn counter_startet_bei_null() {
    let c = Counter::neu();
    assert_eq!(c.wert, 0);
}

#[test]
#[should_panic(expected = "overflow")]
fn counter_panic_bei_overflow() {
    let mut c = Counter { wert: u32::MAX };
    c.inkrementieren();    // erwarteter Panic
}

In Tests sind Panics ein normales Werkzeug. assert_eq! panickt bei Ungleichheit; #[should_panic] markiert Tests, die einen Panic erwarten. Das ganze Test-Framework basiert auf der Tatsache, dass jeder Test in einem eigenen Thread läuft und Panics dort isoliert behandelt werden.

Library-Konstruktor mit Validierung

Rust Validierender Konstruktor
pub struct Port(u16);

impl Port {
    pub fn neu(wert: u16) -> Result<Self, &'static str> {
        if wert < 1024 {
            return Err("Port < 1024 ist privilegiert");
        }
        Ok(Port(wert))
    }

    // Alternative für Prototypen / Tests: panic bei Misuse
    pub fn neu_unsafe(wert: u16) -> Self {
        assert!(wert >= 1024, "Port < 1024 nicht erlaubt");
        Port(wert)
    }
}

Library-APIs sollten typischerweise Result für Validierungs-Fehler nutzen — der Konsument kann dann entscheiden, ob er den Fehler weiterreicht, einen Default verwendet, oder den Nutzer fragt. Eine Panic-Variante kann zusätzlich angeboten werden (manchmal mit _unchecked-Suffix), aber sollte nicht die einzige Form sein.

Interessantes

Result für erwartete Fehler, panic für Bugs.

Die wichtigste Faustregel im Rust-Error-Handling. Wenn ein Aufrufer den Fehler sinnvoll behandeln kann, ist Result die richtige Wahl. Wenn der „Fehler" ein Code-Bug ist, der gar nicht auftreten dürfte, ist panic angemessen.

panic! macht Stack-Unwinding (Default).

Beim Default-Panic-Modus laufen alle Drop-Implementierungen im Stack — File-Handles schließen, Mutex freigeben, Transaktionen rollback. Diese Cleanup-Garantie ist der Hauptgrund, beim Default zu bleiben.

panic = "abort" für Performance und kleine Binaries.

In Cargo.toml einstellbar. Spart Unwinding-Overhead und Binary-Größe, opfert aber Cleanup-Garantie und catch_unwind. Sinnvoll bei Embedded-Targets oder sehr performance-kritischen Anwendungen.

catch_unwind ist kein normales Error-Handling.

Es ist eine Notbremse für FFI-Grenzen, Plugin-Systeme und Test-Frameworks. Wer es für gewöhnliches Error-Handling nutzt, hat etwas missverstanden — Result ist das richtige Werkzeug.

assert! immer, debug_assert! nur im Debug-Build.

assert! läuft in jedem Build — für kritische Invarianten ohne Performance-Bedeutung. debug_assert! nur im Debug — für teure Checks, die in Production weggespart werden sollen.

unreachable! für „kann nicht passieren“-Code-Stellen.

In Match-Armen, wo der Compiler die Exhaustiveness nicht selbst sehen kann. Wenn der Code doch erreicht wird, ist es ein Bug. Klare Signalisierung der Annahme.

Library-Code: meist Result. Application-Code: gemischt.

Libraries wissen nicht, wie ihre Konsumenten reagieren wollen — also liefern sie Result und lassen die Wahl offen. Applications kennen ihren eigenen Kontext und können entsprechend einsetzen.

Vec[i] panickt; v.get(i) ist sicher.

Direkter Index-Zugriff ist die schnelle Variante mit Panic-Risiko. get() ist die sichere Variante mit Option. Wahl je nach Sicherheit der Index-Quelle.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Error Handling

Zur Übersicht