Variables, quoting, and arrays¶
Level 2 · Chapter 2 · ⏱️ ~55 min read · Prerequisites: Your first script, Globbing and expansion
Variables hold data, quoting controls how bash treats that data, and arrays hold lists of it. This chapter covers how bash expands a variable underneath, the quoting rules that prevent most shell bugs, the parameter expansion tricks that replace whole programs, and how to store lists of file names safely.
Why it matters¶
Alex writes a cleanup script for old exports:
It works for weeks. Then a colleague runs it as ./cleanup.sh "/srv/exports/Q3 archive", with a space in the folder name. Without quotes, bash splits $dir/* into two words, /srv/exports/Q3 and archive/*. The first is a different, real directory, and its contents are deleted. Another day someone runs the script with no argument at all. $dir is empty, so the command becomes rm -rf /*.
Two missing pairs of double quotes and one missing check caused both disasters. Once you understand what bash does with a variable after it substitutes the value, you'll see these bugs at a glance. The fix is three characters: rm -rf "${dir:?}"/*.
Concepts¶
Variables and assignment¶
A variable is a named piece of memory in the shell that holds a string. You create or change one with an assignment:
The rules:
- No spaces around
=.name = alexdoesn't assign anything. Bash reads it as the commandnamewith two arguments,=andalex, and saysname: command not found.name= alexis different again. It runs the commandalexwithnameset to empty. - Names may contain letters, digits, and underscores, and can't start with a digit. They are case-sensitive:
fileandFILEare different. - Every value is a string.
count=42stores the two characters4and2. Bash only treats them as a number inside arithmetic (covered below). - A value with spaces needs quotes:
greeting="Hello, world".
You read a variable with $: echo "$name". Reading a variable that was never set gives an empty string, not an error. That is convenient, and it is also dangerous, as the cleanup story showed. Chapter 4 shows how set -u turns it into an error.
$var vs ${var}¶
$name and ${name} mean the same thing. The braces mark exactly where the name ends. You need them when the variable is followed by characters that could be part of a name:
name=alex
echo "$name_file" # looks up a variable called "name_file": empty
echo "${name}_file" # alex_file
Braces are also the doorway to every parameter expansion feature below (${name:-default}, ${#name}, and so on). And you need them for positional parameters above 9: ${10}, not $10 (which is $1 followed by a 0).
Many style guides use braces only when needed. Others use them always for consistency. Either is fine. Quoting is what matters.
What bash does to a command line¶
To understand quoting you need to know what bash does to a line before it runs it. Bash processes the line in a fixed order of expansions. An expansion is any step where bash replaces part of the text with something else.
flowchart TD
A["1. Brace expansion<br/>{a,b} → a b"] --> B["2. Tilde expansion<br/>~ → /home/alex"]
B --> C["3. Parameter, arithmetic,<br/>command substitution<br/>$var, $((1+2)), $(date)"]
C --> D["4. Word splitting<br/>(only on unquoted results of step 3)"]
D --> E["5. Pathname expansion (globbing)<br/>(only on unquoted words)"]
E --> F["6. Quote removal"]
F --> G["Run the command<br/>with the final list of words"]
Steps 4 and 5 are where bugs come from. After bash substitutes the value of an unquoted variable, it:
- Splits the result into separate words wherever it finds a character from
IFS(by default: space, tab, newline). This is called word splitting. - Treats each resulting word as a glob pattern. If it contains
*,?, or[...]and matches files, the word is replaced by the matching file names.
Double quotes switch both steps off. The value stays exactly one word, no matter what it contains.
Watch it happen:
Unquoted, the value was split into two words, Hello and world, and echo joined them with one space. Quoted, it stayed one argument with three spaces inside.
Unquoted, the value *.csv was globbed into the matching file names. Quoted, it's just text.
The deadly one, with a file name containing a space:
ls: cannot access 'My': No such file or directory
ls: cannot access 'Report.csv': No such file or directory
ls received two arguments. Now imagine rm instead of ls, and a file called Report.csv that you wanted to keep.
The golden rule
Always double-quote variable and command substitution expansions: "$var", "${arr[@]}", "$(cmd)". Leave them unquoted only when you deliberately want word splitting and globbing, and you can explain why. ShellCheck flags unquoted expansions as SC2086 and SC2046.
The three kinds of quoting¶
| Quoting | What happens inside | Use it for |
|---|---|---|
None: $var |
All expansions, then word splitting and globbing | Almost never on variables |
Double: "..." |
$var, $(...), $((...)), and `...` expand. No splitting, no globbing. \ escapes only $, `, ", \, and newline |
Anything containing variables |
Single: '...' |
Nothing at all. Every character is literal | Fixed strings, regexes, awk/sed programs |
Inside single quotes, nothing is special, not even a backslash. So you can't put a single quote inside single quotes. Two workarounds:
echo "It's" # switch to double quotes
echo 'It'\''s' # end the quote, add an escaped ', start a new quote
Bash also has a fourth form, ANSI-C quoting $'...', which turns escape sequences into real characters: $'tab:\there' contains an actual tab character. It's handy for setting IFS=$'\n' or matching literal tabs.
A backslash outside quotes escapes the single next character: echo \$HOME prints $HOME.
Empty values and quoting¶
Quoting also preserves empty values. An unquoted empty variable disappears completely. It becomes zero arguments, not one empty argument:
(set -- words replaces the positional parameters with words, and $# counts them. It's a quick way to see how many words something expands to.) This matters for tests such as [ $x = yes ]. Chapter 3 shows how they explode when $x is empty.
IFS: the Internal Field Separator¶
IFS is a variable that lists the characters used for word splitting, and by read to split lines into fields. Its default value is space, tab, and newline:
(printf %q prints a value in a form you could paste back into the shell, which makes invisible characters visible.)
Two rules for the default whitespace IFS: runs of whitespace count as one separator, and leading and trailing whitespace is ignored. If IFS contains a non-whitespace character like , or :, each occurrence is a separator, so a,,b has an empty field in the middle.
The safe way to split with a custom IFS is to set it only for one read command. A VAR=value command prefix sets the variable just for that command:
Avoid changing IFS for the whole script. It silently changes how every unquoted expansion behaves from then on. If you must, save and restore it, or do it inside a function with local IFS=,.
"$@" vs "$*" vs $@¶
Your script's arguments are the positional parameters $1, $2, and so on. $# is how many there are. Two special parameters expand to all of them, and quoting changes everything:
| Form | Expands to | Use |
|---|---|---|
"$@" |
Each argument as a separate word, exactly as given | Passing arguments on. Almost always what you want. |
"$*" |
All arguments joined into one word, separated by the first character of IFS |
Building a single message string |
$@ or $* (unquoted) |
All arguments, then word-split and globbed | Almost never. A bug. |
The examples section demonstrates all four with tricky arguments. Remember: "$@" is the only form that passes arguments through intact. The same rule applies to arrays: "${arr[@]}".
Parameter expansion¶
Parameter expansion is bash's built-in toolkit for transforming a variable's value: defaults, lengths, trimming, search and replace, and case changes. It runs inside bash, with no extra process. It replaces many calls to sed, cut, basename, and dirname, and it's much faster inside loops.
Defaults and required values:
| Syntax | If var is unset or empty |
If var has a value |
|---|---|---|
${var:-default} |
Use default (var unchanged) |
Use $var |
${var:=default} |
Assign default to var, then use it |
Use $var |
${var:?message} |
Print message to stderr and exit the script |
Use $var |
${var:+alt} |
Use nothing (empty) | Use alt |
Without the colon (${var-default}), only unset counts. An empty-but-set variable keeps its empty value. With the colon, empty counts as missing too. You almost always want the colon.
${var:=default} can't be used with positional parameters, and on its own line it needs a command to live in. The idiom is the null command :, which does nothing and succeeds:
: "${OUTPUT_DIR:=/srv/exports}" # set a default if the caller didn't
: "${DB_HOST:?DB_HOST must be set}" # stop immediately if missing
Length: ${#var} is the number of characters in the value.
Trimming patterns from the ends. The patterns use glob syntax (*, ?, [...]), not regular expressions:
| Syntax | Removes | Mnemonic |
|---|---|---|
${var#pat} |
Shortest match of pat from the start |
# is left of $ on the keyboard |
${var##pat} |
Longest match from the start | Double means greedy |
${var%pat} |
Shortest match from the end | % is right of $ |
${var%%pat} |
Longest match from the end |
Think of a path as a row of layers. # peels from the left, % peels from the right:
path=/var/log/nginx/access.log.2.gz
${path##*/} → access.log.2.gz (like basename)
${path%/*} → /var/log/nginx (like dirname)
${path%.*} → /var/log/nginx/access.log.2 (drop last extension)
${path%%.*} → /var/log/nginx/access (drop all extensions)
${path#*/} → var/log/nginx/access.log.2.gz (drop up to first /)
Search and replace:
| Syntax | Meaning |
|---|---|
${var/old/new} |
Replace the first match of old |
${var//old/new} |
Replace every match |
${var/#old/new} |
Replace only if it matches at the start |
${var/%old/new} |
Replace only if it matches at the end |
${var//old} |
Delete every match |
Case conversion (bash 4+):
| Syntax | Result for hello world |
|---|---|
${var^^} |
HELLO WORLD |
${var^} |
Hello world (first character) |
${var,,} |
hello world (all lowercase) |
${var,} |
first character lowercase |
Substrings: ${var:offset:length}. The offset starts at 0. Leave out the length to go to the end. A negative offset counts from the end, but needs a space (${var: -2}) so bash doesn't confuse it with :-.
Command substitution¶
Command substitution runs a command and replaces itself with the command's standard output. The modern form is $(command). The old form uses backticks: `command`.
Prefer $( ) because:
- It nests cleanly:
$(dirname "$(readlink -f "$0")"). Nested backticks need escaping (`echo \`date\``) and quickly become unreadable. - Quotes inside
$( )start fresh. You can write"$(grep "$pattern" "$file")", and the inner quotes don't clash with the outer ones. - Backticks are easy to confuse with single quotes in many fonts.
Two details:
- Trailing newlines are removed.
$(printf 'a\nb\n\n\n')givesa<newline>b. Usually that's what you want. - The command runs in a subshell, a child copy of your shell. Variables it sets don't survive.
$(cd /tmp; x=1)changes nothing in your shell.
Command substitution is subject to word splitting and globbing like any expansion, so quote it: "$(cmd)". The exception is the right-hand side of an assignment. x=$(cmd) is never split, so it's safe without quotes (but quoting doesn't hurt).
Arithmetic¶
Bash does integer arithmetic only. There are two forms:
$(( expression ))is arithmetic expansion. It is replaced by the result:echo $((a + b)).(( expression ))is an arithmetic command. It evaluates the expression for its side effects or its truth. Its exit status is 0 (success) if the result is non-zero, and 1 if the result is zero. That's perfect forif (( count > 10 )).
Inside (( )) you don't need $ before variable names, and spaces are allowed. The operators are like C's:
| Operators | Meaning |
|---|---|
+ - * / % |
add, subtract, multiply, integer divide, remainder |
** |
power |
++ -- += -= *= |
increment, decrement, compound assignment |
< <= > >= == != |
comparisons (true = 1, false = 0) |
&& || ! |
logical and, or, not |
cond ? a : b |
ternary |
let "expression" is an older builtin equivalent to (( expression )). Quote its argument, or * gets globbed. Prefer (( )) in new scripts.
Integers are 64-bit signed. Division truncates toward zero, so $((10 / 3)) is 3. For decimals, hand off to a tool that does floating point:
Common mistake: leading zeros mean octal
In arithmetic, a number starting with 0 is read as octal (base 8). $((010)) is 8, and $((08)) is an error, because 8 isn't an octal digit. This bites when you do math on dates and times like 08 or 09. Force base 10 with 10#: $((10#08)) is 8.
Local and global variables¶
By default every variable in a script is global: visible and changeable everywhere in that script, including inside functions. A function that sets count=99 overwrites the script's count.
Declaring a variable with local inside a function creates a separate variable that exists only while the function runs. It hides any global with the same name. Make every function variable local unless you deliberately want to change a global.
Bash uses dynamic scoping: a function can see the local variables of the function that called it. This differs from most programming languages, and it's one more reason to always declare locals and pass data through arguments.
Environment variables and export¶
A shell variable lives only inside the current shell process. The environment is a separate list of NAME=value strings that the kernel copies to every child process when it starts. PATH, HOME, USER, and LANG are environment variables.
export NAME marks a shell variable so that it's copied into the environment of every child started afterward. The relationship looks like this:
flowchart LR
subgraph shell["Your shell"]
V1["STAGE=dev<br/>(exported)"]
V2["REGION=eu<br/>(not exported)"]
end
shell -- "starts child:<br/>copies exported vars only" --> child["Child process<br/>sees STAGE=dev<br/>REGION is unset"]
The copy is one-way. A child can change its copy, but never the parent's (the rule from Chapter 1).
There are three ways to put something in a child's environment:
export STAGE=dev # from now on, every child gets STAGE
REGION=us ./deploy.sh # only this one command gets REGION=us
env REGION=us ./deploy.sh # same thing, using the env program
Convention: environment variables and exported constants are UPPER_CASE. Script-internal variables are lower_case. This avoids clobbering important variables like PATH by accident. A script that does PATH=/srv/data breaks every command after that line.
readonly and declare¶
readonly NAME=value creates a constant. Any later attempt to change or unset it fails with an error. Use it for settings at the top of a script so a typo further down can't silently change them.
declare (also spelled typeset) sets attributes on variables:
| Command | Effect |
|---|---|
declare -r NAME=v |
Read-only (same as readonly) |
declare -x NAME=v |
Exported (same as export) |
declare -i n |
Integer: assignments are evaluated as arithmetic |
declare -l s / declare -u s |
Convert to lowercase / uppercase on assignment |
declare -a arr |
Indexed array |
declare -A map |
Associative array (required; see below) |
declare -p NAME |
Print the variable's attributes and value. Great for debugging |
declare -g NAME |
Inside a function: create a global, not a local |
Inside a function, declare creates a local variable, just like local.
Indexed arrays¶
A plain variable holds one string. An indexed array holds an ordered list of strings, numbered from 0. It is the correct way to store a list of file names, because each element keeps its spaces and special characters intact. A space-separated string can't do that.
The syntax:
| Syntax | Meaning |
|---|---|
arr=(a b c) |
Create (or replace) |
arr+=(d e) |
Append elements |
arr[5]=x |
Set one element (indexes can have gaps) |
"${arr[0]}" |
One element (braces required!) |
"${arr[-1]}" |
Last element |
"${arr[@]}" |
All elements, each a separate word |
"${arr[*]}" |
All elements joined into one word with the first char of IFS |
${#arr[@]} |
Number of elements |
"${!arr[@]}" |
The list of indexes |
"${arr[@]:1:2}" |
Slice: 2 elements starting at index 1 |
unset 'arr[1]' |
Remove one element (leaves a gap in the indexes) |
Common mistake: $arr is only the first element
echo $files prints only report.csv, element 0. Writing $arr[1] gives element 0 followed by the literal text [1]. Array access always needs braces: "${arr[1]}", "${arr[@]}".
Associative arrays¶
An associative array (also called a map, dictionary, or hash) indexes elements by string keys instead of numbers. Bash requires you to declare it first with declare -A:
If you forget declare -A, bash silently creates an indexed array. It evaluates each key as arithmetic, where an unknown word counts as 0, so every key overwrites element 0. The examples show this happening.
Keys come back in no particular order. Pipe through sort if order matters.
Iterating safely¶
The safe loop over an array is always:
For indexes (when you need the position): for i in "${!arr[@]}". For an associative array, "${!map[@]}" gives the keys.
The unsafe patterns are anything that turns the list into a single string and then splits it again: for f in $(ls), for f in ${arr[@]}, files=$(find ...). Every one breaks on spaces, and some also on *.
mapfile / readarray¶
mapfile (its synonym is readarray) reads lines from standard input into an indexed array, one line per element. It's the right way to capture command output as a list.
| Option | Why |
|---|---|
-t |
Strip the trailing newline from each element. You almost always want this. |
-d '' |
Use NUL (the zero byte) as the line separator instead of newline. Pair it with find -print0 for file names that might contain newlines. |
-s N |
Skip the first N lines (such as a CSV header) |
-n N |
Read at most N lines |
Feed it with redirection < file or process substitution < <(command). Process substitution runs the command and makes its output look like a file. Don't pipe into mapfile. A pipe runs mapfile in a subshell, and the array vanishes when the pipeline ends. Chapter 3 explains this pipe pitfall in depth.
Commands and examples¶
Quoting in action: "$@", "$*", $@¶
Save this as args.sh. The show function prints how many arguments it got and wraps each one in brackets:
#!/usr/bin/env bash
# args.sh - show how "$@", "$*", and $@ expand
show() { printf ' %d args:' "$#"; printf ' [%s]' "$@"; echo; }
echo '"$@"'; show "$@"
echo '"$*"'; show "$*"
echo '$@'; show $@
echo '$*'; show $*
IFS=,
echo '"$*" with IFS=,'; show "$*"
Run it in a directory containing notes.txt, with three tricky arguments:
"$@"
3 args: [My Report.csv] [two spaces] [*.txt]
"$*"
1 args: [My Report.csv two spaces *.txt]
$@
5 args: [My] [Report.csv] [two] [spaces] [notes.txt]
$*
5 args: [My] [Report.csv] [two] [spaces] [notes.txt]
"$*" with IFS=,
1 args: [My Report.csv,two spaces,*.txt]
Line by line:
"$@"delivered exactly the 3 arguments you typed, with spaces and*intact."$*"glued them into 1 string, joined with a space (the first char ofIFS).- Unquoted
$@and$*behave identically. Both were split on whitespace (5 words) and the*.txtwas globbed intonotes.txt. The user's arguments were rewritten. - With
IFS=,,"$*"joined with commas. That's a neat trick for building CSV lines.
Parameter expansion recipes¶
Defaults:
:- substituted a value but left port unset. := also assigned it.
The colon matters when a variable is set but empty:
Required values. In a script, :? prints the message with the script name and line, and exits with status 1:
Paths without basename/dirname:
path="/var/log/nginx/access.log.2.gz"
echo "${#path}"
echo "${path##*/}"
echo "${path%/*}"
echo "${path%.*}"
echo "${path%%.*}"
Changing file names:
file="sales_2026-10-01.csv"
echo "${file%.csv}.parquet"
echo "${file/sales/orders}"
echo "${file//-/_}"
echo "${file/#sales/SALES}"
echo "${file/%csv/tsv}"
sales_2026-10-01.parquet
orders_2026-10-01.csv
sales_2026_10_01.csv
SALES_2026-10-01.csv
sales_2026-10-01.tsv
The ${file%.csv}.parquet pattern is the one you'll use most: strip an extension and add another.
Case and substrings:
s="hello world"
echo "${s^^} | ${s^} | ${s,,}"
d="2026-10-01"
echo "year=${d:0:4} month=${d:5:2} day=${d: -2} rest=${d:5}"
Taking a URL apart in pure bash:
url="https://mint.example.com:8443/api/v1"
noproto=${url#*://} # mint.example.com:8443/api/v1
hostport=${noproto%%/*} # mint.example.com:8443
echo "host=${hostport%%:*} port=${hostport##*:} path=/${noproto#*/}"
Lowercasing user input for comparisons
answer=${answer,,} makes Y, y, YES, and yes easy to compare. That's much simpler than listing every spelling.
Command substitution and arithmetic¶
echo "Kernel: $(uname -r), users: $(wc -l < /etc/passwd)"
echo "outer $(echo "inner $(echo deepest)")"
Note the nested double quotes inside $( ). They don't end the outer string.
Trailing newlines are stripped:
Arithmetic:
a=7 b=3
echo $((a + b)) $((a - b)) $((a * b)) $((a / b)) $((a % b)) $((a ** b))
count=0
((count++))
((count += 5))
echo "$count"
n=5
if (( n > 3 )); then echo "big"; fi
echo $(( n > 3 ? 1 : 0 ))
Number bases, and the octal trap:
(( )) sets an exit status based on the value. That's what makes it usable in if, but it can surprise you:
Common mistake: ((count++)) under set -e
count++ evaluates to the old value. When count is 0, the expression is 0, so the exit status is 1. Under set -e (Chapter 4), that ends your script on the very first increment. Use ((++count)), count=$((count + 1)), or ((count++)) || true.
Local, global, and dynamic scope¶
#!/usr/bin/env bash
count=10
no_local() {
count=99 # overwrites the global
temp="leaked" # creates a NEW global
}
with_local() {
local count=1 # a separate variable that hides the global
local temp="kept inside"
echo "inside with_local: count=$count temp=$temp"
}
with_local
echo "after with_local: count=$count temp=${temp:-<unset>}"
no_local
echo "after no_local: count=$count temp=${temp:-<unset>}"
inside with_local: count=1 temp=kept inside
after with_local: count=10 temp=<unset>
after no_local: count=99 temp=leaked
with_local changed nothing outside itself. no_local overwrote count and leaked a brand-new global, temp. In a 300-line script, that kind of leak causes bugs that are very hard to trace.
Dynamic scoping in action:
outer() { local level="outer"; inner; }
inner() { echo "inner sees level=$level"; }
level="global"
outer
inner
When outer calls inner, inner sees outer's local level. Called directly, it sees the global.
Environment and export¶
envdemo.sh just prints two variables:
STAGE=dev; REGION=eu
./envdemo.sh
export STAGE
./envdemo.sh
REGION=us ./envdemo.sh
echo "REGION in my shell: $REGION"
- Neither variable was exported, so the child saw neither.
- After
export STAGE, the child gotSTAGE. - The
REGION=usprefix putREGIONinto that one child's environment only. - Your shell's own
REGIONwas never touched.
Inspect a variable's attributes with declare -p:
-x means exported. env with no arguments prints the current environment.
readonly and declare attributes¶
#!/usr/bin/env bash
readonly BACKUP_DIR=/srv/backups
BACKUP_DIR=/tmp
echo "status=$? BACKUP_DIR=$BACKUP_DIR"
unset BACKUP_DIR
echo "status=$?"
./ro.sh: line 3: BACKUP_DIR: readonly variable
status=1 BACKUP_DIR=/srv/backups
./ro.sh: line 5: unset: BACKUP_DIR: cannot unset: readonly variable
status=1
The constant survived both attempts. With set -e, the first attempt would also stop the script.
Other attributes:
declare -i num
num="4 * 5"; echo "$num"
num=abc; echo "$num"
declare -l lower="MiXeD"
declare -u upper="MiXeD"
echo "$lower $upper"
declare -i evaluated "4 * 5" as arithmetic. It also silently turned abc into 0 (an unknown name counts as 0), which can hide bad input. For that reason many people avoid -i and validate numbers explicitly (Chapter 5).
Indexed arrays¶
files=("report.csv" "My Report.csv" "notes.txt")
echo "${files[1]}"
echo "${files[-1]}"
echo "count=${#files[@]} indexes=${!files[*]}"
Appending, gaps, and removing:
files+=("extra.log")
files[10]="far.log"
echo "${#files[@]} elements, indexes: ${!files[*]}"
unset 'files[10]'
echo "${#files[@]} elements"
Arrays can be sparse: the indexes jump from 3 to 10, and the count is 5, not 11. That's why you loop over "${!arr[@]}" instead of 0 to ${#arr[@]}-1. Quote the unset argument: files[10] unquoted is a glob pattern that could match a file named files1.
Quoted vs unquoted iteration:
for f in "${files[@]}"; do printf '<%s>\n' "$f"; done
echo "--- unquoted"
for f in ${files[@]}; do printf '<%s>\n' "$f"; done
<report.csv>
<My Report.csv>
<notes.txt>
<extra.log>
--- unquoted
<report.csv>
<My>
<Report.csv>
<notes.txt>
<extra.log>
Index and value together, plus slices and joins:
for i in "${!files[@]}"; do printf '%d: %s\n' "$i" "${files[i]}"; done
printf '<%s>\n' "${files[@]:1:2}"
(IFS=,; echo "csv: ${files[*]}")
0: report.csv
1: My Report.csv
2: notes.txt
3: extra.log
<My Report.csv>
<notes.txt>
csv: report.csv,My Report.csv,notes.txt,extra.log
The ( ... ) around the IFS change runs it in a subshell, so your IFS stays untouched afterward.
Globs straight into arrays. This is the safest way to collect file names:
csvs=(*.csv)
echo "${#csvs[@]}: ${csvs[*]}"
none=(*.parquet)
echo "${#none[@]}: ${none[*]}"
shopt -s nullglob
none=(*.parquet)
echo "${#none[@]}"
By default, a glob that matches nothing stays as the literal text *.parquet, so you'd get a one-element array containing a file name that doesn't exist. shopt -s nullglob makes a non-matching glob expand to nothing instead.
Commands as arrays. Build a command with options in an array, then run it with "${cmd[@]}". It's the safe alternative to building a command in a string:
You'll use this in the capstone to add -v to tar only when verbose mode is on.
Associative arrays¶
declare -A port=([http]=80 [https]=443 [ssh]=22)
port[postgres]=5432
for svc in "${!port[@]}"; do
printf '%-9s %5d\n' "$svc" "${port[$svc]}"
done
The order is arbitrary. Testing for a key, using a default, and deleting:
[[ -v port[ssh] ]] && echo "ssh known"
echo "[${port[ftp]:-none}]"
unset 'port[ssh]'
echo "${#port[@]} keys left"
What happens without declare -A:
Both http and ssh were evaluated as arithmetic, both became index 0, and the second overwrote the first. There is no error and no warning, just lost data.
A realistic use, counting requests per IP in a web access log:
10.0.0.5 - - [01/Oct/2026:10:00:01] "GET /api/orders HTTP/1.1" 200 512
10.0.0.7 - - [01/Oct/2026:10:00:02] "GET /api/users HTTP/1.1" 404 128
10.0.0.5 - - [01/Oct/2026:10:00:03] "POST /api/orders HTTP/1.1" 201 64
10.0.0.9 - - [01/Oct/2026:10:00:04] "GET /health HTTP/1.1" 200 2
10.0.0.5 - - [01/Oct/2026:10:00:05] "GET /api/orders HTTP/1.1" 500 0
declare -A hits=()
while read -r ip _; do
hits[$ip]=$(( ${hits[$ip]:-0} + 1 ))
done < access.log
for ip in "${!hits[@]}"; do echo "$ip ${hits[$ip]}"; done | sort -k2,2nr
read -r ip _ puts the first field in ip and the rest of the line in _, a conventional "throwaway" name. (In Level 1 you'd do this with awk '{print $1}' | sort | uniq -c. Both are fine. The array version shines when you need the counts inside the script for further logic.)
mapfile and safe file lists¶
Lines of a file into an array:
Command output into an array, with process substitution:
10.0.0.7 - - [01/Oct/2026:10:00:02] "GET /api/users HTTP/1.1" 404 128
10.0.0.5 - - [01/Oct/2026:10:00:05] "GET /api/orders HTTP/1.1" 500 0
The bulletproof pattern for file names from find, compared with the naive one:
mkdir -p data && touch data/jan.csv "data/feb report.csv" data/mar.csv
bad=( $(find data -name '*.csv') )
declare -p bad
mapfile -d '' -t good < <(find data -name '*.csv' -print0 | sort -z)
declare -p good
declare -a bad=([0]="data/mar.csv" [1]="data/feb" [2]="report.csv" [3]="data/jan.csv")
declare -a good=([0]="data/feb report.csv" [1]="data/jan.csv" [2]="data/mar.csv")
The naive version split feb report.csv into two bogus names. -print0 separates names with a NUL byte, and mapfile -d '' splits on NUL. A NUL byte is the one character that can never appear in a Linux file name, so this pattern handles every possible name.
The pipe trap, previewed:
printf 'x\ny\n' | mapfile -t arr
echo "${#arr[@]}"
mapfile -t arr < <(printf 'x\ny\n')
echo "${#arr[@]}"
After the pipe, the array is empty. The mapfile filled an array in a subshell that has already exited.
Exercises¶
Exercise 1: Extension swap preview (easy)¶
In a directory with some .csv files (create a few with touch), write a loop that prints old -> new for each file, where the new name has .tsv instead of .csv. Use parameter expansion, not sed or basename. Make sure a file named My Report.csv works.
Solution
${f%.csv} removes the shortest .csv match from the end. Quoting "$f" keeps My Report.csv as one argument. The glob in the for line is unquoted on purpose, because there you want globbing. To actually rename, replace printf with mv -- "$f" "${f%.csv}.tsv".
Exercise 2: Split a connection string (easy)¶
Given conn="postgres://alex@mint:5432/sales", use only parameter expansion to print user=alex host=mint port=5432 db=sales.
Solution
conn="postgres://alex@mint:5432/sales"
rest=${conn#*://} # alex@mint:5432/sales
user=${rest%%@*} # alex
rest=${rest#*@} # mint:5432/sales
db=${rest#*/} # sales
hostport=${rest%%/*} # mint:5432
echo "user=$user host=${hostport%%:*} port=${hostport##*:} db=$db"
Work from the outside in, peeling layers with # (from the left) and % (from the right). Doubling the symbol makes the match greedy.
Exercise 3: Find the quoting bugs (medium)¶
This script is meant to copy a file into a backup folder with a date suffix. Find every bug, explain what input triggers it, and fix it.
#!/usr/bin/env bash
src=$1
dest=$2
stamp=`date +%F`
name=`basename $src`
mkdir -p $dest
cp $src $dest/$name.$stamp
echo Copied $src to $dest
Solution
Problems:
$src,$dest, and$nameare unquoted everywhere. A path with spaces splits into several arguments, and a path containing*gets globbed.- Backticks: they work, but
$( )nests and reads better. basenameis an extra process.${src##*/}does the same in bash.- No check that both arguments were given. With no arguments,
mkdir -p ""fails andcpgets bogus arguments. echowith variable data. Useprintf.
Fixed:
#!/usr/bin/env bash
src=${1:?usage: $0 SOURCE_FILE DEST_DIR}
dest=${2:?usage: $0 SOURCE_FILE DEST_DIR}
stamp=$(date +%F)
name=${src##*/}
mkdir -p -- "$dest"
cp -- "$src" "$dest/$name.$stamp"
printf 'Copied %s to %s\n' "$src" "$dest"
Test with ./copy.sh "My Report.csv" "backup dir". -- stops option parsing, so a file named -rf is treated as a file.
Exercise 4: Status code report (medium)¶
Write status-count.sh LOGFILE that reads an access log like the one in this chapter and prints each HTTP status code with its count, sorted by code. Use an associative array. If no file is given, exit with a usage message using ${1:?...}.
Solution
#!/usr/bin/env bash
# status-count.sh - count HTTP status codes in an access log.
set -euo pipefail
log="${1:?usage: status-count.sh ACCESS_LOG}"
declare -A count=()
while read -r -a f; do
status=${f[-2]} # second-to-last field
count[$status]=$(( ${count[$status]:-0} + 1 ))
done < "$log"
for status in "${!count[@]}"; do
printf '%s %d\n' "$status" "${count[$status]}"
done | sort
read -r -a f splits each line into the array f. ${f[-2]} is the second-to-last field, which works no matter how many words the request has. ${count[$status]:-0} treats a new key as 0.
Exercise 5: Safe file inventory (hard)¶
Write sizes.sh [DIR] that lists every regular file under DIR (default .) with its size in bytes. It must work with file names containing spaces, and even newlines. First print how many files were found. Use find -print0, mapfile, and stat -c %s.
Solution
#!/usr/bin/env bash
# sizes.sh - list files under a directory with their sizes, safely.
set -euo pipefail
dir="${1:-.}"
mapfile -d '' -t found < <(find "$dir" -type f -print0)
echo "Found ${#found[@]} files in $dir"
for f in "${found[@]}"; do
printf '%8d %s\n' "$(stat -c %s -- "$f")" "$f"
done
find -print0 ends each name with a NUL byte, and mapfile -d '' splits on NUL, so no name can break the list. Process substitution < <(...) keeps mapfile in the current shell, so found survives. Test the newline case with touch "data/$(printf 'new\nline.csv')". It's counted as one file.
Check yourself¶
-
Why does
name = alexfail, and what does bash think you meant?Answer
Bash splits the line into words before deciding what it is. With spaces, the first word
namebecomes a command name, and=andalexare its arguments, so you get "name: command not found". An assignment must be a single word:name=alex. -
Name the two things bash does to the result of an unquoted
$varthat double quotes prevent.Answer
Word splitting (breaking the value into separate words on characters in
IFS) and pathname expansion/globbing (replacing words containing*,?,[...]with matching file names). Quoting keeps the value as exactly one word, taken literally. -
What's the difference between
"$@"and"$*"? Which do you use to pass your arguments to another command?Answer
"$@"expands to each argument as a separate word, preserving spaces and special characters."$*"joins all arguments into a single word, separated by the first character ofIFS. Use"$@"to pass arguments on. -
Given
f=/data/in/orders.2026.csv.gz, what do${f##*/},${f%%.*}, and${f%.*}print?Answer
${f##*/}→orders.2026.csv.gz(greedy removal up to the last/).${f%%.*}→/data/in/orders(greedy removal from the first.to the end).${f%.*}→/data/in/orders.2026.csv(shortest removal: just.gz). -
What is the difference between
${x:-y}and${x:=y}, and when would${x:?msg}be useful?Answer
Both give
yifxis unset or empty, but:=also assignsytox, while:-leavesxunchanged.${x:?msg}exits the script withmsgifxis unset or empty. It's useful for required settings like${DB_HOST:?}or to guardrm -rf "${dir:?}"/*against an empty variable. -
A function sets
result=42withoutlocal. What happens to a variable calledresultelsewhere in the script?Answer
It's overwritten, because variables are global by default. If no
resultexisted, a new global is created and leaks out of the function. Declare function variables withlocal. -
What does
exportactually do? Can a child process change an exported variable in its parent?Answer
exportmarks a variable so that its value is copied into the environment of every child process started afterward. The copy is one-way: a child can change its own copy, but can never modify the parent's variables. -
Why is
mapfile -d '' -t arr < <(find . -print0)safer thanarr=( $(find .) ), and why notfind . -print0 | mapfile -d '' -t arr?Answer
The
$(find .)version word-splits and globs the output, so names with spaces or*break apart or change. NUL-separated output withmapfile -d ''keeps every name intact, because NUL can't appear in a file name. Piping intomapfileruns it in a subshell, so the array disappears when the pipeline ends. Process substitution keepsmapfilein the current shell.
Key takeaways¶
- Assignments have no spaces:
name=value. Every value is a string until arithmetic uses it. - Unquoted expansions are word-split and globbed. Double-quote every expansion (
"$var","$(cmd)","${arr[@]}") unless you can explain why not. - Use
"$@"to pass arguments on."$*"joins them into one string. - Parameter expansion (
:-,:?,#,%,//,^^,:offset:length) replaces many external tools and guards against empty variables. - Prefer
$( )over backticks and(( ))overlet. Bash math is integer-only, and leading zeros mean octal. - Declare function variables
local.exportonly what child processes need, and usereadonlyfor constants. - Store lists in arrays, never in space-separated strings. Use
declare -Afor maps, andmapfile -d '' < <(find ... -print0)for file names.
Next¶
Continue with Conditionals, loops, and functions, where you'll make scripts that make decisions and repeat work.