The article doing the rounds on HN has 611 points and 261 comments. Lists like this always perform well because shell productivity is genuinely uneven across developers: some people feel at home in a terminal and others feel like they’re fighting it constantly. The problem with reading these lists is predictable. You skim, you nod at the ones you already know, you try two or three new ones, and two weeks later you’ve forgotten most of them.
The more durable approach is to understand the handful of systems that nearly every shell trick draws from. Once you know how these mechanisms work, you can derive shortcuts on demand rather than relying on memory. The shell has been around long enough that almost any problem has a built-in solution; you just need to know where to look.
Readline: the layer most tutorials ignore
The interactive bash experience is not actually bash. It is GNU Readline, a separate library that handles line editing, history navigation, completion, and key bindings. This distinction matters because everything you configure in ~/.inputrc applies to every program that links against Readline, not just bash. That includes psql, irb, gdb, python, sqlite3, and many others.
A few Readline settings worth enabling immediately:
# ~/.inputrc
set show-all-if-ambiguous on
set colored-stats on
set mark-symlinked-directories on
"\e[A": history-search-backward
"\e[B": history-search-forward
The last two lines rebind the up and down arrows to history search rather than plain sequential traversal. Type git and press up, and you cycle through only your previous git commands. This one change removes most of the motivation for piping history through grep.
bind -l lists every Readline function available. bind -p shows current bindings. The built-in edit-and-execute-command function, bound to Ctrl+x Ctrl+e by default, opens the current line in $EDITOR. This is useful when a command grows beyond what is comfortable to edit inline. The fc command does the same thing for the previous command after it has already run, and fc 50 60 opens a range of history entries in your editor for batch work.
History expansion: a sublanguage most people half-know
Most developers know !! repeats the last command and !$ gives the last argument. The full history expansion syntax is richer and works like a small word-processing language operating on your command history.
Word designators extract specific parts of the previous command:
$ git diff src/main.rs tests/main_test.rs
$ git add !^ # first argument: src/main.rs
$ git add !$ # last argument: tests/main_test.rs
$ git add !:1-2 # both arguments
$ cp !:2 !:2.bak # copy the second argument to a backup
Modifiers transform what you extract. !!:h gives the directory component of the last command’s last argument. !!:t gives the filename. !!:r strips the extension. These compose:
$ cat /var/log/nginx/error.log
$ cd !!:$:h # cd /var/log/nginx
The !:s/old/new/ modifier substitutes within the previous command. The shorthand ^old^new is faster for fixing a typo:
$ git comit -m "fix typo"
$ ^comit^commit
shopt -s histverify is worth setting if you use history expansion regularly. It prints the expanded command for confirmation before execution, which prevents surprises when the expansion is not what you expected.
Parameter expansion: replacing sed and awk for common cases
Parameter expansion in bash is far richer than $var or ${var}. The full syntax handles defaults, error conditions, pattern matching, and string substitution without spawning any subprocesses. This matters both for performance in loops and for script clarity.
Default and error handling:
${var:-default} # use default if var is unset or empty
${var:=default} # assign and use default if var is unset or empty
${var:?error msg} # exit with error message if var is unset or empty
${var:+alt} # use alt value only if var is set and non-empty
${var:?} is a lightweight assertion. ${DEPLOY_ENV:?must set DEPLOY_ENV} fails immediately with a clear message instead of proceeding with an empty value.
Pattern-based trimming handles most path manipulation without external commands:
file="/var/log/nginx/access.log.gz"
${file##*/} # access.log.gz (basename equivalent)
${file%/*} # /var/log/nginx (dirname equivalent)
${file%%.*} # /var/log/nginx/access (strip all extensions)
${file##*.} # gz (last extension only)
${file%.gz} # /var/log/nginx/access.log
The # and % operators remove from the left and right respectively. Doubling them switches from shortest to longest match. These replace basename, dirname, and most sed calls for path work. No fork, no IPC, no subprocess.
Substitution follows a familiar syntax:
${var/pattern/replacement} # replace first occurrence
${var//pattern/replacement} # replace all occurrences
${var/#pattern/replacement} # replace only if match is at start
${var/%pattern/replacement} # replace only if match is at end
Bash 4.0, released in 2009, added case conversion operators that are still underused:
${var^^} # uppercase entire string
${var,,} # lowercase entire string
${var^} # uppercase first character only
${#var} gives the string length. ${var:offset:length} extracts a substring. Combined, these cover a large fraction of what people routinely pipe through awk.
Process substitution and the subshell problem
Process substitution, <(command), creates a named pipe and provides a file path that you can pass to any program expecting a file argument. The most common use is with diff and comm:
diff <(sort -u file1.txt) <(sort -u file2.txt)
comm -23 <(sort expected.txt) <(sort actual.txt)
The less obvious use is solving the subshell problem in loops. This pattern does not behave as expected:
count=0
cat file.txt | while read line; do
((count++))
done
echo $count # prints 0
The pipe creates a subshell for the while loop. Variable assignments inside a subshell do not propagate back to the parent. Redirecting from a process substitution instead of piping keeps the loop in the current shell:
count=0
while read line; do
((count++))
done < <(cat file.txt)
echo $count # correct
The space between < and <( is required; without it bash interprets << as a here-document. This pattern comes up whenever you need to iterate over command output while accumulating state. Process substitution is documented in the bash manual but rarely explained in the context of why it exists.
Script safety: set options that matter
Interactive tricks and script safety are different concerns. For scripts, three set options matter more than most other advice:
set -euo pipefail
-e exits on any command returning non-zero. -u treats unset variables as errors rather than silently expanding to empty string; this turns rm -rf $TYPO_DIR/ into an immediate failure rather than a catastrophe. pipefail makes a pipeline return non-zero if any stage fails, not just the last one. Without it, broken_command | tee output.txt returns 0 because tee succeeded.
Pair set -euo pipefail with trap for resource cleanup:
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# work in $tmpdir
trap CMD EXIT runs the handler on any exit, including errors and signals caught by -e. It is the only reliable way to clean up temporary resources in a bash script.
These four areas, Readline configuration, history expansion, parameter expansion, and process substitution, account for the majority of shell tricks that circulate in lists like this one. The HN thread has nearly three hundred comments of people sharing their own favorites, which is worth reading for breadth, but the depth lives in the documentation. The EXPANSION and READLINE sections of man bash are long and dense, but each one is worth a dedicated hour. Most of the tricks you will ever want are already there, waiting to be found.