A shell tricks roundup hit the top of Hacker News this week with 611 points and 261 comments. The list is solid. What I want to do here is explain the layer underneath the tricks, because that’s what turns a list into a mental model you can extend yourself.
Readline is the foundation, not bash
GNU Readline is the line-editing library that bash uses for interactive input. It’s also used by Python’s REPL, GDB, MySQL’s CLI client, PostgreSQL’s psql, SQLite’s shell, and a long list of other tools. When you learn readline keybindings, you’re learning something that transfers everywhere those tools are installed.
The core bindings follow Emacs conventions:
Ctrl+A/Ctrl+E: jump to start or end of lineCtrl+W: delete word backwardsAlt+F/Alt+B: move forward or back one wordCtrl+K: kill (cut) from cursor to end of lineCtrl+Y: yank (paste) what was killedCtrl+R: reverse incremental history searchCtrl+X Ctrl+E: open the current line in$EDITOR
That last one earns its weight. When you’ve assembled a long command and want to edit it properly, Ctrl+X Ctrl+E hands it to your editor, lets you work with full cursor movement, and executes on save.
Readline is configured via ~/.inputrc, and changes there take effect in every readline-based program, not just bash. A few settings worth adding:
# ~/.inputrc
set completion-ignore-case on
set show-all-if-ambiguous on
set mark-symlinked-directories on
"\e[A": history-search-backward
"\e[B": history-search-forward
The history-search bindings are the most useful change here. With them, typing git and pressing the up arrow cycles through only commands that start with git, not your entire history in reverse. This is the behavior fish shell calls “history suggestions” and makes look like a novel feature; readline has supported it since 1994 via history-search-backward.
History configuration nobody ships correctly
The default bash history configuration on most Linux systems is too small and too lossy. HISTSIZE=1000 fills up in a busy week, history from closed terminals overwrites rather than appends, and there are no timestamps. A more durable setup:
export HISTSIZE=100000
export HISTFILESIZE=200000
export HISTCONTROL=ignoredups:erasedups
export HISTTIMEFORMAT="%F %T "
shopt -s histappend
HISTCONTROL=ignoredups:erasedups deduplicates the history, keeping only the most recent occurrence of each command. histappend makes bash append to ~/.bash_history when a session exits rather than overwrite it, so multiple open terminals don’t clobber each other’s history. HISTTIMEFORMAT timestamps every entry using strftime(3) format strings. Once timestamps are in place, history | grep "2026-03-" is a legitimate way to reconstruct what you were doing on a given date.
History expansion is the other half of this. A few forms worth internalizing:
!! # repeat the last command (sudo !!)
!$ # last argument of the previous command
!* # all arguments of the previous command
!:2 # second argument of the previous command
^foo^bar # repeat last command with foo replaced by bar
!$ and !* are the ones that compound most naturally into real workflows. After mkdir -p some/deeply/nested/path, running cd !$ puts you there. After cp file1.txt file2.txt file3.txt /destination/, running ls !$ lets you verify the destination without retyping the path.
Parameter expansion as a portable toolkit
Bash’s parameter expansion is verbose but consistent. Once you understand the pattern, it’s more reliable than reaching for sed or awk for simple string manipulation:
# Default value if var is unset or empty
${var:-default}
# Assign default and return it
${var:=default}
# Error with message if unset
${var:?"Error: var is required"}
# Strip longest matching prefix (like basename)
${path##*/}
# Strip shortest matching suffix (remove extension)
${path%.*}
# Substring
${string:offset:length}
# Uppercase / lowercase (bash 4+)
${var^^}
${var,,}
The ${var:?message} form is particularly useful at the top of scripts that depend on environment variables. Rather than a block of if [ -z "$VAR" ] checks:
: ${API_KEY:?API_KEY is required}
: ${DATABASE_URL:?DATABASE_URL is required}
: ${ENVIRONMENT:?ENVIRONMENT must be set to staging or production}
The : is the null command; it evaluates its arguments and does nothing else. Each of those lines causes the script to exit with a clear error message before any work begins if the variable is missing or empty.
For filename manipulation, ${path##*/} and ${path%.*} cover the two most common cases without spawning a subprocess. The double ## and % mean “longest match”; single # and % mean “shortest match.” That distinction matters for paths with multiple extensions: ${file%%.*} strips everything from the first dot, while ${file%.*} strips only the last extension.
Process substitution
Process substitution, <(cmd), is one of the capabilities bash and zsh have that fish does not support. It presents command output as a named pipe that looks like a file path:
diff <(ssh server1 cat /etc/hosts) <(ssh server2 cat /etc/hosts)
comm -23 <(sort list1.txt) <(sort list2.txt)
wc -l <(find . -name "*.go")
Without process substitution you need temporary files, cleanup logic, and error handling around both. With it, the pipeline is self-contained. The kernel creates a FIFO under /dev/fd/ or /proc/self/fd/ and passes the path to the command. Both sides run concurrently.
The output form, >(cmd), gets less use but enables some clean patterns:
# Write to a file and process simultaneously
tee >(grep -c ERROR > error_count.txt) > full_output.log
# Split a stream to multiple processors
some_command | tee >(gzip > output.gz) >(wc -l) > /dev/null
Directory navigation
CDPATH works like PATH but for directory changes. Setting:
export CDPATH=.:~:~/projects
means cd ralph from anywhere will find ~/projects/ralph without typing the full path. It’s the kind of configuration that pays back its setup time within the first week.
pushd and popd maintain a directory stack. pushd /some/path changes to that directory and saves the current one. popd returns you to the previous. dirs -v lists the stack with numeric indices; cd ~2 jumps to index 2. For most workflows, the simpler cd - covers the common case: it toggles between the current directory and the last one, which is enough when you’re moving between two locations repeatedly.
Script defaults
For scripts specifically, three options are worth setting at the top of every file:
set -euo pipefail
-e exits on error. -u treats unset variables as errors rather than silently expanding to empty string. -o pipefail makes a pipeline fail if any command in it fails, not just the last one. Without pipefail, grep pattern file | process_results succeeds even when grep finds nothing and exits with status 1, because pipelines report the exit code of the last command by default.
These three options don’t make bash safe for complex scripts, but they catch the class of silent failures that cause scripts to produce wrong output and exit successfully. The shellcheck linter will flag scripts missing these and explain what each option catches.
fzf changes the interaction model
Individual tricks are marginal improvements. fzf is something different: it replaces readline’s Ctrl+R history search with a fuzzy interactive interface over your entire history, and it integrates the same interface into file and directory selection.
After installation, sourcing the shell integration file:
# bash
source /usr/share/fzf/key-bindings.bash
source /usr/share/fzf/completion.bash
# or with Homebrew
source "$(brew --prefix)/opt/fzf/shell/key-bindings.bash"
gives you Ctrl+R for fuzzy history search, Ctrl+T for fuzzy file selection (inserting the path at the cursor), and Alt+C for fuzzy directory jump. The fuzzy matching finds results even when you type fragments out of order, which makes a large HISTSIZE actually useful rather than a source of noise.
fzf is written in Go, ships as a single binary, and has no runtime dependencies. The shell integration is optional; the binary itself can be composed with other tools via stdin/stdout for custom fuzzy pickers.
The fish comparison
Fish shell presents some of these features, particularly autosuggestions and syntax highlighting, as built-in defaults rather than configuration. The tradeoff is POSIX compatibility: fish’s scripting syntax diverges from bash enough that bash scripts won’t run in fish, and bash’s parameter expansion forms work differently or not at all.
For interactive use, most of what fish offers can be added to zsh via zsh-autosuggestions and zsh-syntax-highlighting. Whether that’s better than switching to fish depends on how much of your time is spent writing shell scripts versus running interactive commands. Fish’s abbr command, which defines abbreviations that expand in-place so you always see what you’re running, is one feature without a clean equivalent in bash or zsh.
The underlying point is that most of the friction in shell workflows comes from defaults that were set for constrained hardware decades ago and never updated. Large history, readline configuration, and a fuzzy finder fix the majority of it without switching shells or learning new syntax.