· 6 min read ·

The Shell Features That Reward the Learning Curve

Source: hackernews

Most shell productivity advice falls into two buckets: navigation shortcuts that make you faster at the keyboard, and scripting patterns that make your code safer. The first kind is satisfying to learn; the second kind is what keeps you out of incidents at 2am. This shell tricks roundup covers both, but I want to dig deeper into the patterns that pay compounding dividends, starting with the ones that change how you think about shell rather than just how fast you type.

set -euo pipefail Is Not a Blanket Safety Net

Every senior sysadmin will tell you to start your scripts with set -euo pipefail. That advice is correct. What they often omit is the set of specific gotchas that will bite you the first time you rely on it without understanding its limits.

-e (errexit) exits the script when any command returns a non-zero status. The catch is that it does not apply inside if conditions, while and until test expressions, commands joined with || or &&, or commands prefixed with !. This is intentional behavior, but it means you can write code that you think is protected by -e when it is not.

The local variable trap catches people repeatedly. When you write local var=$(cmd), the local builtin itself returns 0 regardless of what the command substitution returns, so -e sees a success and keeps running:

set -euo pipefail

# -e will NOT fire here: local swallows the exit code
myfunc() {
  local result=$(false_command_that_fails)
}

# Correct: declare and assign separately
myfunc() {
  local result
  result=$(false_command_that_fails)   # -e fires here
}

-o pipefail has its own nuances. grep pattern file | head -1 will fail the pipeline when grep finds no matches, even though head completed cleanly. The fix is explicit: append || true to pipelines where partial failure is acceptable.

-u (nounset) hits another edge case with empty arrays. In bash (not zsh), ${array[@]} on a genuinely empty array is treated as an unset variable. The standard workaround is ${array[@]+"${array[@]}"}, which only expands if the array is non-empty.

A solid script header that accounts for all of this:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

The IFS change reduces word splitting to newlines and tabs, excluding spaces. That eliminates a large category of bugs around filenames and string variables that contain spaces.

Parameter Expansion Replaces Subprocesses

Bash and zsh both have rich parameter expansion syntax that most developers underuse. Every time you call basename, dirname, tr, or sed just to manipulate a string, you are forking a subprocess. In a tight loop over thousands of files that cost accumulates. More importantly, parameter expansion reads clearly once you know the syntax.

The patterns for path and extension manipulation cover the most common cases:

file="path/to/archive.tar.gz"

