Variables in Bash are untyped strings with a few special rules. Most script bugs come from three things: spaces around =, missing quotes and empty variables. Here’s what you need to write reliable scripts.
Assigning and using
name="web1"
port=8080
echo "Server $name listens on $port"
echo "${name}-backup" # braces separate the name from the following text
No spaces around =. name = web1 runs a command called name with the arguments = and web1.
By convention, use lowercase names for your own variables and UPPERCASE for environment and exported variables.
Quoting
file="my report.txt"
rm $file # tries to delete "my" and "report.txt"
rm "$file" # deletes "my report.txt"
Rules of thumb:
- Double quotes around variable expansions, almost always:
"$var","${array[@]}". - Single quotes for literal text where
$shouldn’t expand:'literal $HOME'. - Unquoted expansion splits on whitespace and expands globs. That’s rarely what you want.
Command substitution
today=$(date +%F)
ip=$(hostname -I | awk '{print $1}')
count=$(wc -l < access.log)
Prefer $(…) over backticks. It nests cleanly and is easier to read.
Default values and required variables
port="${PORT:-3000}" # use 3000 if PORT is unset or empty
: "${BACKUP_DIR:=/var/backups}" # assign the default if unset or empty
db="${DB_NAME:?DB_NAME must be set}" # exit with an error if unset or empty
echo "${DEBUG:+debug mode on}" # print only if DEBUG is set and non-empty
${VAR:?} is the best protection for destructive commands. It stops rm -rf "$DIR/"* from becoming rm -rf /*. See rm safely.
String manipulation
path="/var/www/site/index.php"
echo "${#path}" # length: 23
echo "${path##*/}" # index.php (remove longest prefix up to /)
echo "${path%/*}" # /var/www/site (remove shortest suffix from /)
echo "${path%.php}.html" # /var/www/site/index.html
echo "${path/www/srv}" # replace the first match
echo "${path//\//_}" # replace all / with _
echo "${path:0:4}" # substring: /var
echo "${name^^}" # UPPERCASE (Bash 4+)
echo "${name,,}" # lowercase
#/## trim from the start and %/%% trim from the end. These replace many basename, dirname and sed calls.
Arithmetic
n=5
(( n++ ))
total=$(( n * 2 + 1 ))
(( total > 10 )) && echo "big"
Bash arithmetic is integer only. For decimals, use awk or bc: echo "scale=2; 10/3" | bc.
Arrays
servers=(web1 web2 db1)
servers+=(cache1)
echo "${servers[0]}" # web1
echo "${#servers[@]}" # 4 elements
for s in "${servers[@]}"; do echo "$s"; done
Associative arrays (Bash 4+):
declare -A ports=([http]=80 [https]=443 [ssh]=22)
echo "${ports[https]}"
for k in "${!ports[@]}"; do echo "$k=${ports[$k]}"; done
Read lines into an array:
mapfile -t hosts < hosts.txt
Scope: local, export, readonly
greet() {
local msg="hello" # visible only inside the function
echo "$msg"
}
export APP_ENV=production # passed to child processes
readonly CONFIG=/etc/app.conf
A plain variable stays inside the current shell. export makes it visible to programs you start. Child processes can never change the parent’s variables. That’s why running ./set-vars.sh doesn’t affect your shell, but source set-vars.sh does. See environment variables.
Special variables
| Variable | Meaning |
|---|---|
$0 |
Script name |
$1 … $9, ${10} |
Positional arguments |
$# |
Number of arguments |
"$@" |
All arguments, each quoted separately |
$? |
Exit status of the last command |
$$ |
PID of the current shell |
$! |
PID of the last background job |
$RANDOM |
Random number 0–32767 |
Use "$@" (quoted) to pass arguments through unchanged.
Safer scripts
Start scripts with:
#!/usr/bin/env bash
set -euo pipefail
-u turns any use of an unset variable into an error, which catches typos like $BACKUP_DRI. -e stops on errors, and pipefail makes a pipeline fail when any of its commands fails. Run shellcheck script.sh. It catches most quoting mistakes automatically.
Related: Bash if/else, Bash functions and echo vs printf.