Scripting and data cheat sheetBash logo

Bash cheat sheet

Bash syntax on one page: variables, quoting, parameter expansion, [[ ]] tests, loops, arrays, redirection and set -euo pipefail, checked against Bash 5.3.

Last updated

Bash is the command language that runs in most Linux and macOS terminals, and the usual way to automate a job you would otherwise type by hand. The reference below is grouped by what you are trying to do, and the filter box searches all of it at once. Type array to see everything about arrays, or macos to see what the Mac's built-in Bash cannot do.

Every snippet is checked against Bash 5.3, the current release, and was run on 5.3.9. Anything that needs more than Bash 3.2, the version still at /bin/bash on every Mac, says so in the notes column. This page is the language itself. Tools such as grep, find and sed appear only as things to pipe into; the Linux commands cheat sheet covers them.

Shebang and running scripts

A Bash script is a text file of commands. The first line, the shebang, says which program runs it.

TaskSyntaxNotes
Shebang, portable#!/usr/bin/env bashFirst line of the file. Uses the first bash on your PATH, so a Mac picks up a newer Homebrew bash
Shebang, fixed path#!/bin/bashAlways the system bash. On macOS that is 3.2, a release from 2006
Make a script executablechmod +x deploy.sh
Run it./deploy.shThe ./ is needed because the current folder is not on the PATH
Run it with bash explicitlybash deploy.shIgnores the shebang and does not need the execute bit
Run it in the current shellsource ./env.shOr . ./env.sh. Variables it sets and any cd it does stay afterwards. This is how a Python venv's activate works
Check the syntax without runningbash -n deploy.sh
Print each command as it runsbash -x deploy.shEach line is shown with a + in front. set -x and set +x turn it on and off inside a script
Run a one-line scriptbash -c 'echo "$0 got $1"' myname helloPrints myname got hello. The first word after the string becomes $0
Which version is thisbash --versionInside a script, echo "$BASH_VERSION" gives the version of the bash running it
Comment# everything after the hash is ignored
Several commands on one linecd build; makeRuns both, even if the cd fails. Use && to stop on failure
Split a long line./configure --prefix=/opt \The backslash must be the very last character. A trailing space after it breaks the line
Run in the background./long-task.sh &wait on its own line blocks until every background job has finished
Path of the running script"${BASH_SOURCE[0]}"Works when the script is sourced too, unlike $0
Folder the script lives indir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)An absolute path, whichever folder you ran the script from

A script run as ./deploy.sh or bash deploy.sh gets a new shell, so it cannot change your current folder or set variables in your terminal. Only source can do that.

Script arguments and input

TaskSyntaxNotes
First argument"$1"Then $2, $3 and so on. $0 is the script name as it was run
Tenth argument"${10}"$10 is $1 followed by a 0
All arguments, each kept whole"$@"Always in double quotes. An argument with a space stays one argument
All arguments as one string"$*"Joined with a space, or the first character of IFS
Number of arguments$#
Drop the first argumentshift$2 becomes $1. shift 2 drops two
Default when missingname="${1:-world}"
Stop when missingfile="${1:?usage: backup.sh FILE}"Prints the message to stderr and exits with status 1
Parse -v and -o value flagswhile getopts "vo:" opt; doA colon after a letter means it takes a value, found in $OPTARG. The script template below has the whole loop
Ask for a line of inputread -rp "Name: " name-r keeps backslashes as typed, -p shows a prompt
Ask for a passwordread -rsp "Password: " pass-s hides what is typed. Print a newline after it
Give up waitingread -rt 5 -p "Continue? " answerExit status is above 128 after 5 seconds with no input. Bash 3.2 returns 1
Split a line into fieldsIFS=, read -r name age city <<< "Ada,36,London"The last variable takes whatever is left over
Read a file line by linewhile IFS= read -r line; do echo "$line"; done < notes.txtIFS= keeps leading spaces, -r keeps backslashes

Variables and quoting

