Bash Scripting Cheat Sheet
Writing Bash scripts: safe script setup, variables, parameter expansion, tests, loops, arrays, functions, redirection and error handling. Targets Bash 5.2 (most of it works from Bash 4; macOS still ships 3.2, so install a newer Bash there).
On this page
Script Skeleton #
#!/usr/bin/env bash
set -euo pipefail # strict mode (see the caveats below)
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
main() {
echo "Running from $script_dir with ${#} argument(s)"
}
main "$@"
| Code | What it does |
|---|---|
#!/usr/bin/env bash | Shebang: run with the first bash on PATH (portable, unlike #!/bin/bash on macOS/BSD) |
set -e | Exit when a command fails. Not triggered inside if/while conditions or by a && b / a || b (except the last command) |
set -u | Using an unset variable is an error. Use ${var:-} when "maybe unset" is fine |
set -o pipefail | A pipeline fails if any command in it fails, not only the last one |
| Make the script executable (once) | |
./script.sh arg1 arg2 | Run it (uses the shebang) |
bash script.sh | Run it with bash directly (no needed) |
/ . lib.sh | Run a file in the current shell (load functions and variables) |
bash -n script.sh | Check the syntax without running anything |
Variables & Quoting #
| Code | What it does |
|---|---|
name="Milan" | Assign. No spaces around = (name = x runs a command called name) |
| Use a variable. Always double-quote it | |
| Braces mark where the name ends | |
| Single quotes: everything is literal, no expansion | |
Escape ", $ and backslash inside double quotes with a backslash | |
files=$(ls -1 | wc -l) | Store a command's output |
readonly MAX=10 | Constant: assigning again is an error |
local count=0 | Variable that only exists inside the current function |
export DB_HOST=localhost | Make it visible to programs the script starts |
DEBUG=1 ./run.sh | Set a variable for one command only |
declare -i n=5 | Integer variable: n+=2 adds instead of appending |
unset name | Delete a variable |
Parameter Expansion #
Examples use file="photo.final.JPG" and path="/home/milan/notes.txt".
| Code | What it does |
|---|---|
${var:-default} | Value of var, or default if it is unset or empty |
${var:=default} | Same, and also assign default to var |
${var:?missing var} | Exit the script with this error if var is unset or empty |
${var:+yes} | yes if var is set and not empty, else nothing |
${#var} | Length in characters |
${path#*/} | Remove the shortest match from the start: home/milan/notes.txt |
${path##*/} | Remove the longest match from the start: notes.txt (like basename) |
${path%/*} | Remove the shortest match from the end: /home/milan (like dirname) |
${file%%.*} | Remove the longest match from the end: photo |
${file%.*} / ${file##*.} | Name without extension photo.final / extension JPG |
${var/old/new} | Replace the first match |
${var//old/new} | Replace all matches |
${var^^} / ${var,,} | Uppercase / lowercase (Bash 4+) |
${var^} | Uppercase the first character |
${var:0:5} | Substring: 5 characters from position 0 |
${var: -3} | Last 3 characters (the space before - is required) |
Special Variables & Arguments #
| Code | What it does |
|---|---|
$0 | Name of the script as it was called |
$1 .. $9, ${10} | Positional arguments (braces from 10 on) |
$# | Number of arguments |
"$@" | All arguments, each one kept as a separate word (use this) |
"$*" | All arguments joined into one string (with the first char of IFS) |
$? | Exit code of the last command (0 = success) |
$$ | PID of the script itself |
$! | PID of the last background job (cmd &) |
$LINENO / $FUNCNAME | Current line number / current function name |
shift / shift 2 | Drop the first 1 / 2 arguments ($2 becomes $1) |
set -- a b c | Replace the arguments with a b c |
Options with getopts (short flags like -v -o out.txt):
verbose=0 out="result.txt"
while getopts ":vo:h" opt; do # o: means -o takes a value
case $opt in
v) verbose=1 ;;
o) out=$OPTARG ;;
h) echo "usage: $0 [-v] [-o file] files..."; exit 0 ;;
:) echo "-$OPTARG needs a value" >&2; exit 1 ;;
\?) echo "unknown option -$OPTARG" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1)) # "$@" now holds the remaining arguments
Conditionals #
| Code | What it does |
|---|---|
if [[ cond ]]; then ...; elif ...; else ...; fi | If statement |
[[ ... ]] | Bash test: no word splitting, supports &&, ||, == patterns and =~ |
[ ... ] | Old POSIX test (sh): quote every variable and use -a/-o sparingly |
(( a > b )) | Numeric comparison with normal math operators |
cmd && echo ok | Run the second command only if the first succeeds |
cmd || echo failed | Run the second command only if the first fails |
if grep -q x file; then | Any command works as a condition (exit code 0 = true) |
! cmd | Negate an exit code |
| String & number test | True when |
|---|---|
-z "$s" | String is empty |
-n "$s" | String is not empty |
$a == "$b" | Strings are equal (!= = not equal) |
$s == *.txt | Glob match ([[ ]] only, pattern unquoted) |
$s =~ ^[0-9]+$ | Regex match, groups end up in BASH_REMATCH |
$a -eq $b | Numbers are equal (-ne = not equal) |
-lt -le -gt -ge | Less / less-or-equal / greater / greater-or-equal |
| File test | True when |
|---|---|
-e path | Path exists |
-f path | Is a regular file |
-d path | Is a directory |
-r / -w / -x | Readable / writable / executable |
-s path | File exists and is not empty |
-L path | Is a symbolic link |
a -nt b | File a is newer than b |
case $1 in
start|up) echo "starting" ;;
stop) echo "stopping" ;;
*.txt) echo "a text file" ;;
*) echo "usage: $0 start|stop" >&2; exit 1 ;;
esac
Loops #
| Code | What it does |
|---|---|
for x in a b c; do echo "$x"; done | Loop over a list |
for i in {1..5} / {0..20..5} | Brace range / range with step (no variables inside braces) |
for ((i = 0; i < n; i++)) | C-style loop (works with variables) |
for f in ./*.txt; do ...; done | Loop over files (safe with spaces in names) |
for f in ./*.{jpg,png} | Brace expansion: several patterns |
for arg in "$@" | Loop over the script's arguments |
while IFS= read -r line; do ...; done < file | Read a file line by line, exactly as written |
while IFS= read -r line || [[ -n $line ]]; do | Same, but also reads a last line without a trailing newline |
while (( n < 10 )); do ...; done | Loop while a condition is true |
until ping -c1 host &>/dev/null; do sleep 2; done | Loop until a command succeeds |
break / continue | Leave the loop / skip to the next round (break 2 = 2 levels) |
shopt -s nullglob | A glob with no matches becomes nothing instead of the literal *.txt |
Arithmetic #
| Code | What it does |
|---|---|
Arithmetic expansion: 11 (integers only) | |
$(( 7 / 2 )) / $(( 7 % 2 )) | Integer division 3 / remainder 1 |
$(( 2 ** 10 )) | Power: 1024 |
(( count += 1 )) | Change a variable (no $ needed inside) |
(( n > 5 )) && echo big | Arithmetic test: exit code 0 if the result is not 0 |
n=$(( n + 1 )) | Increment, safe with set -e |
$(( 16#ff )) | Other bases: 255 |
Decimals need an external tool: 3.50 |
Arrays #
| Code | What it does |
|---|---|
arr=(one two "three four") | Create an indexed array |
arr+=(five) | Append |
| One element (index starts at 0) | |
| Last element | |
"${arr[@]}" | All elements, each one a separate word |
${#arr[@]} | Number of elements |
"${!arr[@]}" | All indexes (or keys) |
"${arr[@]:1:2}" | Slice: 2 elements starting at index 1 |
unset 'arr[1]' | Remove an element (indexes are not renumbered) |
for x in "${arr[@]}"; do ...; done | Iterate over the values |
mapfile -t lines < file | Read a file into an array, one line per element |
declare -A user=([name]=Milan [city]=Antwerp) | Associative array (key/value, Bash 4+) |
user[role]=dev | Add or change a key |
for k in "${!user[@]}"; do echo "$k=${user[$k]}"; done | Iterate over keys and values (order not guaranteed) |
[[ -v user[name] ]] | Key exists |
Functions #
greet() {
local name=${1:-world} # $1, $2... are the function's own arguments
echo "Hello, $name" # output = the "return value"
}
msg=$(greet Milan) # capture the output: "Hello, Milan"
is_even() { (( $1 % 2 == 0 )); } # exit code = true/false
if is_even 4; then echo "even"; fi
die() { echo "error: $*" >&2; exit 1; }
[[ -f config.json ]] || die "config.json not found"
| Code | What it does |
|---|---|
return 1 | Leave the function with an exit code (0-255 only, not data) |
+ $(func) | Return data by printing it and capturing the output |
local -n ref=$1 | Name reference: change the caller's variable by name (Bash 4.3+) |
declare -f name | Show a function's code |
Substitution & Redirection #
| Code | What it does |
|---|---|
today=$(date +%F) | Command substitution: insert a command's output |
diff <(sort a.txt) <(sort b.txt) | Process substitution: use a command's output as a file |
while read -r x; do ...; done < <(cmd) | Loop over output without a subshell (variables survive) |
cmd > out.txt / cmd >> out.txt | Write / append stdout |
cmd 2> err.txt | Write stderr |
cmd &> all.txt | stdout and stderr to the same file |
cmd > all.txt 2>&1 | Same, POSIX style (order matters: file first, then 2>&1) |
| Print to stderr (for error messages) | |
exec > log.txt 2>&1 | Redirect everything the rest of the script prints |
tr a-z A-Z <<< "$text" | Here-string: feed one string as stdin |
cat <<EOF > config.ini # variables and $(...) are expanded
user=$USER
date=$(date +%F)
EOF
cat <<'EOF' # quoted 'EOF': everything stays literal
Price: $10, run $(this) later
EOF
<<-EOF also strips leading tabs (not spaces), so the here-doc can be indented.
Errors, Exit Codes & Traps #
| Code | What it does |
|---|---|
exit 0 / exit 1 | End the script: 0 = success, 1-255 = failure |
cmd || exit 1 | Stop if one command fails |
if ! cmd; then ...; fi | Handle a failure yourself |
command -v jq >/dev/null || die "jq is required" | Check that a program is installed |
trap cleanup EXIT | Run cleanup whenever the script ends (success, error, exit, Ctrl+C) |
trap 'echo "failed at line $LINENO" >&2' ERR | Run code when a command fails (add set -E so functions inherit it) |
trap '' INT | Ignore Ctrl+C |
tmp=$(mktemp) / tmp=$(mktemp -d) | Create a unique temporary file / folder |
"${PIPESTATUS[@]}" | Exit codes of every command in the last pipeline |
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT # temp folder is removed even on errors
curl -fsSL "$url" -o "$tmpdir/file.zip"
Input & Debugging #
| Code | What it does |
|---|---|
read -r -p "Name: " name | Ask for input (-r keeps backslashes as typed) |
read -rs -p "Password: " pw; echo | Hidden input (-s = no echo) |
read -r -t 10 -p "Continue? " ans | Give up after 10 seconds |
[[ $ans == [yY]* ]] | Accept y, Y, yes, Yes... |
read -ra parts <<< "$line" | Split a line into an array on whitespace |
IFS=, read -ra parts <<< "a,b,c" | Split on a custom separator |
bash -x script.sh | Run and print every command before it runs |
set -x / set +x | Turn tracing on / off for part of a script |
PS4='+ ${LINENO}: ' | Show line numbers in the -x trace |
shellcheck script.sh | Static checker that catches quoting bugs and pitfalls (install it, use it) |
Common Pitfalls #
| Wrong | Right | Why |
|---|---|---|
| Unquoted variables are split on spaces and globbed | ||
for f in $(ls) | for f in ./* | output breaks on spaces and newlines in names |
[ $a == $b ] | [[ $a == "$b" ]] | An empty $a gives "unary operator expected" |
[ "$x" > 5 ] | (( x > 5 )) or [[ $x -gt 5 ]] | > in [ ] redirects and creates a file named 5 |
while read -r l; do ...; done < f | The piped loop runs in a subshell, so n is lost | |
local out=$(cmd) | local out; out=$(cmd) | local hides the failure of cmd from set -e |
(( count++ )) with set -e | count=$((count + 1)) | (( )) that evaluates to 0 returns exit code 1, so set -e stops the script |
| If fails you delete files in the wrong folder |
Tips #
- Quote every expansion (
"$var","$@","${arr[@]}") unless you know why not; that removes most Bash bugs. - Start scripts with
set -euo pipefail, but don't rely on it alone: check important commands explicitly with|| die "...". - Send messages to stderr (
>&2) and keep stdout for real output, so the script works in pipes. - Use
printf '%s\n' "$var"instead ofechofor data that may start with-or contain backslashes. - Run
shellcheckon every script (there are VS Code extensions for it) before you trust it. - When a script grows past about 100 lines or needs real data structures, consider Python instead.
chmodChange file permissions. chmod [options] mode file…
| Mode / option | What it does |
|---|---|
+x / -x | Add / remove execute for everyone |
u+x | Owner (user) may execute |
g+w, o-r | group may write, others may not read |
a=r | Everyone (all) read-only |
u=rwx,go=rx | Set exact permissions per class |
755 | rwxr-xr-x: folders, scripts |
644 | rw-r--r--: normal files |
600 | rw-------: private files (SSH keys) |
700 | rwx------: private folders (~/.ssh) |
-R | Apply to a folder and everything in it |
-v / -c | Show every change / only real changes |
--reference=file | Copy permissions from another file |
Numbers: r=4 w=2 x=1, added up per owner / group / others.
sourceRun a script in the current shell, so its variables, aliases and functions stay. source file [args]
| Usage | What it does |
|---|---|
source ~/.bashrc | Reload your Bash settings after editing them |
. ~/.bashrc | Same: . is the POSIX spelling of source |
source .venv/bin/activate | Activate a Python virtual environment |
source .env | Load KEY=value lines as shell variables |
set -a; source .env; set +a | …and export them to programs you start |
echoPrint text. echo [options] text…
| Option | What it does |
|---|---|
-n | No newline at the end |
-e | Interpret escapes: \n newline, \t tab |
-E | Don't interpret escapes (default) |
echo $HOME | Print a variable |
echo "text" > file | Write to a file (>> appends) |
echo $? | Exit code of the last command |
rmDelete files and folders. There is no recycle bin. rm [options] file…
| Option | What it does |
|---|---|
-r, -R | Delete folders and everything in them |
-f | Force: no questions, no error if the file is missing |
-i | Ask before every file |
-I | Ask once (when deleting more than 3 files or recursively) |
-v | Show what is deleted |
-d | Delete empty folders |
--preserve-root | Refuse to delete / (on by default) |
--one-file-system | Don't cross into other mounted drives |
⚠ Double-check before rm -rf. Test the pattern first with ls: ls *.tmp → rm *.tmp.
lsList the files and folders in a directory. ls [options] [path…]
| Option | What it does |
|---|---|
-l | Long list: permissions, owner, size, date |
-a, --all | Also show hidden files (names starting with .) |
-A, --almost-all | Hidden files too, but without . and .. |
-h, --human-readable | Sizes like 4.0K, 12M, 1.1G (use with -l) |
-t | Sort by modification time, newest first |
-S | Sort by size, largest first |
-X | Sort by file extension |
-v | Natural sort of numbers (file2 before file10) |
-r, --reverse | Reverse the sort order |
-R, --recursive | List all subfolders too |
-d | List the folder itself, not what's inside (ls -d */ = only folders) |
-1 | One name per line |
-F, --classify | Add / after folders, * after executables, @ after links |
-i | Show inode numbers |
-n | Numeric user and group IDs |
-s | Show the allocated size of each file |
--color=auto | Colour output (always / never) |
--group-directories-first | Folders before files |
--time-style=long-iso | Dates as 2026-10-01 14:05 |
--sort=WORD | Sort by size, time, extension, version or none |
Popular combinations: ls -la (everything, detailed) · ls -lah (+ readable sizes) · ls -lt (newest first) · ls -lS (biggest first).
macOS: -G enables colour; the long --options don't exist there. Full list: man ls or ls --help.
catPrint files (or join them). cat [options] file…
| Option | What it does |
|---|---|
-n | Number every line |
-b | Number non-empty lines only |
-s | Squeeze multiple empty lines into one |
-A | Show invisible characters: tabs as ^I, line ends as $ |
-E | Show $ at every line end (spot trailing spaces) |
-T | Show tabs as ^I |
cat a b > ab | Join files into a new one |
cat >> notes.txt | Type text and append it (end with Ctrl + D) |
cdChange the current folder. cd [path]
| Usage | What it does |
|---|---|
cd folder | Go into a folder (relative to where you are) |
cd /var/log | Go to an absolute path |
cd .. | One folder up (cd ../.. = two up) |
cd or cd ~ | Your home folder |
cd - | Back to the previous folder |
cd / | The root of the filesystem |
cd "My Folder" | Paths with spaces need quotes (or My\ Folder) |
-P | Resolve symbolic links to the real path |
-L | Follow symbolic links as they are (default) |
Git Bash: drives are /c/, /d/… WSL: Windows drives are under /mnt/c/.
Nothing on this sheet matches “”.