Bash vs Zsh
Zsh is "bash with the rough edges sanded off" — almost script-compatible, much nicer interactively.
Summary
Bash and zsh share ancestry — both descend from the Bourne shell family — so most of what you know about pipes, redirection, conditionals, and command substitution carries over unchanged.
Zsh adds genuinely useful upgrades: smarter tab completion, glob qualifiers (`*(.m-1)` for "modified in the last day"), associative arrays without `declare -A` ceremony, and the plugin ecosystem behind Oh My Zsh / Prezto.
The catch: zsh changes a small set of defaults — word-splitting, array indexing, glob behavior on no-match — that quietly break bash scripts unless you set `emulate sh` or fix the script. Apple shipping zsh as the macOS default since Catalina has not eliminated this footgun.
Syntax & semantic differences
POSIX compatibility
BashPOSIX-extended. Most POSIX `/bin/sh` scripts run in bash unchanged.
ZshMostly bash-compatible, but with quirks. Use `emulate sh` or `emulate bash` at the top of a script for the closest thing to a clean port.
Word splitting on unquoted variables
Bash`$var` is split on `$IFS` (default: space/tab/newline) — the source of countless bugs around filenames with spaces.
Zsh`$var` does **not** split by default. To force bash-style splitting use `${=var}` or `setopt SH_WORD_SPLIT`.
This is the single most common "my script works in bash but breaks in zsh" cause — `for x in $list; do ...` iterates once with the whole string in zsh, not once per word.
Array indexing
BashArrays are 0-indexed. `arr=(a b c); echo ${arr[0]}` prints `a`.
ZshArrays are 1-indexed. `arr=(a b c); echo $arr[1]` prints `a`. `$arr[0]` is empty (or an error under `set -u`).
Off-by-one bugs migrating array code from bash → zsh are common. `${arr[@]}` / `${arr[*]}` still work in both for "all elements".
Glob no-match behavior
Bash`ls *.nope` → bash echoes the literal pattern `*.nope` (and `ls` then errors). Override with `shopt -s nullglob` / `failglob`.
Zsh`ls *.nope` → zsh **errors out before running the command** ("no matches found"). Override with `setopt NULL_GLOB` (empty list) or `setopt CSH_NULL_GLOB`.
Glob qualifiers
BashNo qualifiers — combine `find` + `xargs` or shell loops.
ZshRich qualifiers: `*(.)` (regular files), `*(/)` (directories only), `*(.m-1)` (modified < 1 day), `*(.L+1M)` (larger than 1 MiB), `*(om[1,5])` (5 newest).
Qualifiers are zsh's headline feature. A one-liner like `rm -- *.log(.m+30)` deletes only regular `.log` files older than 30 days — no `find` needed.
Extended globbing
BashEnable with `shopt -s extglob`. Patterns: `?(pat)`, `*(pat)`, `+(pat)`, `@(pat)`, `!(pat)`.
ZshEnable with `setopt EXTENDED_GLOB`. Different syntax: `(pat|pat)`, `pat~pat` (exclude), `pat#pat` (zero+), `pat##pat` (one+). Not interchangeable.
Associative arrays
Bash`declare -A map; map[key]=value; echo "${map[key]}"` — requires bash 4+. macOS's default bash is still 3.2 (license reasons).
Zsh`typeset -A map; map[key]=value; echo $map[key]` — works since zsh 4.x.
Prompt configuration
Bash`PS1='\u@\h:\w\$ '` with backslash escapes (`\u`, `\h`, `\w`, `\$`, `\[\]` for non-printing).
Zsh`PROMPT='%n@%m:%~ %# '` with `%`-escapes (`%n`, `%m`, `%~`, `%#`, `%F{red}…%f` for color). Different placeholders entirely.
History file
Bash`~/.bash_history`. Append on exit. `HISTSIZE` (in memory) + `HISTFILESIZE` (on disk).
Zsh`~/.zsh_history`. Configurable per-shell append/share with `setopt SHARE_HISTORY` / `INC_APPEND_HISTORY`. `HISTSIZE` + `SAVEHIST`.
Config file
Bash`~/.bashrc` (interactive non-login) and `~/.bash_profile` / `~/.profile` (login). Sourcing one from the other is a long-standing source of confusion.
Zsh`~/.zshrc` (interactive), `~/.zprofile` (login), `~/.zshenv` (every shell — even non-interactive scripts). Better separation than bash, but reading `zshenv` for every script can be slow if you stuff it.
Tab completion
BashBuilt-in completion with `bash-completion` package adding most of the polish. `complete -F _func name` registers functions.
ZshCompletion system (`compinit`) is far richer out of the box — context-aware, with descriptions and menu selection. `autoload -Uz compinit && compinit` in `~/.zshrc`.
Function-local variables
Bash`local var=value` inside a function. Only visible while the function runs.
Zsh`local var=value` works the same way. Plus `typeset` and `declare` for explicit type flags.
Substring expansion
Bash`${var:offset:length}` (0-indexed). `${var:2:3}` of `abcdef` is `cde`.
Zsh`${var:offset:length}` works, but zsh is 1-indexed by default with `KSH_ARRAYS` unset — `${var[2,4]}` of `abcdef` is `bcd`. Different operator, different indexing.
Side-by-side commands
The 32 most-compared commands in Bash and Zsh. See all Bash commands · See all Zsh commands.
- aliasCreate a shortcut name for a longer command line.Bash
alias ll='ls -la'Zshalias ll='ls -la' - awkPattern scanning and processing language for structured text.Bash
awk '{print $1}' fileZshawk '{print $1}' file - catPrint file contents to standard output.Bash
cat file.txtZshcat file.txt - chmodChange file mode bits (read / write / execute permissions) on Unix files.Bash
chmod 755 fileZshchmod 755 file - cpCopy files and directories.Bash
cp source destZshcp source dest - curlTransfer data from or to a server over HTTP, HTTPS, FTP, and many other protocols.Bash
curl https://example.comZshcurl https://example.com - cutExtract sections (fields or characters) from each line.Bash
cut -d',' -f1 file.csvZshcut -d',' -f1 file.csv - echoPrint arguments to standard output, separated by spaces, followed by a newline.Bash
echo "hello world"Zshecho "hello world" - exportSet an environment variable and mark it for export to child processes.Bash
export NAME=valueZshexport NAME=value - findLocate files by name, size, time, or other attributes.Bash
find . -name "*.log"Zshfind . -name "*.log" - grepSearch file contents for a pattern.Bash
grep -r "pattern" .Zshgrep -r "pattern" . - headOutput the first lines of a file.Bash
head -n 10 fileZshhead -n 10 file - historyShow previously run commands from the shell history.Bash
historyZshhistory - killSend a signal to a process (typically to terminate it).Bash
kill <pid>Zshkill <pid> - lsList directory contents.Bash
ls -laZshls -la - mkdirCreate a new directory.Bash
mkdir dirZshmkdir dir - mvMove or rename files and directories.Bash
mv source destZshmv source dest - pingSend ICMP echo requests to test reachability and round-trip latency.Bash
ping example.comZshping example.com - psList a snapshot of currently running processes.Bash
ps auxZshps aux - rmDelete files and directories.Bash
rm fileZshrm file - sedStream editor for filtering and transforming text.Bash
sed 's/old/new/g' fileZshsed 's/old/new/g' file - sortSort lines of text.Bash
sort fileZshsort file - sshOpen a secure shell on a remote host or run a remote command.Bash
ssh user@hostZshssh user@host - tailOutput the last lines of a file.Bash
tail -n 10 fileZshtail -n 10 file - tarBundle and unbundle files into a single archive (often combined with gzip / bzip2 / xz).Bash
tar -czf archive.tar.gz dir/Zshtar -czf archive.tar.gz dir/ - topInteractive process viewer — show running processes sorted by CPU or memory, refreshed in place.Bash
topZshtop - touchCreate an empty file or update its modification time if it exists.Bash
touch file.txtZshtouch file.txt - wcCount lines, words, or bytes.Bash
wc -l fileZshwc -l file - wgetNon-interactive network downloader for HTTP, HTTPS, and FTP.Bash
wget https://example.com/file.zipZshwget https://example.com/file.zip - whichLocate an executable in PATH and print its full path.Bash
which python3Zshwhich python3 - xargsBuild and execute command lines from standard input.Bash
find . -name '*.tmp' | xargs rmZshfind . -name '*.tmp' | xargs rm - zipPackage and compress files into a ZIP archive.Bash
zip -r archive.zip dir/Zshzip -r archive.zip dir/
Gotchas when porting between them
- Bash scripts that use `read -p` (read with prompt) work in zsh, but `read` without `-p` and a separate `print -n "Prompt: "` is more idiomatic in zsh.
- Don't rely on `#!/bin/bash` being bash on Linux but zsh on macOS — `#!/bin/bash` is still `bash` on macOS too (Apple ships an old 3.2 for backwards compatibility). zsh is only the *interactive* default; scripts with `#!/bin/bash` still run under bash.
- Zsh's `=` in patterns is the "approximate-match" qualifier under `EXTENDED_GLOB`, not assignment. Pattern `f(=cmd)` matches files named like the result of `which cmd`.
- `set -e` works in both, but error propagation through pipelines differs unless you also set `set -o pipefail` (works in both). Without it, only the last command's exit status counts.
- Tab-completing a partial path on bash treats `*` as literal; on zsh, `*` may expand and select multiple matches, which can be jarring if you typed it expecting completion.