Ein Bash-Skript ohne Fehlerbehandlung ist eine Wundertüte: Bei der ersten unerwarteten Situation läuft es weiter, als wäre nichts passiert — und richtet im schlimmsten Fall realen Schaden an. Bash bringt mit set -e, set -u, set -o pipefail und trap Werkzeuge mit, um genau das zu verhindern. Diese Optionen sind aber voller Feinheiten: set -e greift nicht überall, trap läuft auch bei Erfolg, und ein vergessenes ${var:-} kann das Skript trotz aller Vorsicht zum Stillstand bringen. Wer die Mechanik versteht, schreibt Skripte, die im Fehlerfall sauber aufräumen statt halbe Zustände zu hinterlassen.

Strategien für Fehlerbehandlung

In der Praxis lassen sich drei grundsätzlich unterschiedliche Haltungen zu Fehlern in Bash beobachten — und alle drei haben ihre Berechtigung, je nach Aufgabe.

StrategieVorgehenStärkenSchwächen
IgnorierenSkript läuft auch bei Fehlern weiterMaximal nachsichtig, gut für „Best-Effort”-CleanupFehler werden geschluckt, halbe Zustände bleiben unbemerkt
Abbrechenset -euo pipefail lässt das Skript beim ersten Fehler endenWenig Code, deckt 80 % der Fälle abGreift nicht überall (Pipes, if, &&), wirkt manchmal zu hart
Explizit prüfenJeder kritische Befehl mit if, `oderrc=$?`

Die meisten produktiven Skripte mischen die letzten beiden: ein robuster Header mit set -euo pipefail als Sicherheitsnetz, dazu gezielte Prüfungen an Stellen, an denen die Standard-Semantik nicht reicht oder eine spezifische Fehlermeldung erwünscht ist. Reine „Ignorieren”-Skripte gehören in die Kategorie Wegwerf-Code — als Default für alles, was länger als ein paar Zeilen ist, sind sie ein Risiko.

set -e (errexit)

set -e — auch set -o errexit — lässt das Skript abbrechen, sobald ein einfacher Befehl mit einem Exit-Code ungleich 0 endet. Klingt simpel, ist es aber nicht: Die Option hat eine Reihe bewusst eingebauter Ausnahmen, die regelmäßig zu Verwirrung führen.

set -e greift nicht in folgenden Situationen:

  • Befehle in der Bedingung von if, while, until oder &&/||-Ketten (außer als letzter Befehl)
  • Befehle mit vorangestelltem ! (Negation)
  • Nicht-letzte Stufen einer Pipe — solange pipefail nicht aktiv ist
  • local var=$(cmd) oder declare var=$(cmd) — der Exit-Code des local/declare-Builtins zählt, nicht der von cmd
Bash set -e wirkt nicht in if-Bedingungen
#!/usr/bin/env bash
set -e
if grep "muster" datei.txt; then
    echo "Treffer"
fi
echo "Skript laeuft weiter"

Findet grep nichts und liefert 1, läuft das Skript trotzdem weiter — die Bedingung des if ist genau dafür da, einen Exit-Code zu prüfen. Genauso bewusst maskiert cmd && other einen Fehler von cmd: Das ist ja gerade die Semantik der Kette.

Die häufigste Falle ist der „false sense of security”: Ein Skript mit set -e fühlt sich sicher an, schluckt aber in Pipes klammheimlich Fehler. Erst die Kombination mit pipefail macht aus set -e ein zuverlässiges Sicherheitsnetz. Details zu den Ausnahmen finden sich im Artikel zu den Exit-Codes.

set -u (nounset)

set -u — auch set -o nounset — bricht ab, sobald eine Variable benutzt wird, die nie gesetzt wurde. Ohne diese Option liefert Bash für eine undefinierte Variable einfach einen leeren String — was selten gewollt ist und gerne Katastrophen produziert.

Bash set -u verhindert leere Variablen
#!/usr/bin/env bash
set -u
rm -rf "$PORJECT_DIR/cache"

