Bash vs. Zsh vs. sh: What Actually Differs Between POSIX Shells
Bash, Zsh, and sh accept most of the same commands, which is exactly what makes their real differences easy to miss until a script breaks.
Bash, Zsh, and sh all run most of the same everyday commands correctly — which is exactly why their real, occasionally script-breaking differences are so easy to overlook until one of them actually bites.
What “POSIX shell” actually means
POSIX (Portable Operating System Interface) defines a standard specification for shell behavior and utilities, intended to let a script written against the standard run correctly on any conforming system. sh conventionally refers to whatever shell a system provides as its baseline POSIX-compliant interpreter — on Linux systems this is very often Dash, a minimal shell built specifically for POSIX compliance and fast execution, not Bash running in a compatibility mode.
Bash: POSIX-compatible, but not POSIX-only
Bash (Bourne Again Shell) implements the POSIX shell specification but adds substantial extensions beyond it — arrays, [[ ]] extended test syntax, string manipulation operators, and process substitution among them. Scripts using these Bash-specific features will run correctly under bash but can fail, sometimes silently producing wrong results rather than an obvious error, when actually executed by a strict POSIX sh like Dash.
Zsh: POSIX-compatible, interactively richer
Zsh also supports POSIX-compliant scripting (particularly when explicitly run in sh emulation mode), but its real distinguishing strength is interactive use — more powerful globbing patterns, superior tab completion, and highly customizable prompts, covered in more depth in this blog’s dedicated prompt customization post. Zsh scripting syntax diverges from Bash’s in several specific, easy-to-miss ways, including array indexing starting at 1 rather than 0 by default.
The “bashism” problem this creates
A bashism is shell script code that relies on a Bash-specific extension without the script’s author realizing it isn’t portable — the script “works,” including on systems where /bin/sh happens to symlink to bash, right up until it runs on a system where /bin/sh points to a stricter shell like Dash instead, at which point it can fail outright or, worse, run without error but produce subtly wrong output.
Why the shebang line matters more than it looks like it should
#!/bin/sh # should contain ONLY POSIX-compliant syntax
#!/bin/bash # may use Bash-specific extensions safely
A script’s shebang line is a promise about what syntax it’s safe to use inside it — writing Bash-specific syntax under a #!/bin/sh shebang is the single most common source of “works on my machine” script portability bugs, since many developers’ /bin/sh happens to be Bash-compatible even when the shebang claims otherwise.
Where this distinction actually bites in practice
Distribution maintainers, container base images, and CI environments frequently use a strict, minimal /bin/sh (Dash on Debian/Ubuntu-derived systems, for instance) specifically for faster boot and script execution — a script that only ever ran under an interactive Bash session, and was never actually tested against a strict POSIX shell, can fail the first time it’s executed in exactly this kind of minimal environment.
Why understanding the actual differences, not just “they’re similar,” matters
Treating Bash, Zsh, and POSIX sh as interchangeable because everyday commands mostly overlap is precisely the assumption that produces scripts which fail unpredictably depending on which shell actually executes them — knowing specifically which features are Bash-only, Zsh-only, or genuinely portable is what lets you write a script that behaves the same way everywhere it claims to run.
Where “POSIX shell” as a standard actually comes from
The shell command language most people mean by “POSIX shell” was standardized as part of POSIX.2 in 1992, and its scripting behavior draws heavily on the Korn shell, which by then had already spent nearly a decade demonstrating a stable, coherent Bourne-compatible feature set. This is part of why “does ksh support it” is often a reliable proxy for “is it actually POSIX,” and why Bash’s own --posix flag and Zsh’s emulate sh mode both exist specifically to suppress each shell’s extensions and approximate that ksh-influenced baseline on demand.
Concrete features that separate Bash from strict POSIX sh
| Feature | POSIX sh | Bash |
|---|---|---|
| Indexed arrays | Not specified | arr=(a b c) |
| Associative arrays | Not specified | declare -A (Bash 4.0+) |
[[ ... ]] extended test |
Not specified | Yes |
local in functions |
Not specified | Yes |
String manipulation (${var//x/y}) |
Not specified | Yes |
printf builtin |
Specified | Yes |
Scripts using the right column’s Bash-only features run fine under bash, including when /bin/sh happens to symlink to it, and then fail — sometimes with a cryptic syntax error, sometimes by silently misbehaving — the moment they execute under a strict /bin/sh like Dash. This exact gap is why Debian policy and Ubuntu packaging both explicitly require /bin/sh scripts under a #!/bin/sh shebang to avoid Bash-only syntax, commonly checked in practice with tools like checkbashisms.
Emulation modes: approximating POSIX without leaving the shell
bash --posix
emulate sh
Both Bash and Zsh ship a mode that suppresses their own extensions to more closely track POSIX behavior, useful for testing whether a script actually needs a specific shell’s extensions or was just written under one out of habit. Neither mode is a perfect stand-in for testing against a real POSIX sh implementation like Dash — subtle differences in builtin behavior can remain — but both catch the large majority of accidental bashisms or zshisms before a script ships somewhere that only offers a genuinely minimal shell.
Why the performance angle isn’t just theoretical
Ubuntu’s 2006 switch of /bin/sh to Dash was driven by measurable startup-script performance: a minimal shell with a smaller parser and no interactive-only features to initialize executes large numbers of small scripts — as a Unix boot sequence historically ran — measurably faster in aggregate than Bash performing the same work. That same performance gap is why container base images and CI runners frequently default to a minimal /bin/sh today: the tradeoff that motivated a Linux distribution’s boot sequence in 2006 applies just as directly to a container that starts and discards processes constantly.
Related:
Sources: