Lesson 1 of 12 · The language in depth
Variables, expansions and quoting: slice, replace and compute without ever breaking a file name
Objective
Process strings inside bash itself (file name, extension, host name, case, default values, arithmetic), then know exactly what bash does with a line before running the command, so you choose the right kind of quotes every time. This is the foundation for the rest of the course, and the first cause of bugs in operations scripts.
Key points
#/##remove a pattern at the start (shortest / longest),%/%%at the end;${v/a/b}replaces,${v^^}and${v,,}change case,${#v}gives the length.${v:-default},${v:=default},${v:?message},${v:+other}: with the colon, an empty variable counts as missing.$(( … ))computes with integers;printf -v varformats into a variable without a subshell.- Bash transforms a line in a fixed order: expansions, then word splitting, then globs. Double quotes block the last two steps.
"$@"passes each argument intact;printf %qprotects a string that another shell will read again (ssh, replayable log).
This lesson assumes you know simple variables, ${1:-default} and the globs seen in the "Fundamentals" course (lessons 1.11, 1.13 and 1.17). We go much further.
1. Why avoid basename, cut or sed?
In a loop that processes 20,000 log files, every $(basename "$f") starts a new process. The expansion ${f##*/} does the same job inside bash, hundreds of times faster.
f=/var/backups/ml-web-01-2026-10-20.tar.gz
echo "${#f}" # 40 length in characters
echo "${f:13:9}" # ml-web-01 9 characters from position 13 (counting from 0)
echo "${f: -6}" # tar.gz the last 6 (the space before - is mandatory)
2. Removing a pattern at the start or at the end
Patterns are shell globs (*, ?, […]), not regular expressions.
| Expansion | Removes… | Memory aid |
|---|---|---|
${v#pattern} |
the shortest matching prefix | # is to the left of $ on the keyboard |
${v##pattern} |
the longest prefix | |
${v%pattern} |
the shortest suffix | % is to the right |
${v%%pattern} |
the longest suffix |
f=/var/backups/ml-web-01-2026-10-20.tar.gz
echo "${f##*/}" # ml-web-01-2026-10-20.tar.gz (= basename)
echo "${f%/*}" # /var/backups (= dirname)
nom=${f##*/}
echo "${nom%.tar.gz}" # ml-web-01-2026-10-20 exact extension removed
echo "${nom#*.}" # tar.gz the "full" extension
echo "${nom##*.}" # gz only the last extension
fqdn=ml-db-01.masterlinux.lab
echo "${fqdn%%.*}" # ml-db-01 short name
echo "${fqdn#*.}" # masterlinux.lab domain
for f in /srv/exports/*.CSV; do
mv -- "$f" "${f%.CSV}.csv" # bulk rename, without rename or sed
done
3. Replacing and changing case
ligne="agence de fes;agence de rabat;agence de oujda"
echo "${ligne/agence/AGENCE}" # AGENCE de fes;agence de rabat;... first one only
echo "${ligne//;/ | }" # agence de fes | agence de rabat | ... all of them
echo "${ligne//agence de /}" # fes;rabat;oujda delete = replace with nothing
chemin=/srv/site/
echo "${chemin/#\/srv/\/data}" # /data/site/ # = anchored at the START
echo "${chemin/%\//}" # /srv/site % = anchored at the END
role=web ; ENV=PROD
echo "${role^} ${role^^} ${ENV,,}" # Web WEB prod
Case changes require bash 4 or later: every current distribution has it, but not Debian's sh (dash).
4. Default values, mandatory variables
| Expansion | If v is missing |
If v is empty |
If v has a value |
|---|---|---|---|
${v-d} |
d |
empty | the value |
${v:-d} |
d |
d |
the value |
${v:=d} |
d, and v receives d |
d, and v receives d |
the value |
${v:?msg} |
error, the script stops | error | the value |
${v:+x} |
empty | empty | x |
#!/bin/bash
# /usr/local/bin/ml-purge — configuration through environment variables
: "${RETENTION_JOURS:=14}" # ":" does nothing: only the assignment matters
CIBLE=${1:?Usage : ml-purge HOTE (exemple : ml-app-01)}
echo "Purge sur $CIBLE au-delà de $RETENTION_JOURS jours${DRY_RUN:+ (simulation)}"
${v:+…} adds an option only when it makes sense: rsync -a ${VERBOSE:+-v} …. And indirection ${!nom} reads the variable whose name is stored in nom (nom=seuil_db; echo "${!nom}"); as soon as cases multiply, an associative array (lesson 2.2) is cleaner.
5. Computing: $(( )) and printf -v
echo $(( 45 * 100 / 60 )) # 75 INTEGER division (truncated)
echo $(( 2 * 100 / 3 )) # 66 while 66.67 rounds to 67
echo $(( (2 * 100 + 3 / 2) / 3 )) # 67 rounding: add half of the divisor
echo $(( 17 % 5 )) $(( 2 ** 10 )) # 2 1024
(( compteur += 1 )) # no $ needed inside (( ))
printf -v jour '%(%F)T' -1 # today's date, without running the date command
printf -v hote 'ml-web-%02d' 3 # hote=ml-web-03
Bash only knows integers: for amounts, compute in cents (12350 for 123.50 MAD) or hand the calculation to awk.
6. What bash does with a line
Take a variable containing a space and a star, in a directory that contains rapport de juin.txt and simple.txt:
motif="ventes *"
echo $motif # ventes rapport de juin.txt simple.txt ← split THEN the star replaced
echo "$motif" # ventes * ← intact
Without quotes, bash has: 1) replaced $motif with its value; 2) split it on spaces (the two spaces are gone); 3) replaced * with the list of files. Double quotes prevent steps 2 and 3. Golden rule: every expansion inside double quotes, unless you precisely want the splitting.
f="rapport de juin.txt"
wc -c $f # wc: rapport: No such file or directory (3 files requested)
wc -c "$f" # 4 rapport de juin.txt
7. The three kinds of quotes
hote=ml-web-01
echo 'Serveur : $hote' # Serveur : $hote nothing is interpreted
echo "Serveur : $hote" # Serveur : ml-web-01 $ is interpreted
echo "Prix : 120 \$" # Prix : 120 $ \ protects one character inside "…"
echo $'Colonne1\tColonne2' # $'…' understands \t, \n, \e
echo 'l'\''agence' # l'agence (close, \' , reopen: the only way to get ' inside '…')
| Syntax | $v, $( ) |
\n |
Typical use |
|---|---|---|---|
'…' |
no | no | regexes, awk programs, sed patterns |
"…" |
yes | no | almost everything else |
$'…' |
no | yes | special characters (IFS=$'\n', tabs) |
8. "$@", "$*" and IFS
# The script receives three arguments: "Rabat centre" Fes Agadir
for a in "$@"; do echo "[$a]"; done # [Rabat centre] [Fes] [Agadir] 3 iterations
for a in "$*"; do echo "[$a]"; done # [Rabat centre Fes Agadir] 1 iteration
for a in $@; do echo "[$a]"; done # [Rabat] [centre] [Fes] [Agadir] 4 iterations: broken
A wrapper script that adds options then hands over writes exec rsync -a --exclude='*.tmp' "$@": arguments intact, and the exit code is directly the one from rsync.
IFS (space, tab, newline by default) is used for word splitting and by read. Change it for a single command:
IFS=: read -r nom ip ville <<< "ml-db-01:10.0.0.20:casablanca"
echo "$nom / $ip / $ville" # ml-db-01 / 10.0.0.20 / casablanca
9. Globs under control, and names starting with -
By default, a glob that matches nothing stays as is: for f in /srv/vide/* runs once with the literal value /srv/vide/*.
| Option | Effect |
|---|---|
shopt -s nullglob |
a glob with no match disappears: the loop does not run |
shopt -s failglob |
a glob with no match is an error |
shopt -s dotglob |
* includes hidden files |
shopt -s globstar |
**/*.log goes down into every subdirectory |
shopt -s extglob |
extended patterns: !(*.gz), `@(web |
A file named -rf is taken as an option by rm *. Fixes: rm -- * (-- = end of options) or rm ./*.
10. printf %q: re-injecting a string safely
ssh joins its arguments and has them read again by the remote shell:
chemin="/srv/partage/compta 2026;reboot"
printf '%q\n' "$chemin" # /srv/partage/compta\ 2026\;reboot
ssh ml-web-01 rm -f "$chemin" # DANGER: the remote shell runs "reboot"
ssh ml-web-01 "rm -f $(printf '%q' "$chemin")" # safe
A file name supplied by a user and passed as is to ssh lets someone run a command on the remote server: it is a real security hole.
11. Real case: the purge script that emptied the wrong directory
On a MasterLinux file server, a cleanup script contained rm -rf $DOSSIER/*. One day it was started without an argument by a badly written scheduled task: $DOSSIER was empty, and the command became rm -rf /*. Three protections, each one enough on its own:
DOSSIER=${1:?Usage : purge.sh DOSSIER} # 1. mandatory argument
[ -d "$DOSSIER" ] || exit 1 # 2. it must exist
rm -rf -- "${DOSSIER:?}"/* # 3. ${v:?} refuses an empty value, quotes everywhere
Common pitfalls
- Mixing up
#and%, or forgetting the colon:${PORT-8080}letsPORT=""through. ${v: -3}without the space:${v:-3}is a default value, not "the last three characters".- Unquoted variables:
[ $v = oui ]with an emptyvbecomes[ = oui ];for f in $(ls)breaks names. Always"$v", andfor f in ./*. - Quotes stored inside a variable do nothing:
opts='-a "mon dossier"'; cmd $optspasses"monanddossier". For a list of arguments, use an array (lesson 2.2).
Lab
On Rocky Linux 9, you write six small scripts in /root/bash:
nom-archive.sh: the name of an archive without directory or extension, without external commands;court.sh: the short name of a machine, in uppercase;conf.sh: default values that survive empty variables;pourcent.sh: a correctly rounded integer percentage;compte-args.sh: the arguments received, intact thanks to"$@";cmd-distante.sh: a safesshcommand withprintf %q.
Quiz
- With
f=/srv/www/index.old.html, what do${f##*/},${f%.*}and${f%%.*}give? - What is the difference between
${PORT-80}and${PORT:-80}? - In which order does bash apply variable expansion, word splitting and globs?
- What is the difference between
"$@"and"$*"? - Why is
ssh host rm -f "$f"dangerous if$fcomes from a user?
Answers
index.old.html;/srv/www/index.old;/srv/www/index.- The first only replaces when
PORTis missing; the second also replaces whenPORTis empty. - First the expansions (
$v,$( ),$(( ))), then word splitting according toIFS, then globs. Double quotes prevent the last two steps. "$@"gives one word per argument, intact;"$*"joins all the arguments into a single string separated by the first character ofIFS.- ssh joins the arguments and has the remote shell interpret them again: a name containing
;or$( )becomes a command there. The value must be protected withprintf %q.