TaskSyntaxNotes
Set a variablename="Ada"No spaces around the =. name = Ada runs a command called name
Use itecho "$name"Quote it, almost always. See the gotchas
Separate it from textecho "${name}_backup"$name_backup would look up a variable called name_backup
Append to itPATH+=":$HOME/bin"
Save a command's outputtoday=$(date +%F)Trailing newlines are removed. Backquotes do the same job but do not nest
Save output without a subshelltoday=${ date +%F; }Bash 5.3 only. The command runs in the current shell, so variables it sets survive
Single quotes: nothing expandsecho 'Cost: $5'
Double quotes: variables expandecho "Hello, $name"So do $( ), $(( )) and backquotes. \ escapes $, backquote, " and \
Escape one characterecho "Price: \$5"
Tabs and newlines in a stringecho $'col1\tcol2\nnext line'ANSI-C quoting: \n, \t and \' work inside $' '
A single quote inside single quotesecho 'It'\''s here'Close, add an escaped quote, reopen. Or $'It\'s here'
Print safelyprintf '%s\n' "$name"echo can swallow a value such as -n or -e as an option. printf never does
Print formattedprintf '%-10s %6.2f\n' "$item" "$price"Left-aligned in 10 characters, then a number to 2 decimal places
Format into a variableprintf -v padded '%05d' 42padded is 00042. No subshell
Pass to programs you runexport API_URL="https://example.com"Without export, only this shell sees it
Set for one command onlyLC_ALL=C sort names.txtOnly that command sees the value
Make it read-onlyreadonly MAX_RETRIES=5
Remove itunset name
This script's process ID$$
Last background job's process ID$!
Random number, 0 to 32767$RANDOM
Seconds since the script started$SECONDSHandy for timing. Assign 0 to it to reset
Current Unix time$EPOCHSECONDSBash 5.0 and later, so not the macOS default. date +%s works everywhere

Every variable is a string unless you say otherwise. Bash has no floats at all: $((10 / 3)) is 3.

Parameter expansion

Bash can trim, replace and test a variable's value without calling another program. The examples use path=/usr/local/bin/bash and file=report.tar.gz.