Ohne set -u würde der Tippfehler PORJECT_DIR zu einem leeren String, und der Befehl würde zu rm -rf /cache. Mit set -u bricht Bash sofort mit PORJECT_DIR: unbound variable ab — bevor irgendetwas Schaden anrichten kann.

Wer optionale Variablen oder Default-Werte braucht, nutzt die Parameter-Expansion mit Default-Suffix:

Bash Defaults bei set -u erlaubt
#!/usr/bin/env bash
set -u
log_level="${LOG_LEVEL:-info}"
config_path="${CONFIG_PATH:-/etc/app/config}"
echo "Level: $log_level, Config: $config_path"

${VAR:-default} liefert default, wenn VAR nicht gesetzt oder leer ist, ohne set -u auszulösen. Genauso ${VAR:-} für „leerer String, falls nicht gesetzt” — das ist der Standard-Trick, um optionale Argumente in Funktionen zu behandeln, ohne set -u zu deaktivieren.

set -o pipefail

Standardmäßig ist der Exit-Code einer Pipeline der des letzten Befehls. Ein Fehler in einer früheren Stufe geht still verloren — und macht set -e bei Pipes nutzlos.

set -o pipefail dreht das um: Die Pipeline endet mit dem Exit-Code des am weitesten links gelegenen Befehls, der mit Fehler endete. Nur wenn alle Stufen sauber 0 zurückgeben, ist auch die Pipeline 0.

Bash Mit pipefail werden Pipe-Fehler sichtbar
set -o pipefail
cat /nichtvorhanden | grep "x"
echo "Exit: $?"
Output
cat: /nichtvorhanden: No such file or directory
Exit: 1

Ohne pipefail wäre der Exit-Code 1 von grep (kein Treffer), aber cats eigentlicher Fehler bliebe unsichtbar. In Kombination mit set -e ist pipefail quasi Pflicht — ohne diese Kombi schluckt jede Pipe stillschweigend Fehler in den vorderen Stufen. Mehr zu Pipes und PIPESTATUS findet sich im Artikel zu den Exit-Codes.

set -E (errtrace)

Standardmäßig wird ein trap ... ERR nicht in Funktionen, Subshells oder Command-Substitutions vererbt. Die Folge: Bei einem Fehler innerhalb einer Funktion läuft der ERR-Trap nicht — der Handler greift nur auf der obersten Skript-Ebene.

set -E (kurz für errtrace) ändert das: ERR-Traps werden in alle Funktionen, Subshells und $(...)-Aufrufe vererbt.

Bash set -E sorgt für ERR-Trap auch in Funktionen
#!/usr/bin/env bash
set -eE
trap 'echo "Fehler in Zeile $LINENO" >&2' ERR

meine_funktion() {
    false
}

meine_funktion

Ohne set -E würde die false zwar zum Skript-Abbruch führen (set -e), der ERR-Trap im Handler aber nicht ausgelöst. Mit set -E greift der Trap auch im Funktions-Body. Das gleiche gilt für set -T (functrace) bei DEBUG- und RETURN-Traps — beides wichtig, sobald man systematisch in Funktionen Fehler abfangen will.

Standard-Header

Die folgenden vier Zeilen sind die Mindestausstattung für jedes Skript, das verlässlich laufen soll. Sie tauchen in fast jeder Bash-Style-Guide-Empfehlung auf — manchmal „Bash Strict Mode” genannt, auch wenn es keinen offiziellen Modus gleichen Namens gibt.

Bash Robuster Skript-Header
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Was die einzelnen Bestandteile leisten:

OptionWirkung
set -eSkript bricht beim ersten fehlschlagenden Befehl ab (mit den bekannten Ausnahmen)
set -uVerwendung undefinierter Variablen bricht ab — schützt vor Tippfehlern
set -o pipefailPipes liefern den Code der ersten gescheiterten Stufe statt nur der letzten
IFS=$'\n\t'Wort-Splitting nur an Newline und Tab, nicht mehr an Leerzeichen

