bash · level 9

Bash vs Zsh

Compatibility, key zsh wins (extended globbing, prompts, completions, arrays), when each shell is right.

200 XP

Bash vs Zsh

Two shells, similar lineage, surprisingly different ergonomics. Knowing where they diverge is the difference between "I write portable scripts" and "my scripts work on my machine".

Quick history

  • bash (Bourne Again SHell) — GNU's reimplementation of the Bourne shell (1989). The default on most Linux distros. Bash 4 (2009) added associative arrays and globstar. Bash 5 (2019) added more printf formats and BASH_ARGV0.
  • zsh (Z shell) — Created by Paul Falstad at Princeton (1990) as a Bourne-compatible shell with extensible completions and ergonomic interactive features. Apple made it the default macOS login shell starting with Catalina (10.15, 2019).

The licensing footnote: bash 4+ is GPLv3, which Apple won't ship. macOS is frozen on bash 3.2 (2007) at /bin/bash. If you want modern bash on a Mac, you brew install bash and add /opt/homebrew/bin/bash to /etc/shells.

What they share

Most everyday shell knowledge transfers cleanly:

  • Pipes (|), redirection (>, 2>, <), heredocs (<<EOF).
  • Globs (*, ?, [abc]).
  • Conditionals ([[ ]], (( )), if, case).
  • Loops (for, while, until).
  • Functions, local, positional args ($1, "$@").
  • set -euo pipefail and trap.
  • Variable expansion forms — ${var}, ${var:-default}, ${var%suffix}, ${var//pat/rep}.
  • Command substitution $(cmd).

If you write a script that sticks to these, it will run unchanged in either shell.

What zsh does better (interactively)

Tab completion

bash has tab completion. zsh has tab completion that knows about flags, file types, command-specific arguments, ssh hosts from ~/.ssh/config, git branches, kubectl resources, and so on. The completion system is its own DSL (compdef) and there are hundreds of community-maintained completions.

git checkout <TAB>             # zsh: lists branches
ssh <TAB>                       # zsh: lists hosts from ~/.ssh/known_hosts + config

bash's completion isn't bad — bash-completion ships hundreds — but zsh's is the better default.

Globbing

zsh's globbing is a small superpower:

ls **/*.log                     # recursive (built-in; bash needs shopt -s globstar)
ls *(.)                         # only regular files
ls *(/)                         # only directories
ls *(om[1])                     # MOST RECENTLY MODIFIED file (one expansion)
ls *.log(L+1k)                  # .log files larger than 1KB
ls *(L0)                        # zero-byte files
ls *.{log,txt}(.,N)             # .log + .txt files, only regular, allow no-match

(qualifiers) after a glob narrows by file type, size, age, ownership, and more. There's no equivalent in bash without piping through find.

Prompts

bash has PS1. zsh has:

  • PROMPT (or PS1) — left prompt.
  • RPROMPTright-side prompt. Useful for git status, time, exit code.
  • Theme system: prompt -p list to preview, prompt powerlevel10k to apply.
RPROMPT='%F{green}$(git_branch)%f'

The right-side prompt updates without reflowing your input — you can show "live" info there.

History

zsh has shared-across-sessions history if you opt in (setopt SHARE_HISTORY). bash needs a PROMPT_COMMAND hack to do the same.

zsh's history-substring-search (a plugin) is also neat: type git and Up-arrow walks through past git ... commands matching that prefix.

Spelling correction

setopt CORRECT

Now gti status prompts: "did you mean git status?". Toggleable; not for everyone.

Anonymous functions

() { echo "scoped block"; local x=1; }

Runs immediately as a scoped block. bash needs ( ... ) (subshell) for the same effect — but a subshell forks; zsh's anonymous function doesn't.

What bash does better (or at least, more universally)

Universal availability

Every Linux server, every container, every CI runner has bash. Most have only bash (or just POSIX sh). Writing your script for bash means it runs everywhere.

zsh availability is patchier on minimal containers. If your script runs in Alpine or Debian-slim, bash is a near-certain bet; zsh is a apt install away.

POSIX-script compatibility

bash mostly behaves like a POSIX shell when invoked as sh (e.g. /bin/sh on Ubuntu is dash; on macOS it's bash). zsh has emulate sh but its non-emulated default differs from POSIX in more places.

The script-by-default mental model

bash users tend to write scripts; zsh users tend to write configs. The bash community has stronger conventions around set -euo pipefail, ShellCheck, bats-core, etc. Following those conventions in zsh isn't impossible, just less common.

Subtle compatibility gotchas

When you write #!/usr/bin/env bash and run it under bash on Linux, then someone tries the same script on macOS:

  • macOS bash is 3.2. No associative arrays (declare -A). No &> shorthand for stdout+stderr (use >file 2>&1). No mapfile/readarray. No ${var^^} uppercase. No ${var,,} lowercase. No <<< here-string... wait, that one IS in 3.2. But many bash 4+ idioms are absent.

When you write #!/usr/bin/env zsh and someone tries the same script on Linux:

  • zsh ships everywhere, but isn't installed by default on most Linux servers / minimal containers. apt install zsh first.

When you write #!/usr/bin/env bash but use a zsh-only construct:

  • e.g. setopt EXTENDED_GLOB (zsh only). bash will setopt: command not found and abort.
  • e.g. ${(s/-/)var} parameter flag (zsh-only). bash sees ${(s/-/)var} as a syntax error.

Mixing is the worst outcome. Pick a shell, write for that shell.

Array indexing — a famous trap

# bash:
arr=(a b c)
echo "${arr[0]}"        # a
echo "${arr[1]}"        # b

# zsh (default):
arr=(a b c)
echo "${arr[1]}"        # a       — 1-INDEXED!
echo "${arr[2]}"        # b

zsh provides setopt KSH_ARRAYS to switch to 0-indexed for compat. Most bash scripts that use arrays will break in zsh without it.

What your shebang means

Three correct shebangs and what they say:

Shebang Meaning
#!/bin/sh "I'm POSIX-portable; run me with the system's sh." Bash-isms forbidden.
#!/usr/bin/env bash "Find bash on the user's PATH." Allows bash 4+ features if your scripts assume them — assert version: (( BASH_VERSINFO[0] >= 4 )) || die "bash 4+ required".
#!/usr/bin/env zsh "I am zsh. Don't try to run me elsewhere."

Don't write #!/bin/bash — it hardcodes the path, which on macOS gives you bash 3.2 even if bash 5 is on PATH.

When to use which

Use case Shell
Writing a script you'll commit to a repo bash (#!/usr/bin/env bash)
Writing a script for production CI / containers bash
Writing a script that targets only macOS bash 4+ (via Homebrew) or zsh
Your interactive shell (Mac) zsh + Oh My Zsh / starship
Your interactive shell (Linux) bash or zsh — taste
One-off REPL exploration whatever's already running

The one rule that actually matters: don't write a script that uses zsh-isms with a bash shebang. It will work for you and break for everyone else.

Migration tips

Going from bash to zsh interactively:

  1. Run chsh -s $(which zsh).
  2. Install Oh My Zsh: sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)".
  3. Edit ~/.zshrc. Most bash aliases, functions, and exports work as-is.
  4. Set export DISABLE_AUTO_UPDATE=true if you don't want OMZ pinging on every shell start.
  5. Try setopt KSH_ARRAYS if you write scripts that use 0-indexed arrays interactively.

Going from zsh to bash interactively (rare): just chsh -s /opt/homebrew/bin/bash (with the modern bash you brewed) and copy your aliases into ~/.bashrc.

Common bugs

Script with #!/bin/bash shebang on macOS: gets bash 3.2, no declare -A, scripts using bash 4 features die. Use #!/usr/bin/env bash and assert version.

zsh script with bash-isms: less common, but [[ -v var ]] (test variable existence) works in bash 4.2+ and zsh, while [[ -R var ]] (test nameref) is bash-only.

0-indexed bash array used in zsh: ${arr[0]} is empty in zsh by default (only ${arr[1]} etc. work). Either set KSH_ARRAYS or write ${arr[1]} everywhere.

shopt in zsh: zsh uses setopt, not shopt. A bash script that does shopt -s nullglob will fail in zsh. Use setopt nullglob for zsh, or check the shell first.

Pipe-into-loop variable scope: in bash, ... | while read x; do y=1; doney is in a subshell, gone after the loop. In zsh (default), the same code keeps y alive in the parent. Identical syntax, different semantics. To make bash work, use process substitution: while read x; do y=1; done < <(...).

Tools in the wild

4 tools
  • Oh My Zshfree tier

    300+ plugins, 150+ themes, the de facto framework for zsh users. Set up in one curl command.

    library
  • bash-itfree tier

    Oh-My-Zsh's bash counterpart — themes, completions, aliases, the same project structure.

    library
  • starshipfree tier

    Cross-shell prompt (bash, zsh, fish, pwsh). One config, one binary, one prompt across every shell you use.

    cli
  • Homebrew bashfree tier

    Modern bash 5+ on macOS. Install + add `/opt/homebrew/bin/bash` to /etc/shells if you want it as login shell.

    cli