Your Mac switched you from bash to zsh, and your ~/.zshrc is a few hundred lines pasted from a framework install. Lines such as setopt, bindkey, and zstyle look opaque, and *(om[1]) looks like line noise. This guide shows what stock zsh does on its own, with no plugins, so you can read your own config and decide what belongs in it.

It covers the 20% of zsh that does 80% of the work: globbing and glob qualifiers, parameter expansion and arrays, completion, history expansion, options, key bindings, and ~/.zshrc. A searchable command reference and a curated set of videos, podcasts, books, and online resources close the article.

What You’ll Learn

  • When zsh is the right shell, and when bash or Nushell fits better.
  • How to match files by name, type, size, and age with globs and glob qualifiers.
  • How to change strings and split, join, count, and index arrays with parameter expansion.
  • How to turn on completion and make the Tab menu work for you.
  • How to repeat and fix earlier commands with history expansion.
  • How to try options and key bindings, then keep the ones you like in ~/.zshrc.

When to Use Zsh, Bash, or Nushell

Zsh (the Z shell) is an interactive shell and scripting language that reads most bash syntax and adds its own: recursive globs, glob qualifiers, expansion flags, and a programmable completion system. It has been the default login shell on macOS since Catalina in 2019.

Where Zsh Fits Best

  • Typing commands all day. Completion, history expansion, and key bindings save keystrokes.
  • Finding files without a separate tool. **/*.log and *(om[1]) replace many find invocations.
  • Shaping strings and arrays in place, without sed, cut, or awk.
  • Reading the shell on a Mac, where zsh is already the default.

When Bash Fits Better

  • Scripts that run on servers, in containers, and in CI. Bash is installed almost everywhere, and #!/bin/bash is the safe default for shared scripts.
  • Following tutorials and Stack Overflow answers written for bash. Most work in zsh, but arrays start at 1 in zsh and at 0 in bash, and unquoted variables don’t split on spaces in zsh.

When Nushell Fits Better

  • Working with JSON, CSV, and other structured data. Nushell passes tables between commands instead of text. Learn Nushell covers it.
  • Writing cross-platform scripts that also run on Windows.

Install zsh with your package manager if it isn’t already your shell. On macOS, it already is. On Debian or Ubuntu, this needs the network and administrator rights:

Runs on your machine, not in the practice shell

sudo apt install zsh

The examples below run in a small practice home directory. It holds a note, a to-do list, three photos, a tiny website, and three log files:

notes.txt
todo.md
photos/IMG_2041.JPG
photos/IMG_2042.JPG
photos/IMG_2043.JPG
projects/website/index.html
projects/website/style.css
projects/website/app.js
logs/error.log
logs/app.log
logs/app-2026-09.log

The files are one day apart in age, oldest first in the order listed, so logs/app-2026-09.log is the newest and the largest. Every example prints its result with zsh’s own print command, so the output you see matches the output in this guide. Each example starts in the home directory.

The examples use zsh 5.9.2 with no plugins. To follow along without installing anything, use the practice shell in Practice Zsh.

Match Files with Globs and Glob Qualifiers

A glob is a pattern the shell expands into matching file names before the command runs. print -l prints one argument per line, which makes it the simplest way to see what a glob matches.

List every log file in any directory below the current one with the recursive glob **/:

print -l **/*.log
logs/app-2026-09.log
logs/app.log
logs/error.log

In the practice shell, zsh sorts these matches so app-2026-09.log comes first.

Add a glob qualifier in parentheses at the end of a pattern to filter matches by file type, size, or age. (/) keeps directories:

print -l *(/)
logs
photos
projects

(.) keeps plain files, so **/*(.) lists every file and no directories:

print -l **/*(.)
logs/app-2026-09.log
logs/app.log
logs/error.log
notes.txt
photos/IMG_2041.JPG
photos/IMG_2042.JPG
photos/IMG_2043.JPG
projects/website/app.js
projects/website/index.html
projects/website/style.css
todo.md

Sort and pick with o and a subscript. om sorts by modification time, newest first, and [1] keeps the first match, so this prints the newest log:

print -l logs/*(om[1])
logs/app-2026-09.log

Filter by size with L. Lk+10 keeps files larger than 10 KB:

print -l **/*(Lk+10)
logs/app-2026-09.log
logs/app.log

Turn on extended_glob for negation. ^error.log matches everything except error.log:

setopt extended_glob
print -l logs/^error.log
logs/app-2026-09.log
logs/app.log

Each pattern answers “which files are newest, biggest, or of this type” in one line, without reaching for find.

Shape Strings and Arrays with Parameter Expansion

Parameter expansion changes a value as the shell reads it. Zsh adds flags in parentheses right after the opening brace. (U) converts to uppercase:

x=hello
print ${(U)x}
HELLO

