# Bash Basics Cheatsheet

- URL: https://karandeepsingh.ca/cheatsheets/bash/bash-basics/
- Author: Karandeep Singh
- Published: 2025-03-30
- Tags: bash, linux
- Summary: A dense quick-reference for Bash 5.x scripting. Quoting, parameter expansion, test operators, loops, functions, arrays, traps, and here-docs.

I write Bash almost every day (deploy scripts, CI glue, one-off automation) and I still forget the exact parameter expansion syntax more often than I'd like to admit. This is the page I keep open: the Bash 5.x bits I actually use, condensed. For text-wrangling inside scripts, see my [Bash sed & awk Cheatsheet](https://karandeepsingh.ca/cheatsheets/bash/bash-sed-awk/).

## Shebang & Safe Defaults

> **Docs:** [The Set Builtin, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#The-Set-Builtin)

Start every script with this:

```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
```

| Option | Effect |
|--------|--------|
| `set -e` | Exit immediately on any command failure |
| `set -u` | Error on unset variables (catches typos) |
| `set -o pipefail` | A pipeline fails if *any* stage fails, not just the last |
| `set -x` | Print each command before running (debug mode) |

```bash
bash -x script.sh          # debug an existing script without editing it
set +e                     # temporarily allow failures
some_flaky_command || true # tolerate a single failing command under set -e
```

`set -e` has one hole worth knowing, and it is the most common way a script
reports success after it failed. Assigning with `local` on the same line
returns the status of `local`, which is always 0:

```text
$ bash deploy.sh
# exits with status 0
grep: /var/log/app.log: No such file or directory
| count=''
| script continued, exit status was hidden
# `local count=$(...)` is one command, and its status is `local`'s, not
# grep's. -e never sees the failure and the script runs on with no count.
```

```diff
# deploy.sh
@@ -4,6 +4,7 @@ set -euo pipefail
 count_errors() {
-    local count=$(grep -c ERROR /var/log/app.log)
+    local count
+    count=$(grep -c ERROR /var/log/app.log)
     echo "count='$count'"
 }
```

Split that way the script stops where it should, exiting 2 from grep. The
same applies to `export`, `readonly` and `declare`.

## Variables & Quoting

> **Docs:** [Quoting, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Quoting)

```bash
name="karandeep"           # no spaces around =
readonly REGION="ca-central-1"
export PATH="$PATH:/opt/bin"
unset name

echo "$name"               # double quotes expand variables; use by default
echo '$name'               # single quotes: literal, no expansion
echo "${name}_backup"      # braces to delimit the variable name
```

**warning:**
Always quote variable expansions: `rm -rf "$dir/"`. Left unquoted, an empty or space-containing `$dir` can wipe the wrong path. `shellcheck` will catch most of these; run it on every script.

## Parameter Expansion

> **Docs:** [Shell Parameter Expansion, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Shell-Parameter-Expansion)

| Expansion | Meaning |
|-----------|---------|
| `${var:-default}` | Use `default` if `var` is unset or empty |
| `${var:=default}` | Assign `default` to `var` if unset or empty |
| `${var:?message}` | Exit with `message` if `var` is unset or empty |
| `${var:+alt}` | Use `alt` only if `var` IS set |
| `${#var}` | Length of `var` |
| `${var#pattern}` | Remove shortest match from the front |
| `${var##pattern}` | Remove longest match from the front |
| `${var%pattern}` | Remove shortest match from the end |
| `${var%%pattern}` | Remove longest match from the end |
| `${var/a/b}` | Replace first `a` with `b` |
| `${var//a/b}` | Replace all `a` with `b` |
| `${var^^}` / `${var,,}` | Uppercase / lowercase |
| `${var:2:5}` | Substring: offset 2, length 5 |

```bash
file="app.tar.gz"
echo "${file%%.*}"     # app          (strip longest from end-matching .*)
echo "${file%.*}"      # app.tar      (strip shortest)
echo "${file#*.}"      # tar.gz
path="/var/log/nginx/access.log"
echo "${path##*/}"     # access.log   (basename)
echo "${path%/*}"      # /var/log/nginx (dirname)
env="${DEPLOY_ENV:?DEPLOY_ENV must be set}"
```

## Conditionals & `[[ ]]` Test Operators

> **Docs:** [Bash Conditional Expressions, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Bash-Conditional-Expressions)

```bash
if [[ -f "$config" && "$env" == "prod" ]]; then
  echo "prod config found"
elif [[ "$env" =~ ^(dev|stage)$ ]]; then
  echo "non-prod"
else
  echo "unknown env" >&2
  exit 1
fi
```

| Operator | True if... |
|----------|------------|
| `-f file` | Regular file exists |
| `-d dir` | Directory exists |
| `-e path` | Path exists (any type) |
| `-r` / `-w` / `-x` | Readable / writable / executable |
| `-s file` | File exists and is non-empty |
| `-z str` | String is empty |
| `-n str` | String is non-empty |
| `str1 == str2` | Strings equal (glob match if unquoted RHS) |
| `str =~ regex` | Regex match (capture groups in `BASH_REMATCH`) |
| `n1 -eq n2` | Integers equal (`-ne -lt -le -gt -ge`) |
| `f1 -nt f2` | `f1` newer than `f2` |

Use `[[ ]]` over `[ ]`: no word splitting, and it supports `&&`, `||`, `=~`, and glob matching.

## Loops

> **Docs:** [Looping Constructs, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Looping-Constructs)

```bash
for f in /etc/*.conf; do echo "$f"; done          # glob loop
for i in {1..5}; do echo "attempt $i"; done       # brace range
for ((i = 0; i < 10; i++)); do echo "$i"; done    # C-style

while read -r line; do                            # read a file safely
  echo "line: $line"
done < servers.txt

until curl -sf http://localhost:8080/health; do   # wait for a service
  sleep 2
done

while true; do date; sleep 5; done                # poll loop
break; continue                                   # work as expected
```

## Functions

> **Docs:** [Shell Functions, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Shell-Functions)

```bash
deploy() {
  local env="$1"                  # local keeps vars scoped
  local version="${2:-latest}"    # default for optional args
  echo "deploying $version to $env"
  return 0                        # numeric status only; echo for data
}

deploy prod v1.2.3
result="$(deploy stage)"          # capture output via command substitution
```

`$1..$9 ${10}` positional args, `$#` count, `$@` all args (quote it: `"$@"`), `$0` script name, `$FUNCNAME` current function.

## Arrays & Associative Arrays

> **Docs:** [Arrays, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Arrays)

```bash
hosts=("web1" "web2" "db1")
hosts+=("cache1")                 # append
echo "${hosts[0]}"                # first element
echo "${hosts[@]}"                # all elements
echo "${#hosts[@]}"               # length
echo "${hosts[@]:1:2}"            # slice: web2 db1
for h in "${hosts[@]}"; do ssh "$h" uptime; done

declare -A ports=([http]=80 [https]=443 [ssh]=22)
ports[dns]=53
echo "${ports[https]}"            # 443
for k in "${!ports[@]}"; do echo "$k -> ${ports[$k]}"; done

mapfile -t lines < servers.txt    # file into array, one line per element
```

## Exit Codes

> **Docs:** [Exit Status, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Exit-Status)

```bash
somecommand
echo "$?"                         # status of last command: 0 = success

grep -q "ERROR" app.log && echo "errors found"
systemctl is-active nginx || systemctl start nginx
command -v jq >/dev/null || { echo "jq required" >&2; exit 127; }
```

| Code | Convention |
|------|------------|
| `0` | Success |
| `1` | General error |
| `2` | Misuse of builtin / bad usage |
| `126` | Found but not executable |
| `127` | Command not found |
| `130` | Killed by Ctrl-C (SIGINT) |

## Traps

> **Docs:** [Bourne Shell Builtins (trap), GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Bourne-Shell-Builtins)

```bash
tmpdir="$(mktemp -d)"
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT                          # runs on any exit, even set -e

trap 'echo "failed at line $LINENO" >&2' ERR
trap 'echo "interrupted"; exit 130' INT TERM
```

`EXIT` is the workhorse: temp files, lock files, and spawned background jobs all get cleaned up no matter how the script dies.

## Here-Docs & Here-Strings

> **Docs:** [Redirections, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Redirections)

```bash
cat <<EOF > /etc/myapp/config.yaml
env: $DEPLOY_ENV
region: ca-central-1
EOF

cat <<'EOF'                       # quoted delimiter: NO expansion
literal $HOME stays as-is
EOF

sudo tee /etc/motd <<EOF >/dev/null
Managed by automation. Do not edit.
EOF

grep "prod" <<< "$config_text"    # here-string: feed a variable to stdin
```

## Command Substitution & Arithmetic

> **Docs:** [Shell Arithmetic, GNU Bash manual](https://www.gnu.org/software/bash/manual/bash.html#Shell-Arithmetic)

```bash
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"       # prefer $() over backticks
files="$(find /var/log -name '*.gz' | wc -l)"

(( count = files + 1 ))                     # arithmetic context
echo $(( 7 / 2 ))                           # 3 (integer division only)
(( count > 100 )) && echo "log buildup"
((i++)); ((total += batch))
echo $(( RANDOM % 6 + 1 ))                  # dice roll

awk 'BEGIN { printf "%.2f\n", 7/2 }'        # need floats? use awk or bc
```

Everything here compounds with the interactive tricks in the [Bash CLI One-Liners Cheatsheet](https://karandeepsingh.ca/cheatsheets/bash/bash-cli/). The loop you prototype at the prompt becomes the script you commit.

**Question:** Take your longest deploy script. How many of its assignments use `local x=$(...)` on one line — and would you find out if any of them started failing?

## Related Cheatsheets

- [Bash sed & awk](https://karandeepsingh.ca/cheatsheets/bash/bash-sed-awk/): substitution, address ranges, capture groups, and awk field handling for when plain Bash string manipulation runs out of road
- [Bash CLI One-Liners](https://karandeepsingh.ca/cheatsheets/bash/bash-cli/): find/xargs, grep, jq, rsync, and process-triage pipelines worth running interactively before they ever become scripts
- [Go (Golang)](https://karandeepsingh.ca/cheatsheets/languages/golang/): a different escape hatch. When a script needs real types, tests, and a single static binary, port it to Go

More Bash pages live in the [Bash group](https://karandeepsingh.ca/cheatsheets/bash/), and everything else is in the [cheatsheet library](https://karandeepsingh.ca/cheatsheets/).
