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.
| Strategie | Vorgehen | Stärken | Schwächen |
|---|---|---|---|
| Ignorieren | Skript läuft auch bei Fehlern weiter | Maximal nachsichtig, gut für „Best-Effort”-Cleanup | Fehler werden geschluckt, halbe Zustände bleiben unbemerkt |
| Abbrechen | set -euo pipefail lässt das Skript beim ersten Fehler enden | Wenig Code, deckt 80 % der Fälle ab | Greift nicht überall (Pipes, if, &&), wirkt manchmal zu hart |
| Explizit prüfen | Jeder 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,untiloder&&/||-Ketten (außer als letzter Befehl) - Befehle mit vorangestelltem
!(Negation) - Nicht-letzte Stufen einer Pipe — solange
pipefailnicht aktiv ist local var=$(cmd)oderdeclare var=$(cmd)— der Exit-Code deslocal/declare-Builtins zählt, nicht der voncmd
#!/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.
#!/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:
#!/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.
set -o pipefail
cat /nichtvorhanden | grep "x"
echo "Exit: $?"cat: /nichtvorhanden: No such file or directory
Exit: 1Ohne 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.
#!/usr/bin/env bash
set -eE
trap 'echo "Fehler in Zeile $LINENO" >&2' ERR
meine_funktion() {
false
}
meine_funktionOhne 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.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'Was die einzelnen Bestandteile leisten:
| Option | Wirkung |
|---|---|
set -e | Skript bricht beim ersten fehlschlagenden Befehl ab (mit den bekannten Ausnahmen) |
set -u | Verwendung undefinierter Variablen bricht ab — schützt vor Tippfehlern |
set -o pipefail | Pipes 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/Pseudo | Auslöser | Typische Verwendung |
|---|---|---|
| EXIT | Skript-Ende — egal ob normal, durch exit oder Fehler | Aufräumen (Temp-Dateien, Locks, offene Verbindungen) |
| ERR | Befehl mit Exit-Code ungleich 0 (bei aktivem set -e) | Fehler loggen, Stack-Trace ausgeben |
| SIGINT | Strg+C / Interrupt vom Terminal | Sauberer Abbruch oder Ignorieren |
| SIGTERM | Beendigung von außen (z. B. kill PID) | Wie SIGINT — kontrolliert herunterfahren |
| SIGHUP | Terminal geschlossen, Dienst-Reload | Konfiguration neu laden oder beenden |
| DEBUG | Vor jedem einfachen Befehl | Eigenes Tracing implementieren |
| RETURN | Funktion oder gesourcetes Skript kehrt zurück | Per-Funktion-Cleanup |
Drei Sonderformen sind besonders wichtig:
trap 'cleanup' EXIT
trap 'echo "Fehler in $LINENO" >&2' ERR
trap '' SIGINTtrap '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.
#!/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:
cleanup() {
local rc=$?
rm -rf "${TMPDIR:-}"
rm -f "${LOCKFILE:-}"
kill "${BG_PID:-}" 2>/dev/null || true
exit "$rc"
}
trap cleanup EXITlocal 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
#!/usr/bin/env bash
set -eEuo pipefail
IFS=$'\n\t'
umask 077set -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
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
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"' EXITEin 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
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/healthDie 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
#!/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
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
- Bash-Manpage: SHELL ATTRIBUTES (man7.org) — Alle
set-Optionen mit offizieller Definition - GNU Bash-Handbuch: The Set Builtin —
set -e,set -u,pipefail,errtraceim Detail - GNU Bash-Handbuch: Bourne Shell Builtins (trap) — Vollstaendige Trap-Referenz inkl. Signal-Liste
- Greg’s Wiki: BashFAQ/105 — set -e — Detaillierte Analyse aller Faelle, in denen
set -enicht greift - Aaron Maxwell: Use the Unofficial Bash Strict Mode — Klassischer Artikel zur
set -euo pipefail-Kombination
Verwandte Artikel
- Exit-Codes —
$?,exit N, Pipe-Verhalten undPIPESTATUSals Grundlage jeder Fehlerbehandlung - Skript-Grundlagen — Shebang, Permissions und der robuste Skript-Header im Detail
- Funktionen — Lokale Variablen,
returnund 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