IFS=$'\n\t' ist der unscheinbarste Teil, aber relevant: Ohne diese Zeile splittet Bash unquoted Variablen an jedem Whitespace — Dateinamen mit Leerzeichen werden so in mehrere Worte zerschnitten und Befehle wie rm $datei löschen plötzlich die falschen Dinge. Mit dem engen IFS wird das Verhalten vorhersagbar — und du gewöhnst dir trotzdem an, Variablen konsequent in Quotes zu schreiben ("$datei").

Wer zusätzlich Funktionen mit ERR-Trap absichern will, ergänzt set -E und kommt auf set -eEuo pipefail — die längste, aber sicherste Variante.

trap — Cleanup beim Beenden

Mit trap definierst du Handler, die bei bestimmten Signalen oder besonderen Skript-Ereignissen ausgeführt werden. Die Syntax: trap 'BEFEHL' SIGNAL [SIGNAL ...]. Der Befehl wird in einfachen Anführungszeichen übergeben, damit er erst zur Trigger-Zeit expandiert wird — sonst würden Variablen schon beim Setzen aufgelöst.

Signal/PseudoAuslöserTypische Verwendung
EXITSkript-Ende — egal ob normal, durch exit oder FehlerAufräumen (Temp-Dateien, Locks, offene Verbindungen)
ERRBefehl mit Exit-Code ungleich 0 (bei aktivem set -e)Fehler loggen, Stack-Trace ausgeben
SIGINTStrg+C / Interrupt vom TerminalSauberer Abbruch oder Ignorieren
SIGTERMBeendigung von außen (z. B. kill PID)Wie SIGINT — kontrolliert herunterfahren
SIGHUPTerminal geschlossen, Dienst-ReloadKonfiguration neu laden oder beenden
DEBUGVor jedem einfachen BefehlEigenes Tracing implementieren
RETURNFunktion oder gesourcetes Skript kehrt zurückPer-Funktion-Cleanup

Drei Sonderformen sind besonders wichtig:

Bash trap-Grundformen
trap 'cleanup' EXIT
trap 'echo "Fehler in $LINENO" >&2' ERR
trap '' SIGINT

trap 'cleanup' EXIT läuft bei jedem Skript-Ende, auch nach exit 0. trap 'msg' ERR triggert nur bei Fehler — sinnvoll für aussagekräftige Diagnose. trap '' SIGINT — ein leerer String — ignoriert das Signal komplett; das Skript lässt sich dann nicht mehr per Strg+C unterbrechen, was meist eine schlechte Idee ist und höchstens für sehr kurze kritische Abschnitte gilt.

Wer einen vorher gesetzten Trap löschen will, schreibt trap - SIGNAL (Bindestrich als Argument). trap -p zeigt alle aktuell registrierten Traps an — praktisch zum Debuggen.

Klassisches Cleanup-Pattern

Das mit Abstand häufigste trap-Idiom: ein temporäres Verzeichnis am Anfang des Skripts anlegen, einen EXIT-Trap registrieren, der es löscht — und dann sorglos darin arbeiten. Egal wie das Skript endet, das Verzeichnis verschwindet zuverlässig.

Bash Temp-Dir mit garantiertem Cleanup
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

TMPDIR="$(mktemp -d -t mein-skript.XXXXXX)"
trap 'rm -rf "$TMPDIR"' EXIT

echo "Arbeite in $TMPDIR"
cp /etc/hostname "$TMPDIR/host.txt"
sort "$TMPDIR/host.txt" > "$TMPDIR/sorted.txt"
cat "$TMPDIR/sorted.txt"

mktemp -d erzeugt sicher ein einzigartiges Verzeichnis (die XXXXXX werden durch Zufallszeichen ersetzt) und gibt den Pfad aus. Der Trap registriert das rm -rf für den Skript-End-Zeitpunkt — bei normalem Ende, bei exit 1, bei einem Fehler durch set -e, sogar bei Strg+C. Wichtig: Das trap muss direkt nach dem mktemp stehen. Schlägt mktemp fehl, hat TMPDIR keinen sinnvollen Wert; ein zu früh gesetzter Trap würde dann mit leerer Variable laufen — rm -rf "" ist zwar harmlos, aber unsauber.

