▶Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
zsh [options] [script [arguments...]]
zsh --version | echo $ZSH_VERSION
Zsh (the Z shell) is a powerful interactive shell that is very close to bash for everyday use, and adds a much stronger tab-completion system, smarter
wildcards (globbing), better history handling, spelling correction, themeable prompts, and a huge plugin ecosystem (Oh My Zsh and friends).
It is the default login shell on macOS (since Catalina) and is popular on Linux desktops and dev machines. This sheet covers using zsh at the prompt,
configuring it, and the differences from bash that catch people out. For bash see the Bash sheet, and for the friendliest alternative see the Fish sheet.
Install and switch
zsh --version # is it installed? (macOS has it built in)
sudo apt install zsh # Debian/Ubuntu sudo dnf install zsh Fedora/RHEL family brew install zsh macOS (newer version)
echo $SHELL # your login shell echo $0 the current shell echo $ZSH_VERSION only set in zsh
which zsh # for example /usr/bin/zsh or /bin/zsh
chsh -s "$(which zsh)" # make it YOUR login shell, then log out and back in. (needs zsh listed in /etc/shells)
chsh -s /bin/bash # go back
zsh # try it without changing anything: start zsh from your current shell. Type exit to leave
First run: zsh offers a configuration wizard (zsh-newuser-install) when ~/.zshrc does not exist. Choose (q) to quit or (0) to create an empty .zshrc.
zsh -f # start with no startup files (a clean shell for troubleshooting) zsh -n script.zsh syntax check zsh -x script.zsh trace
Startup files (which one runs when)
~/.zshenv EVERY zsh (scripts too). Keep it tiny: only environment variables that must always exist
~/.zprofile Login shells (like ~/.bash_profile). Good for PATH and environment set once per login
~/.zshrc Interactive shells (every new terminal). Aliases, prompt, completion, key bindings, plugins. This is the main file
~/.zlogin Login shells, after .zshrc ~/.zlogout On logout
/etc/zshenv, /etc/zprofile, /etc/zshrc, /etc/zlogin System-wide (Linux may use /etc/zsh/...)
Order for a login shell: zshenv > zprofile > zshrc > zlogin. For a plain new terminal window: zshenv > zshrc.
Set ZDOTDIR to keep the files in another folder: export ZDOTDIR=~/.config/zsh (set it in /etc/zshenv or ~/.zshenv)
Reload after editing: source ~/.zshrc (or: exec zsh which replaces the shell with a fresh one)
macOS notes: Terminal opens LOGIN shells, and /etc/zprofile runs path_helper. Put PATH changes in ~/.zprofile (or ~/.zshrc) and check with echo $PATH
Getting help
man zsh # the manual is split into parts: man zshbuiltins zshexpn (expansion) zshoptions zshparam zshmisc zshzle (line editor) zshcompsys (completion) zshcontrib
run-help ls or Alt+H # help for the command you are typing (autoload -U run-help; alias help=run-help)
whence -v cmd type cmd which cmd # what is this? (whence -a shows all matches) where cmd all locations (zsh builtin)
setopt # options that are ON unsetopt turn options off setopt | sort everything set set -o all options and their state
print -l $path # the PATH as a list, one folder per line print -l ${(k)aliases} all alias names
echo $ZSH_VERSION $ZSH_NAME $ZSH_ARGZERO # version and script name
Differences from bash you will notice
Arrays start at 1, not 0 arr=(a b c); echo $arr[1] prints a. (Bash starts at 0. setopt KSH_ARRAYS gives you 0-based arrays)
No word splitting of variables files="a b"; ls $files runs ls with ONE argument "a b" (bash would split into two). Use an array: files=(a b); ls $files Or setopt SH_WORD_SPLIT for bash behavior
Globs that match nothing are errors zsh: no matches found: *.xyz (bash passes the text through). Quote it ('*.xyz'), use *.xyz(N) for "or nothing", or setopt NO_NOMATCH / NULL_GLOB
Unquoted $var is safer Zsh does not glob-expand or split the RESULT of a variable. Still quote "$var" in scripts that may run in bash too
[[ ]] and (( )) work the same Also math with decimals: echo $(( 10 / 3.0 )) prints 3.33333
echo -e and read -p differ Use print (or echo, which understands escapes by default), and read "?Prompt: " var (read -p means "read from a coprocess" in zsh)
Prompt uses % not \ PROMPT='%n@%m %~ %# ' (bash uses \u@\h \w \$). Colors %F{red}text%f
Comments at the prompt A # comment is not allowed interactively unless: setopt INTERACTIVE_COMMENTS
History expansion !! in scripts Same as bash, but ! in double quotes triggers it too
Functions and options Options are set with setopt and are case-insensitive, underscores ignored: setopt AUTO_CD = setopt autocd = setopt auto_cd
$SHELL vs the running shell $SHELL is the login shell you chose. Use $0 or $ZSH_VERSION to know what is actually running
Run bash-style code: emulate bash -c 'source script.sh' or emulate sh (inside a function: emulate -L sh)
Keyboard shortcuts (emacs mode, the default)
Ctrl+A / Ctrl+E Start / end of the line Alt+B / Alt+F Back / forward a word Ctrl+B / Ctrl+F Back / forward a character
Ctrl+U Delete the whole line (bash deletes only up to the cursor) Ctrl+K Delete to the end of the line
Ctrl+W Delete the word before the cursor (zsh treats / as part of a word: use Alt+Backspace for path pieces)
Ctrl+Y Paste what you cut Ctrl+L Clear the screen Ctrl+C Cancel Ctrl+D Exit on an empty line Ctrl+Z Suspend the job
Ctrl+R / Ctrl+S Search history backward / forward (incremental) Up / Down History (or prefix search, see below)
Alt+. Insert the last argument of the previous command Alt+? Run-help Ctrl+X Ctrl+E Edit the command in $EDITOR (needs a setup line)
Ctrl+X Ctrl+B Jump to the matching bracket Alt+Q Push the current line aside and run another first Alt+' or Alt+" Quote the line
Tab Complete (press again for a menu) Ctrl+D List possible completions Alt+/ Complete from files
Esc then h Help Esc then u/l/c Upper/lower/capitalize the next word
bindkey List all key bindings bindkey -e emacs style keys (default) bindkey -v vi mode (press Esc for command mode; KEYTIMEOUT=1 makes it snappy)
bindkey '^R' history-incremental-search-backward bindkey '^[[H' beginning-of-line bindkey '^[[F' end-of-line bindkey '^[[3~' delete-char
Prefix search on the arrow keys (type "git" then Up):
autoload -U up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search ; zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search ; bindkey '^[[B' down-line-or-beginning-search # some terminals send ^[OA and ^[OB
Edit a long command in your editor with Ctrl+X Ctrl+E:
autoload -U edit-command-line ; zle -N edit-command-line ; bindkey '^X^E' edit-command-line
Make Ctrl+W stop at slashes: WORDCHARS=${WORDCHARS/\/} Find a key's code: run cat -v and press the key, or Ctrl+V then the key
On macOS Terminal, Alt (Option) shortcuts need "Use Option as Meta key" in the profile settings. On some terminals Esc then a key works instead.
History
setopt SHARE_HISTORY # share history between open terminals (and append as you go)
setopt HIST_IGNORE_DUPS # do not record an immediate repeat HIST_IGNORE_ALL_DUPS remove older duplicates too
setopt HIST_IGNORE_SPACE # commands starting with a space are not saved (handy for secrets)
setopt HIST_REDUCE_BLANKS # tidy extra spaces
setopt EXTENDED_HISTORY # save timestamps and durations setopt INC_APPEND_HISTORY write each command immediately (SHARE_HISTORY includes this)
HISTFILE=~/.zsh_history HISTSIZE=50000 SAVEHIST=50000 # put these in ~/.zshrc. macOS has small defaults, so set them
history # zsh shows only the last 16 by default. Use history 1 for everything, or history -50 for the last 50
history -E 1 | less # with real dates and times history -i ISO-format times
fc -l -20 # list the last 20 fc -e vim 5 10 edit commands 5-10 in vim and run them fc -ln -1 | pbcopy copy the last command (macOS)
!! !$ !* !n !-2 !git !?text? ^old^new # history expansion works like bash (and !$ is the last word)
setopt HIST_VERIFY # show the expanded !! command and let you edit it before running
Prefix search: type the start of a command and press Up (after the bindkey lines above), or Ctrl+R for incremental search.
Do not save secrets: start the line with a space (with HIST_IGNORE_SPACE), or use fc -p for a private history in a subshell.
Directories and navigation
setopt AUTO_CD # typing a directory name changes into it: ~/projects or ..
setopt AUTO_PUSHD PUSHD_IGNORE_DUPS PUSHD_SILENT # every cd is pushed on the directory stack
dirs -v # list the stack with numbers cd -3 or cd +2 jump to an entry cd - previous directory
alias d='dirs -v | head -10' # handy with AUTO_PUSHD
cd old new # zsh feature: replace "old" with "new" in the current path and go there
~ ~user ~+ ~- # home, another user's home, current dir, previous dir ~1 ~2 (stack entries)
hash -d proj=~/projects/myapp # a named directory: then cd ~proj and ls ~proj/src (also completes)
CDPATH=(. ~ ~/projects) # cd finds relative names in these folders too (use with care)
Type a bare .. or ... (with AUTO_CD and an alias: alias -g ...='../..' alias -g ....='../../..' )
Globbing: zsh's superpower (turn on with setopt EXTENDED_GLOB )
* ? [abc] [a-z] [^abc] # the usual {a,b,c} {1..10} {01..10} braces work as in bash
**/*.py # recursive: every .py file in every subfolder (no globstar option needed) ***/* also follows symlinks
*.txt(.) # qualifiers in ( ) at the end: . plain files / directories @ symlinks * executable files x executable by owner
*(/) # only directories *(.) only files *(@) symlinks *(*) executables
*(om[1,5]) # the 5 most recently modified (o = order, m = modification time; use On for oldest first)
*(.L+1m) # files larger than 1 MB (L = size: k, m, g, p; + bigger, - smaller)
*(.mh-1) # files modified in the last hour *(.mm-30) last 30 minutes *(.md+7) older than 7 days (h hours, m minutes, d days, w weeks, M months)
*(.N) # N = if nothing matches, expand to nothing (no error)
^*.log # everything EXCEPT .log files (needs EXTENDED_GLOB) *~*.log all except .log files (the ~ means "but not")
*.c~foo.c # all .c files except foo.c
(#i)readme* # case-insensitive match (EXTENDED_GLOB) (#a1) approximate match with 1 error
<1-100> # a number range: file<1-20>.txt matches file1.txt ... file20.txt <-> any number
(foo|bar)*.txt # alternatives (^foo) not foo [[:digit:]] POSIX classes
Examples:
ls -l *(.oL[1,3]) # the 3 smallest files (oL = order by length/size, [1,3] = first three)
rm -i *.log(.md+30) # ask before deleting logs older than 30 days (always test with print -l or ls first!)
print -l **/*(.mm-10) # files changed in the last 10 minutes, anywhere below here
for f in *.jpg(N); do echo $f; done # safe loop: no error when there are no jpg files
Also useful: setopt GLOB_DOTS (wildcards match hidden files), setopt NUMERIC_GLOB_SORT (file2 before file10), setopt NULL_GLOB (like (N) on everything).
Always preview a glob with print -l pattern before you use it with rm or mv.
Variables, arrays and expansion
name="Alice"; echo "Hello, $name" # no spaces around = export EDITOR=vim typeset -x VAR=value unset name
typeset -r PI=3.14 # read-only typeset -i n=5 integer typeset -l s / -u s keep it lower/upper case typeset -g global from inside a function
local x=1 # function-local (also typeset)
arr=(apple banana cherry) # array: ONE-based indexes
echo $arr[1] $arr[2] $arr[-1] # apple banana cherry (negative counts from the end) echo $arr[2,3] slice echo ${#arr} length echo $arr all items
arr+=(date) # append arr[2]=blueberry change arr[2]=() remove item 2
for f in $arr; do echo $f; done # loops over each element (no quoting tricks needed) "${arr[@]}" also works and keeps empty items
typeset -A color; color=(apple red banana yellow) # associative array (dictionary) color[cherry]=dark
echo $color[apple] echo ${(k)color} keys echo ${(v)color} values echo ${(kv)color} both for k v in ${(kv)color}; do echo $k=$v; done
Parameter expansion flags go in ( ) right after the {:
${(U)name} ${(L)name} # upper / lower case ${(C)name} Capitalize Each Word
${(s:,:)line} # split on commas into an array ${(j:,:)arr} join an array with commas
${(o)arr} ${(O)arr} ${(u)arr} # sort ascending / descending / unique
${(f)$(cmd)} # split command output into lines (an array of lines) ${(F)arr} join with newlines
${(q)name} ${(qq)name} # quote for the shell
${(@)arr} # keep empty elements when quoted: "${(@)arr}"
${name:l} ${name:u} # lower/upper case (history-style modifiers)
File name modifiers (work on any variable holding a path):
f=/var/log/app.tar.gz
echo ${f:t} # app.tar.gz (tail: the file name) ${f:h} /var/log (head: the folder)
echo ${f:r} # /var/log/app.tar (root: without the last extension) ${f:e} gz (extension) ${f:t:r} app.tar
echo ${f:A} # absolute path, with symlinks resolved ${0:A:h} the folder of the running script
Bash-style tools work too: ${var:-default} ${var:=x} ${var:?msg} ${#var} ${var:2:3} ${var#pat} ${var##pat} ${var%pat} ${var%%pat} ${var/old/new} ${var//old/new}
Extra: ${name:?required} ${(P)varname} the value of the variable NAMED in $varname (indirect) ${(e)var} expand $vars inside the value
Math: print $(( 7 / 2 )) (3) print $(( 7 / 2.0 )) (3.5) (( count++ )) zmodload zsh/mathfunc ; print $(( sqrt(16) + sin(0) )) zcalc an interactive calculator (autoload -U zcalc)
Aliases: regular, global and suffix
alias ll='ls -lah' # a normal alias (only at the start of a command) alias list unalias ll remove \ls skip the alias
alias -g L='| less' # a GLOBAL alias expands ANYWHERE on the line: ps aux L alias -g G='| grep -i' alias -g NE='2>/dev/null'
alias -s txt=vim # a SUFFIX alias: typing notes.txt opens it in vim alias -s {jpg,png}=xdg-open alias -s git='git clone' (paste a git URL and press Enter)
alias -r # regular aliases only alias -g global ones alias -s suffix ones
Aliases cannot take arguments in the middle. Use a function for that. Global aliases can surprise you: keep them short and few.
Functions
greet() { echo "Hello, ${1:-World}!" } # a function (braces, no "function" keyword needed) function greet { ... } also works
mkcd() { mkdir -p -- "$1" && cd -- "$1" }
extract() { # one function for many archive types
case "$1" in
*.tar.gz|*.tgz) tar xzf "$1" ;; *.tar.bz2) tar xjf "$1" ;; *.tar.xz) tar xJf "$1" ;; *.zip) unzip "$1" ;; *.gz) gunzip "$1" ;; *) echo "unknown: $1" ;;
esac
}
Arguments: $1 $2 ... $@ $* $# (same as bash). Return a status with return N. local variables with local or typeset. whence -f greet show its code unfunction greet remove
Autoloaded functions (fast startup, one file per function): put files in a folder on $fpath, then:
fpath=(~/.zfunc $fpath) ; autoload -Uz mkcd myfunc # each file ~/.zfunc/mkcd contains the function BODY only
Hook functions run automatically:
precmd() { ... } # before each prompt is drawn (update the window title, refresh git info)
preexec() { ... } # just before a command runs ($1 has the command line): good for timing
chpwd() { ls } # after every directory change
add-zsh-hook precmd my_fn # the proper way to add hooks (autoload -Uz add-zsh-hook)
periodic() { ... } # every $PERIOD seconds
Prompt (PROMPT / RPROMPT)
PROMPT='%n@%m %~ %# ' # user@host directory % (PS1 is the same variable)
%n user %m hostname %M full hostname %~ current dir (with ~) %/ full path %c %1~ last folder(s) %# "%" for users, "#" for root %? last exit status
%T time (24h) %* time with seconds %D{%Y-%m-%d} a formatted date %j number of jobs %h history number %L shell level %(?.OK.FAIL) a conditional on the last exit status
%F{red}text%f %F{#ff8800}text%f %B bold %b %U underline %u %K{blue}background%k colors: black red green yellow blue magenta cyan white or 0-255 or hex
%{ ... %} # text that takes up no space (raw escape codes)
setopt PROMPT_SUBST # allow $variables and $(commands) inside the prompt (put the PROMPT in single quotes so they are evaluated each time)
RPROMPT='%F{240}%*%f' # a prompt on the RIGHT side of the line (a clock here)
PROMPT='%F{green}%n@%m%f:%F{blue}%~%f %(?.%F{green}✓.%F{red}✗)%f %# ' # a colored prompt with a success/failure mark
Git branch in the prompt without any plugin (vcs_info):
autoload -Uz vcs_info ; precmd_functions+=(vcs_info)
zstyle ':vcs_info:git:*' formats ' (%b)' ; setopt PROMPT_SUBST
PROMPT='%~${vcs_info_msg_0_} %# '
Themed prompts: Powerlevel10k (very fast, wizard-driven: run p10k configure . It has had limited maintenance recently, so also consider the others), Starship (starship.rs, cross-shell), Oh My Posh (see its sheet), Pure. A themed prompt wants a Nerd Font in your terminal.
Preview a prompt string: print -P '%F{green}%n%f@%m' Reset: PROMPT='%# '
Completion system (the reason many people switch)
Add to ~/.zshrc (it is off until you turn it on):
autoload -Uz compinit && compinit # load and initialize completion (compinit -u skips the security check; compaudit lists the unsafe folders)
zstyle ':completion:*' menu select # navigate the list with arrow keys and Enter
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' 'r:|=*' 'l:|=* r:|=*' # case-insensitive and partial-word matching
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS} # colored file names in menus (on macOS use LSCOLORS or set LS_COLORS first)
zstyle ':completion:*' verbose yes # show descriptions zstyle ':completion:*:descriptions' format '%B%d%b' zstyle ':completion:*' group-name ''
zstyle ':completion::complete:*' use-cache on ; zstyle ':completion::complete:*' cache-path ~/.zcompcache # cache slow completions
zstyle ':completion:*' special-dirs true # complete . and ..
zstyle ':completion:*:*:kill:*' menu yes select # a menu of processes for kill
zstyle ':completion:*' completer _complete _approximate # allow small typos (with _approximate)
setopt COMPLETE_IN_WORD ALWAYS_TO_END AUTO_MENU AUTO_LIST # completion behavior options (MENU_COMPLETE is optional: it inserts the first match at once)
Try: git ch<Tab> ssh <Tab> (hosts from known_hosts and config) kill <Tab> cd <Tab> docker <Tab> (it completes options WITH descriptions)
More completions: install zsh-completions (brew install zsh-completions, or the plugin), then add its folder to fpath BEFORE compinit:
fpath=(/usr/local/share/zsh-completions $fpath) ; on Apple silicon fpath=(/opt/homebrew/share/zsh-completions $fpath)
Tools that ship completions (kubectl, docker, gh, helm, terraform): source them, for example source <(kubectl completion zsh) and compdef k=kubectl for an alias.
Rebuild the cache after installing new completions: rm -f ~/.zcompdump* && compinit Slow startup? compinit -C skips checks once your setup is stable.
Correction and typo help: setopt CORRECT (commands) setopt CORRECT_ALL (all words). It asks "zsh: correct 'gti' to 'git' [nyae]?". Some people find it noisy: turn it off with unsetopt CORRECT
Useful options (put setopt lines in ~/.zshrc)
setopt AUTO_CD # type a folder name to go there setopt AUTO_PUSHD PUSHD_IGNORE_DUPS # directory stack
setopt EXTENDED_GLOB # the extra glob syntax above setopt GLOB_DOTS # * matches hidden files
setopt INTERACTIVE_COMMENTS # allow # comments on the command line setopt NO_BEEP # silence the bell
setopt SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_VERIFY EXTENDED_HISTORY # history behavior
setopt PROMPT_SUBST # variables and commands in the prompt setopt NO_CLOBBER # > will not overwrite files (>! forces it)
setopt NOTIFY # tell me when background jobs finish setopt LONG_LIST_JOBS # jobs shows PIDs
setopt PIPE_FAIL # a pipeline fails if any step fails (like bash pipefail) setopt NO_NOMATCH # unmatched globs stay as text (bash behavior)
setopt RM_STAR_WAIT # wait 10 seconds and ask before rm * setopt CORRECT # spelling correction of commands
setopt NUMERIC_GLOB_SORT # sort file2 before file10 setopt COMPLETE_ALIASES # completion treats aliases as separate commands
setopt SH_WORD_SPLIT # bash-style splitting of unquoted variables (avoid in new code) setopt KSH_ARRAYS # 0-based arrays
Turn one off with unsetopt NAME or setopt NO_NAME. Options are case-insensitive and ignore underscores. In scripts prefer setopt local_options inside functions.
Plugins and frameworks
Oh My Zsh (a big collection of plugins and themes, easy to start with):
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" # read the script first if you prefer: curl -fsSL URL | less
Then edit ~/.zshrc: ZSH_THEME="robbyrussell" plugins=(git z sudo extract docker kubectl history-substring-search) and run source ~/.zshrc Update: omz update
Popular plugins to know: git (aliases like gst, gco), z (jump to frequent folders), sudo (press Esc twice to add sudo), extract, colored-man-pages, fzf, command-not-found.
The two you probably want (manual install, no framework needed):
git clone https://github.com/zsh-users/zsh-autosuggestions ~/.zsh/zsh-autosuggestions # grey suggestions from your history. Press Right Arrow to accept
git clone https://github.com/zsh-users/zsh-syntax-highlighting ~/.zsh/zsh-syntax-highlighting # red for bad commands, green for good
echo 'source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh' >> ~/.zshrc
echo 'source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh' >> ~/.zshrc # syntax highlighting must be sourced LAST (after other plugins and key bindings)
Homebrew alternative: brew install zsh-autosuggestions zsh-syntax-highlighting then source the files it prints
Lighter plugin managers (faster than Oh My Zsh): zinit, antidote, sheldon, znap. fzf adds fuzzy Ctrl+R history, Ctrl+T files and Alt+C directories:
brew install fzf && $(brew --prefix)/opt/fzf/install or sudo apt install fzf then in ~/.zshrc: source <(fzf --zsh) (fzf 0.48+)
Only install plugins from sources you trust: they run code every time you open a terminal. Check what a plugin does before you add it.
Heavy setups slow the shell. Measure: time zsh -i -c exit Profile: add zmodload zsh/zprof at the top of ~/.zshrc and run zprof at the bottom.
Handy zsh built-ins and modules
print -l $path # one item per line print -P '%F{red}text%f' prompt-style colors print -r -- "$var" raw output print -n no newline
print -z 'ls -l' # put text on the next command line without running it
vared name # edit a variable's value interactively read "?Continue? [y/n] " ans read -k 1 "?Press any key" key (one key, no Enter)
zmv (autoload -U zmv): mass rename with patterns
zmv '(*).jpeg' '$1.jpg' # rename every .jpeg to .jpg zmv -n ... dry run first! zmv -w '*.txt' '*.md' short form using wildcards
zmv -W 'IMG_*.JPG' 'photo_*.jpg'
zargs -- **/*.log -- gzip # like xargs but with zsh globs, without argument-length limits (autoload -U zargs)
zparseopts -D -E -- v=verbose o:=outfile # parse options in a script or function
zstat (zmodload zsh/stat), zsh/datetime (strftime, EPOCHSECONDS), zsh/mathfunc (sqrt sin), zsh/regex ([[ $s -regex-match 'pat' ]]), zsh/net/tcp (ztcp)
Regex in [[ ]]: [[ $s =~ ^[0-9]+$ ]] && print number the matched groups are in $match[1] (zsh) or $BASH_REMATCH if setopt BASH_REMATCH
Process substitution, here-docs, redirection: same as bash. Extra: cat < file1 < file2 (multios, both files are concatenated) ls > a > b (writes to both) &>| file force overwrite
setopt MULTIOS is on by default. Turn it off if it surprises you.
Scripting in zsh
#!/usr/bin/env zsh
emulate -L zsh # a clean, predictable zsh inside this script or function (ignores the user's options)
setopt ERR_EXIT NO_UNSET PIPE_FAIL # strict mode (like set -euo pipefail in bash)
script_dir=${0:A:h} # the folder this script lives in
for f in *.log(N); do print -r -- "$f"; done # (N) = no error when nothing matches
Bash scripts should keep the #!/usr/bin/env bash shebang and be run with bash. Do not source a bash script into zsh unless it is written for both.
For scripts other people will run, prefer bash or POSIX sh. Zsh is great interactively, but it is not installed everywhere.
Functions to run bash code: bash -c 'command' or emulate bash -c 'source ./script.sh'
Bash and zsh side by side
bash zsh
arr=(a b c); ${arr[0]} arr=(a b c); $arr[1] (first item is 1)
${arr[@]} ${#arr[@]} $arr ${#arr} (or "${arr[@]}")
for f in $files splits on spaces in bash, but not in zsh. Use an array
shopt -s globstar; **/*.py **/*.py works by default
shopt -s nullglob setopt NULL_GLOB or *(N)
shopt -s extglob setopt EXTENDED_GLOB (different syntax: ^foo, *~bar, (#i))
PS1='\u@\h:\w\$ ' PROMPT='%n@%m:%~ %# '
read -p "Prompt: " v read "?Prompt: " v
echo -e "a\tb" print "a\tb" or echo "a\tb" (zsh echo interprets escapes by default)
$'...' same
export -f func not available (use autoload and fpath)
set -o pipefail setopt PIPE_FAIL
set -e setopt ERR_EXIT
complete -F fn cmd compdef _fn cmd
alias with arguments functions in both
type -a cmd whence -a cmd or type -a cmd
history history 1
shopt -s histappend setopt APPEND_HISTORY (or SHARE_HISTORY)
$BASH_SOURCE, ${BASH_SOURCE[0]} ${(%):-%x} or ${0:A} (in a sourced file: ${(%):-%N})
Practical one-liners
# The 10 biggest files below the current folder
ls -lh **/*(.OL[1,10])
# Files changed in the last 24 hours
print -l **/*(.m-1)
# Delete all empty files (preview first with print -l)
rm -i **/*(.L0)
# Rename every .jpeg to .jpg
autoload -U zmv && zmv '(*).jpeg' '$1.jpg'
# Open the most recently changed file in vim
vim *(om[1])
# Make a folder and go into it
mkdir -p ~/projects/new && cd $_
# Show the PATH one folder per line
print -l $path
# Add a folder to PATH without duplicates
typeset -U path # makes the $path array unique path=(~/bin ~/.local/bin $path)
# Run a command and see how long it took
time long-command (or set REPORTTIME=5 to time anything that takes more than 5 seconds)
# List only directories, sorted by name
print -l *(/)
# Copy the last command to the clipboard (Linux with xclip, macOS with pbcopy)
fc -ln -1 | pbcopy
# Count files by extension
print -l **/*(.) | sed 's/.*\.//' | sort | uniq -c | sort -rn | head
# Global alias for a quiet run
alias -g Q='>/dev/null 2>&1' ; ping -c1 example.com Q && echo up
# Start a fresh, clean shell for troubleshooting
env -i HOME=$HOME zsh -f
# Reload your config after editing it
exec zsh
# Show which startup files run (debug)
zsh -xv 2>&1 | head -50
Common problems
"zsh: no matches found: *.txt" A glob matched nothing (zsh treats that as an error). Quote it ('*.txt'), use *.txt(N), or setopt NO_NOMATCH. Also affects scp host:*.txt and pip install pkg[extra] (quote them)
"zsh: command not found: compdef" / completions not working compinit has not run. Add autoload -Uz compinit && compinit BEFORE any plugin or line that uses compdef
"zsh compinit: insecure directories and files, run compaudit" Fix permissions (compaudit shows the folders, then chmod go-w and correct ownership), or use compinit -u to ignore
Variables with spaces do not split like in bash By design. Put words in an array, or setopt SH_WORD_SPLIT for old scripts. Quote variables in scripts you share
echo $arr[0] prints nothing Arrays start at 1 in zsh
"zsh: bad substitution" A bash-only construct (like ${!var} or ${var^^}). Use zsh forms: ${(P)var} and ${(U)var}
"read: -p: no coprocess" Use read "?Prompt: " var instead of read -p
"zsh: parse error near ..." when pasting from the web The command uses bash-only syntax, or a special character. Try it in bash: bash -c '...'
"zsh: event not found: !" when using ! in text History expansion. Escape it \! or use single quotes, or setopt NO_BANG_HIST to turn history expansion off
The history is tiny or not saved Set HISTFILE, HISTSIZE and SAVEHIST (macOS defaults are small), and use SHARE_HISTORY. Check the file is writable
PATH is wrong on macOS or after login Login shells read ~/.zprofile (not .zshrc). Put PATH exports in ~/.zprofile (and remember /etc/zprofile runs path_helper after .zshenv)
Aliases or functions from .zshrc are missing in scripts Non-interactive shells do not read .zshrc. Put script code in the script, and env vars in ~/.zshenv
Slow terminal startup Time it: time zsh -i -c exit. Too many plugins, nvm/rbenv/conda init, a slow prompt, or compinit without -C. Lazy-load heavy tools, and use zprof
Prompt is slow in big git repositories vcs_info or the git status segment is slow: use a fast prompt (Powerlevel10k, Starship) or turn off dirty checks
Ctrl+Arrow, Home, End or Delete print odd characters Bind them with bindkey (see the keyboard section) and check your terminal's key codes (cat -v)
Syntax highlighting stopped working / weird colors zsh-syntax-highlighting must be sourced LAST in ~/.zshrc
"chsh: PAM: Authentication failure" / "non-standard shell" Add the zsh path to /etc/shells (echo $(which zsh) | sudo tee -a /etc/shells), then run chsh again
Oh My Zsh update or theme problems omz update, and check ZSH_THEME. If the theme needs special symbols install a Nerd Font
Unicode or emoji misalign in the prompt The terminal and font disagree on character widths. Use a Nerd Font, and %{ %} around escape codes
Different behavior in VS Code or IDE terminals They may start a login or non-login shell differently. Check with echo $0 and [[ -o login ]] && echo login
Tips
Add a small ~/.zshrc: history options, AUTO_CD, compinit with the menu-select style, and the two plugins (autosuggestions and syntax highlighting). That gives most of the benefit.
Learn the glob qualifiers. Things like *(.om[1,5]) and **/*.py(.mh-1) replace long find pipelines.
Remember that arrays are 1-based and that unquoted variables are not split. Both surprise people coming from bash.
Use print -l pattern to preview a glob before you use it with rm or mv, and zmv -n for a dry run of mass renames.
Turn on SHARE_HISTORY, HIST_IGNORE_DUPS and a large SAVEHIST, and start secret commands with a space (HIST_IGNORE_SPACE).
Run compinit once, keep it near the top, and put syntax highlighting last. Most "completion is broken" problems are ordering problems.
Keep startup fast: measure with time zsh -i -c exit, lazy-load version managers, and avoid loading every plugin you tried once.
Write scripts for others in bash or sh. Use zsh scripts only where you know zsh is installed, and start them with emulate -L zsh.
Keep your dotfiles (.zshrc, .zprofile) in git so every machine feels the same.
Read man zshall (or the individual zsh manuals) when you want the full story: search with / and use man zshexpn for expansion and globbing.