What Is the Difference Between sh, bash, and zsh Shells?

sh, bash, and zsh are all Unix shells but differ in scope and features. sh (the Bourne shell, or a POSIX-compliant equivalent like dash) is the minimal, standardized shell used for portable scripts. bash (Bourne Again SHell) extends sh with interactive features like command history, tab completion, and additional scripting capabilities, and is the default shell on most Linux distributions. zsh (Z Shell) extends bash further with more advanced interactive features — better tab completion, spell correction, and extensive customization — and is popular among power users and the default shell on macOS since 2019.

Three Shells, One Lineage

sh, bash, and zsh are all part of the same family tree of Unix shells, each building on the ideas of its predecessors while adding new capabilities. Understanding their relationship — what each one is, how they differ, and where compatibility boundaries lie — resolves a common source of confusion: why does a script that works in one shell fail in another? Why do some systems use sh and others bash? Why do so many developers switch to zsh?

The short version: sh represents the original, minimal Unix shell specification — a small, standardized, portable core. bash is GNU’s popular reimplementation and extension of sh, adding substantial interactive and scripting features while remaining largely backward-compatible. zsh takes bash’s capabilities further still, adding even more powerful interactive features aimed at making the shell experience as smooth and customizable as possible.

This article traces each shell’s history and purpose, details the concrete feature and syntax differences, explains the compatibility issues that arise when scripts assume the wrong shell, and helps you decide which shell to use for interactive work versus scripting.

sh: The Original Standard

What “sh” Actually Refers To

“sh” is both a specific historical shell (the Bourne shell, written by Stephen Bourne at Bell Labs in 1979) and, on modern systems, a generic term for “whatever shell fulfills the POSIX sh specification” — which is often not literally the original Bourne shell binary but a POSIX-compliant substitute.

On most Linux systems today, /bin/sh is a symbolic link to another shell running in a compatibility mode:

$ ls -la /bin/sh
lrwxrwxrwx 1 root root 4 Jan 15 2023 /bin/sh -> dash

On Ubuntu and Debian, /bin/sh points to dash (Debian Almquist Shell) — a small, fast, POSIX-compliant shell optimized for script execution speed rather than interactive use. On some other systems, /bin/sh might point to bash running in POSIX-compatibility mode (bash --posix), which disables bash-specific extensions to behave more like standard sh.

Why sh Is Minimal by Design

The POSIX sh standard defines a baseline of shell functionality that any compliant shell must support — basic variables, conditionals (if/then/else), loops (for, while), functions, and I/O redirection. It deliberately excludes many convenience features that later shells added, because the goal of POSIX sh is portability: a script written strictly for POSIX sh should run identically on any Unix-like system, regardless of which specific shell provides /bin/sh on that system.

What sh/dash Lacks Compared to bash

  • No arrays
  • No [[ ]] extended test syntax (only [ ], the older test command)
  • No == string comparison in [ ] (must use single =)
  • No {1..10} brace expansion for ranges
  • No += string/array append operator
  • No local in some minimal implementations (though dash does support it)
  • No process substitution (<(...))
  • No select loops for menus
  • No associative arrays
  • Simpler prompt customization

Why Scripts Should Target sh When Portability Matters

System startup scripts, package installation scripts (postinst, prerm in Debian packages), and scripts intended to run across diverse Unix-like systems (Linux, BSD, macOS, embedded systems) often use #!/bin/sh deliberately, restricting themselves to POSIX-compliant syntax to guarantee they will run correctly regardless of which specific shell /bin/sh happens to be on a given system.

#!/bin/sh
# Portable script — avoid bash-specific syntax
if [ "$1" = "start" ]; then
    echo "Starting service"
fi

bash: The GNU Extension

What bash Adds Over sh

Bash (Bourne Again SHell), created by Brian Fox for the GNU Project in 1989, implements the full POSIX sh specification while adding a substantial set of extensions for both interactive use and scripting power.

Interactive features:

  • Command history with Ctrl+R reverse search
  • Tab completion (basic, extended significantly with bash-completion package)
  • Command-line editing (Emacs or Vi-style keybindings)
  • Job control (Ctrl+Z, bg, fg, jobs)
  • Aliases
  • Programmable prompt (PS1 with escape sequences)

