Skip to content

05 · Cross-Shell Compatibility

Not every system where your script runs has bash, and not every bash is the same version. This module covers the practical differences between POSIX sh, bash, and zsh, and how to write scripts that either run correctly everywhere or fail with a clear message instead of a confusing one.

Why this matters

#!/bin/sh on Debian/Ubuntu is dash, not bash — a strict POSIX shell with none of bash's extensions. macOS ships an old bash 3.2 (licensing, not neglect) but makes zsh the default interactive shell. Alpine Linux containers (common in Docker images) often have only sh (BusyBox ash), no bash at all. A script that assumes "bash everywhere" will break silently — or loudly — on any of these.

Picking the right shebang

#!/bin/sh
# Use this when your script only needs POSIX features — most portable,
# runs on dash, ash, bash, and zsh's sh-compatibility mode.

#!/usr/bin/env bash
# Use this when you need bash-specific features (arrays, [[ ]], etc.)
# `env` finds bash on $PATH rather than assuming /bin/bash exists.

Never write #!/bin/bash directly on scripts meant to be portable — bash isn't guaranteed to live at /bin/bash on every system (NixOS, some BSDs). #!/usr/bin/env bash is the safer bash shebang.

Bash-only features that break in POSIX sh

# arrays — NOT in POSIX sh, works in bash/zsh
names=(alice bob carol)
echo "${names[1]}"          # bob

# [[ ]] extended test — NOT in POSIX sh, use [ ] instead
[[ "$str" == *foo* ]]       # bash/zsh only
[ "${str#*foo*}" != "$str" ]  # POSIX-portable equivalent (rough)

# string manipulation shortcuts — NOT in POSIX sh
echo "${var^^}"              # bash 4+ only: uppercase
echo "${var,,}"               # bash 4+ only: lowercase

# process substitution — NOT in POSIX sh
diff <(sort file1) <(sort file2)   # bash/zsh only

# `local` is common but technically not POSIX (widely supported anyway)
my_func() { local x=1; }

POSIX-safe equivalents

# instead of arrays, use a space/newline-separated string or positional params
names="alice bob carol"
for name in $names; do
    echo "$name"
done

# instead of [[ == glob ]], use case, which IS POSIX
case "$str" in
    *foo*) echo "matched" ;;
esac

# instead of ${var^^} (uppercase), use tr — works everywhere
upper=$(echo "$var" | tr '[:lower:]' '[:upper:]')

# instead of process substitution, use temp files or pipes
sort file1 > /tmp/s1
sort file2 > /tmp/s2
diff /tmp/s1 /tmp/s2
rm -f /tmp/s1 /tmp/s2

[[ ]] vs [ ]

# [ ] is the POSIX test command — an ordinary program, needs careful quoting
if [ "$name" = "alice" ]; then echo "hi alice"; fi
[ -z "$var" ]           # true if $var is empty — ALWAYS quote $var in [ ]

# [[ ]] is a bash/zsh/ksh keyword — safer (no word-splitting surprises),
# supports pattern matching and && / || directly, but not portable to sh
if [[ "$name" == "alice" && -n "$id" ]]; then echo "hi"; fi

If a script must run under sh, use [ ] and combine conditions with separate [ ] tests joined by &&/|| rather than relying on [[ ]].

zsh-specific gotchas (vs. bash)

# zsh does NOT word-split unquoted variables by default (bash does)
files="a.txt b.txt"
for f in $files; do echo "$f"; done
# bash: two iterations (a.txt, b.txt)
# zsh:  ONE iteration ("a.txt b.txt") unless `setopt SH_WORD_SPLIT` is set

# array indexing: bash arrays are 0-indexed, zsh arrays are 1-indexed by default
arr=(a b c)
echo "${arr[1]}"
# bash: "b"   (index 1 is the second element)
# zsh:  "a"   (index 1 is the FIRST element)

# zsh has no `getopts`-free equivalent gap, but option-parsing built-ins
# (zparseopts) differ from bash's getopts entirely

If a codebase must support both, avoid relying on either shell's default word-splitting or array-indexing behavior — use explicit quoting and "${arr[@]}"-style expansion, which behaves consistently in both.

Writing scripts that work in all three

#!/bin/sh
# portable-example.sh — runs under sh, bash, and zsh identically
set -eu   # note: no `pipefail` — it's not POSIX; see below

name="${1:-world}"

case "$name" in
    "") echo "name must not be empty" >&2; exit 1 ;;
esac

echo "Hello, $name!"

count=0
for item in one two three; do
    count=$((count + 1))
    echo "$count: $item"
done

The pipefail problem

# `set -o pipefail` is a bash/zsh/ksh extension — NOT in POSIX sh (dash
# silently ignores it rather than erroring, which is its own trap)
set -euo pipefail   # fine in bash, but do not rely on it under #!/bin/sh

If a script must be POSIX-portable AND needs pipeline-failure detection, check each command in the pipeline separately, or use PIPESTATUS/pipestatus (bash/zsh only, different array names) instead of pipefail.

Detecting which shell you're actually running under

if [ -n "${BASH_VERSION:-}" ]; then
    echo "running under bash $BASH_VERSION"
elif [ -n "${ZSH_VERSION:-}" ]; then
    echo "running under zsh $ZSH_VERSION"
else
    echo "running under a plain POSIX sh"
fi

This is useful in shared library scripts (sourced from .bashrc and .zshrc alike) that need to branch on shell-specific syntax.

How It Actually Works

Detecting which shell you're running under ($BASH_VERSION vs $ZSH_VERSION vs POSIX sh) works because each shell implementation sets its own distinguishing internal variables at startup as part of populating its variable table — there's no universal, portable "which shell am I" API; scripts probe for these shell-specific fingerprints because the shells themselves don't agree on a common introspection mechanism.

#!/bin/sh is especially tricky because /bin/sh is frequently a symlink to a different binary depending on the OS — dash on Debian/Ubuntu, bash running in POSIX-compatibility mode on macOS/RHEL, or even a genuine minimal sh — and the kernel's execve() shebang resolution doesn't care what's behind that symlink, it just execs whatever /bin/sh currently points at. This is precisely why arrays, [[ ]], and local (all bash extensions, not POSIX) can silently parse differently or error out depending purely on which physical binary that symlink resolves to on a given machine, with zero warning at write time.

Word splitting, glob behavior, and even [ ] test operator support differ subtly between POSIX-mode shells because each interpreter has its own independent parser and expansion engine implementing the POSIX shell grammar — they're separate codebases aiming at the same specification, not one engine with compatibility flags, so edge cases in expansion order or undefined-behavior corners of the spec are where the actual divergence between bash, dash, and zsh shows up.

Cheat sheet

Feature POSIX sh bash zsh (default opts)
Arrays arr=(a b) no yes, 0-indexed yes, 1-indexed
[[ ]] extended test no yes yes
${var^^} / ${var,,} no yes (bash 4+) no
Process substitution <(...) no yes yes
set -o pipefail no yes yes
Unquoted var word-splitting yes yes no (needs SH_WORD_SPLIT)
local in functions not POSIX, widely supported yes yes
Safe cross-shell test case/[ ] anything anything except relying on word-splitting

Exercise

Take the backup.sh script from Level 1 (or any bash script you've written) and identify every bash-only construct it uses (arrays, [[ ]], ${var^^}, etc.). Rewrite a POSIX-sh-compatible version using only case, [ ], and tr/sed as needed, then verify it with sh ./backup-posix.sh (or dash ./backup-posix.sh if installed) to confirm it runs without bash-specific errors.