Variables are what turn a fixed list of commands into a program. Bash’s model is simpler than most languages — everything is a string — and the two places it surprises people are typing and inheritance.
Topic 1: Assignment
name="deploy"
count=42
path="/var/log/app.log"
No spaces around =. This is the first error everyone hits:
name = "deploy" # bash runs the COMMAND `name` with args `=` and `deploy`
name= "deploy" # runs `deploy` with name set to empty in its environment
name ="deploy" # runs the command `name` with arg `=deploy`
The rule falls out of how the shell parses a line: an assignment is recognised only when NAME= appears with no whitespace at the start of a word.
Read a variable with $name or ${name}. The braces are required whenever the following character could be part of the name:
file="report"
echo "$file_2026" # looks for a variable called file_2026 — empty
echo "${file}_2026" # report_2026
Valid names are letters, digits and underscore, not starting with a digit. By convention: lower_snake for local variables, UPPER_SNAKE for environment variables and constants.
Topic 2: Everything Is a String
Bash has one type: string. count=42 stores the two characters 4 and 2.
count=42
echo "$(( count + 8 ))" # 50 — converted to integer for the arithmetic, back to string
Arithmetic requires an arithmetic context:
x=$(( 5 + 3 )) # arithmetic expansion — the usual form
(( x = 5 + 3 )) # arithmetic command
(( x++ )) # increment
let "x = 5 + 3" # legacy, avoid
Inside (( )) you write x, not $x — arithmetic context expands names automatically. And (( )) returns exit status 1 when the result is 0, which is the set -e trap covered in the error-handling lesson.
Bash has no floating point:
echo $(( 10 / 3 )) # 3 — integer division, truncated
echo "scale=2; 10/3" | bc # 3.33
awk 'BEGIN { printf "%.2f\n", 10/3 }' # 3.33 — no extra dependency
Needing decimals repeatedly is one of the signals it is time to leave bash.
Attributes with declare:
declare -i n=5 # integer: n=n+1 works without $(( ))
declare -r CONST=fixed # readonly — reassignment is an error
declare -a list=() # indexed array
declare -A map=() # associative array
declare -l lower # auto-lowercase on assignment
declare -u upper # auto-uppercase
declare -x EXPORTED # same as export
readonly is worth using for anything that should never change — a config path, a threshold. It converts a silent bug into an error at the point of the mistake.
Topic 3: Shell vs Environment Variables
This is the distinction that causes “my script cannot see the variable”.
name="local" # SHELL variable — this process only
export API_URL="..." # ENVIRONMENT variable — this process AND its children
Every process gets a copy of its parent’s environment, not its shell variables.
#!/usr/bin/env bash
shell_var="not exported"
export env_var="exported"
bash -c 'echo "shell_var=[$shell_var] env_var=[$env_var]"'
# shell_var=[] env_var=[exported]
Three consequences worth internalising:
1. Inheritance is one-way. A child can never modify its parent’s environment. This is why cd inside a script does not change your shell’s directory, and why a script that sets a variable cannot hand it back — you must source it instead of running it.
2. export marks a variable, it does not copy a value. Exporting after assigning works fine:
API_URL="https://api.example.com"
export API_URL # same effect as exporting on the assignment line
3. Per-command environment: a VAR=value prefix applies to that command only.
DEBUG=1 ./script.sh # DEBUG set for this run only
PGPASSWORD="$pw" psql -c 'SELECT 1'
This is the correct way to pass a secret to a single command — it lands in the environment, which is not visible in ps output, rather than on the command line, which is.
env # every environment variable
printenv API_URL # one
set # environment PLUS shell variables and functions
unset name # remove
export -n API_URL # un-export but keep as a shell variable
The variables that matter:
| Variable | Holds |
|---|---|
PATH | Colon-separated directories searched for commands |
HOME | The user’s home directory |
PWD / OLDPWD | Current and previous working directory |
USER, UID | Who is running this |
SHELL | The login shell — not the shell running the script |
IFS | Internal field separator for word splitting |
LANG / LC_ALL | Locale — changes sort order and date output |
TMPDIR | Where mktemp puts things |
$$ | PID of the current shell |
$? | Exit status of the last command |
$0 | Name of the script |
RANDOM | A new random integer on each reference |
SECONDS | Seconds since the shell started |
BASH_VERSION | Useful for guarding bash-4-only features |
PATH, and why cron breaks:
echo "$PATH"
# /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
The shell searches these directories in order and runs the first match. Cron gives you a nearly empty PATH, which is why docker and kubectl are “not found” at 3am. Set it explicitly at the top of any scheduled script:
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Never put . in PATH. A directory containing a malicious file named ls becomes a trap the moment you cd into it.
command -v kubectl # where would this resolve? Empty if absent
type -a python # EVERY match, in order — finds shadowing
hash -r # clear the command lookup cache after installing something
Topic 4: Quoting, Briefly
Covered fully in the next lesson, but the rule starts here:
VAR="SRE Labs"
echo "$VAR" # SRE Labs — one argument
echo '$VAR' # $VAR — literal
echo $VAR # SRE Labs — TWO arguments, boundary lost
- Double quotes allow
$var,$(cmd)and\escapes; suppress word splitting and globbing. - Single quotes are completely literal.
- No quotes means the value is split on
IFSand then glob-expanded.
Quote every expansion. The next lesson explains exactly what goes wrong when you do not.
Topic 5: Reading Input
read -r -p "Target host: " host
| Flag | Effect |
|---|---|
-r | Do not interpret backslashes. Always use it. |
-p "text" | Print a prompt (to stderr, no trailing newline) |
-s | Silent — no echo. For passwords |
-t N | Time out after N seconds, returning non-zero |
-n N | Return after N characters, without waiting for Enter |
-a arr | Read into an array |
-d DELIM | Use DELIM instead of newline |
-u FD | Read from a specific file descriptor |
#!/usr/bin/env bash
set -Eeuo pipefail
read -r -p "Environment [dev/staging/prod]: " env
case $env in
dev|staging|prod) ;;
*) echo "invalid environment: ${env}" >&2; exit 2 ;;
esac
if ! read -r -s -t 30 -p "API key: " api_key; then
echo $'\nno input within 30s' >&2
exit 1
fi
echo # newline, since -s swallowed the user's Enter
read -r -n 1 -p "Deploy to ${env}? [y/N] " confirm
echo
[[ $confirm == [yY] ]] || { echo "aborted"; exit 0; }
Three habits in that snippet: -t on every interactive read so the script cannot hang forever under automation, validation against an allow-list immediately after reading, and an explicit echo after -s because the user’s newline was suppressed.
Detecting non-interactive execution:
if [[ -t 0 ]]; then
read -r -p "Continue? [y/N] " reply
else
reply=${AUTO_CONFIRM:-n} # no terminal — take it from the environment
fi
[[ -t 0 ]] is true when stdin is a terminal. Without this check, a script prompting under cron blocks until the timeout, every single run.
Reading a whole file:
read gets one line at a time. Reading files properly — with all the traps — is its own lesson later in the module:
while IFS= read -r line; do
printf '%s\n' "$line"
done < input.txt
Try it yourself: Set a variable without export, then run bash -c 'echo "$var"' and confirm it is empty. Add export and repeat.
Common mistake: Writing read password without -r and without -s. The backslashes in a strong password get eaten, and the password itself appears on screen and in the terminal scrollback.