The Bash shell
The ubiquitous shell on Linux systems, warts and all. Not to be confused with /bin/sh, which is often a less-fully-featured shell.
Cheatsheet
Strings
${s%suffix} # remove suffix
${s#prefix} # remove prefix
${s/from/to} # replace first occurrence
${s//from/to} # replace all occurrences
${s:-whatever} # take value of $s or 'whatever' if unset
${x:-default} # variable or default value
Redirection
# redirect stdout to stderr
echo hello >&2
# redirect both stdout and stderr
echo hello &>log.txt
Order matters! echo hello >log.txt 2>&1 redirects both standard output and standard error to the file log.txt. Think of the redirection operator as an assignment.
Heredocs
cat << EOF
Line 1
Line 2
EOF
cat << "EOF"
No parameter expansion is done.
EOF
cat <<- EOF
The <<- operator lets you indent your code.
EOF
s=$(cat << EOF
Line 1
Line 2
EOF
)
Control flow
for p in *.csv; do
# ...
done
if [[ ... ]]; then
fi
case "$x" in
pattern)
;;
*)
;;
esac
Conditions
[[ "$s" == "prefix"* ]] # string has prefix
[[ -z "$s" ]] # string is empty
[[ -n "$s" ]] # string is not empty
[[ -e "$f" ]] # file exists
[[ -L "$f" ]] # symlink exists (regardless of whether target exists)
[[ -d "$f" ]] # file is directory
[[ -f "$f" ]] # file is regular file
[[ -h "$f" ]] # file is a symlink
[[ -x "$f" ]] # file is executable
(( x == 100 )) # no needs for '$'
Arrays
arr=()
arr+=("$x")
${#arr[@]} # array length
${arr[@]:1} # slice starting from first element
for x in "${arr[@]}"; do .. done
# pass as arguments with proper quoting
ls -l "${arr[@]}"
Associative arrays
declare -A mymap
mymap["key"]="value"
echo "${mymap[key]}"
unset mymap["key"]
# check if a key is set (even if empty)
# NOTE that it's `mymap["key"]`, not `${mymap["key"]}`
[[ -v mymap["key"] ]] && echo exists
# iterate over keys
for key in "${!mymap[@]}"; do
# ...
done
# iterate over values
for key in "${mymap[@]}"; do
# ...
done
# pre-declare and make read-only (-r)
declare -A -r mymap=(
["key1"]="value1"
["key2"]="value2"
)
Temporary files and directories
tmp="$(mktemp)"
tmpdir="$(mktemp -d)"
# with specific extension
# https://stackoverflow.com/a/59638023/3934904
tmpdir="$(mktemp -d)"
tmp="$tmpdir/test.md"
Clean-up functions
cleanup() { ... }
trap cleanup EXIT
Get script path
SCRIPT_PATH="$(realpath "$0")"
Temporarily allow unset variables
if [[ $- = *u* ]]; then
restore_unset="1"
else
restore_unset="0"
fi
set +u
# ...
if (( restore_unset == 1 )); then
set -u
fi
tar
# create a gzipped archive of a directory with relative paths
# NOTE: '.' at the end
tar -czf OUTFILE --directory=DIR .
# exclude a directory
# NOTE: exclude path is relative to DIR, not the current working directory
tar -czf OUTFILE --exclude=EXCLUDE --directory=DIR .
# extract tarball
tar -xzf TARBALL -C DIR
Tips
set -euo pipefail-e: exit when a command exits with non-zero status-u: exit on undefined variable-o pipefail: set return value ofcmd1 | cmd2 | cmd3to last command to exit with non-zero status (instead of exit status ofcmd3by default)- Though this is not always what you want; see "Be careful" below.
- Use
${PIPESTATUS[@]}to retrieve the exit status of each command.
set -xto print each command as it is run- Wrap the script in
main() { ... }and putmain "$@"at the bottom.- Forces Bash to load and parse entire file before beginning execution.
- Use
/usr/bin/env bashas the shebang for your scripts, not/bin/bash. On macOS,/bin/bashis an ancient version and some users have installed a newer version via Homebrew.
Be careful
- If you press Ctrl+Z in a compound command (
cmd1 && cmd2, aforloop, etc.), then when you runfgonly the command that was interrupted will be resumed, not the whole compound command. Try it yourself:sleep 5 && echo done- But if you enclose the compound command in parentheses, then it will resume the whole expression.
ln -sf target nameis supposed to replace the symlink atnamewith one pointing totarget, but ifnameis a directory, it instead creates a symlinkname/targetthat points attarget.-o pipefailcan cause flaky failures with commands that truncate the input (likehead); incmd | head, ifheadexits beforecmdis finished writing, the kernel will deliver aSIGPIPEsignal tocmd, killing it with a non-zero exit status.MYVAR=hello echo "$MYVAR"seems like it should work, but doesn't;MYVARis defined when the program executes, but not when the command line is parsed. You can doMYVAR=hello; echo "$MYVAR"(note the semicolon), though this leavesMYVARdefined in your shell afterechoexits.;and&&have different precedence: insleep 5; echo hello &, the ampersand applies only toecho, but insleep 5 && echo hello &it applies to bothsleepandecho.
Posts
- "Robustly parsing flags in Bash scripts" (Aug 2025)
Bibliography
Reference:
- ⭐️ Bash guide (Greg's Wiki)
- Bash reference manual (GNU)
bash(1)manpage- Bash cheatsheet (devhints.io)
Writing robust scripts:
- "Writing Shell Scripts" (Signs of Triviality, 2016)
- "How to write idempotent Bash scripts" (Fatih Arslan, 2019)
- "How can I ensure that only one instance of a script is running at a time (mutual exclusion, locking)?" (Greg's Wiki)
Minutiae:
- "Seven Surprising Bash Variables" (zwischenzugs, 2019)