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.
Searches the task, the command and the third column. Press / from anywhere on the page.
214 commands
Shebang and running scripts
A Bash script is a text file of commands. The first line, the shebang, says which program runs it.
| Task | Syntax | Notes |
|---|---|---|
| Shebang, portable | #!/usr/bin/env bash | First line of the file. Uses the first bash on your PATH, so a Mac picks up a newer Homebrew bash |
| Shebang, fixed path | #!/bin/bash | Always the system bash. On macOS that is 3.2, a release from 2006 |
| Make a script executable | chmod +x deploy.sh | |
| Run it | ./deploy.sh | The ./ is needed because the current folder is not on the PATH |
| Run it with bash explicitly | bash deploy.sh | Ignores the shebang and does not need the execute bit |
| Run it in the current shell | source ./env.sh | Or . ./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 running | bash -n deploy.sh | |
| Print each command as it runs | bash -x deploy.sh | Each line is shown with a + in front. set -x and set +x turn it on and off inside a script |
| Run a one-line script | bash -c 'echo "$0 got $1"' myname hello | Prints myname got hello. The first word after the string becomes $0 |
| Which version is this | bash --version | Inside a script, echo "$BASH_VERSION" gives the version of the bash running it |
| Comment | # everything after the hash is ignored | |
| Several commands on one line | cd build; make | Runs 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 in | dir=$(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
| Task | Syntax | Notes |
|---|---|---|
| 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 argument | shift | $2 becomes $1. shift 2 drops two |
| Default when missing | name="${1:-world}" | |
| Stop when missing | file="${1:?usage: backup.sh FILE}" | Prints the message to stderr and exits with status 1 |
| Parse -v and -o value flags | while getopts "vo:" opt; do | A 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 input | read -rp "Name: " name | -r keeps backslashes as typed, -p shows a prompt |
| Ask for a password | read -rsp "Password: " pass | -s hides what is typed. Print a newline after it |
| Give up waiting | read -rt 5 -p "Continue? " answer | Exit status is above 128 after 5 seconds with no input. Bash 3.2 returns 1 |
| Split a line into fields | IFS=, read -r name age city <<< "Ada,36,London" | The last variable takes whatever is left over |
| Read a file line by line | while IFS= read -r line; do echo "$line"; done < notes.txt | IFS= keeps leading spaces, -r keeps backslashes |
Variables and quoting
| Task | Syntax | Notes |
|---|---|---|
| Set a variable | name="Ada" | No spaces around the =. name = Ada runs a command called name |
| Use it | echo "$name" | Quote it, almost always. See the gotchas |
| Separate it from text | echo "${name}_backup" | $name_backup would look up a variable called name_backup |
| Append to it | PATH+=":$HOME/bin" | |
| Save a command's output | today=$(date +%F) | Trailing newlines are removed. Backquotes do the same job but do not nest |
| Save output without a subshell | today=${ date +%F; } | Bash 5.3 only. The command runs in the current shell, so variables it sets survive |
| Single quotes: nothing expands | echo 'Cost: $5' | |
| Double quotes: variables expand | echo "Hello, $name" | So do $( ), $(( )) and backquotes. \ escapes $, backquote, " and \ |
| Escape one character | echo "Price: \$5" | |
| Tabs and newlines in a string | echo $'col1\tcol2\nnext line' | ANSI-C quoting: \n, \t and \' work inside $' ' |
| A single quote inside single quotes | echo 'It'\''s here' | Close, add an escaped quote, reopen. Or $'It\'s here' |
| Print safely | printf '%s\n' "$name" | echo can swallow a value such as -n or -e as an option. printf never does |
| Print formatted | printf '%-10s %6.2f\n' "$item" "$price" | Left-aligned in 10 characters, then a number to 2 decimal places |
| Format into a variable | printf -v padded '%05d' 42 | padded is 00042. No subshell |
| Pass to programs you run | export API_URL="https://example.com" | Without export, only this shell sees it |
| Set for one command only | LC_ALL=C sort names.txt | Only that command sees the value |
| Make it read-only | readonly MAX_RETRIES=5 | |
| Remove it | unset name | |
| This script's process ID | $$ | |
| Last background job's process ID | $! | |
| Random number, 0 to 32767 | $RANDOM | |
| Seconds since the script started | $SECONDS | Handy for timing. Assign 0 to it to reset |
| Current Unix time | $EPOCHSECONDS | Bash 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.
| Task | Syntax | Result 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
| Task | Syntax | Result or notes |
|---|---|---|
| Calculate | echo $((3 + 4 * 2)) | 11. Normal precedence, and brackets work |
| Use variables | total=$((price * qty)) | No $ needed on names inside $(( )) |
| Divide | echo $((7 / 2)) | 3. Whole numbers only, rounded towards zero |
| Remainder | echo $((7 % 2)) | 1 |
| Power | echo $((2 ** 10)) | 1024 |
| Add one | count=$((count + 1)) | Safe under set -e, unlike ((count++)). See the gotchas |
| Add to a variable | ((total += price)) | |
| Compare numbers | if (( count > 10 )); then | True when the result is not zero |
| Decimals | awk 'BEGIN { printf "%.2f\n", 10 / 3 }' | 3.33. Bash has no floats; awk and bc do |
| Random number from 1 to 6 | echo $((RANDOM % 6 + 1)) | |
| Hex to decimal | echo $((16#ff)) | 255. printf '%x\n' 255 goes the other way |
| A number with a leading zero | echo $((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.
| Task | Syntax | Notes |
|---|---|---|
| If | if [[ -f config.yml ]]; then echo found; fi | The spaces inside the brackets are required |
| If, else if, else | if [[ $n -gt 10 ]]; then echo big; elif [[ $n -gt 5 ]]; then echo medium; else echo small; fi | |
| Test a command directly | if grep -q error app.log; then echo found; fi | No 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 regex | re='^([^@]+)@(.+)$'; [[ $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 build | The right side runs only when the left one fails |
| Several choices | case "$1" in start) run ;; stop|halt) halt ;; *) usage ;; esac | Each 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
| Task | Syntax | Notes |
|---|---|---|
| Over a list | for fruit in apple banana cherry; do echo "$fruit"; done | |
| Over files | for f in *.jpg; do echo "$f"; done | Safe with spaces in names. With no match it runs once with the literal *.jpg, unless shopt -s nullglob |
| Over files in every subfolder | shopt -s globstar; for f in **/*.md; do | Bash 4.0 and later, so not the macOS default |
| Over a range | for i in {1..5}; do echo "$i"; done | The numbers must be literal: {1..$n} does not expand |
| Over a range, counting in steps | for i in {0..100..10}; do | Bash 4.0 and later, so not the macOS default |
| Counting up to a variable | for ((i = 1; i <= n; i++)); do echo "$i"; done | C-style. Works with variables |
| Over the script's arguments | for arg in "$@"; do | for arg; do means the same |
| Over an array | for item in "${items[@]}"; do | |
| While a test is true | while [[ $n -gt 0 ]]; do n=$((n - 1)); done | |
| Until a command works | until curl -sf http://localhost:8080/health; do sleep 1; done | Waits for a server to come up |
| Forever | while true; do ./poll.sh; sleep 60; done | Ctrl+C stops it |
| Lines of a command's output | while IFS= read -r f; do echo "$f"; done < <(git ls-files) | Variables set in the loop survive, unlike cmd | while read |
| Leave the loop | break | break 2 leaves two nested loops |
| Skip to the next round | continue | continue 2 moves on the outer loop |
| Loop on one line | for f in *.log; do gzip "$f"; done | The semicolon before done is required on one line |
Functions
| Task | Syntax | Notes |
|---|---|---|
| Define one | greet() { echo "Hello, $1"; } | The space after { and the ; before } are required on one line |
| Call it | greet Ada | No brackets. Arguments are separated by spaces, like any command |
| Bash keyword form | function 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 variable | local count=0 | Without local, every variable in a function is global |
| Return success or failure | return 1 | 0 to 255 only. It is an exit status, not a return value |
| Return a value | result=$(get_name) | The function echoes it and the caller captures it. Runs in a subshell |
| Write to a caller's variable | set_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 array | process "${files[@]}" | Arrives as separate arguments, collected again with "$@" |
| Status of a function | if check_config; then | It is the status of the last command it ran, unless it returns one |
| List defined functions | declare -F | |
| Show a function's code | declare -f greet | type 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.
| Task | Syntax | Notes |
|---|---|---|
| Create | fruits=(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 end | fruits+=(date) | |
| Change one | fruits[1]=blueberry | |
| Remove one | unset '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 index | for i in "${!fruits[@]}"; do echo "$i ${fruits[$i]}"; done | |
| Lines of a file into an array | mapfile -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 commas | IFS=, 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 copy | mapfile -t sorted < <(printf '%s\n' "${fruits[@]}" | sort) | Bash 4.0 and later, so not the macOS default |
| Print it for debugging | declare -p fruits | Shows every index and value, quoted |
| Create an associative array | declare -A ages=([ada]=36 [alan]=41) | declare -A is required. Bash 4.0 and later, so not the macOS default |
| Get and set by key | ages[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 key | unset '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.
| Task | Syntax | Notes |
|---|---|---|
| Status of the last command | echo $? | Read it straight away: the next command replaces it |
| Keep it for later | make; status=$? | |
| End the script | exit 1 | exit on its own uses the status of the last command |
| Run the next one only if this worked | make && make install | |
| Run the next one only if this failed | cd /srv/app || exit 1 | The usual guard after a cd |
| Invert a status | if ! grep -q TODO notes.txt; then | True when grep finds nothing |
| Status of every part of a pipe | false | true; echo "${PIPESTATUS[@]}" | 1 0. $? alone only shows the last command |
| Ignore one failure under set -e | rm -f old.log || true | |
| Status of a background job | wait "$pid" | Returns that job's status. Save the PID with pid=$! after starting it |
| Success | 0 | |
| General failure | 1 | |
| Wrong usage | 2 | What builtins and most tools return for a bad option |
| Found but not executable | 126 | Usually a missing chmod +x |
| Command not found | 127 | A typo, or it is not on the PATH |
| Killed by a signal | 128 + N | 130 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.
| Task | Syntax | Notes |
|---|---|---|
| Output to a file, replacing it | ls > files.txt | |
| Output to a file, adding to the end | date >> run.log | |
| Errors to a file | ./build.sh 2> errors.txt | |
| Output and errors to one file | ./build.sh > build.log 2>&1 | Order matters. 2>&1 > build.log still sends errors to the screen |
| Output and errors, short form | ./build.sh &> build.log | Bash 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 message | echo "error: config not found" >&2 | Errors go to stderr, so they still show when output is redirected |
| Input from a file | sort < names.txt | |
| Pipe output into a command | history | 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.log | tee -a appends |
| Write to a file you need sudo for | echo "127.0.0.1 app.local" | sudo tee -a /etc/hosts > /dev/null | sudo echo ... >> /etc/hosts fails: the redirect runs as you, not as root |
| Treat output as a file | diff <(sort a.txt) <(sort b.txt) | Process substitution. Bash only, not sh |
| Refuse to overwrite files | set -o noclobber | Then > fails on an existing file. >| overwrites anyway |
| Send all later output to a log too | exec > >(tee -a script.log) 2>&1 | Near the top of a script. Everything after it is shown and logged |
| Open a file on descriptor 3 | exec 3> debug.log | Write 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.
| Task | Syntax | Notes |
|---|---|---|
| Several lines, variables expanded | cat <<EOF | $name and $(commands) are replaced. Ends at a line that is exactly EOF |
| Several lines, nothing expanded | cat <<'EOF' | Quoting the word turns expansion off. Use it for code and templates |
| Indented with tabs | cat <<-EOF | Strips leading tabs, not spaces, so the body can be indented inside an if |
| Into a file | cat > config.ini <<EOF | |
| Into a file that needs sudo | sudo tee /etc/app.conf > /dev/null <<'EOF' | |
| Into a variable | text=$(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 machine | ssh 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 variables | read -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.
| Task | Syntax | Notes |
|---|---|---|
| Strict mode | set -euo pipefail | The usual second line of a script, straight after the shebang |
| Stop when a command fails | set -e | Not inside if, while, && or || tests, or a function called from one |
| Stop on an unset variable | set -u | Use ${1:-} to read an argument that may be missing |
| A pipe fails if any part fails | set -o pipefail | Without it, a pipe's status is the last command's alone |
| Stop inside command substitution too | shopt -s inherit_errexit | Without it, set -e is off inside $( ). Bash 4.4 and later |
| Clean up however the script ends | trap 'rm -rf "$tmp"' EXIT | Runs on success, on failure and on Ctrl+C |
| Report the failing line | trap 'echo "failed at line $LINENO" >&2' ERR | Add set -E so it also fires inside functions |
| Catch Ctrl+C | trap 'echo interrupted; exit 130' INT | |
| Trace from here on | set -x | set +x stops it |
| Show more in the trace | PS4='+ ${BASH_SOURCE}:${LINENO}: ' | Adds the file and line number to every set -x line |
| Check a script for mistakes | shellcheck deploy.sh | A 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_DIRgetopts 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 write | Bash sees | Why |
|---|---|---|
echo $name | Two arguments, Ada and Lovelace | Unquoted, the value is split on spaces and globbed |
echo "$name" | One argument, Ada Lovelace | Double quotes expand the variable and keep it whole |
echo '$name' | The text $name | Single quotes keep everything literally |
echo "\$name" | The text $name | A 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.txt | Two files, Ada and Lovelace.txt | The 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 is | The POSIX test command | A Bash keyword | Bash arithmetic |
Works in sh | Yes | No | No |
| Quote variables | Always | Not needed, except the right side of == | No $ needed |
| Strings equal | = | == or = | No |
| Pattern match | No | [[ $f == *.txt ]] | No |
| Regular expression | No | [[ $s =~ $re ]] | No |
| Numbers | -lt, -gt, -eq | -lt, -gt, -eq | <, >, == |
| And, or | [ a ] && [ b ] | &&, || inside | &&, || inside |
| Use it for | Scripts that must run in sh | Strings and files in Bash | Numbers 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.csvA 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 10GNU 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.
| Code | What happens | Fix |
|---|---|---|
local out=$(false) | Carries on: local succeeded, and that status hides the failure | local out; out=$(false) |
out=$(step1; step2) where step1 fails | step2 still runs inside the $( ) | shopt -s inherit_errexit (Bash 4.4+) |
if deploy; then | set -e is off for everything inside deploy | Check for errors explicitly inside the function |
count=0; ((count++)) | Stops the script: the expression is 0, so the status is 1 | count=$((count + 1)) |
n=$(grep foo log | wc -l) with no match | Stops the script: grep returned 1 and pipefail passed it on | n=$(grep -c foo log || true) |
read -r -d '' text <<'EOF' | Stops the script: read returns 1 at end of input | text=$(cat <<'EOF' ...) |
echo "$1" with no arguments | Stops with "unbound variable" | "${1:-}" |
"${files[@]}" on an empty array | Fine on Bash 4.4+, "unbound variable" on 3.2 | ${files[@]+"${files[@]}"} on old Bash |
trap '...' ERR and a failing function | The trap does not fire inside the function | set -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:
| Feature | Needs | On macOS's /bin/bash |
|---|---|---|
declare -A associative arrays | Bash 4.0 | invalid option |
mapfile and readarray | Bash 4.0 | command not found |
${name^^} and ${name,,} | Bash 4.0 | bad substitution |
** with shopt -s globstar | Bash 4.0 | invalid shell option name |
{0..100..10} | Bash 4.0 | Printed literally, unexpanded |
&>> and |& | Bash 4.0 | Syntax error |
;& and ;;& in case | Bash 4.0 | Syntax error |
[[ -v name ]] | Bash 4.2 | Syntax error |
${fruits[-1]} | Bash 4.3 | bad array subscript |
local -n namerefs | Bash 4.3 | invalid option |
${name@Q} | Bash 4.4 | bad substitution |
Empty "${arr[@]}" under set -u | Bash 4.4 | unbound variable |
$EPOCHSECONDS | Bash 5.0 | Empty |
${ command; } | Bash 5.3 | bad 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 right | What actually happens | Do this instead |
|---|---|---|
name = "Ada" | Runs a command called name with two arguments | name="Ada", no spaces |
rm $file | A file called my notes.txt becomes two arguments | rm -- "$file" |
for f in $(ls *.txt) | Breaks every file name with a space into pieces | for 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 subshell | while ...; 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 numbers | Compares 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 folder | cd build || exit 1, or set -e |
rm -rf "$build_dir"/* with build_dir unset | Runs rm -rf /* | "${build_dir:?}"/*, or set -u |
cmd && echo ok || echo failed | Prints failed if echo ok itself fails: it is not an if/else | if cmd; then ...; else ...; fi |
sudo echo line >> /etc/hosts | Permission denied: the redirect runs as you | echo 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 Windows | bad interpreter: /bin/bash^M | Save with LF line endings |
alias in a script | Ignored: scripts do not expand aliases | A 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.