Für mehrere Trap-Aktionen kommt eine Cleanup-Funktion ins Spiel:

Bash Cleanup-Funktion für mehrere Aktionen
cleanup() {
    local rc=$?
    rm -rf "${TMPDIR:-}"
    rm -f "${LOCKFILE:-}"
    kill "${BG_PID:-}" 2>/dev/null || true
    exit "$rc"
}
trap cleanup EXIT

local rc=$? als allererste Zeile sichert den Exit-Code, mit dem das Skript ankam — danach kann jeder Cleanup-Befehl seinen eigenen Status setzen, ohne dass der ursprüngliche Code verloren geht. Das || true am kill verhindert, dass Cleanup-Fehler den Exit-Code überschreiben. Am Ende wird der gesicherte rc explizit weitergereicht.

Praxis-Patterns

Die folgenden sechs Bausteine decken die häufigsten Anforderungen produktiver Skripte ab. Jeder funktioniert isoliert, in Kombination ergeben sie ein robustes Skript-Skelett.

Robuster Header

Bash Maximaler Schutz am Skript-Anfang
#!/usr/bin/env bash
set -eEuo pipefail
IFS=$'\n\t'
umask 077

set -eEuo pipefail aktiviert Fehlerabbruch (auch in Funktionen), Schutz gegen undefinierte Variablen und Pipe-Fehlerpropagation. IFS=$'\n\t' schaltet das fatale Wort-Splitting an Spaces ab. umask 077 sorgt dafür, dass alle vom Skript erzeugten Dateien standardmäßig nur für den eigenen User lesbar sind — wichtig für Skripte, die Credentials oder temporäre Logs anlegen.

Temp-Dir mit Auto-Cleanup

Bash mktemp + EXIT-Trap als Standard-Idiom
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT

cp datei.bin "$TMPDIR/work.bin"
process "$TMPDIR/work.bin" > "$TMPDIR/result.txt"
mv "$TMPDIR/result.txt" /var/lib/output/

Egal wie das Skript endet — sauberes Ende, Fehler, Strg+C — das Verzeichnis ist weg. Solange mv erst am Ende kommt, bleiben Zwischenstände nur im Temp-Dir und nichts halbes landet im Zielordner.

Lock-Datei mit Trap

Bash Verhindert parallele Laeufe
LOCKFILE="/var/run/mein-skript.lock"

if [[ -e "$LOCKFILE" ]]; then
    echo "Skript laeuft bereits (PID $(cat "$LOCKFILE"))" >&2
    exit 1
fi

echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT

Ein einfacher Lock: Existenz der Datei prüfen, eigene PID hineinschreiben, Trap zum Aufräumen registrieren. $$ ist die PID des laufenden Skripts. Vorsicht: Das hat eine Race-Condition zwischen Prüfung und Schreiben — für strikte Atomarität nutzt man besser flock oder mkdir (atomar). Für die meisten Wartungs-Skripte ist diese Variante aber pragmatisch ausreichend.

Retry-Loop mit max attempts

Bash Befehl mit Wiederholung und Backoff
retry() {
    local max=$1; shift
    local delay=1
    local attempt=1

    until "$@"; do
        if (( attempt >= max )); then
            echo "Fehlgeschlagen nach $max Versuchen: $*" >&2
            return 1
        fi
        echo "Versuch $attempt fehlgeschlagen, warte ${delay}s..." >&2
        sleep "$delay"
        delay=$(( delay * 2 ))
        attempt=$(( attempt + 1 ))
    done
}

retry 5 curl -fsS https://api.example.com/health

Die Funktion ruft den übergebenen Befehl bis zu max mal auf, mit exponentiellem Backoff (1s, 2s, 4s, 8s, …). until "$@" läuft, solange der Befehl scheitert — "$@" reicht alle Argumente korrekt durch, auch solche mit Leerzeichen. curl -fsS ist hier wichtig: -f lässt curl bei HTTP-4xx/5xx mit Fehler enden, sonst würde es trotz Server-Fehler 0 zurückgeben.

