Bash variables have no types. A variable is a string of characters, and whether it is read as a number, an array or a path depends on the context where you use it. Understanding this simple but powerful model lets you use parameter expansion, integer arithmetic and arrays to write concise and robust scripts.
What you will learn
- Declare variables and read the special variables
$?,$#,$@,$0,$$ - Compute with
$(( ))without callingbcorexpr - Apply default values with
${var:-value}for configurable scripts - Extract and transform strings without awk or sed
- Create indexed and associative arrays to group data
- Control the scope of a variable with
exportandlocal
When is it useful?
Variables and their expansions show up in almost every intermediate-level script:
- read the configuration from environment variables with a default value when they are not set (
${TIMEOUT:-30}) - extract the file name or the extension of a path without calling
basename - count the arguments passed to a script and print a usage message when
$# -lt 2 - store the list of servers to process in an array and loop over it
- check the exit code of the last command with
$?
The RHCSA objective "Create simple shell scripts" (EX200, RHEL 10) names two points this page covers directly: process the script's arguments ($1, $2) and use the output of commands ($( )). Arithmetic and arrays are not listed: they serve your scripts, not the exam.
Simple variables
A variable is declared without spaces around the =. The name starts with a letter or an underscore, and the value goes in quotes as soon as it contains a space:
name="Alice"age=30path="/var/log/nginx"echo "Name: $name, Age: $age"# Name: Alice, Age: 30Double quotes let variables expand. Single quotes turn expansion off, and the text stays literal:
echo "$name" # Aliceecho '$name' # $name (literal)Command substitution
$() runs a command and captures its output, which you can store in a variable:
iso_date=$(date '+%Y-%m-%d')current_user=$(whoami)nb_files=$(ls /etc | wc -l)
echo "Date: $iso_date, User: $current_user, Files in /etc: $nb_files"# Date: 2026-04-10, User: bob, Files in /etc: 222Special variables
These special parameters are set automatically by Bash, and none of them can be assigned with =: 0=x or 1=x answer command not found. Only the positional arguments can be replaced, all at once, with set -- value1 value2. Read the table line by line, with the example of a script called with two arguments.
| Variable | Meaning | Example |
|---|---|---|
$0 | Script name | demo-variables.sh |
$1 … $9 | Positional arguments | $1 = first argument |
$# | Number of arguments | 2 when two args are passed |
$@ | All the arguments (list) | "one" "two" |
$* | All the arguments (one string) | "one two" |
$? | Exit code of the last program | 0 = success |
$$ | PID of the current process | 12345 |
$! | PID of the last background process | 12346 |
# Called as: ./script.sh one twoecho "Script : $0" # Script : ./script.shecho "First : $1" # First : oneecho "Nb args: $#" # Nb args: 2echo "All : $@" # All : one two
ls /nonexistent 2>/dev/nullecho "Exit code: $?" # Exit code: 2$@ vs $*
The difference only shows up inside double quotes. set -- replaces the positional arguments of the current shell, which simulates a script called with "with space" and arg2:
set -- "with space" "arg2"
for a in "$@"; do echo "→ $a"; done# → with space# → arg2
for a in "$*"; do echo "→ $a"; done# → with space arg2 (merged into a single argument)Always use "$@" to pass or walk the arguments: each argument stays a distinct word, internal spaces included. "$*" merges all the arguments into a single string, separated by a space, and the loop runs only once.
Integer arithmetic
$(( expression )) evaluates an arithmetic expression on integers, without calling expr or any external process:
a=10; b=3echo "$((a + b))" # 13echo "$((a - b))" # 7echo "$((a * b))" # 30echo "$((a / b))" # 3 (integer division)echo "$((a % b))" # 1 (modulo)echo "$((a ** b))" # 1000 (power)To increment a variable, prefer an explicit assignment: its exit code is always 0, which makes it compatible with set -e, the mode that stops the script at the first failing command:
counter=0counter=$((counter + 1))counter=$((counter + 5))echo "$counter" # 6Bash only handles integers. For a decimal result, you hand over to bc or awk. bc is not always installed: it is missing from the debian:13 and almalinux:10 images measured, whereas awk is present everywhere.
echo "scale=2; 10 / 3" | bc # 3.33, when bc is installedawk 'BEGIN { printf "%.2f\n", 10 / 3 }' # 3.33, with awkParameter expansion
Parameter expansion ${ } offers much more than plain substitution: default value, path slicing, case change, replacement, all without starting a process.
Default values
# Use the value when set, otherwise the defaultTIMEOUT=${TIMEOUT:-30}PREFIX=${PREFIX:-"/usr/local"}
echo "Timeout: $TIMEOUT" # Timeout: 30 (when TIMEOUT is not set)echo "Prefix : $PREFIX" # Prefix : /usr/localThe four forms differ by a single character. The table below is the one to keep at hand: :- and := provide a fallback, :+ an alternative, :? a guard that stops the script.
| Syntax | Behaviour |
|---|---|
${var:-default} | Returns default when var is empty or unset |
${var:=default} | Assigns and returns default when var is empty or unset |
${var:+value} | Returns value when var is set and not empty |
${var:?message} | Error with message when var is empty or unset |
# Practical example: make a script configurable through the environment#!/bin/bashset -euo pipefail
DEST=${DEST:-"/tmp/backup"}KEEP=${KEEP:-7}LOG=${LOG:-"/var/log/backup.log"}
echo "Backup to $DEST, keep $KEEP days, log $LOG"Called without variables, the script uses the defaults. Called with DEST=/mnt/nas ./script.sh, it uses /mnt/nas: the variable is set in the environment of that single command, without changing anything in your session.
String manipulation
Without calling sed or awk, Bash can transform strings directly. The results below were checked on Bash 5.2, with an nginx log path:
path="/var/log/nginx/access.log"
echo "${#path}" # 25 (length)echo "${path^^}" # /VAR/LOG/NGINX/ACCESS.LOG (upper case)echo "${path,,}" # /var/log/nginx/access.log (lower case)echo "${path##*/}" # access.log (everything after the last /)echo "${path%/*}" # /var/log/nginx (everything before the last /)echo "${path##*.}" # log (extension)echo "${path%.log}.bak" # /var/log/nginx/access.bak (replaces the extension)echo "${path/nginx/apache}" # /var/log/apache/access.log (first occurrence)The same syntax, summed up. Remember the rule of the doubled sign: ## and %% remove the longest match, # and % the shortest; # works from the start, % from the end.
| Syntax | Result |
|---|---|
${#var} | Length of the string |
${var^^} | All upper case |
${var,,} | All lower case |
${var##pattern} | Removes the longest matching prefix |
${var%%pattern} | Removes the longest matching suffix |
${var#pattern} | Removes the shortest prefix |
${var%pattern} | Removes the shortest suffix |
${var/pattern/repl} | Replaces the first occurrence |
${var//pattern/repl} | Replaces every occurrence |
Indexed arrays
An indexed array stores several values reachable by an integer index, which starts at 0:
servers=("web01" "web02" "db01" "cache01")
echo "${servers[0]}" # web01echo "${servers[-1]}" # cache01 (last item)echo "${#servers[@]}" # 4 (number of items)echo "${servers[@]}" # web01 web02 db01 cache01echo "${servers[@]:1:2}" # web02 db01 (slice: 2 items from index 1)To walk all the items, loop over "${servers[@]}", quotes included, so that each value stays whole:
for s in "${servers[@]}"; do echo " → $s"done# → web01# → web02# → db01# → cache01To add an item at the end of the array, use += with parentheses:
servers+=("monitor01")echo "${servers[@]}" # web01 web02 db01 cache01 monitor01Associative arrays
Associative arrays (declare -A) use text keys, the equivalent of a dictionary. The iteration order of the keys is not the insertion order: Bash 5.2 returns ssh https mysql http here, and nothing guarantees that order from one version to the next.
declare -A portsports["ssh"]=22ports["http"]=80ports["https"]=443ports["mysql"]=3306
echo "${ports[ssh]}" # 22echo "${!ports[@]}" # All keys: ssh https mysql httpecho "${ports[@]}" # All values: 22 443 3306 80To walk keys and values together, loop over the keys and read each value with ${ports[$service]}:
for service in "${!ports[@]}"; do echo " $service → ${ports[$service]}"done# ssh → 22# https → 443# mysql → 3306# http → 80Export and scope
export, make a variable visible to child processes
export puts a variable into the environment passed to every child process: the programs your script starts can then read it.
LANG=en_US.UTF-8export LANG
# Same thing in one line:export LANG=en_US.UTF-8A non-exported variable is not passed to subprocesses, including a bash -c started from the script:
MY_VAR="hello"bash -c 'echo $MY_VAR' # (empty: not passed on)
export MY_VARbash -c 'echo $MY_VAR' # hellolocal, limit the scope to a function
Without local, any assignment inside a function changes the global variable, with side effects that are hard to follow:
GLOBAL="initial"
my_function() { local LOCAL="I am local" GLOBAL="changed in the function" echo "In the function: $LOCAL / $GLOBAL"}
my_functionecho "After: $GLOBAL" # changed in the function# echo $LOCAL # bash: LOCAL: unbound variable (with set -u)Rule: in every function, declare the internal variables with local to avoid side effects.
Troubleshooting
Most variable errors can be recognised by their message, captured here on Bash 5.2 (Debian 13 and AlmaLinux 10). Two causes come back all the time: a space around the = and a script launched with sh instead of Bash.
| Symptom | Likely cause | Solution |
|---|---|---|
name: command not found and empty variable | Space around the = | var=value without spaces |
: unbound variable under set -u | Uninitialised variable | Initialise it or use ${var:-} as an empty default |
| An argument containing a space arrives split in two | $@ used without quotes | Always write "$@" |
(( expr )) stops the script under set -e | Arithmetic result = 0 | Use var=$((var + 1)) |
declare: not found (dash) or declare: -A: invalid option (Bash 3) | Script launched with sh, or Bash older than 4.0 | Run with bash or ./script.sh, check bash --version |
Key points
$?is the exit code of the last program, to read immediately: the next command overwrites it."$@"passes the arguments correctly, spaces preserved;"$*"merges them.${var:-default}makes scripts configurable without anif.${path##*/}extracts the file name;${path%/*}extracts the directory, without callingbasename/dirname.declare -Acreates an associative array, available only with Bash 4+.- Always declare the internal variables of a function with
local.
Knowledge check
Check that the essentials of this guide stuck. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Bash conditions: if, tests and case by example
A new full quiz with random questions
Work again only on the questions you missed