Bash vs. Zsh vs. sh: What Actually Differs Between POSIX Shells
They all accept most of the same basic commands, which is exactly what makes their real differences easy to miss until a script written for one breaks silently on another.
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.