ohne Code doppelt zu pflegen
Seit macOS Catalina ist zsh das Standard-Login-Shell, waehrend Server und CI Umgebungen weiter auf Bash setzen. Wer ein Skript oder eine Dotfile-Funktion fuer beide Shells schreiben will, muss Array-Indizes, Globbing und Optionsnamen kennen, die sich fundamental unterscheiden.
Inhaltsverzeichnis
- 1. Warum Bash und zsh Kompatibilitaet ueberhaupt zaehlt
- 2. Array-Indizes: 0-basiert vs 1-basiert
- 3. Globbing-Unterschiede und nomatch
- 4. Wortaufteilung: implizit vs explizit
- 5. Optionsnamen und setopt vs shopt
- 6. emulate sh und POSIX-Modus in zsh
- 7. Die aktive Shell zur Laufzeit erkennen
- 8. Dotfiles fuer beide Shells strukturieren
- 9. Bash vs zsh im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Bash und zsh Kompatibilitaet ueberhaupt zaehlt
Seit macOS Catalina im Jahr 2019 ist zsh die Standard Login Shell auf jedem neuen Mac, waehrend Linux Server, Docker Container und die meisten CI Umgebungen weiterhin auf Bash setzen. Entwickler, die taeglich zwischen macOS Terminal und Linux Server wechseln, stossen dadurch regelmaessig auf Skripte und Funktionen, die unter Bash und zsh unterschiedlich reagieren, obwohl beide Shells auf denselben Bourne Shell Wurzeln aufbauen und einen grossen gemeinsamen Funktionsumfang teilen.
Der praktische Anlass ist meist eine Dotfile Funktion oder ein kleines Utility Skript, das im .zshrc genauso funktionieren soll wie im .bashrc oder in einem eigenstaendigen Deployment Skript. Bash und zsh Kompatibilitaet zu erreichen bedeutet dabei nicht, auf den kleinsten gemeinsamen POSIX Nenner zu verzichten, sondern gezielt die Stellen zu kennen, an denen beide Shells unterschiedliche Defaults haben, und diese Unterschiede entweder zu vermeiden oder explizit zu behandeln, statt sie durch Zufall funktionieren zu lassen.
2. Array-Indizes: 0-basiert vs 1-basiert
Der bekannteste und am haeufigsten unterschaetzte Unterschied zwischen Bash und zsh betrifft die Array Indizierung. Bash Arrays sind wie in fast allen Programmiersprachen 0 basiert: ${array[0]} liefert das erste Element. zsh Arrays sind dagegen standardmaessig 1 basiert: ${array[1]} liefert das erste Element, waehrend ${array[0]} in der Standardkonfiguration ins Leere greift. Ein Skript, das unter Bash entwickelt wurde und Array Zugriffe mit Index 0 enthaelt, liefert unter zsh entweder leere Werte oder einen Off by One Fehler, der sich subtil durch die gesamte Logik zieht.
Fuer echte Kompatibilitaet zwischen Bash und zsh gibt es zwei Strategien: Entweder man vermeidet direkte numerische Array Indizes komplett und arbeitet ausschliesslich mit Iterationen ueber alle Elemente, was in beiden Shells identisch funktioniert, oder man setzt in zsh explizit die Option KSH_ARRAYS, die 0 basierte Indizierung fuer Kompatibilitaet mit Bash und ksh aktiviert. Letzteres aendert aber das Verhalten global fuer das gesamte Skript und kann andere zsh spezifische Konstrukte unerwartet beeinflussen, weshalb die iterationsbasierte Loesung meist die robustere Wahl ist.
#!/usr/bin/env bash
# This file is sourced by both .bashrc and .zshrc
hosts=(web1 web2 web3)
# WRONG: relies on 0-based indexing, breaks under default zsh
# echo "First host: ${hosts[0]}"
# RIGHT: iterate instead of indexing directly — identical in bash and zsh
for host in "${hosts[@]}"; do
echo "Checking host: $host"
done
# If a single element is truly needed, detect the shell explicitly:
if [ -n "${ZSH_VERSION:-}" ]; then
first_host="${hosts[1]}" # zsh: 1-based by default
else
first_host="${hosts[0]}" # bash: 0-based
fi
echo "First host resolved as: $first_host"
3. Globbing-Unterschiede und nomatch
Ein zweiter zentraler Unterschied zwischen Bash und zsh betrifft das Verhalten bei Datei Mustern ohne Treffer. Wenn ein Glob Muster wie *.txt in einem Verzeichnis ohne passende Dateien verwendet wird, gibt Bash standardmaessig das Muster unveraendert als literalen String zurueck. zsh dagegen bricht mit einem Fehler ab: zsh: no matches found: *.txt, weil die Option NOMATCH standardmaessig aktiv ist. Ein Skript, das sich auf das Bash Verhalten verlaesst und den literalen String als Sonderfall abfaengt, funktioniert unter zsh gar nicht erst, weil es dort vorher mit einem Fehler abbricht.
Fuer Kompatibilitaet zwischen Bash und zsh sollte ein Skript entweder explizit pruefen, ob Dateien existieren, bevor es das Glob Ergebnis verwendet, oder in zsh gezielt setopt nullglob setzen, was leere Ergebnislisten statt eines Fehlers oder eines literalen Strings liefert und damit dem in Skripten ueblicherweise gewuenschten Bash Verhalten mit shopt -s nullglob entspricht. Diese explizite Angleichung ist deutlich robuster als sich auf implizites Shell Verhalten zu verlassen.
#!/usr/bin/env bash
# Portable globbing: works the same in bash and zsh
# Normalize glob behavior explicitly instead of relying on shell defaults
if [ -n "${ZSH_VERSION:-}" ]; then
setopt null_glob # zsh: empty match list instead of an error
else
shopt -s nullglob # bash: empty match list instead of literal string
fi
files=(*.txt)
if [ ${#files[@]} -eq 0 ]; then
echo "No .txt files found"
else
for f in "${files[@]}"; do
echo "Found: $f"
done
fi
4. Wortaufteilung: implizit vs explizit
Ein dritter Unterschied, der viele Bash Entwickler beim ersten zsh Kontakt ueberrascht, ist die Wortaufteilung unquotierter Variablen. Bash teilt eine unquotierte Variable mit mehreren durch Leerzeichen getrennten Woertern automatisch in mehrere Argumente auf, etwa bei for w in $words. zsh dagegen behandelt eine unquotierte Skalarvariable standardmaessig als ein einziges Argument, selbst wenn sie Leerzeichen enthaelt, und teilt sie nicht implizit auf. Ein Skript, das sich auf implizites Word Splitting verlaesst, verhaelt sich deshalb unter zsh fundamental anders als unter Bash.
Die zuverlaessige Loesung ist, sich in beiden Shells niemals auf implizites Splitting zu verlassen, sondern von Anfang an mit Arrays statt mit durch Leerzeichen getrennten Strings zu arbeiten. Wo das aus Kompatibilitaetsgruenden mit POSIX sh nicht moeglich ist, sollte in zsh explizit ${=variable} verwendet werden, was Word Splitting fuer diesen einen Ausdruck erzwingt und damit dem Bash Standardverhalten entspricht, ohne das globale Shell Verhalten zu aendern.
5. Optionsnamen und setopt vs shopt
Auch beim Setzen von Shell Optionen unterscheiden sich Bash und zsh fundamental in der Syntax, nicht nur im Namen einzelner Optionen. Bash nutzt shopt -s optionname fuer die meisten erweiterten Optionen und set -o optionname fuer POSIX definierte Optionen. zsh nutzt ausschliesslich setopt optionname und unsetopt optionname, unabhaengig davon, ob die Option POSIX definiert oder zsh spezifisch ist. Ein Skript, das shopt Aufrufe enthaelt und ohne Anpassung in zsh gesourced wird, bricht mit command not found ab, weil shopt in zsh schlicht nicht existiert.
Fuer eine Dotfile Funktion, die in beiden Shells funktionieren soll, empfiehlt sich deshalb eine kleine Wrapper Funktion, die abhaengig von der erkannten Shell die passende Syntax waehlt, oder die konsequente Vermeidung von Shell Optionen zugunsten von portablem POSIX Code, der ganz ohne shopt oder setopt auskommt. Fuer Optionen, die tatsaechlich beide Shells brauchen, etwa das oben genannte nullglob Verhalten, ist die explizite if Verzweigung mit ZSH_VERSION die robusteste Loesung.
#!/usr/bin/env bash
# Setting shell options portably across bash and zsh
set_shell_option() {
local option="$1"
if [ -n "${ZSH_VERSION:-}" ]; then
setopt "$option" 2>/dev/null || true
else
shopt -s "$option" 2>/dev/null || set -o "$option" 2>/dev/null || true
fi
}
set_shell_option nullglob
set_shell_option extended_glob
6. emulate sh und POSIX-Modus in zsh
zsh bringt einen eigenen, sehr weitreichenden Kompatibilitaetsmechanismus mit: den Befehl emulate. emulate sh am Anfang eines Skripts oder einer Funktion versetzt zsh in einen Modus, der viele zsh spezifische Verhaltensweisen deaktiviert und stattdessen POSIX Semantik erzwingt, inklusive 0 basierter Array Indizierung und Bash aehnlichem Word Splitting. emulate bash geht noch einen Schritt weiter und aktiviert speziell auf Bash Kompatibilitaet ausgelegte Optionen.
Der Vorteil von emulate ist, dass ein einzelner Befehl viele Einzeloptionen gleichzeitig setzt, statt jede Option manuell abzugleichen. Der Nachteil: emulate aendert das Verhalten global fuer die aktuelle Funktion oder das aktuelle Skript und kann andere zsh spezifische Features, die man eigentlich behalten moechte, unbeabsichtigt mit deaktivieren. Fuer klar abgegrenzte Kompatibilitaetsfunktionen in einem Dotfile ist emulate -L sh innerhalb einer einzelnen Funktion, mit dem Flag -L fuer lokale Geltung, oft die praktikabelste Loesung.
# zsh-only syntax, but the function still works when sourced from bash too
# because emulate is silently ignored / undefined there in a guarded call
portable_split() {
# Local emulation: only affects this function, not the whole shell session
if [ -n "${ZSH_VERSION:-}" ]; then
emulate -L sh
fi
local input="$1"
for word in $input; do
echo "word: $word"
done
}
portable_split "one two three"
7. Die aktive Shell zur Laufzeit erkennen
Die Grundlage jeder Bash und zsh Kompatibilitaetsloesung ist eine zuverlaessige Erkennung, welche Shell das Skript gerade ausfuehrt. Bash setzt die Variable BASH_VERSION automatisch, zsh setzt entsprechend ZSH_VERSION. Eine einfache Pruefung mit [ -n "${ZSH_VERSION:-}" ] oder [ -n "${BASH_VERSION:-}" ] funktioniert zuverlaessig in beiden Shells, ohne externe Abhaengigkeiten und ohne die im Vergleich fehleranfaelligere Pruefung des Programmnamens ueber $0, die je nach Aufrufkontext variieren kann.
Diese Erkennung sollte am Anfang jeder Dotfile Datei stehen, die in beiden Shells gesourced wird, und als Grundlage fuer alle folgenden Verzweigungen dienen, etwa bei der Wahl zwischen shopt und setopt oder bei Array Indizierung. Ein sauberer Aufbau kapselt diese Erkennung in einer einzigen Wrapper Variable oder Funktion am Dateianfang, statt die Pruefung an jeder einzelnen Stelle im Skript zu wiederholen.
8. Dotfiles fuer beide Shells strukturieren
Fuer Entwickler, die dieselben Aliase, Funktionen und Umgebungsvariablen in Bash und zsh nutzen wollen, ist eine gemeinsame Datei mit reinem POSIX kompatiblem Code die wartbarste Loesung. Diese gemeinsame Datei, etwa ~/.shell_common, wird sowohl vom .bashrc als auch vom .zshrc am Ende per source eingebunden und enthaelt ausschliesslich Konstrukte, die in beiden Shells identisch funktionieren: POSIX Funktionsdefinitionen, Iterationen statt Array Indizierung, printf statt echo.
Shell spezifische Anpassungen, etwa das Prompt Format oder zsh spezifische Completion Funktionen, bleiben dagegen in der jeweiligen .bashrc oder .zshrc und werden nicht in die gemeinsame Datei gemischt. Diese klare Trennung zwischen gemeinsamem, POSIX kompatiblem Kern und Shell spezifischer Konfiguration ist der Schluessel dazu, Bash und zsh Kompatibilitaet dauerhaft zu pflegen, ohne bei jeder Aenderung zwei fast identische Dateien synchron halten zu muessen.
9. Bash vs zsh im direkten Vergleich
Die folgende Tabelle fasst die wichtigsten Unterschiede zwischen Bash und zsh zusammen, die bei gemeinsamen Skripten am haeufigsten zu Fehlern fuehren.
| Verhalten | Bash | zsh (Standard) | Portable Loesung |
|---|---|---|---|
| Array-Index | 0-basiert | 1-basiert | Iteration statt Index |
| Glob ohne Treffer | literaler String | Fehler (NOMATCH) | nullglob explizit setzen |
| Word Splitting | implizit bei $var | nicht implizit | Arrays statt Strings |
| Optionen setzen | shopt / set -o | setopt / unsetopt | Wrapper-Funktion |
| Shell erkennen | $BASH_VERSION | $ZSH_VERSION | Beide Variablen pruefen |
Der Vergleich zeigt: Bash und zsh sind sich in der Grundsyntax sehr aehnlich, unterscheiden sich aber in genau den Details, die in Dotfiles und kurzen Utility Skripten am haeufigsten vorkommen. Wer diese fuenf Unterschiede kennt und konsequent auf portable Muster setzt, kann ein und dieselbe Codebasis in beiden Shells verwenden, ohne Funktionen doppelt zu pflegen.
Mironsoft
Shell-Automatisierung, Dotfile-Pflege und plattformuebergreifende Skripte
Dotfiles und Skripte, die in Bash und zsh gleich funktionieren?
Wir bauen gemeinsame Shell Bibliotheken, loesen Array-Index und Globbing Probleme sauber und trennen shellspezifische Konfiguration vom portablen Kern.
Dotfile-Audit
Bestehende .bashrc und .zshrc auf doppelten und inkompatiblen Code pruefen
Refactoring
Gemeinsame, POSIX kompatible Shell Bibliothek fuer Team-Dotfiles aufbauen
CI-Skripte
Utility Skripte fuer macOS Entwickler und Linux CI Runner gleichermassen absichern
10. Zusammenfassung
Bash und zsh Kompatibilitaet scheitert in der Praxis fast immer an denselben fuenf Stellen: Array Indizierung, Globbing ohne Treffer, implizites Word Splitting, die Syntax fuer Shell Optionen und das Fehlen einer klaren Erkennung, welche Shell gerade aktiv ist. Alle fuenf Unterschiede lassen sich mit einfachen, portablen Mustern loesen, ohne Code doppelt zu pflegen: Iteration statt Index, explizites nullglob, Arrays statt Strings, eine kleine Wrapper Funktion fuer Optionen und eine Pruefung von BASH_VERSION oder ZSH_VERSION am Dateianfang.
Der zsh eigene emulate Befehl bietet zusaetzlich einen schnellen Weg, POSIX oder Bash aehnliches Verhalten fuer eine einzelne Funktion zu erzwingen, ohne das globale Shell Verhalten zu veraendern. Wer eine gemeinsame Datei mit reinem, portablem Code fuer Bash und zsh pflegt und shellspezifische Konfiguration klar davon trennt, spart auf Dauer erheblichen Wartungsaufwand gegenueber zwei parallel gepflegten, fast identischen Dotfile Sammlungen.
Bash und zsh: das Wichtigste auf einen Blick
Array-Indizes
Bash ist 0-basiert, zsh standardmaessig 1-basiert. Iteration statt direktem Index vermeidet den Unterschied komplett.
Globbing
zsh bricht bei Mustern ohne Treffer standardmaessig mit einem Fehler ab. nullglob explizit in beiden Shells setzen.
Optionen
shopt und set -o in Bash, setopt und unsetopt in zsh. Eine Wrapper Funktion kapselt den Unterschied.
Shell erkennen
$BASH_VERSION und $ZSH_VERSION pruefen, als Grundlage fuer alle folgenden Verzweigungen im Skript.