Split a string into an array with (s:,:), where the character between the colons is the separator. Then index and count the array. Zsh arrays start at 1:

csv=web-01,db-01,cache-01
hosts=(${(s:,:)csv})
print $hosts[1]
print ${#hosts}
web-01
3

Join an array back into one string with (j:,:):

hosts=(web-01 db-01 cache-01)
print ${(j: | :)hosts}
web-01 | db-01 | cache-01

Combine globs and arrays. Store matches in an array, then use the :t modifier to keep only each file’s name:

logs=(logs/*.log)
print ${#logs}
print ${logs:t}
3
app-2026-09.log app.log error.log

The same modifiers pull a path apart: :t is the tail, :r drops the extension, :e keeps only the extension, and :h is the head:

f=photos/IMG_2041.JPG
print ${f:t} ${f:r} ${f:e} ${f:h}
IMG_2041.JPG photos/IMG_2041 JPG photos

typeset -p shows a variable’s type and value, which helps when an array isn’t what you expect:

hosts=(web-01 db-01 cache-01)
typeset -p hosts
typeset -a hosts=( web-01 db-01 cache-01 )

Turn On Completion

Zsh’s completion system loads with two lines that most ~/.zshrc files already hold:

autoload -Uz compinit
compinit

autoload -Uz marks compinit as a function zsh loads from its function path the first time you call it. compinit then sets up completion for hundreds of commands.

With completion on, type cd pro and press Tab. Zsh completes the directory name to cd projects/. Press Tab again to step into website/. Type print -l logs/ and press Tab twice to see every log file.

The zstyle command configures completion. This line turns Tab into a menu you move through with the arrow keys:

zstyle ':completion:*' menu select

Print every completion style that’s set with zstyle -L:

zstyle -L ':completion:*'
zstyle ':completion:*' menu select

Repeat and Fix Commands with History Expansion

History expansion reuses parts of earlier commands. Zsh shows the expanded command line before it runs, so you can see what ran.

!! repeats the last command, and !$ stands for its last word:

print -l logs/*.log
!!
print -l !$

^old^new reruns the last command with the first old replaced by new. Here it turns app.log into error.log:

print -l logs/app.log
^app^error

Zsh prints the first result, then the rewritten command, then its result:

logs/app.log
print -l logs/error.log
logs/error.log

List recent commands with their history numbers:

fc -l

Press Ctrl-r to search your history as you type, and press Up to step back through earlier commands.

Try Options

Options change how the shell behaves. setopt turns one on, unsetopt turns it off, and [[ -o name ]] tests it. With autocd on, typing a directory’s name changes into it:

setopt autocd
logs
cd

The prompt changes to reader@practice ~/logs % after logs, and cd with no argument returns home. Check and clear the option:

setopt autocd
[[ -o autocd ]] && print on || print off
unsetopt autocd
[[ -o autocd ]] && print on || print off
on
off

Run setopt with no arguments to list the options that differ from the default. setopt correct makes zsh offer a fix for a mistyped command: type sl and zsh asks zsh: correct 'sl' to 'ls' [nyae]?.

Change Key Bindings

Zsh’s line editor ships with emacs keys (Ctrl-a for line start, Ctrl-e for line end) and vi keys. bindkey shows and changes them. Ask what a key does:

bindkey '^R'
"^R" history-incremental-search-backward

Switch to vi keys, check which keymap is active, then switch back:

bindkey -v
bindkey -lL main
bindkey -e
bindkey -lL main
bindkey -A viins main
bindkey -A emacs main

In vi mode, press Esc to move into command mode, then 0 to jump to the start of the line and i to type again.

Keep What You Like in ~/.zshrc

Every interactive zsh reads ~/.zshrc at startup. Print the practice home’s copy with zsh’s own file reader, $(<file):

print -r -- "$(<~/.zshrc)"
# ~/.zshrc: the practice shell's startup file. Changes last until Reset.
PROMPT='reader@practice %~ %# '
autoload -Uz compinit && compinit
zstyle ':completion:*' menu select
HISTSIZE=1000

On your own machine, back up the file first with cp ~/.zshrc ~/.zshrc.bak. If a new line breaks startup, run cp ~/.zshrc.bak ~/.zshrc and open a new terminal.

Add a line and apply it to the current shell with source, without opening a new terminal:

print 'setopt autocd' >> ~/.zshrc
source ~/.zshrc
[[ -o autocd ]] && print on || print off
on

Load an extra function the same way compinit loads. zmv renames files by pattern, and zsh doesn’t know it until you autoload it. whence -w shows the change:

whence -w zmv
autoload -Uz zmv
whence -w zmv
zmv: none
zmv: function

zmv -n prints the renames without doing them. (*) captures part of each name, and $1 reuses it:

autoload -Uz zmv
zmv -n 'photos/IMG_(*).JPG' 'photos/photo-$1.jpg'
mv -- photos/IMG_2041.JPG photos/photo-2041.jpg
mv -- photos/IMG_2042.JPG photos/photo-2042.jpg
mv -- photos/IMG_2043.JPG photos/photo-2043.jpg

Drop -n to rename the files for real. Try a line in the current shell first, then add it to ~/.zshrc once it does what you want.

Practice Zsh

Zsh sticks once you type it. The practice shell below runs real zsh 5.9.2 in this page. Nothing downloads until you press Start, nothing you type leaves your browser, and Reset brings back a fresh shell. Click the shell to type. Tab completes commands there, so press Esc then Tab to leave it.

The practice shell has no network and only BusyBox’s basic programs. Its home holds a few practice files, including log files under logs/. The list after the drill names what it cannot run.

Practice the commands on this page in real zsh, right here. Nothing downloads until Start (about 6 MB).

reader@practice ~ % print -l **/*.log
logs/app-2026-09.log
logs/app.log
logs/error.log
reader@practice ~ %

The practice shell needs JavaScript. Every command in the reference also works in zsh on your own machine.

Run the drill below in the practice shell or in your own terminal. Type each command exactly as shown and press Enter. It takes about ten minutes:

  1. List every plain file in the practice home and the folders below it: print -l **/*(.). The -l is a dash and a lowercase L, so print puts each name on its own line. **/ searches every folder, and (.) keeps files and drops folders. You see the 11 practice files, from logs/app-2026-09.log to todo.md.
  2. Print the newest log: print -l logs/*(om[1]). The letters in parentheses after a pattern are glob qualifiers. om sorts the matches by modification time, newest first, and [1] keeps only the first one. You see logs/app-2026-09.log.
  3. List the files larger than 10 KB: print -l **/*(Lk+10). In the qualifier, L means size, k means kilobytes, and +10 means more than 10. You see logs/app-2026-09.log and logs/app.log.
  4. Split a comma list into an array, then count it and print its first item. Type these three lines one at a time: csv=web-01,db-01,cache-01, then hosts=(${(s:,:)csv}), then print ${#hosts} $hosts[1]. The (s:,:) flag splits on commas, ${#hosts} counts the items, and $hosts[1] is the first item, because zsh arrays start at 1. You see 3 web-01.
  5. Change directories without typing cd. Type setopt autocd, then type logs and press Enter. The prompt changes to reader@practice ~/logs %. Type cd to go back home, then unsetopt autocd to turn the option off.
  6. Try vi keys. Type bindkey -v, then type print one two without pressing Enter. Press Esc to switch to command mode, press 0 to jump to the start of the line, then $ to jump to the end, then a to add text after the cursor. Type three and press Enter. You see one two three. Type bindkey -e to switch back to the usual keys. In the practice shell, Esc then Tab leaves the terminal, so avoid Tab right after Esc.
  7. Preview a batch rename. Type autoload -Uz zmv to load the zmv function, then zmv -n 'photos/IMG_(*).JPG' 'photos/beach-$1.jpg'. The -n previews the rename without doing it: you see one mv line per photo, such as mv -- photos/IMG_2041.JPG photos/beach-2041.jpg, and no file changes.

The practice shell is zsh plus BusyBox, not a full computer. When a command needs something it lacks, zsh prints its own error and a note under the shell explains why. Run these on your own machine instead:

  • Other programs. git, curl, brew, man, ssh, sudo, vim, python3, and most programs beyond BusyBox are not installed. zsh prints zsh: command not found: git and the command exits with status 127.
  • Plugins. Plugin managers and frameworks such as oh-my-zsh (its omz command), zinit, antigen, zplug, and antidote are not installed. Plugins and Frameworks on Your Machine shows how to set them up.
  • No network. wget, nc, ping, nslookup, traceroute, and whois run, but nothing outside the page is reachable. wget prints bad address and ping prints can't create raw socket.
  • GNU options. ls, sed, grep, date, and the other basic programs come from BusyBox 1.37, not GNU, so some GNU options fail. date -d yesterday reports an invalid date.

Troubleshooting

  • zsh: no matches found: *.foo. Zsh stops when a glob matches nothing, where bash passes the pattern through unchanged. Quote the pattern ('*.foo'), or add the (N) qualifier to expand to nothing: print -l *.foo(N). Quote URLs that contain ? or * for the same reason.
  • print -l logs/^error.log prints logs/^error.log. Negation needs setopt extended_glob. Without it, ^ is an ordinary character. Turn the option on, then rerun the glob.
  • A bash script prints an empty first item. Zsh arrays start at 1, so $arr[0] is empty. Use $arr[1], or run the script with bash.
  • A loop over $words runs once. Zsh doesn’t split unquoted variables on spaces. Split explicitly with ${=words}.
  • zsh compinit: insecure directories, run compaudit for list. Another user can write to a directory in your function path. Run compaudit to list the directories, then remove group and other write permission from each one with chmod g-w,o-w.

Command Reference

Verified with zsh 5.9.2, no plugins. Search the reference to find a command fast. Each entry shows the command, then what it does.

Globbing

  • print -l **/*.log - List matching files in every directory below the current one
  • print -l **/*(.) - List plain files only, no directories
  • print -l *(/) - List directories only
  • print -l logs/*(om[1]) - Print the newest file: sort by modification time, keep the first
  • print -l **/*(Lk+10) - List files larger than 10 KB
  • print -l photos/*(On) - List files in reverse name order
  • setopt extended_glob - Turn on extended patterns such as negation
  • print -l logs/^error.log - List every file except one (needs extended_glob)

Parameter Expansion and Arrays

  • print ${(U)x} - Print a value in uppercase
  • arr=(${(s:,:)x}) - Split a string into an array on commas
  • print ${(j:,:)arr} - Join an array into one string with commas
  • print ${#arr} - Count the items in an array
  • print $arr[1] - Print the first item of an array (arrays start at 1)
  • print ${f:t} - Print the last part of a path
  • print ${f:r} - Print a path without its extension
  • typeset -p arr - Show a variable’s type and value

History

  • !! - Repeat the last command
  • !$ - Reuse the last word of the last command
  • ^old^new - Rerun the last command with old replaced by new
  • fc -l - List recent commands with their history numbers

Options

  • setopt - List the options that differ from the default
  • setopt autocd - Turn on an option
  • unsetopt autocd - Turn off an option
  • [[ -o autocd ]] - Test whether an option is on

Key Bindings

  • bindkey -v - Switch to vi keys
  • bindkey -e - Switch to emacs keys
  • bindkey '^R' - Show what a key does
  • bindkey -lL main - Show which keymap is active

Completion

  • autoload -Uz compinit && compinit - Load and start the completion system
  • zstyle ':completion:*' menu select - Turn Tab into a menu you move through with the arrow keys
  • zstyle -L - List every style that’s set

Functions and Startup Files

  • autoload -Uz zmv - Load a function from zsh’s function path on first use
  • zmv -n 'photos/IMG_(*).JPG' 'photos/photo-$1.jpg' - Preview a pattern rename without doing it
  • whence -w zmv - Show whether a name is a builtin, function, command, or none
  • source ~/.zshrc - Run a file’s commands in the current shell

Examples

Each example runs on the practice files and shows what zsh prints.

print -l **/*(.om[1])
logs/app-2026-09.log
print -l **/*.(html|css)
projects/website/index.html
projects/website/style.css
hosts=(web-01 db-01 cache-01); print -l ${(o)hosts}
cache-01
db-01
web-01
files=(photos/*.JPG); print -l ${files:t:r}
IMG_2041
IMG_2042
IMG_2043

Plugins and Frameworks on Your Machine

Plugins and frameworks are where many ~/.zshrc files come from. They add commands and behavior that stock zsh doesn’t have, so try them on your own machine once the basics feel familiar.

Install the two most popular plugins with Homebrew. This needs the network:

Runs on your machine, not in the practice shell

brew install zsh-autosuggestions zsh-syntax-highlighting

Or clone a plugin with git:

Runs on your machine, not in the practice shell

git clone https://github.com/zsh-users/zsh-autosuggestions ~/.zsh/zsh-autosuggestions

Then add a source line for each plugin to the end of ~/.zshrc, using the path the install step printed. The Beyond the Basics section links to each plugin and framework.

To check that a plugin loaded, ask zsh whether one of its functions exists. The practice shell runs stock zsh, so it prints 0. On your machine, open a new terminal after you add the source line, and it prints 1:

print ${+functions[_zsh_autosuggest_start]}
0

Learn Zsh: Beyond the Basics

With globs, expansion, completion, and a tidy ~/.zshrc in hand, these resources take you further. Start with a video, then keep the zsh manual open while you edit your config. The popular add-ons are worth reading about: oh-my-zsh bundles themes and hundreds of plugins, zsh-autosuggestions suggests commands from your history as you type, and zsh-syntax-highlighting colors commands as you type them.

Video

Audio and Podcasts

Books

Online

  • Learn Nushell covers a shell built on structured data, for the tasks where zsh’s text pipelines get awkward.
  • Learn Tmux pairs with zsh: run a shell in each pane and keep sessions alive after you disconnect.
  • Configure your shell walks through shell startup files and settings.
  • What Is Zoxide? explains a smarter cd that works in zsh.
  • Learn fd finds files by name, skips what .gitignore ignores, and runs a command on each result.
  • Learn AWS Lambda puts a simulated AWS CLI in this same zsh practice shell, so you can rehearse Lambda commands without an account.