Error-Handler mit Stack-Trace

Bash ERR-Trap mit Zeilennummer und Datei
#!/usr/bin/env bash
set -eEuo pipefail

on_error() {
    local exit_code=$?
    local line=$1
    local source=${BASH_SOURCE[1]:-?}
    echo "FEHLER: Exit $exit_code in $source Zeile $line" >&2
    echo "Befehl: $BASH_COMMAND" >&2
    exit "$exit_code"
}

trap 'on_error $LINENO' ERR

false

$LINENO liefert die Zeilennummer, in der der Fehler auftrat — wichtig: als Argument zur Trap-Funktion übergeben, sonst meldet sie die Zeile innerhalb der Funktion. ${BASH_SOURCE[1]} zeigt auf die Datei, von der aus der Trap getriggert wurde (Index 0 wäre die Datei der Funktion selbst). $BASH_COMMAND enthält den fehlgeschlagenen Befehl als Text. Zusammen ergibt das eine aussagekräftige Diagnose, mit der sich Fehler in größeren Skripten schnell lokalisieren lassen.

Try-Catch-Imitat

Bash cmd || handle als Catch-Block
process_file() {
    local file=$1
    if ! validate "$file"; then
        echo "Validierung fehlgeschlagen: $file" >&2
        return 1
    fi

    transform "$file" || {
        echo "Transformation fehlgeschlagen, restore..." >&2
        cp "$file.bak" "$file"
        return 2
    }

    echo "OK: $file"
}

Bash kennt kein try/catch — der Effekt entsteht durch || mit einem Block in geschweiften Klammern. Vorteil gegenüber if !-Verschachtelung: Der „Erfolgsfall” ist die Hauptlinie des Codes, der Fehlerfall ein klar abgesetzter Block. Wichtig sind die geschweiften Klammern (gleicher Shell-Scope, Variablen und return wirken in der Funktion) und das Semikolon vor der schließenden Klammer.

Häufige Stolperfallen

set -e ist ein Sicherheitsnetz, kein Bug-Schutz

Ein Skript, das nur mit set -e versehen ist, fuehlt sich sicher an, ist es aber nicht. Die Option greift bewusst nicht in if/while/until-Bedingungen, nicht vor &&/||, nicht bei mit ! negierten Befehlen und nicht bei nicht-letzten Pipe-Stufen ohne pipefail. Wer sich darauf verlässt, dass jeder Fehler abbricht, übersieht regelmaessig genau diese Ausnahmen. Praxis: set -euo pipefail als Minimum, plus explizite Prüfungen an kritischen Stellen.

set -u lässt sich mit ${var:-} umgehen

Wer optionale Variablen braucht, deaktiviert oft set -u voruebergehend mit set +u und vergisst, es wieder einzuschalten. Sauberer ist die Default-Expansion: ${VAR:-default} liefert default, wenn VAR nicht gesetzt ist, ohne set -u auszuloesen. Für „leerer String, falls nicht gesetzt” reicht ${VAR:-}. Auch in Tests bewaehrt: if [[ -n "${VAR:-}" ]] statt if [[ -n "$VAR" ]] — letzteres bricht mit set -u ab, sobald VAR nicht definiert ist.

trap EXIT läuft auch bei exit 0

trap 'cleanup' EXIT triggert bei jedem Skript-Ende — nach exit 0, nach Fehler, nach Signal. Das ist meistens genau gewollt (man will ja immer aufraeumen), kann aber überraschen, wenn der Cleanup-Code laut ist (Logmeldungen, Mails). Wer nur bei Fehler reagieren will, nutzt ERR statt EXIT — oder prüft im Handler if (( $? != 0 )) als erste Bedingung.

Mehrere traps stapeln sich nicht — sie überschreiben

