Bash scripting fundamentals
Listen to this lesson
This episode is a study companion for CompTIA Linux+ XK0-006 and is not produced by or endorsed by CompTIA.
Why this matters
Everything up to this point you have done once, by hand. A script is what turns that into something that happens every night at three in the morning without you, and does it identically the thousandth time as the first.
The Automation, Orchestration and Scripting domain is 17% of XK0-006, and this objective is the largest part of it. More to the point, scripting is the skill that separates an administrator who can fix a machine from one who can run a fleet — and it is the one the exam can test in the most detail, because a shell script is unambiguous in a way "describe the boot process" is not.
Bash is also where a small syntax mistake becomes a destructive one. rm -rf "$dir"/ with dir unset deletes from /. Almost every horror story in this lesson is a quoting or an expansion error.
The lesson
The interpreter directive
A script's first line tells the kernel what to run it with:
#!/bin/bash
Those two characters — #!, the interpreter directive, spoken as "shebang" or "hashbang" — must be the very first bytes of the file. A blank line above them, or a UTF-8 byte order mark from a Windows editor, and the kernel does not recognise it. The failure is memorably unhelpful:
./deploy.sh: line 1: #!/bin/bash: No such file or directory
That message, and the similar bad interpreter: No such file or directory, is almost always one of two things: an editor that saved CRLF line endings, so the kernel is looking for an interpreter named /bin/bash\r, or a directive that is not on line 1. file script.sh reports "CRLF line terminators" and dos2unix fixes it.
Two forms you will see:
#!/bin/bash # this exact path
#!/usr/bin/env bash # the first bash on PATH
env is more portable — it finds bash wherever it lives, which matters on BSD and macOS where it is not in /bin, or when a newer bash is installed under /usr/local. The fixed path is more predictable and cannot be influenced by a caller's PATH, which is why security-sensitive scripts prefer it. Either is defensible; #!/bin/sh when you have written bash-specific syntax is not — on Debian and Ubuntu /bin/sh is dash, which has no arrays, no [[ ]] and no local in the way you expect, and your script fails on the one distribution you did not test.
Then make it executable, and run it with a path:
chmod +x deploy.sh
./deploy.sh
The ./ is required because the current directory is not on PATH, which is the deliberate safety property from the shell environment lesson.
Expansion
Expansion is what the shell does to your command line before running it, and understanding the order it happens in explains most surprising behaviour.
Parameter expansion substitutes a variable's value:
name=web01
echo $name
echo ${name}
echo "${name}_backup" # braces required here
$name_backup would look for a variable called name_backup, find nothing, and expand to the empty string silently. The braces in ${name} say where the name ends. Use ${var} by default — it costs one character and removes an entire class of silent failure.
Parameter expansion does far more than substitute, and these forms are worth knowing because they replace whole pipelines:
echo ${var:-default} # value, or "default" if unset or empty
echo ${var:=default} # same, and ASSIGNS default to var
echo ${var:?not set} # error and exit if unset -- excellent in scripts
echo ${#var} # length
echo ${var:0:3} # substring: 3 characters from position 0
file=report.tar.gz
echo ${file%.gz} # report.tar -- strip shortest match from the END
echo ${file%%.*} # report -- strip longest match from the end
echo ${file#report.} # tar.gz -- strip from the START
echo ${path##*/} # basename, without calling basename
echo ${var/old/new} # replace first occurrence
echo ${var//old/new} # replace all
${var:?} deserves particular attention. A script that begins
target=${1:?usage: deploy.sh <target-directory>}
rm -rf "${target:?}"/*
cannot be the script that deletes the root filesystem, because it refuses to run with an empty variable. That one idiom prevents the most famous class of shell accident.
Brace expansion generates lists, before any variable is expanded:
mkdir -p /srv/{app,db,cache}/{logs,data}
cp config.yml{,.bak} # a neat copy-to-backup trick
echo {1..10}
echo file{01..12}.log
Command substitution
Command substitution runs a command and puts its output where the substitution was:
today=$(date +%F)
count=$(grep -c ERROR /var/log/app.log)
echo "There were $count errors on $today"
The older backtick form does the same thing:
today=`date +%F`
Both are on the objectives, and you should recognise both, but write $(foo), not `foo`. Two reasons that are not stylistic: backticks nest badly — an inner one needs escaping and quickly becomes unreadable — while $(...) nests directly:
newest=$(basename $(ls -t /var/log/*.log | head -1))
And backslashes behave differently inside backticks, which produces bugs that are genuinely hard to see.
Quote command substitutions. files=$(ls) unquoted in a later command is subject to word splitting, so a filename with a space in it becomes two arguments. "$(...)" keeps it as one.
Subshells
Wrapping commands in parentheses — (foo) — runs them in a subshell: a child process with a copy of the environment.
(cd /tmp && tar czf backup.tar.gz data/)
pwd # unchanged -- the cd happened in the child
That is useful precisely because changes do not escape. It is also the source of a bug that catches everybody at least once:
count=0
cat access.log | while read -r line; do
count=$((count + 1))
done
echo "$count" # prints 0
Every command in a pipeline runs in its own subshell, so the loop increments a copy of count and the copy is discarded when the pipeline ends. The fix is to keep the loop in the current shell by redirecting instead of piping:
count=0
while read -r line; do
count=$((count + 1))
done < access.log
echo "$count" # correct
This also happens to be the reason cat file | while read is worth unlearning generally: the redirect is faster, shorter and does not lose your variables.
Note $(( )) in there — arithmetic expansion, which is how bash does numerical work. $((count + 1)), $((a * b)), $((total / n)). Bash is integer-only: $((7 / 2)) is 3, and if you need decimals you are reaching for bc or awk.
Test
Test is how a script asks a question. Three spellings of nearly the same thing:
test -f /etc/passwd
[ -f /etc/passwd ]
[[ -f /etc/passwd ]]
[ is genuinely a command, not syntax — which is why the spaces are mandatory. [-f file] is the shell looking for a program named [-f, and [ $x = 1 ] with no spaces around = is a string test of the literal $x=1. This is the most common beginner error in shell scripting and the error message never says so.
The file tests worth memorising:
[ -f path ] # exists and is a regular file
[ -d path ] # exists and is a directory
[ -e path ] # exists, of any type
[ -r path ] # readable -r/-w/-x for the three permissions
[ -s path ] # exists and is NOT empty
[ -L path ] # is a symbolic link
[ -z "$var" ] # string is empty
[ -n "$var" ] # string is non-empty
Prefer [[ ]] in bash. It is a shell keyword rather than a command, so it does not word-split or glob its operands — meaning [[ -f $file ]] is safe even when file contains a space, where [ -f $file ] breaks. It also adds pattern matching and the regex operator below. Use [ ] only when the script must run under POSIX sh.
Comparisons come in two families that must not be mixed: numerical comparison uses -eq, -lt, -gt and friends; string comparison uses =, == and !=. Using the wrong family gives wrong answers rather than errors — [[ "10" < "9" ]] is true, because as strings "1" sorts before "9". The operators themselves are the next lesson's subject.
Conditional statements
if [[ -f /etc/redhat-release ]]; then
pkg=dnf
elif [[ -f /etc/debian_version ]]; then
pkg=apt
else
echo "unsupported distribution" >&2
exit 1
fi
then on the same line needs the semicolon; fi closes it. Note >&2 — errors belong on stderr, as in the redirection lesson, so a caller can separate them from output.
An if tests a command's exit status, not a value. That is why this works with no brackets at all:
if grep -q "^root:" /etc/passwd; then
echo "root account present"
fi
if ! systemctl is-active --quiet nginx; then
systemctl start nginx
fi
case is the right structure when one variable is checked against several patterns:
case "$1" in
start) start_service ;;
stop) stop_service ;;
restart) stop_service; start_service ;;
status) systemctl status myapp ;;
*.log) echo "that is a log file, not a command" ;;
*) echo "usage: $0 {start|stop|restart|status}" >&2; exit 1 ;;
esac
Each branch ends with ;;, the whole thing ends with esac, and the patterns are globs, not regular expressions — *.log matches by filename pattern. *) is the catch-all and should be last. Forgetting ;; runs the next branch too, which is a bug you will chase for a while.
case is clearer than a stack of elif for this shape, and it is how nearly every init script ever written handles its argument.
Looping statements
for iterates over a list:
for host in web01 web02 db01; do
ssh "$host" uptime
done
for f in /var/log/*.log; do
gzip "$f"
done
for i in {1..5}; do echo "attempt $i"; done
for (( i=0; i<5; i++ )); do echo "$i"; done # C-style
Quote the loop variable. for f in *.log handles filenames with spaces correctly, but gzip $f unquoted then splits them apart again.
while repeats as long as a condition holds:
while [[ $(systemctl is-active myapp) != "active" ]]; do
sleep 2
done
while read -r user shell; do
echo "$user uses $shell"
done < <(awk -F: '{print $1, $7}' /etc/passwd)
while read -r line is the standard way to process a file line by line. The -r matters — without it, read treats backslashes as escapes and mangles any line containing one, such as a Windows path.
until is while inverted — it repeats until the condition becomes true:
until ping -c1 -W1 db01 &>/dev/null; do
echo "waiting for db01..."
sleep 5
done
echo "db01 is up"
Anything until does, while ! also does. It exists because "until this is ready" reads better than "while this is not ready", and waiting for something to come up is exactly the case it fits.
break leaves the loop entirely; continue skips to the next iteration. Both take a number — break 2 leaves two levels of nesting — which is occasionally exactly what you need and usually a sign the loop should be a function.
The Internal Field Separator
IFS, the Internal Field Separator, is the variable the shell uses to decide where one word ends and the next begins. By default it is space, tab and newline, which is why unquoted expansions split on whitespace.
Setting it deliberately is how you parse structured text without a single external command:
while IFS=: read -r user _ uid gid _ home shell; do
[[ $uid -ge 1000 ]] && echo "$user ($uid) -> $shell"
done < /etc/passwd
IFS=: applies to that one read only, because a variable assignment prefixed to a command affects just that command. That scoping is the whole reason the idiom is safe.
Changing IFS globally is a legitimate technique and a well-known foot-gun:
OLD_IFS=$IFS
IFS=,
for field in $csv_line; do echo "$field"; done
IFS=$OLD_IFS # ALWAYS restore it
Leave IFS changed and every later unquoted expansion in the script splits on commas, with effects far from where you made the change.
OFS, the Output Field Separator, is the counterpart — but it is awk's, not bash's. It sets what awk puts between fields when it prints:
awk -F: 'BEGIN{OFS=" -> "} {print $1, $7}' /etc/passwd
CompTIA lists IFS and OFS together; the distinction to hold is that IFS governs splitting input in the shell, OFS governs joining output in awk.
Functions
log() {
echo "[$(date +%T)] $*" >&2
}
backup_db() {
local db=$1
local dest=${2:-/backups}
local stamp
stamp=$(date +%F-%H%M)
if ! mysqldump "$db" > "$dest/$db-$stamp.sql"; then
log "backup of $db FAILED"
return 1
fi
log "backed up $db"
return 0
}
backup_db appdb /srv/backups || exit 1
Functions take arguments the same way scripts do: $1, $2, $@ for all of them, $# for the count. They do not take a parameter list in the declaration, which surprises people arriving from other languages.
local is the line that matters. Without it, every variable a function sets is global, so a function using i as a loop counter silently destroys the caller's i. That bug appears only when the function is called from inside a loop, which is to say much later, in production.
A function returns an exit status, not a value. return 1 sets $?; it does not hand back a string. To return data, print it and let the caller capture it:
newest_log() { ls -t /var/log/*.log | head -1; }
f=$(newest_log)
Note also stamp=$(date ...) being declared and assigned on separate lines above. Combining them — local stamp=$(date +%F) — makes the exit status that of local, which always succeeds, so a failure in the substitution is hidden. It is a small thing that matters in a script with set -e.
Regular expressions in tests
Bash matches regular expressions with =~ inside [[ ]]:
if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "looks like an IPv4 address"
fi
if [[ $filename =~ \.(tar|tgz|zip)$ ]]; then
echo "an archive"
fi
The general form is [[ $foo =~ regex ]], and the rule that catches everyone is do not quote the right-hand side. Quoting turns the regex into a literal string:
[[ $var =~ ^[0-9]+$ ]] # regex -- correct
[[ $var =~ "^[0-9]+$" ]] # literal characters -- never matches
Put a complex pattern in a variable and use it unquoted, which is both readable and safe:
re='^(GET|POST) /api/v[0-9]+'
[[ $line =~ $re ]] && echo "API request"
Captured groups land in the BASH_REMATCH array — ${BASH_REMATCH[0]} is the whole match, ${BASH_REMATCH[1]} the first group:
if [[ $version =~ ^([0-9]+)\.([0-9]+) ]]; then
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
fi
These are ERE — extended regular expressions, as in grep -E — so +, ?, | and {n,m} work without backslashes. And =~ is a bash feature: it does not exist in [ ], and it does not exist in dash.
The three lines to start every script with
#!/usr/bin/env bash
set -euo pipefail
-
-eexits on any command that fails, instead of ploughing on -
-utreats an unset variable as an error, which catches typos in names -
-o pipefailmakes a pipeline fail if any stage failed, not just the last
Without pipefail, curl bad-url | grep x reports success, because grep was the last command and grep was fine. That is how a script "succeeds" having done nothing at all.
set -e has real edge cases — it does not fire inside if conditions or on the left of &&, by design — and it is not a substitute for checking exit status where it matters. It is still much better than the default.
On the exam
-
#!must be the first two bytes.bad interpreterusually means CRLF line endings or a blank first line. -
${var}braces delimit the name;${var:-default},${var:?message},${var%.gz}and${var//old/new}are all fair game. -
$(foo)and`foo`are both command substitution;$(...)nests and is preferred. -
(foo)is a subshell — variable changes inside it do not survive, which is whycat f | while readloses its counter. -
$(( ))is arithmetic and integer-only. -
[is a command, so the spaces are required.[[ ]]is a bash keyword and does not word-split. - Numerical comparison and string comparison are different operator families; mixing them gives wrong answers, not errors.
-
if/elif/else/fitests an exit status.case/esacbranches end with;;and match globs, not regexes. -
foriterates a list,whilerepeats while true,untilrepeats until true.read -rstops backslash mangling. - IFS splits input in the shell; OFS joins output in awk. Restore IFS if you change it globally.
-
localin a function, or you are writing to globals. A function returns an exit status; print what you want to hand back. -
[[ $foo =~ regex ]]— unquoted right-hand side, ERE syntax, groups inBASH_REMATCH. -
set -euo pipefailat the top of anything that matters.
Practise what you just read
1. A script fails with "bad interpreter: No such file or directory" although /bin/bash exists. What is the most likely cause?
Select one
Show answer
B. A file saved by a Windows editor ends each line with CR LF, so the kernel looks for an interpreter literally named /bin/bash followed by a carriage return. file reports "CRLF line terminators" and dos2unix fixes it. The other classic cause is the shebang not being on line 1 -- a blank line or a byte order mark above it has the same effect.
2. Why does "cat access.log | while read line; do count=$((count+1)); done; echo $count" print 0?
Select one
Show answer
C. A pipeline puts each command in its own subshell, and a subshell's variable changes vanish when it exits. Redirect instead of piping -- done < access.log -- and the loop runs in the current shell where the counter survives. This is also the reason to unlearn cat file | while read generally: the redirect is faster and shorter.
3. Which idiom makes a script refuse to run rather than delete from the root of the filesystem?
Select one
Show answer
C. ${var:?message} makes the shell print the message and exit if the variable is unset or empty, so the destructive command is never reached with an empty path. That single construct prevents the most famous class of shell accident, and it costs one line at the top of the script.
10 more questions on this objective are part of the full course.
Hands-on labs
Part of the free CompTIA Linux+ XK0-006 course — 48 lessons and 82 hands-on labs.