Most developers use the shell daily but treat it as a command launcher: type a thing, press enter, read the output. The shell as a programming environment, with its own flow control, string manipulation, process model, and history system, sits largely untouched. That gap matters less when you have a GUI for everything, but the moment you’re on a remote machine, writing automation, or debugging something under time pressure, fluency pays off.
A recent article from Hofstede’s blog made the rounds on Hacker News with 600+ points and 261 comments, which is a decent signal that shell ergonomics still resonate. The discussion surfaced a lot of community-contributed tricks alongside the original post. What follows isn’t a rehash of that list. It’s an attempt to explain the underlying mechanics so the tricks stop feeling like magic you have to memorize.
History Is a Database
Bash and zsh both maintain a command history file (~/.bash_history or ~/.zsh_history), but the defaults are conservative to the point of uselessness. Bash’s default HISTSIZE is 500 commands. That’s enough history to last maybe two days of active work before older commands start dropping off.
The fix is straightforward:
export HISTSIZE=100000
export HISTFILESIZE=100000
export HISTCONTROL=ignoredups:erasedups
shopt -s histappend
histappend tells bash to append to the history file rather than overwrite it when the shell exits, which matters if you run multiple terminal sessions in parallel. Without it, whichever session exits last wins, and everything else is lost.
On the retrieval side, Ctrl+R triggers a reverse incremental search through history. Type a fragment, and bash walks backward through matching commands. What fewer people know is that Ctrl+S searches forward (you may need stty -ixon in your .bashrc to unfreeze that key from its historical flow-control role). And !! expands to the last command, while !$ gives you the last argument of the last command. These aren’t obscure Vim-style bindings; they’re the readline shortcuts that bash has used since the 1990s.
zsh users get history substring search via the zsh-history-substring-search plugin, which is more forgiving than reverse-incremental because you can type anywhere in the middle of a command rather than from the start.
Brace Expansion and Why It Matters
Brace expansion is one of those features that looks like a trick but is actually a core part of the shell’s grammar:
# Create multiple directories at once
mkdir -p project/{src,tests,docs,scripts}
# Rename a file without retyping the path
mv /very/long/path/to/some/file{.bak,}
# Back up before editing
cp config.yaml{,.bak}
That last one is worth internalizing. The pattern file{,.bak} expands to file file.bak, so cp config.yaml{,.bak} is equivalent to cp config.yaml config.yaml.bak. It’s not a shortcut born of laziness; it’s meaningful when you’re in a deep directory path and don’t want to type it twice with a typo risk the second time.
Brace expansion also supports sequences: {1..10}, {a..z}, {01..20} (zero-padded). Combined with a loop, this covers a lot of batch operations without reaching for Python:
for i in {01..12}; do
mkdir -p "data/2025-${i}"
done
Process Substitution: The Underused One
Process substitution (<(...) and >(...)) is supported in bash and zsh but rarely taught. It lets you treat the output of a command as a file:
diff <(sort file1.txt) <(sort file2.txt)
Without process substitution, you’d sort both files into temporaries, diff them, then clean up. With it, the shell creates named pipes (or /dev/fd/N file descriptors on Linux) and wires everything together. The result is cleaner and doesn’t pollute the filesystem.
This composes naturally with tools that expect file arguments but can’t read from stdin:
# Compare two remote files without downloading them
diff <(ssh host1 cat /etc/nginx/nginx.conf) <(ssh host2 cat /etc/nginx/nginx.conf)
Process substitution has been in bash since version 2.0, released in 1996. It’s not new; it just doesn’t get covered in introductory material because it requires understanding that the shell mediates file descriptor plumbing.
The set -euo pipefail Pattern
This one is more relevant for shell scripts than interactive use, but it’s worth understanding because scripts that omit it are genuinely dangerous:
#!/usr/bin/env bash
set -euo pipefail
set -ecauses the script to exit on the first command that returns a non-zero status.set -utreats references to unset variables as errors.set -o pipefailcauses a pipeline to return the exit code of the rightmost failing command rather than always returning the exit code of the last command.
The pipefail piece is the subtle one. Without it, false | true returns 0 because true succeeds. That means a grep that finds nothing inside a pipeline could silently pass, and your script continues under a false assumption. Google’s shell style guide mandates all three, which is a reasonable baseline.
Parameter Expansion: The Part People Google Every Time
Bash parameter expansion has a syntax that doesn’t reward casual reading, but the operations it covers are common enough that knowing them saves a lot of subprocess overhead:
file="/path/to/archive.tar.gz"
echo "${file##*/}" # archive.tar.gz (filename only, strips longest prefix match)
echo "${file%/*}" # /path/to (directory, strips shortest suffix match)
echo "${file%%.*}" # /path/to/archive (strips longest suffix match starting from .)
echo "${file#*.}" # tar.gz (strips shortest prefix match up to first .)
The # strips from the left, % strips from the right. Doubling (##, %%) makes the match greedy. This covers most “get the extension” or “get the basename” operations without spawning a subprocess for basename or dirname.
Default values follow a similar pattern:
echo "${NAME:-anonymous}" # use 'anonymous' if NAME is unset or empty
echo "${NAME:=anonymous}" # assign 'anonymous' to NAME if unset, then use it
echo "${NAME:?'NAME must be set'}" # exit with error if NAME is unset
These are particularly useful in scripts that accept environment variables as configuration, because they let you document required variables and provide sensible defaults inline.
Job Control and the Forgotten disown
Everyone knows Ctrl+Z suspends a process and bg resumes it in the background. Fewer people remember that processes started from a shell are children of that shell. When the shell exits, it sends SIGHUP to its process group, which terminates anything running in the background.
disown detaches a job from the shell’s job table:
long_running_process &
disown %1
After disown, the process will survive the shell exiting. This is a lighter alternative to nohup for cases where you forgot to use nohup before starting something. Note that disown doesn’t redirect stdout and stderr, so if the process writes output after you close the terminal, it may fail with a broken pipe. For anything serious, use tmux or screen instead, but disown is a useful escape hatch.
cd - and pushd/popd
cd - returns to the previous working directory. It reads $OLDPWD, which the shell maintains automatically. This covers the common case of switching between two directories repeatedly.
For more than two directories, pushd and popd maintain a stack:
pushd /var/log # navigate there, push current dir onto stack
pushd /etc/nginx # navigate there, push /var/log onto stack
popd # back to /var/log
popd # back to wherever you started
dirs shows the current stack. This is essentially the same mechanism that the z and autojump tools build on, except those tools add frecency weighting based on your actual navigation history. If you spend significant time jumping around directory trees, zoxide (a Rust rewrite of z) is worth the setup cost.
Composability as the Real Lesson
The shell tricks that accumulate the most value aren’t the individual commands but the way they compose. Process substitution plus diff plus ssh is not three tricks; it’s one capability built from three pieces. Brace expansion plus mkdir plus loops covers directory scaffolding without a separate script. Parameter expansion plus default values plus set -u gives you a configuration protocol for shell scripts that’s self-documenting.
This composability traces back to the design philosophy Ken Thompson and Dennis Ritchie established with the original Unix shell in the early 1970s: small tools that do one thing, text as the universal interface, pipelines as the composition mechanism. The shell has accumulated syntax and features on top of that foundation across more than fifty years, and the result is occasionally arcane, but the underlying model is coherent.
Learning the tricks in isolation gets you productivity gains proportional to how often you hit each specific case. Learning the model gets you the ability to construct new solutions on the fly. The Hofstede article is a solid entry point; the HN discussion thread surfaced several more (notably around fc for editing the last command in your $EDITOR, and CDPATH for making cd search across multiple root directories). Read those, but also spend time with the bash manual, particularly the sections on expansion and parameter substitution. It reads like a reference, not a tutorial, but the density is appropriate for material you’ll return to repeatedly.