Scripting extensions:

  • Indexed arrays: arr=(1 2 3)
  • Associative arrays: declare -A map
  • Extended test syntax: [[ condition ]] with pattern matching and regex support
  • Arithmetic expansion: $(( expression ))
  • Brace expansion: {1..10}, {a,b,c}
  • String manipulation: ${var//pattern/replacement}, ${var:0:5}
  • += operator for appending to variables and arrays
  • Process substitution: <(command) and >(command)
  • select loops for interactive menus
  • local keyword for function-scoped variables

bash Script Example Using Extensions

#!/bin/bash
# This script uses bash-specific features and requires bash, not sh

declare -A fruit_prices
fruit_prices[apple]=1.50
fruit_prices[banana]=0.75

for fruit in "${!fruit_prices[@]}"; do
    echo "$fruit costs ${fruit_prices[$fruit]}"
done

# Brace expansion
mkdir -p project/{src,tests,docs}

# Extended test with pattern matching
filename="report.pdf"
if [[ "$filename" == *.pdf ]]; then
    echo "This is a PDF file"
fi

None of this syntax works correctly in strict POSIX sh — running this script with sh script.sh instead of bash script.sh (or ./script.sh with the correct #!/bin/bash shebang) would produce errors or unexpected behavior.

Why bash Became the Default

Bash’s combination of full sh compatibility (for running existing scripts) with substantially improved interactive usability made it the natural default when Linux distributions needed a shell. Its GNU pedigree also aligned with the broader GNU/Linux ecosystem, and its widespread adoption created a virtuous cycle — more documentation, more tutorials, more scripts written for bash specifically, further cementing its position as the default choice.

zsh: The Power User’s Shell

What zsh Adds Over bash

Zsh (Z Shell), created by Paul Falstad in 1990, takes the trajectory of adding interactive convenience even further than bash. Zsh maintains substantial bash compatibility (most bash scripts run correctly in zsh, though not perfectly identically) while adding:

Superior tab completion: Zsh’s completion system is significantly more powerful — context-aware completions that understand command-specific options, menu-driven selection when multiple completions exist, and completion for a vastly larger set of commands out of the box.

$ git chec[TAB]
checkout   (shows description and lets you select with arrow keys in some configurations)

Spelling correction:

$ cd /hmoe/sarah
zsh: correct '/hmoe/sarah' to '/home/sarah' [nyae]?

Zsh notices likely typos in commands and paths and offers to correct them.

Advanced globbing:

$ ls **/*.txt              # Recursive glob — find .txt files in all subdirectories
$ ls *.txt~exclude*.txt    # Glob with exclusion

Zsh’s extended globbing (setopt extended_glob) provides pattern-matching capabilities beyond what bash’s globbing supports natively.

Right-side prompt: Zsh supports RPROMPT — content displayed on the right side of the terminal (like the current git branch or time), separate from the main left-side prompt.

Better array handling: Zsh arrays are 1-indexed by default (unlike bash’s 0-indexed arrays) and have somewhat different, arguably more intuitive syntax for certain operations.

Extensive theming and plugin ecosystem: The “oh-my-zsh” framework, along with alternatives like “Prezto” and “zinit,” provides hundreds of pre-built themes and plugins, making zsh customization accessible without deep shell scripting knowledge.

$ sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"

zsh Compatibility Considerations

While zsh runs most bash scripts successfully, subtle differences exist:

  • Array indexing starts at 1 in zsh by default (vs. 0 in bash) — though setopt KSH_ARRAYS can change this
  • Word splitting behaves differently in some contexts
  • Certain bash-specific parameter expansions behave slightly differently

For portable scripts meant to run under bash specifically, always use #!/bin/bash as the shebang rather than relying on zsh’s compatibility mode, and test scripts under the shell they explicitly target.

Why zsh Became macOS’s Default

Apple switched macOS’s default shell from bash to zsh in Catalina (2019), largely due to licensing considerations — bash’s later versions (4.0+) are licensed under GPLv3, which Apple’s legal position avoids using in system software, while macOS had been stuck shipping an old bash 3.2 for years. Zsh’s more permissive licensing and its rich feature set made it the natural replacement.

This decision has had ripple effects: developers using macOS for daily work more frequently install zsh (with oh-my-zsh) on Linux systems too, contributing to zsh’s growing popularity even outside macOS.

Feature Comparison Table

Feature sh (dash) bash zsh
POSIX compliant Yes (strictly) Yes (with extensions) Mostly (with extensions)
Arrays No Indexed + associative Indexed + associative (1-indexed)
[[ ]] extended test No Yes Yes
Command history Minimal Yes, with Ctrl+R search Yes, more powerful search
Tab completion Basic Good (with bash-completion) Excellent, context-aware
Spelling correction No No Yes
Brace expansion No Yes Yes
Globbing power Basic Standard Extended, very powerful
Right-side prompt No No Yes (RPROMPT)
Plugin frameworks No Limited (bash-it exists) Extensive (oh-my-zsh, Prezto)
Startup speed Fastest Fast Slightly slower (more features loading)
Default on Ubuntu/Debian scripts (/bin/sh) Most Linux distros (interactive) macOS (since Catalina)
Script portability Highest Good (ubiquitous but not universal) Lower (fewer systems have it by default)

Checking and Changing Your Shell

Checking Your Current Shell

$ echo $SHELL
/bin/bash

$ echo $0
-bash

Checking What Shells Are Available

$ cat /etc/shells
/bin/sh
/bin/bash
/usr/bin/bash
/bin/dash
/usr/bin/dash
/bin/zsh
/usr/bin/zsh

Installing zsh

$ sudo apt install zsh       # Ubuntu/Debian
$ sudo dnf install zsh       # Fedora

Changing Your Default Login Shell

$ chsh -s /usr/bin/zsh

This changes your account’s default shell (stored in /etc/passwd), taking effect the next time you log in (or open a new terminal, on some configurations). To switch back:

$ chsh -s /bin/bash

Trying a Shell Temporarily Without Changing Defaults

$ zsh              # Start a zsh session within your current terminal
$ exit             # Return to your original shell

This lets you experiment with zsh without committing to it as your default.

Which Shell Should You Use?

For Scripts: Choose Deliberately Based on Portability Needs

Use #!/bin/sh when:

  • Writing system-level scripts (package pre/post-install scripts, init scripts)
  • Portability across different Unix-like systems matters
  • The script’s logic is simple enough not to need bash’s extensions
  • You want maximum execution speed for frequently-run scripts

Use #!/bin/bash when:

  • You need arrays, [[ ]], brace expansion, or other bash-specific features
  • The script only needs to run on systems where bash is available (which is nearly universal on Linux, though not guaranteed on minimal containers or embedded systems)
  • You are writing for an audience/environment where bash is the established standard

Avoid targeting zsh specifically for scripts unless you have a specific reason — zsh scripts are less portable than bash scripts because zsh is not installed by default on as many systems, particularly servers and minimal environments.

For Interactive Daily Use: Personal Preference

Stick with bash if:

  • You want maximum compatibility with tutorials, Stack Overflow answers, and documentation (most assume bash)
  • You prefer stability and minimal configuration
  • You work across many different Linux systems and want consistency

Try zsh if:

  • You want the best possible tab completion and command-line convenience
  • You enjoy customizing your environment (themes, plugins)
  • You are already comfortable with bash and want to level up your interactive experience
  • You use macOS regularly and want consistency across systems

The interactive shell choice does not lock you into anything permanent — your login shell can be zsh for daily interactive convenience while all your scripts still explicitly specify #!/bin/bash or #!/bin/sh in their shebang lines, ensuring they run correctly regardless of what your personal interactive shell happens to be.

Common Pitfalls When Shells Are Confused

Running a bash Script with sh

$ sh bash_script.sh

If bash_script.sh uses bash-specific syntax (arrays, [[ ]], etc.) but is run explicitly with sh rather than respecting its own shebang, you get errors:

bash_script.sh: 5: [[: not found

The fix: Always run scripts as ./script.sh (respecting the shebang) rather than sh script.sh or bash script.sh explicitly, unless you specifically intend to override the interpreter.

Writing #!/bin/sh But Using bash Syntax

A very common mistake: a script starts with #!/bin/sh (intending portability) but the author uses bash-specific syntax without realizing it, because they tested it by running bash script.sh (which works, since bash can interpret it) rather than ./script.sh (which invokes /bin/sh, i.e., dash on Ubuntu, and fails).

The fix: If you write #!/bin/sh, actually test with sh script.sh or dash script.sh, not bash script.sh, to catch compatibility issues.

Checking Script Portability

$ checkbashisms script.sh    # Install: sudo apt install devscripts

checkbashisms analyzes a script and flags any bash-specific constructs that would fail under strict POSIX sh — invaluable when writing scripts intended to be portable.

Other Shells Worth Knowing About

Beyond sh, bash, and zsh, several other shells serve specific niches:

ksh (Korn Shell) — influenced both bash and zsh’s design; still used in some enterprise Unix environments (particularly older AIX and Solaris systems).

fish (Friendly Interactive Shell) — prioritizes ease of use with syntax highlighting and autosuggestions out of the box, but deliberately breaks POSIX compatibility for a cleaner, more consistent syntax — meaning fish scripts look quite different from sh/bash/zsh scripts.

tcsh/csh — C shell family, historically popular but with scripting quirks that are widely discouraged for new scripts; still found in some older Unix environments and academic settings.

Quick Reference: Shell Comparison

Aspect sh bash zsh
Created 1979 (original) 1989 1990
Primary purpose POSIX standard baseline General-purpose default shell Enhanced interactive shell
Script shebang #!/bin/sh #!/bin/bash #!/bin/zsh (rare for scripts)
Best for scripts Maximum portability Feature-rich scripting Not recommended for portable scripts
Best for interactive use Not designed for this Solid, universal default Best interactive experience
Arrays No Yes (0-indexed) Yes (1-indexed by default)
Default on Ubuntu /bin/sh → dash Yes (interactive default) No (optional install)
Default on macOS /bin/sh → bash (old) No (as of Catalina+) Yes (since Catalina)
Plugin ecosystem None Limited Extensive (oh-my-zsh)

Conclusion: Choosing the Right Tool for Each Context

The relationship between sh, bash, and zsh reflects a natural evolution: sh established the portable, POSIX-compliant foundation; bash extended it into a full-featured default shell balancing compatibility with convenience; zsh pushed the interactive experience further still, prioritizing user comfort and customization.

For scripts, the choice should be deliberate: #!/bin/sh when portability across any POSIX-compliant system matters, #!/bin/bash when you need bash’s richer feature set and can assume bash’s availability. For interactive daily use, bash remains the universal, dependable default that matches nearly every tutorial and piece of documentation you will encounter, while zsh offers a meaningfully improved experience for those willing to invest a little time in setup and customization.

Understanding these distinctions — not just that the shells exist, but why each was created and what problem it solves — turns a source of common confusion into a clear decision framework you can apply confidently in any Linux or Unix environment.

Hot this week

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

std::optional, std::variant, and std::any in Modern C++

Master C++17's three vocabulary types — std::optional for nullable values, std::variant for type-safe unions, and std::any for type-erased storage, with practical examples and best practices.

C++17 Structured Bindings: Unpacking Data

Master C++17 structured bindings — learn how to unpack tuples, pairs, arrays, and structs into named variables, use them in range-for loops, and extend them to custom types.

constexpr Functions: Compile-Time Computation

Master C++ constexpr functions — learn compile-time computation, consteval, constinit, compile-time containers, and how to move work from runtime to compile time for zero-cost abstractions.

Type Traits in C++: Compile-Time Type Information

Master C++ type traits — learn how to query and transform types at compile time using , write your own type traits, and use them with if constexpr and SFINAE.

Topics

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

std::optional, std::variant, and std::any in Modern C++

Master C++17's three vocabulary types — std::optional for nullable values, std::variant for type-safe unions, and std::any for type-erased storage, with practical examples and best practices.

C++17 Structured Bindings: Unpacking Data

Master C++17 structured bindings — learn how to unpack tuples, pairs, arrays, and structs into named variables, use them in range-for loops, and extend them to custom types.

constexpr Functions: Compile-Time Computation

Master C++ constexpr functions — learn compile-time computation, consteval, constinit, compile-time containers, and how to move work from runtime to compile time for zero-cost abstractions.

Type Traits in C++: Compile-Time Type Information

Master C++ type traits — learn how to query and transform types at compile time using , write your own type traits, and use them with if constexpr and SFINAE.

How to View File Contents in Linux Without Opening an Editor

Learn how to view file contents in Linux using cat, less, more, head, tail, and other commands — without opening a text editor. Perfect for quick checks and log monitoring.

Understanding Pipes in Linux: Chaining Commands Together

Learn how Linux pipes work, how to chain commands together with the | operator, practical pipe examples, and the difference between pipes and redirection.

How to Redirect Output in Linux Commands

Learn how to redirect stdout, stderr, and stdin in Linux using >, >>, <, 2>, and 2>&1. Master output redirection with practical examples for scripts and everyday commands.

Related Articles

Popular Categories