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.

  • Bash
  • Scripting
  • Linux