I’ve been using zsh as my daily driver for over a decade. Through Oh My Zsh, then Antidote, then just a bare .zshrc with the plugins I actually need. And for most of that time, I only knew about a third of what the shell could do.
This isn’t a listicle. These are the features that survived years of dotfile churn. The ones I still use, still reach for, still show people when they ask “wait, zsh can do that?”
Some of them are genuinely transformative. Others are niche but irreplaceable when you need them. None of them require installing a framework.
Smarter Navigation#
Directory Jumping That Learns#
The single biggest quality-of-life improvement I’ve made to my shell is zoxide. It’s a standalone tool (written in Rust, brew install zoxide) that learns which directories you visit most frequently and lets you jump to them with partial matches.
The real magic is wiring it into cd so it becomes a transparent fallback:
cd() {
[ -z "$*" ] && builtin cd && return
[ -d "$*" ] && builtin cd "$*" && return
[ "$*" = "-" ] && builtin cd "$*" && return
case "$*" in
..) builtin cd ..; return;;
.) builtin cd .; return;;
esac
z "$*" || builtin cd "$*"
}
After a few weeks, typing cd src takes you to your most-used source directory regardless of where it lives in the tree. After a few months, you stop thinking about where things are.
The key detail: this function tries zoxide last, after exhausting normal cd behavior. If you type an actual path, it works normally. Zoxide only activates when cd can’t find what you typed directly. That distinction matters. You never want a frecency tool to override explicit paths.
Named Directory Shortcuts#
Zsh has a built-in named directory system that doesn’t get nearly enough attention:
hash -d dev=~/projects/development
hash -d docs=~/projects/documentation
Navigate with ~dev, ~docs. Works with tab completion. Persists across sessions if you put them in your .zshrc.
The original version of this page used typeset -e dev=~/projects/development. That’s wrong. typeset -e is for floating-point numbers and produces zsh:typeset:2: bad option: -e. I tested it. The correct incantation is hash -d.
I keep a small handful of these for my most-used paths. Everything else, zoxide handles. The named dirs are for the two or three anchors everything else branches from.
Auto-Cd: Just Type the Path#
This one is deceptively simple. Enable auto_cd and zsh will cd into a directory if you just type its name as a command:
setopt auto_cd
With that single line in your .zshrc, /tmp becomes a command that takes you to /tmp. So does .. (up a directory), ~dev (your named dir), or any path.
The interaction with the zoxide cd() wrapper above is smooth: auto_cd only activates when nothing else matches, so if you type a directory name it cd’s, and if you type cd src your zoxide wrapper handles the frecency. They don’t conflict.
One caveat: auto_cd requires an interactive shell to activate. That’s the default on any real terminal. It only matters if you’re scripting. Every terminal window, tmux pane, and SSH session you open will be interactive.
I resisted this for years because it felt like it would break something. It doesn’t. It’s one of those features you forget you’re using until you’re on a machine that doesn’t have it and instinctively try to cd by typing a path.
Pattern Matching That Replaces find#
Extended Globbing#
Enable setopt extended_glob and zsh’s pattern matching becomes something closer to a query language than file expansion:
ls *(.) # only regular files (no directories)
ls *(/om[1,3]) # three newest directories
*(L0) # zero-length files
*(mh+24) # files older than 24 hours
**/*(.m-7) # log files modified in the last week
The pattern *(.) alone replaces half my find usage. The (.) qualifier means “regular files only.” No directories, no symlinks, no devices. Stack them with om (order by modification time) and [1,3] (first three) and you have a one-liner for “find the three newest files” without piping anything.
These aren’t party tricks. I use *(L0) during cleanup to find empty files. I use *(mh+24) when I need to see what hasn’t been touched in a day. They’re faster than find because there’s no fork. Zsh does the matching in-process.
Mass Renaming with zmv#
The built-in zmv command handles batch renaming without writing loops:
autoload -U zmv
zmv '(*).jpeg' '$1.jpg' # .jpeg → .jpg
zmv '(**/)(*).backup' '$1$2' # strip .backup recursively
zmv -n '(*).jpeg' '$1.jpg' # -n for dry run (always test first)
The -n flag is critical. zmv will happily rename files in ways you didn’t intend. Always dry-run first.
Where this shines is the recursive second example. (**/)(*).backup $1$2 matches any .backup file at any depth and strips the extension, preserving the directory structure. No find | while read loop needed.
I reach for zmv maybe once a month. But when I need it, it saves me writing a for loop with string manipulation that I’ll get wrong on the first try.
FZF: The Interactive Bridge#
fzf is a fuzzy finder, not a zsh feature. But it integrates so deeply into zsh that the line blurs. With the default key bindings installed:
**<TAB>triggers fuzzy completion for files, processes, directoriesCtrl+Rbecomes an interactive history searchAlt+Cbecomes an interactivecd
Beyond the defaults, a couple of functions make file navigation genuinely faster:
# Interactive directory jump
cdd() {
local dir
dir=$(fd -t d | fzf --prompt="Directory: " --height=50% --border)
[[ -n $dir ]] && cd "$dir"
}
# Interactive file open
emf() {
local file
file=$(fzf --prompt="Edit: " --height=50% --border)
[[ -n $file ]] && $EDITOR "$file"
}
One caveat: these functions use fd (a separate Rust tool) for performance. If you don’t have fd installed, replace it with find . -type d for directories or just use fzf’s built-in file search for emf.
I map cdd to Ctrl+G in my zsh config. It’s the fastest way I know to get to a directory I haven’t visited recently enough for zoxide to rank.
Quick History Expansion#
These feel like ancient shell history but I use them every single day:
!!repeats the last command.sudo !!is muscle memory.!$is the last argument of the last command.mkdir /some/long/path; cd !$!*is all arguments of the last command.^old^newreruns the last command replacingoldwithnew.^host^hostnamewhen you fat-finger a flag.
These are inherited from csh through bash and zsh. They’re not zsh-specific, but they’re the kind of thing people who learned computing on GUIs never discover. And they save more keystrokes than any other feature in this article.
The key insight is to combine them. sudo !! is the obvious one. The !$ with tab completion is even better. You can type !$ then press Tab to see what it expands to before running it.
ZLE Widgets: Custom Shortcuts Right in the Editor#
The Zsh Line Editor (ZLE) is the part of zsh that handles what happens when you type a command. You can bind functions to key sequences that manipulate the buffer directly.
Git Diff Without Losing Your Place#
This is the widget I use most often:
function _git-diff-widget {
zle push-input
BUFFER="git diff"
zle accept-line
}
zle -N _git-diff-widget
bindkey '^Xd' _git-diff-widget
When you hit Ctrl+X then d, it pushes your current (half-typed) command onto a stack, runs git diff, then restores your original command when the diff exits. You never lose context.
The zle push-input call is the key. It saves whatever you were typing so it comes back when the widget’s command finishes. I use this constantly when writing code. I’m in the middle of a long git commit message, need to check what changed, run the diff, and land back in the commit message.
Quick Sudo#
function _sudo-widget {
[[ $BUFFER != sudo\ * ]] && BUFFER="sudo $BUFFER"
zle end-of-line
}
zle -N _sudo-widget
bindkey '\e\e' _sudo-widget # double-tap Alt
Double-tap Alt to prefix the current command with sudo. Idempotent. If sudo is already there, it does nothing.
These widgets test correctly against a bare zsh 5.8.1. They work with any framework or none.
The Module System#
Zsh ships with loadable modules that extend the shell itself. These aren’t plugins. They’re part of zsh, just not loaded by default.
Math in the Shell#
zmodload zsh/mathfunc
echo $((sin(0.5) * cos(0.3)))
More practically, I use this for quick byte calculations:
zmodload zsh/mathfunc
echo $((16 * 1024 * 1024 * 1024)) # 16 GiB in bytes
Date/Time Without date#
zmodload zsh/datetime
echo $((strftime("%Y-%m-%d_%H:%M:%S", EPOCHSECONDS)))
I use this in prompt strings and log file names where calling the external date binary would add a fork:
# In prompt or functions — no subprocess needed
local now=$EPOCHSECONDS
local logfile="build-${(%):-%D{%Y%m%d_%H%M%S}}.log"
The strftime builtin is measurably faster than date +%s because there’s no process creation. On a modern Mac, date costs about 2-3ms per call. That adds up in prompt rendering.
Hook Functions: Make Zsh React#
Zsh has a hook system that fires functions automatically at specific events. These replaced a lot of hacky workarounds I used to maintain.
preexec — Before Every Command#
This fires right before you press Enter. I use it to log commands, track how long things take, or set per-command context:
preexec() {
# Log command to a file with timestamp
echo "$(date +%s) $1" >> /tmp/shell-history.log
# Or: start a timer for precmd to report
ZSH_COMMAND_START=$EPOCHREALTIME
}
The $1 argument is the full command text as typed. This is how tools like zsh-autosuggestions learn your patterns.
precmd — After Every Command, Before the Next Prompt#
This fires after each command finishes, right before the next prompt is drawn. Perfect for updating your terminal title:
precmd() {
# Set terminal title to current directory
echo -ne "\033]0;${PWD##*/}\007"
}
I also use precmd to render git status in my prompt, track command durations, and write session summaries to a log file.
chpwd — On Every Directory Change#
chpwd() { ls }
This one is divisive. Every time you cd somewhere, it runs ls automatically. Some people love it; it drove me insane within a day. But it shows the power of the hook system. One line and the shell behaves completely differently on directory changes.
The Little Functions That Stuck#
Over the years, I’ve accumulated a handful of shell functions that survived every config rewrite. These are the ones that made the cut.
take — Create and Enter#
take() {
mkdir -p "$1" && cd "$1"
}
Simple. take projects/new-thing creates the directory and puts you in it. I use this ten times a day.
extract — One Command to Unpack Anything#
extract() {
case $1 in
*.tar.gz|*.tgz) tar -xzf "$1";;
*.tar.bz2|*.tbz2) tar -xjf "$1";;
*.tar.xz|*.txz) tar -xJf "$1";;
*.zip) unzip "$1";;
*.7z) 7z x "$1";;
*.rar) unrar x "$1";;
*.gz) gunzip "$1";;
*.bz2) bunzip2 "$1";;
*) echo "Unknown format: $1";;
esac
}
What I Actually Don’t Use#
The original version of this page had a pskill() function that piped ps aux through grep and awk to kill processes by name. I cut it. In practice, I use pkill (standard on macOS and Linux), and for anything more specific I use pgrep with a filter. The ps | grep | awk | xargs chain is fragile. grep can match itself, and the awk field extraction breaks on different ps output formats.
I also had a section on “Language-Specific Enhancements” that linked to another article without providing any actual zsh content. Sloppy. I’ve replaced it with the module system above, which is actual zsh that you can use right now.
What Actually Made It Into My Dotfiles#
After years of experimentation, here’s the concise list:
cd() with zoxide fallbacktake() for dir creation**<TAB> fzf completionzmv for batch renamescdd() interactive dir pickerzmodload zsh/datetimezsh/mathfunc for calcsextract() for odd formatsIf you’re just getting started, add setopt extended_glob and the take function. That’s two lines and they’ll pay for themselves within a day.
Then install zoxide and wire up the cd() wrapper. That’s the highest-impact change you can make to your shell workflow. More than any theme, more than any plugin manager change, more than any prompt tweak. The difference between typing cd ../../../projects/foo/src and cd src is the difference between thinking about navigation and not thinking about it.
The rest you’ll discover when you need them. That’s the point. These aren’t features to collect. They’re tools to reach for when the problem matches the solution. Zsh has been around for thirty years and it shows. The features are there, they work, they’ve been working, and they’ll keep working.
All examples in this page were tested against zsh 5.8.1 in a clean Docker container. If something doesn’t work on your system, check your zsh version with zsh --version.
