Variables, Input & the Environment

How bash actually stores values, the difference between a shell variable and an environment variable, and why a child process cannot see what you just set.

beginner 17 min lesson hands-on task included

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:

VariableHolds
PATHColon-separated directories searched for commands
HOMEThe user’s home directory
PWD / OLDPWDCurrent and previous working directory
USER, UIDWho is running this
SHELLThe login shell — not the shell running the script
IFSInternal field separator for word splitting
LANG / LC_ALLLocale — changes sort order and date output
TMPDIRWhere mktemp puts things
$$PID of the current shell
$?Exit status of the last command
$0Name of the script
RANDOMA new random integer on each reference
SECONDSSeconds since the shell started
BASH_VERSIONUseful 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 IFS and 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
FlagEffect
-rDo not interpret backslashes. Always use it.
-p "text"Print a prompt (to stderr, no trailing newline)
-sSilent — no echo. For passwords
-t NTime out after N seconds, returning non-zero
-n NReturn after N characters, without waiting for Enter
-a arrRead into an array
-d DELIMUse DELIM instead of newline
-u FDRead 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.