Most bash cheat sheets are a wall of syntax sorted by category, which is useful only if you already know the name of the thing you want. This one is sorted by the job you are doing, and it includes the parts that actually bite: quoting, the difference between the two test syntaxes, and the three lines that stop a script destroying something.
Bash is a language people learn by accretion, copying a line at a time until something works. That produces scripts that run and nobody can modify. The material below is the subset worth genuinely knowing, in the order you tend to need it.
Table of contents
- The three lines that go at the top of every script
- Variables, quoting, and the mistake everyone makes
- Conditionals, and why there are two kinds of brackets
- Loops and reading input without breaking on spaces
- Functions, arguments, and exit codes
- Redirection, and the ordering trap
- Debugging a script that misbehaves
- How this fits the rest of the stack
- FAQ
The three lines that go at the top of every script
Start here, because it prevents the failure mode where a script keeps running after a step has already failed.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
- set -e exits on the first command that returns non-zero, instead of ploughing on.
- set -u treats an unset variable as an error, which catches the typo before it expands to an empty string.
- set -o pipefail makes a pipeline fail if any stage fails, not just the last one.
- The IFS line stops word-splitting on spaces, which is what turns a filename with a space into two arguments.
The classic disaster this prevents: a script does cd into a directory that does not exist, the cd fails, and the next line runs rm -rf against whatever directory it happened to be in. With set -e the script stops at the cd.
Use env bash rather than a hardcoded /bin/bash. On macOS the system bash is version 3.2 from 2007 and lacks associative arrays and much else, and the modern one installed by a package manager lives elsewhere on the PATH.
Variables, quoting, and the mistake everyone makes
Quoting is the single largest source of bash bugs, and the rule is short: quote every expansion unless you have a specific reason not to.
name="World" # no spaces around =
greeting="Hello $name"
echo "$greeting" # correct
echo $greeting # word-splits and glob-expands
"${name}s" # braces when the name runs into text
"${count:-0}" # default if unset or empty
"${count:=0}" # default and assign
"${required:?not set}" # exit with a message if unset
"${#name}" # length
"${path%.txt}" # strip shortest match from the end
"${path##*/}" # strip longest match from the front, ie basename
"${text/old/new}" # replace first
"${text//old/new}" # replace all
The last group replaces most calls to basename, dirname, sed, and cut, and runs without spawning a process.
Command substitution uses $(…) and not backticks. Backticks nest badly and are harder to read, and there is no situation where they are the better choice.
files=$(ls) # fine for display, wrong for iteration
count=$(wc -l < file.txt) # redirect avoids the filename in the output
Conditionals, and why there are two kinds of brackets
This confuses everyone once. Single brackets are a command inherited from the original shell. Double brackets are bash syntax and are better in essentially every way.
# Prefer [[ ]] in bash: no word-splitting, supports && and ||, and =~
if [[ -f "$file" && -r "$file" ]]; then
echo "exists and is readable"
fi
if [[ "$answer" == y* ]]; then # glob matching, unquoted right side
echo "yes"
fi
if [[ "$version" =~ ^v[0-9]+\.[0-9]+$ ]]; then
echo "looks like a version, major ${BASH_REMATCH[1]}"
fi
(( count > 10 )) && echo "arithmetic, no dollar signs needed"
The file and string tests worth memorising:
- -f is a regular file, -d is a directory, -e is either, -L is a symlink.
- -r, -w, -x test readable, writable, executable for the current user.
- -s is exists and is non-empty, which is usually what you meant by -f.
- -z is empty string, -n is non-empty string.
- == and != compare strings, while -eq and -ne compare integers. Mixing them is a common bug that fails quietly.
Inside single brackets an unquoted empty variable makes the test collapse to invalid syntax. Inside double brackets it does not. That alone is reason enough to default to double.
Loops and reading input without breaking on spaces
for i in {1..10}; do echo "$i"; done # brace range
for i in {0..20..5}; do echo "$i"; done # with a step
for (( i = 0; i < 10; i++ )); do :; done # C style, when you need arithmetic
# Iterate files safely. Never parse ls.
for f in *.txt; do
[[ -e "$f" ]] || continue # handles the no-matches case
echo "processing $f"
done
# Read a file line by line, preserving whitespace.
while IFS= read -r line; do
echo "$line"
done < input.txt
That while loop is worth committing to memory exactly as written. Dropping IFS= trims leading and trailing whitespace. Dropping -r makes read interpret backslashes. Both are silent corruption rather than errors.
The safest pattern for file lists produced by find is a null-delimited read, which survives filenames containing newlines.
while IFS= read -r -d '' f; do
echo "$f"
done < <(find . -name '*.log' -print0)
Functions, arguments, and exit codes
greet() {
local name="${1:?name required}" # local, or it leaks into global scope
local greeting="${2:-Hello}"
printf '%s, %s\n' "$greeting" "$name"
}
greet "World"
greet "World" "Goodbye"
# Return a status, print a value. Do not confuse the two.
is_installed() {
command -v "$1" >/dev/null 2>&1
}
if is_installed docker; then echo "present"; fi
The important distinction: return sets an exit status between 0 and 255, it does not return a value. To return data, print it and capture with command substitution. People coming from other languages write return “$result” and get a confusing error or a truncated number.
Positional parameters: $1 through $9 then ${10}, $# is the count, $@ is all of them, $0 is the script name. Always use ”$@” quoted rather than $* when passing arguments through, because $* joins everything into one string.
- $? is the exit status of the last command.
- $$ is the current process ID, handy for temporary file names.
- $! is the process ID of the last background job.
- ${BASH_SOURCE[0]} is the script path, more reliable than $0 when sourced.
Redirection, and the ordering trap
cmd > out.txt # stdout to file, truncating
cmd >> out.txt # append
cmd 2> err.txt # stderr only
cmd &> all.txt # both, bash shorthand
cmd > out.txt 2>&1 # both, portable form
cmd > /dev/null 2>&1 # discard everything
cmd1 | cmd2 # stdout of one into the other
cmd1 |& cmd2 # stdout and stderr into the other
# Heredoc, and the quoted form that stops expansion.
cat <<EOF
User is $USER
EOF
cat <<'EOF'
This $USER stays literal.
EOF
The ordering trap: 2>&1 >file does not do what it reads like. Redirections apply left to right, so that sends stderr to wherever stdout currently points, which is the terminal, and only then moves stdout to the file. The correct order is >file 2>&1.
The quoted heredoc delimiter is the one people forget. Unquoted, every dollar sign in the block gets expanded, which mangles any script or config you were trying to write out verbatim.
Debugging a script that misbehaves
bash -n script.sh # syntax check without executing
bash -x script.sh # trace every command as it runs
set -x # trace from here
set +x # stop tracing
# A more readable trace: file, line, and function on each line.
export PS4='+ ${BASH_SOURCE[0]}:${LINENO}:${FUNCNAME[0]:-main}: '
# Clean up temporary files however the script exits.
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
That PS4 line turns bash -x from a stream of anonymous commands into something you can actually locate in a file. It is the single highest-value debugging change available.
And run shellcheck. It is a static analyser for shell scripts, it catches the quoting bugs described above before they reach production, and its warnings are almost always correct. Any script that is going to be run by someone other than its author should pass it.
How this fits the rest of the stack
Scripts like these usually end up as the deploy step, the backup job, or the nightly task that keeps a service healthy. That is fine until the script is the only record of how a deploy works, and the only way to know whether last night’s run succeeded is to read a log file on a box. Deploying from a repository instead gives each change a build log, a live route, and a previous version to roll back to. The RunxBuild hosting calculator shows what the service, database, and storage cost as separate line items.
Useful related references:
- Bash Append to File:
>>,tee -a, and Heredoc - How to Run .sh File in Linux: bash, chmod, and shebang
- Looping in Bash: for, while, until, and the Loop That Eats Your Filenames
- Services on RunxBuild
FAQ
What should every bash script start with?
A shebang of #!/usr/bin/env bash, then set -euo pipefail so the script stops at the first failure, treats unset variables as errors, and fails on any stage of a pipeline rather than only the last.
What is the difference between single and double brackets in bash?
Single brackets are an inherited command with awkward quoting rules. Double brackets are bash syntax with no word-splitting, support for && and ||, glob matching, and regex via =~. Prefer double brackets in bash scripts.
How do I loop over lines in a file in bash?
Use while IFS= read -r line; do … done < file. Omitting IFS= trims whitespace and omitting -r mangles backslashes, and both corrupt data silently rather than raising an error.
Why does 2>&1 not capture my errors?
Redirections are applied left to right. Writing 2>&1 >file points stderr at the terminal before stdout moves to the file. The correct order is >file 2>&1.
How do I return a value from a bash function?
You cannot. return sets an exit status from 0 to 255. To return data, print it inside the function and capture the output with command substitution at the call site.