${file##*/}    # archive.tar.gz  — equivalent to basename
${file%/*}     # path/to         — equivalent to dirname
${file##*.}    # gz              — last extension
${file%%.*}    # path/to/archive — strip all extensions
${file#*.}     # tar.gz          — everything from first dot

The mnemonic: # strips from the front, % strips from the back. Doubling the character (##, %%) switches from shortest match to longest match (greedy). For the prefix/suffix cases you care about most, you nearly always want the greedy versions.

Default values are a related feature worth committing to memory:

${var:-default}   # use default if unset OR empty
${var:=default}   # assign default to var if unset/empty, then use it
${var:?message}   # abort with message if unset or empty
${var:+other}     # use "other" only if var IS set and non-empty

${var:?message} is particularly useful at the top of scripts to enforce required environment variables before doing any work. Writing ${DATABASE_URL:?DATABASE_URL is required} at the top of a deployment script means a missing variable fails fast with a clear message rather than a confusing error somewhere downstream.

Bash 4+ adds case modification operators that eliminate most calls to tr and awk for simple transformations:

${var^^}    # uppercase all
${var,,}    # lowercase all
${var^}     # capitalize first character

Process Substitution Solves the Subshell Scope Problem

Process substitution, written as <(command), is the feature that eliminates entire classes of temporary files and subshell scope bugs. The most common pattern is feeding two command outputs into a tool that expects files:

diff <(sort file1.txt) <(sort file2.txt)
comm <(sort -u file1.txt) <(sort -u file2.txt)

The less obvious use is fixing the variable scope problem that affects every pipeline loop. When you pipe into a while read loop, the loop runs in a subshell, and any variable modifications inside it disappear when the pipe ends:

# Variables set inside this loop are lost after it ends
count=0
cat file.txt | while IFS= read -r line; do
  ((count++))
done
echo $count   # still 0

Process substitution redirects from a process without creating a subshell for the loop:

count=0
while IFS= read -r line; do
  ((count++))
done < <(cat file.txt)
echo $count   # correct

This pattern applies anywhere you want to iterate over command output and keep the results in the current shell’s scope. It requires bash or zsh; POSIX sh and dash do not support it.

For handling filenames with spaces and special characters safely, pair it with find -print0:

while IFS= read -r -d '' file; do
  process "$file"
done < <(find . -name "*.log" -print0)

The -d '' sets the read delimiter to null, matching print0, so filenames with newlines or spaces are handled without splitting.

History: From Ctrl+R to fzf

The built-in Ctrl+R reverse search works, but it becomes limiting once your history grows long. fzf replaces it with a full-screen fuzzy finder that filters interactively across your entire history. After running fzf --zsh or fzf --bash to generate the init snippet and adding it to your shell config, Ctrl+R becomes substantially more useful. Ctrl+T gives you a fuzzy file picker that inserts the selected path at the cursor, and Alt+C does fuzzy cd into any subdirectory.

The more important change is configuring history itself so there is something worth searching. In bash:

export HISTSIZE=100000
export HISTFILESIZE=200000
export HISTCONTROL=ignoredups:erasedups
shopt -s histappend
PROMPT_COMMAND="history -a; history -c; history -r; $PROMPT_COMMAND"

Without histappend and the PROMPT_COMMAND setup, bash overwrites history on exit instead of appending, and you lose history from any session that exits while another is open. The PROMPT_COMMAND line forces immediate writes after each command and re-reads the file, giving you shared history across concurrent sessions in bash.

In zsh, setopt SHARE_HISTORY handles this correctly without the workaround, and setopt HIST_IGNORE_ALL_DUPS removes duplicates from history entirely rather than just skipping adjacent ones.

For directory navigation, zoxide has largely superseded both autojump and CDPATH. It tracks directory access by frecency (frequency plus recency), is written in Rust so initialization is fast, and integrates with fzf via zi for interactive selection. After some usage you navigate to deeply nested project directories with two or three characters.

trap for Reliable Cleanup

The trap command runs code on specific signals, and the EXIT pseudosignal fires on any exit: clean, error, or interrupted. This makes reliable cleanup straightforward:

#!/usr/bin/env bash
set -euo pipefail

TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT

# The temp file is cleaned up regardless of how this script ends

For scripts that need to capture failure context, add set -E and an ERR trap. The -E flag ensures the ERR trap fires inside functions and subshells, not just at the top level:

set -eEo pipefail
trap 'echo "Failed at line $LINENO (exit $?)"' ERR

One important caveat: subshells do not inherit traps. If you fork work into a subshell with ( ), the EXIT and ERR traps from the parent do not fire inside it. Explicit subprocess scripts inherit the trap setup since they start a fresh shell, but inline subshells do not.

Empty string as the trap handler ignores a signal rather than running code: trap '' INT makes your script immune to Ctrl+C, which is occasionally useful for cleanup scripts that must complete regardless of user input.


These patterns interact well with each other. Safe error handling with set -euo pipefail composes naturally with process substitution because you get correct exit code propagation from the command feeding your loop. Parameter expansion runs in the current shell without forking, so it respects the -u nounset flag correctly. A well-configured history and fzf setup reduces the friction of finding and reusing the patterns you have already written.

The original article covers a wider range of tricks worth browsing. The selection above focuses specifically on the features where understanding the mechanism matters as much as knowing the syntax, because the gotchas are where the time is lost.

Was this interesting?