trap 'a' EXIT; trap 'b' EXIT registriert nicht beide, sondern nur b. Wer mehrere Aktionen am Ende braucht, baut sie in eine einzige Cleanup-Funktion: cleanup() { a; b; } und trap cleanup EXIT. Das gilt auch beim Sourcen von Bibliotheken — wenn lib.sh einen EXIT-Trap setzt und das Hauptskript ebenfalls, gewinnt der zuletzt gesetzte. Bibliotheken sollten deshalb keine globalen Traps setzen, oder zumindest dokumentieren, dass sie es tun.

Subshells haben eigenen Trap-Scope

Eine Subshell — entweder (cmd; cmd) oder $(cmd) — übernimmt Traps vom Eltern-Prozess nicht automatisch. Ein in der Subshell neu gesetzter Trap wirkt nur dort und ist nach Subshell-Ende wieder weg. Wer ERR-Traps auch in $(...)-Aufrufen aktiv haben will, braucht set -E (errtrace). Für EXIT-Traps gilt: Sie laufen am Ende der Subshell, nicht am Ende des Hauptskripts — was häufig der Grund ist, warum ein vermeintlich registrierter Cleanup nie ausgefuehrt wird.

set -e plus local var=$(cmd) greift nicht

Eine subtile Falle: local var=$(cmd) bricht nicht ab, wenn cmd fehlschlägt — denn der Exit-Code des local-Builtins (immer 0) zählt, nicht der von cmd. Das gleiche gilt für declare, readonly und export mit Zuweisung. Lösung: zuerst zuweisen, dann prüfen — local var; var=$(cmd) triggert set -e korrekt, falls cmd mit Fehler endet.

IFS=$'\n\t' verhindert Word-Splitting an Spaces

Standard-IFS ist space-tab-newline. Eine Schleife for f in $(ls dir) mit Dateien wie mein bild.jpg zerlegt den Namen in mein und bild.jpg — und tut dann das Falsche. Mit IFS=$'\n\t' wird nur an Newline und Tab gesplittet, Spaces in Namen bleiben heil. Trotzdem: Variablen immer in Quotes setzen ("$file"), und statt for f in $(ls) lieber Globbing (for f in dir/*) oder find ... -print0 | xargs -0 verwenden — beides ist robuster als jede IFS-Akrobatik.

ERR-Trap greift in Funktionen nur mit set -E

trap 'handler' ERR wird standardmäßig nicht an Funktionen, Subshells oder Command-Substitutions vererbt. Ein Fehler in einer Funktion fuehrt zum Skript-Abbruch (bei set -e), aber der Handler läuft nicht. Mit set -E (errtrace) wird der Trap auch in Funktionen aktiv — ohne diese Option ist ein zentraler Error-Handler nutzlos, sobald Code in Funktionen ausgelagert wird. set -eEuo pipefail ist deshalb für ernsthafte Fehler-Handler die richtige Kombi.

Trap-Reihenfolge bei verschachtelten Skripten

Wenn Skript A das Skript B per ./b.sh aufruft, hat B einen eigenen Prozess und damit eigene Traps. Wenn A B per source b.sh einbindet, teilen sich beide den Trap-Raum — der zuletzt gesetzte Trap gewinnt, und ein Trap aus B kann den von A still überschreiben. Bibliotheks-Skripte, die per source eingebunden werden sollen, sollten deshalb keine globalen traps setzen; wenn sie es doch tun, mindestens dokumentieren und idealerweise eine Setup-Funktion bereitstellen, die der Aufrufer bewusst aktiviert.

Weiterfuehrende Ressourcen

Externe Quellen

  • Exit-Codes$?, exit N, Pipe-Verhalten und PIPESTATUS als Grundlage jeder Fehlerbehandlung
  • Skript-Grundlagen — Shebang, Permissions und der robuste Skript-Header im Detail
  • Funktionen — Lokale Variablen, return und Funktions-Scope für Fehler-Handler
  • Streams und Pipes — stdin, stdout, stderr und korrekte Fehler-Ausgabe nach &2
  • Shell — Bash-Grundlagen, Built-ins und Shell-Verhalten
/ Weiter

Zurück zu Shell-Scripting

Zur Übersicht