TaskSyntaxResult or notes
Default if unset or empty${name:-guest}guest when name is unset or empty. name is not changed
Default only if unset${name-guest}An empty name stays empty
Set a default${name:=guest}Also assigns it. On its own line, write : "${name:=guest}"
Fail if unset or empty${name:?name is required}Prints the message and stops a script
Something else if set${name:+--name=$name}Empty when name is unset or empty. Useful for optional flags
Length${#name}
Substring${name:0:3}3 characters from position 0
Last 3 characters${name: -3}The space matters. Without it, :- is the default operator
File name from a path${path##*/}bash. Removes the longest match of */ from the front
Folder from a path${path%/*}/usr/local/bin. Removes the shortest match of /* from the end
Remove the extension${file%.*}report.tar. Shortest match from the end
Remove every extension${file%%.*}report. Longest match from the end
Just the extension${file##*.}gz
Remove a prefix${branch#feature/}feature/login becomes login. Nothing happens if it does not match
Replace the first match${text/cat/dog}
Replace every match${text//cat/dog}
Replace at the start only${text/#cat/dog}And ${text/%cat/dog} for the end only
Delete every space${text// /}
Upper case${name^^}Bash 4.0 and later, so not the macOS default. tr a-z A-Z works everywhere
Lower case${name,,}Bash 4.0 and later, so not the macOS default
Capitalise the first letter${name^}Bash 4.0 and later, so not the macOS default
Value of the variable named in another${!var_name}If var_name=name, this gives the value of name
Quote a value for reuse as input${name@Q}Bash 4.4 and later. it's becomes 'it'\''s'

# trims from the front and % from the end, because # sits left of $ on a US keyboard and % sits right of it. Doubling either one makes the match as long as possible.

Arithmetic

TaskSyntaxResult or notes
Calculateecho $((3 + 4 * 2))11. Normal precedence, and brackets work
Use variablestotal=$((price * qty))No $ needed on names inside $(( ))
Divideecho $((7 / 2))3. Whole numbers only, rounded towards zero
Remainderecho $((7 % 2))1
Powerecho $((2 ** 10))1024
Add onecount=$((count + 1))Safe under set -e, unlike ((count++)). See the gotchas
Add to a variable((total += price))
Compare numbersif (( count > 10 )); thenTrue when the result is not zero
Decimalsawk 'BEGIN { printf "%.2f\n", 10 / 3 }'3.33. Bash has no floats; awk and bc do
Random number from 1 to 6echo $((RANDOM % 6 + 1))
Hex to decimalecho $((16#ff))255. printf '%x\n' 255 goes the other way
A number with a leading zeroecho $((10#08))8. $((08)) is an error: a leading 0 means octal

Conditionals and test operators

if runs a command and checks whether it succeeded. [[ ]] is the Bash command for tests; [ ] is the older POSIX one. The comparison table further down shows when each is right.

TaskSyntaxNotes
Ifif [[ -f config.yml ]]; then echo found; fiThe spaces inside the brackets are required
If, else if, elseif [[ $n -gt 10 ]]; then echo big; elif [[ $n -gt 5 ]]; then echo medium; else echo small; fi
Test a command directlyif grep -q error app.log; then echo found; fiNo brackets. if only ever checks an exit status
Strings are equal[[ $a == "$b" ]]Quote the right side. Unquoted, it is a pattern: x* matches xyz
Strings differ[[ $a != "$b" ]]
Matches a pattern[[ $file == *.txt ]]The same * ? [ ] as file globs, but matched against the string
Matches a regexre='^([^@]+)@(.+)$'; [[ $email =~ $re ]]Keep the regex in a variable and leave it unquoted. Groups land in BASH_REMATCH[1], BASH_REMATCH[2]
Empty string[[ -z $name ]]
Not empty[[ -n $name ]]
Variable is set[[ -v name ]]True even when it is set to empty. Bash 4.2 and later, so not the macOS default
Numbers: equal, not equal[[ $a -eq $b ]]-ne for not equal
Numbers: less, greater[[ $a -lt $b ]]Also -le, -gt and -ge. Or (( a < b ))
Sorts before, as text[[ $a < $b ]]Text order, so 10 sorts before 9. Use -lt for numbers
File exists[[ -e $path ]]Anything: file, folder, device
Regular file[[ -f $path ]]
Folder[[ -d $path ]]
Not empty file[[ -s $path ]]
Readable, writable, executable[[ -r $path ]]-w and -x for the other two
Symbolic link[[ -L $path ]]
Newer than another file[[ $src -nt $out ]]-ot for older
Not[[ ! -d $dir ]]
And, or[[ -f $f && -r $f ]]|| for or. Inside [ ], use two tests: [ -f "$f" ] && [ -r "$f" ]
Only if the last command worked[[ -d build ]] || mkdir buildThe right side runs only when the left one fails
Several choicescase "$1" in start) run ;; stop|halt) halt ;; *) usage ;; esacEach pattern ends with ), each branch with ;;. * catches the rest
Carry on into the next case;&Instead of ;;. Bash 4.0 and later, so not the macOS default
POSIX sh test[ "$a" = "$b" ]Quote every variable and use one =. For scripts that must run under sh

Loops

TaskSyntaxNotes
Over a listfor fruit in apple banana cherry; do echo "$fruit"; done
Over filesfor f in *.jpg; do echo "$f"; doneSafe with spaces in names. With no match it runs once with the literal *.jpg, unless shopt -s nullglob
Over files in every subfoldershopt -s globstar; for f in **/*.md; doBash 4.0 and later, so not the macOS default
Over a rangefor i in {1..5}; do echo "$i"; doneThe numbers must be literal: {1..$n} does not expand
Over a range, counting in stepsfor i in {0..100..10}; doBash 4.0 and later, so not the macOS default
Counting up to a variablefor ((i = 1; i <= n; i++)); do echo "$i"; doneC-style. Works with variables
Over the script's argumentsfor arg in "$@"; dofor arg; do means the same
Over an arrayfor item in "${items[@]}"; do
While a test is truewhile [[ $n -gt 0 ]]; do n=$((n - 1)); done
Until a command worksuntil curl -sf http://localhost:8080/health; do sleep 1; doneWaits for a server to come up
Foreverwhile true; do ./poll.sh; sleep 60; doneCtrl+C stops it
Lines of a command's outputwhile IFS= read -r f; do echo "$f"; done < <(git ls-files)Variables set in the loop survive, unlike cmd | while read
Leave the loopbreakbreak 2 leaves two nested loops
Skip to the next roundcontinuecontinue 2 moves on the outer loop
Loop on one linefor f in *.log; do gzip "$f"; doneThe semicolon before done is required on one line

Functions

TaskSyntaxNotes
Define onegreet() { echo "Hello, $1"; }The space after { and the ; before } are required on one line
Call itgreet AdaNo brackets. Arguments are separated by spaces, like any command
Bash keyword formfunction greet { echo "Hello, $1"; }Bash only. The first form also works in sh
Arguments inside"$1" "$@" "$#"Inside a function these are the function's own arguments, not the script's
Local variablelocal count=0Without local, every variable in a function is global
Return success or failurereturn 10 to 255 only. It is an exit status, not a return value
Return a valueresult=$(get_name)The function echoes it and the caller captures it. Runs in a subshell
Write to a caller's variableset_result() { local -n out=$1; out="done"; }Call it as set_result answer. A nameref: Bash 4.3 and later, so not the macOS default
Pass an arrayprocess "${files[@]}"Arrives as separate arguments, collected again with "$@"
Status of a functionif check_config; thenIt is the status of the last command it ran, unless it returns one
List defined functionsdeclare -F
Show a function's codedeclare -f greettype greet tells you whether a name is a function, alias, builtin or file

Arrays

Bash has indexed arrays, numbered from 0, and associative arrays keyed by strings. Neither nests: no arrays of arrays.

TaskSyntaxNotes
Createfruits=(apple banana cherry)Spaces between items, no commas
One item"${fruits[0]}"The braces are required. $fruits[0] is apple[0]
Last item"${fruits[-1]}"Bash 4.3 and later. On 3.2: ${fruits[${#fruits[@]}-1]}
Every item"${fruits[@]}"In double quotes, so an item with a space stays one item
How many${#fruits[@]}
Add to the endfruits+=(date)
Change onefruits[1]=blueberry
Remove oneunset 'fruits[1]'Leaves a gap: the other indexes do not move
A slice"${fruits[@]:1:2}"2 items from index 1
The indexes"${!fruits[@]}"
Loop with the indexfor i in "${!fruits[@]}"; do echo "$i ${fruits[$i]}"; done
Lines of a file into an arraymapfile -t lines < notes.txt-t drops the newlines. Also called readarray. Bash 4.0 and later, so not the macOS default
Split a string on commasIFS=, read -ra parts <<< "a,b,c"
Join with commas(IFS=,; echo "${fruits[*]}")The brackets run it in a subshell, so the change to IFS does not leak
Sorted copymapfile -t sorted < <(printf '%s\n' "${fruits[@]}" | sort)Bash 4.0 and later, so not the macOS default
Print it for debuggingdeclare -p fruitsShows every index and value, quoted
Create an associative arraydeclare -A ages=([ada]=36 [alan]=41)declare -A is required. Bash 4.0 and later, so not the macOS default
Get and set by keyages[grace]=85; echo "${ages[ada]}"
Every key"${!ages[@]}"In no particular order
Key exists[[ -v ages[ada] ]]Bash 4.3 and later. [[ ${ages[ada]+set} ]] also works in 4.0
Remove a keyunset 'ages[ada]'Quoted, so the brackets are not read as a file glob

Exit codes

Every command finishes with a status from 0 to 255. 0 means success and anything else means failure, which is the opposite of true and false in most languages.

TaskSyntaxNotes
Status of the last commandecho $?Read it straight away: the next command replaces it
Keep it for latermake; status=$?
End the scriptexit 1exit on its own uses the status of the last command
Run the next one only if this workedmake && make install
Run the next one only if this failedcd /srv/app || exit 1The usual guard after a cd
Invert a statusif ! grep -q TODO notes.txt; thenTrue when grep finds nothing
Status of every part of a pipefalse | true; echo "${PIPESTATUS[@]}"1 0. $? alone only shows the last command
Ignore one failure under set -erm -f old.log || true
Status of a background jobwait "$pid"Returns that job's status. Save the PID with pid=$! after starting it
Success0
General failure1
Wrong usage2What builtins and most tools return for a bad option
Found but not executable126Usually a missing chmod +x
Command not found127A typo, or it is not on the PATH
Killed by a signal128 + N130 after Ctrl+C (signal 2), 143 after kill (signal 15)

Redirection and pipes

Every command has three streams: standard input (0), standard output (1) and standard error (2). Redirection points them at files; a pipe connects one command's output to the next one's input.

TaskSyntaxNotes
Output to a file, replacing itls > files.txt
Output to a file, adding to the enddate >> run.log
Errors to a file./build.sh 2> errors.txt
Output and errors to one file./build.sh > build.log 2>&1Order matters. 2>&1 > build.log still sends errors to the screen
Output and errors, short form./build.sh &> build.logBash only. &>> appends, from Bash 4.0
Throw the output away./build.sh > /dev/null
Throw everything away./build.sh > /dev/null 2>&1
Print an error messageecho "error: config not found" >&2Errors go to stderr, so they still show when output is redirected
Input from a filesort < names.txt
Pipe output into a commandhistory | grep ssh
Pipe errors too./build.sh 2>&1 | less|& is the Bash 4.0 short form
See the output and save it./build.sh | tee build.logtee -a appends
Write to a file you need sudo forecho "127.0.0.1 app.local" | sudo tee -a /etc/hosts > /dev/nullsudo echo ... >> /etc/hosts fails: the redirect runs as you, not as root
Treat output as a filediff <(sort a.txt) <(sort b.txt)Process substitution. Bash only, not sh
Refuse to overwrite filesset -o noclobberThen > fails on an existing file. >| overwrites anyway
Send all later output to a log tooexec > >(tee -a script.log) 2>&1Near the top of a script. Everything after it is shown and logged
Open a file on descriptor 3exec 3> debug.logWrite with echo hi >&3, close with exec 3>&-

Here documents and here strings

A here document feeds several lines to a command's input, up to a line that holds only the end word. EOF is the convention, not a keyword. The example further down shows each form in full.

TaskSyntaxNotes
Several lines, variables expandedcat <<EOF$name and $(commands) are replaced. Ends at a line that is exactly EOF
Several lines, nothing expandedcat <<'EOF'Quoting the word turns expansion off. Use it for code and templates
Indented with tabscat <<-EOFStrips leading tabs, not spaces, so the body can be indented inside an if
Into a filecat > config.ini <<EOF
Into a file that needs sudosudo tee /etc/app.conf > /dev/null <<'EOF'
Into a variabletext=$(cat <<'EOF'Close with EOF, then ) on the next line. read -d '' also works but returns 1, which set -e treats as a failure
Run a script on another machinessh web1 bash <<'EOF'Quoted, so variables expand there, not here
One string as input (here string)grep -c error <<< "$log"Adds a newline to the end
Split a string into variablesread -r first rest <<< "$line"

set -euo pipefail and debugging

Bash carries on after a failed command by default. These options make a script stop instead. The section after the reference explains where they fall short.

TaskSyntaxNotes
Strict modeset -euo pipefailThe usual second line of a script, straight after the shebang
Stop when a command failsset -eNot inside if, while, && or || tests, or a function called from one
Stop on an unset variableset -uUse ${1:-} to read an argument that may be missing
A pipe fails if any part failsset -o pipefailWithout it, a pipe's status is the last command's alone
Stop inside command substitution tooshopt -s inherit_errexitWithout it, set -e is off inside $( ). Bash 4.4 and later
Clean up however the script endstrap 'rm -rf "$tmp"' EXITRuns on success, on failure and on Ctrl+C
Report the failing linetrap 'echo "failed at line $LINENO" >&2' ERRAdd set -E so it also fires inside functions
Catch Ctrl+Ctrap 'echo interrupted; exit 130' INT
Trace from here onset -xset +x stops it
Show more in the tracePS4='+ ${BASH_SOURCE}:${LINENO}: 'Adds the file and line number to every set -x line
Check a script for mistakesshellcheck deploy.shA separate tool, and the best one there is for Bash. Install it from your package manager

A script template worth copying

Most scripts want the same bones: strict mode, a usage message, flags, a check on the arguments and a temporary folder that is always cleaned up. This one archives a folder into a dated .tar.gz.

#!/usr/bin/env bash
# backup.sh: archive a folder into a dated .tar.gz
set -euo pipefail
 
usage() {
  echo "Usage: ${0##*/} [-v] [-o OUT_DIR] SOURCE_DIR" >&2
  exit 2
}
 
verbose=0
out_dir="."
while getopts "vo:h" opt; do
  case $opt in
    v) verbose=1 ;;
    o) out_dir=$OPTARG ;;
    *) usage ;;
  esac
done
shift $((OPTIND - 1))
(( $# == 1 )) || usage
 
source_dir=${1%/}
[[ -d $source_dir ]] || { echo "error: $source_dir is not a folder" >&2; exit 1; }
 
log() {
  if (( verbose )); then echo "$*" >&2; fi
}
 
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
 
name=${source_dir##*/}
archive="$name-$(date +%Y-%m-%d).tar.gz"
log "Archiving $source_dir"
 
tar -czf "$tmp/$archive" -C "$(dirname "$source_dir")" "$name"
mv "$tmp/$archive" "$out_dir/"
echo "$out_dir/$archive"
$ ./backup.sh -v -o /tmp photos
Archiving photos
/tmp/photos-2026-09-26.tar.gz
 
$ ./backup.sh
Usage: backup.sh [-v] [-o OUT_DIR] SOURCE_DIR

getopts handles -v -o /tmp, -vo /tmp and -o/tmp alike, and shift $((OPTIND - 1)) drops the flags so $1 is the first real argument. Messages go to stderr with >&2, so the only thing on stdout is the path of the archive, which lets another script capture it with $(./backup.sh photos). The trap removes the temporary folder whether the script finishes, fails partway or is stopped with Ctrl+C. Nothing in it needs more than Bash 3.2, so it runs unchanged on a Mac.

Quoting in one table

Most Bash bugs are quoting bugs. With name="Ada Lovelace":

You writeBash seesWhy
echo $nameTwo arguments, Ada and LovelaceUnquoted, the value is split on spaces and globbed
echo "$name"One argument, Ada LovelaceDouble quotes expand the variable and keep it whole
echo '$name'The text $nameSingle quotes keep everything literally
echo "\$name"The text $nameA backslash escapes one character inside double quotes
echo $'a\tb'a, a tab, then b$'...' understands escapes such as \t and \n
echo "Cost: $5"Cost: $5 is the fifth argument, which is empty
touch $name.txtTwo files, Ada and Lovelace.txtThe same splitting, with a surprising result

The rule that covers it: put double quotes around every $variable and every $(command), unless you have a reason not to. "${files[@]}" is the one form that keeps every array item whole.

Single vs double brackets

[ ][[ ]](( ))
What it isThe POSIX test commandA Bash keywordBash arithmetic
Works in shYesNoNo
Quote variablesAlwaysNot needed, except the right side of ==No $ needed
Strings equal=== or =No
Pattern matchNo[[ $f == *.txt ]]No
Regular expressionNo[[ $s =~ $re ]]No
Numbers-lt, -gt, -eq-lt, -gt, -eq<, >, ==
And, or[ a ] && [ b ]&&, || inside&&, || inside
Use it forScripts that must run in shStrings and files in BashNumbers in Bash

Here documents in full

The heredoc rows above show only the first line. Here is each form whole:

name="Ada"
 
# Variables and $( ) expand
cat <<EOF
Hello, $name. It is $(date +%A).
EOF
 
# Quoted word: nothing expands, useful for writing out code
cat > greet.sh <<'EOF'
#!/usr/bin/env bash
echo "Hello, $1"
EOF
 
# <<- strips leading tabs, so the body can follow the indentation
if true; then
	cat <<-EOF
	indented with tabs in the file, printed with none
	EOF
fi
 
# Into a variable
config=$(cat <<EOF
user=$name
debug=false
EOF
)

The end word has to be alone on its line with nothing after it, not even a space. <<- strips tab characters only; an editor that turns tabs into spaces breaks it, which is why many people avoid it and keep heredoc bodies unindented.

Renaming and processing files safely

Two loops that come up constantly, written so a file name with spaces, or starting with a dash, cannot break them.

# Rename every .jpeg to .jpg
for f in *.jpeg; do
  [[ -e $f ]] || continue          # no .jpeg files: skip the literal *.jpeg
  mv -n -- "$f" "${f%.jpeg}.jpg"   # -n: never overwrite, --: end of options
done
 
# Read a CSV, skipping the header, and handle a last line with no newline
while IFS=, read -r name email plan || [[ -n $name ]]; do
  [[ $name == name ]] && continue
  printf '%-10s %s\n' "$name" "$plan"
done < users.csv

A plain comma split like this does not handle quoted fields that contain commas. For real CSV, use a tool that parses it properly, such as Python's csv module from the Python cheat sheet, or load it into SQLite and query it with the SQL cheat sheet.

Pipes are where Bash does most of its real work, one small tool feeding the next. This counts the ten most common lines in a log:

sort access.log | uniq -c | sort -rn | head -n 10

GNU sort, the one on Linux, uses a merge sort, and spills sorted runs to temporary files and merges them when the input does not fit in memory. That is how it sorts files far larger than RAM.

The Linux commands cheat sheet has more pipelines like this one, and the options for each tool in them.

Where set -euo pipefail falls short

Strict mode is worth having, but it has holes that catch everyone once. Each row here was run under set -euo pipefail on Bash 5.3.

CodeWhat happensFix
local out=$(false)Carries on: local succeeded, and that status hides the failurelocal out; out=$(false)
out=$(step1; step2) where step1 failsstep2 still runs inside the $( )shopt -s inherit_errexit (Bash 4.4+)
if deploy; thenset -e is off for everything inside deployCheck for errors explicitly inside the function
count=0; ((count++))Stops the script: the expression is 0, so the status is 1count=$((count + 1))
n=$(grep foo log | wc -l) with no matchStops the script: grep returned 1 and pipefail passed it onn=$(grep -c foo log || true)
read -r -d '' text <<'EOF'Stops the script: read returns 1 at end of inputtext=$(cat <<'EOF' ...)
echo "$1" with no argumentsStops with "unbound variable""${1:-}"
"${files[@]}" on an empty arrayFine on Bash 4.4+, "unbound variable" on 3.2${files[@]+"${files[@]}"} on old Bash
trap '...' ERR and a failing functionThe trap does not fire inside the functionset -E as well

Bash on macOS

macOS has used zsh as the default login shell since Catalina (10.15). /bin/bash is still there, frozen at 3.2.57, a patch release of the Bash 3.2 that came out in 2006, because newer Bash is licensed under GPLv3. Everything on this page without a version note works on it. These do not:

FeatureNeedsOn macOS's /bin/bash
declare -A associative arraysBash 4.0invalid option
mapfile and readarrayBash 4.0command not found
${name^^} and ${name,,}Bash 4.0bad substitution
** with shopt -s globstarBash 4.0invalid shell option name
{0..100..10}Bash 4.0Printed literally, unexpanded
&>> and |&Bash 4.0Syntax error
;& and ;;& in caseBash 4.0Syntax error
[[ -v name ]]Bash 4.2Syntax error
${fruits[-1]}Bash 4.3bad array subscript
local -n namerefsBash 4.3invalid option
${name@Q}Bash 4.4bad substitution
Empty "${arr[@]}" under set -uBash 4.4unbound variable
$EPOCHSECONDSBash 5.0Empty
${ command; }Bash 5.3bad substitution

Two ways to live with it. Install a current Bash with brew install bash and start scripts with #!/usr/bin/env bash, which finds it on your PATH. Or, for a script other people will run on their Macs, stick to what 3.2 supports. zsh runs most simple Bash one-liners unchanged but differs in the details, such as arrays starting at 1, so run Bash scripts with Bash rather than pasting them into zsh.

Gotchas

The mistakes almost everyone makes in their first month of Bash.

Looks rightWhat actually happensDo this instead
name = "Ada"Runs a command called name with two argumentsname="Ada", no spaces
rm $fileA file called my notes.txt becomes two argumentsrm -- "$file"
for f in $(ls *.txt)Breaks every file name with a space into piecesfor f in *.txt
cat file | while read line; do count=...count is back to its old value after the loop: the pipe ran it in a subshellwhile ...; done < file
if [ $name == "Ada" ]too many arguments when name has a space, unary operator expected when it is empty[[ $name == "Ada" ]]
[[ $a > $b ]] on numbersCompares text, so 10 > 9 is false(( a > b )) or -gt
for i in {1..$n}One loop with i set to {1..5}for ((i = 1; i <= n; i++))
$((08 + 1))value too great for base: a leading 0 means octal$((10#08 + 1))
echo "Cost: $5"Cost: : $5 is an argument'Cost: $5' or "Cost: \$5"
cd build; rm -rf *If cd fails, deletes everything in the current foldercd build || exit 1, or set -e
rm -rf "$build_dir"/* with build_dir unsetRuns rm -rf /*"${build_dir:?}"/*, or set -u
cmd && echo ok || echo failedPrints failed if echo ok itself fails: it is not an if/elseif cmd; then ...; else ...; fi
sudo echo line >> /etc/hostsPermission denied: the redirect runs as youecho line | sudo tee -a /etc/hosts
#!/bin/sh on a script using [[ ]]Fine on a Mac, [[: not found on Ubuntu, where sh is dash#!/usr/bin/env bash
A script saved on Windowsbad interpreter: /bin/bash^MSave with LF line endings
alias in a scriptIgnored: scripts do not expand aliasesA function

For containers, the Docker cheat sheet explains why CMD in shell form makes the shell PID 1. The usual Bash entrypoint script ends in exec "$@", which replaces the shell with the real command so it receives signals directly. For the Git side of a script, such as a hook that runs before every commit, the Git cheat sheet has the commands to call.

Common questions

Which version of Bash does this cheat sheet cover?

Bash 5.3, the current release, and every snippet was run on 5.3.9. Almost everything also works in Bash 4.4 and later, which is what current Linux distributions ship. Anything that needs more than Bash 3.2 says so in the notes column, because 3.2 is still the version at /bin/bash on every Mac. Run bash --version to see which one you have.

What is the difference between Bash and sh?

sh is the POSIX shell language, a standard that several shells implement. Bash is one of them, with a lot added on top: [[ ]], arrays, (( )), brace expansion, process substitution, here strings and more. On Debian and Ubuntu, /bin/sh is dash, which has none of those extras, so a script with a #!/bin/sh shebang that uses them fails there. If a script uses anything from this page, give it a #!/usr/bin/env bash shebang.

Should I use [ ] or [[ ]] in Bash?

In a Bash script, use [[ ]]. It does not split or glob unquoted variables, so a value with a space cannot break the test, and it adds pattern matching with == and regular expressions with =~. [ ] is the older test command, which you need only for scripts that must run under plain sh. For numbers, (( a < b )) reads more naturally than either.

What does set -euo pipefail do?

It turns on three options. -e stops the script when a command fails. -u stops it when you use a variable that was never set, which catches typos. -o pipefail makes a pipe fail when any command in it fails, not just the last one. Together they stop a script from carrying on after something has gone wrong. They have gaps: -e is switched off inside if and while tests and anything joined with && or ||, so do not treat them as full error handling.

When should I quote variables in Bash?

Nearly always. An unquoted $name is split on spaces and each piece is treated as a file pattern, so a file name with a space in it becomes two arguments, and one containing a star can turn into a list of files. Write "$name" and "${files[@]}" by default. The exceptions are rare: inside [[ ]] and (( )), on the right of a plain assignment, and when you deliberately want the splitting.

Why is Bash on my Mac so old?

Bash 4.0 and later are licensed under GPLv3, and Apple stopped updating the Bash it ships at 3.2.57, the last GPLv2 release. Since macOS Catalina the default login shell is zsh, and /bin/bash is left at 3.2 for old scripts. To get a current Bash, run brew install bash and use #!/usr/bin/env bash as the shebang, so scripts pick up the newer version from your PATH.

What is the difference between $@ and $*?

Unquoted, they behave the same and both break arguments apart on spaces. Quoted, "$@" gives every argument as a separate word, exactly as it was passed, while "$*" joins them all into one string. Passing arguments on to another command is almost always "$@".

Can I run Bash scripts on Windows?

Yes, two ways. WSL, the Windows Subsystem for Linux, runs a real Linux distribution with a current Bash and is the closest to a Linux server. Git for Windows includes Git Bash, a lighter option that runs most scripts but maps Windows paths in ways that occasionally surprise. Either way, save scripts with Unix line endings: a Windows line ending on the shebang line makes the script fail with a bad interpreter or No such file or directory error.

See all cheat sheets

Want this explained by a cat?

The videos cover the same ground in sixty seconds. If there is a tool you want a cheat sheet for next, ask.