Throughout the module you have written "$CSV_PATH", "$@", ${report_date} and ${VELOZ_THRESHOLD:-50} following instructions, without a full explanation of why. This lesson pays off that debt, and it is no minor detail: the vast majority of Bash scripting bugs — the ones that show up three months later, with a file that had a space in its name or a variable that arrived empty — are quoting and expansion problems. Understanding the exact order in which Bash processes a line is what separates a script that works almost always from one that works always.

Contents

  1. The order of expansions
  2. Double quotes, single quotes and no quotes
  3. Always quote your variables, and escaping with \
  4. Parameter expansion: default values and validation
  5. Command substitution and arithmetic expansion
  6. IFS and word splitting
  7. The disaster of for f in $(ls)
  8. printf versus echo
  9. Final review of daily-report.sh

  1. The order of expansions

Before running any command, Bash transforms the line you wrote following always the same sequence, and knowing it explains almost everything else:

flowchart LR
    A["Line as<br/>written"] --> B["1 Braces<br/>2 Tilde"]
    B --> D["3 Parameters<br/>$var"]
    D --> E["4 Commands<br/>5 Arithmetic"]
    E --> G["6 Word<br/>splitting"]
    G --> H["7 Globbing<br/>*.csv"] --> I["Command<br/>executed"]

With concrete examples: braces turn shipments-{01,02}.csv into two names; the tilde turns ~/veloz-ops into /home/joan/veloz-ops; parameters turn $CSV_PATH into the CSV's path; command substitution turns $(date +%F) into 2026-08-03; arithmetic turns $(( 3 * 4 )) into 12; word splitting breaks the result into arguments; and globbing turns *.csv into the files that exist.

The key to the whole lesson lies in the order of steps 3 and 6. Bash first substitutes the variable for its value, and then splits the result into words on spaces. That is: if a variable contains spaces, those spaces become argument separators after the variable has disappeared.

file="shipments july.csv"
wc -l $file      # Bash runs: wc -l shipments july.csv   → TWO files, error
wc -l "$file"    # Bash runs: wc -l "shipments july.csv" → ONE, correct

Double quotes do not prevent the variable from expanding; they prevent step 6, word splitting. That is their whole secret.

  1. Double quotes, single quotes and no quotes

Bash offers three levels of protection, and this table sums up what happens at each one:

Gets expanded No quotes "Double" 'Single'
Variables $var, $(cmd), $(( )) Yes Yes No
Escape \ Yes Partial No
Word splitting Yes No No
Globbing * Yes No No
Tilde ~ and braces {a,b} Yes No No

The rows in bold are the ones that matter: double quotes expand variables but block word splitting and globbing, which is exactly what you want 95% of the time.

city="San Sebastián";  pattern="*.csv"
echo $city      # San Sebastián  (two arguments that echo joins with a space)
echo "$city"    # San Sebastián  (a single argument)
echo '$city'    # $city          (literal, unexpanded)
ls $pattern     # lists the real .csv files (globbing active)
ls "$pattern"   # looks for a file literally named *.csv

With echo the difference between the first two looks cosmetic, but with mkdir or rm it is catastrophic: mkdir $city would create two directories, San and Sebastián. Single quotes, for their part, are absolute: nothing expands inside them, not even the backslash. They are the right choice for regular expressions (05-04), sed patterns (06-02) and any text that must reach another program literally:

grep '^2026-08-03 .*\[ERROR\]' /var/log/veloz/app.log   # literal regex
awk -F, '{ print $3 }' /srv/veloz/data/shipments.csv     # $3 belongs to awk, not Bash

If those patterns were in double quotes, Bash would try to expand $3 as a variable — which would be empty — and awk would receive { print }: a perfect silent failure. Since single quotes accept no escapes, to include a single quote inside them you have to close, escape and reopen: 'does'\''work' produces does'work. It is ugly, but it is the only way.

  1. Always quote your variables, and escaping with \

The professional norm is simple and has hardly any exceptions: quote every variable expansion, always. These are the three disasters it prevents. Paths with spaces: wc -l $file is split into several arguments. With a destructive command, rm -rf $directory on directory="/srv/veloz data" would delete /srv/veloz and data.

Empty variables: when expanded without quotes, an empty variable disappears completely instead of becoming an empty string:

city="";  pattern="ERROR*"
[ $city = "Bilbao" ]       # bash: [: =: unary operator expected
[ "$city" = "Bilbao" ]     # works: compares "" with "Bilbao"
grep $pattern app.log      # if a file ERRORS.txt exists, the pattern becomes it
grep "$pattern" app.log    # the pattern reaches grep intact

Without quotes, test receives [ = Bilbao ] and understands nothing, because the first operand vanished before it could see it. Remember from 03-03 that [[ ]] is immune to this; [ ] is not.

Accidental globbing: that is the case of the last two lines — if a variable contains * or ?, without quotes Bash tries to expand it against the files in the directory. It is especially treacherous because the script works until someone creates a file with the wrong name in the working directory.

The only reasonable exceptions are the inside of [[ ]] on the left of the operator, the inside of (( )), and the cases where you deliberately want word splitting. When in doubt, quote: putting in too many quotes has never broken a script; putting in too few breaks them every day.

Escaping with the backslash

The backslash \ protects a single character from the next processing step:

For example, echo "The cost is \$50" prints The cost is $50, and in echo File:\ shipments\ july.csv the escaped spaces do not separate words. Inside double quotes, the backslash only has an effect on four characters: $, `, " and \ (plus the newline). Faced with any other, it prints literally, so echo "path\name" produces path\name. That detail surprises people coming from other languages: echo "\n" does not print a newline in Bash by default. For that you need echo -e or, better, printf (section 8).

At the end of a line, the backslash escapes the newline itself and lets you split long commands, which is what you have seen in every script in the module. Careful: there must not be a single space after the \. If there is, the backslash escapes that space instead of the newline and the command breaks with a bewildering error.

  1. Parameter expansion: default values and validation

The ${var...} family goes far beyond reading a value; these five forms are the ones in daily use:

Form What it does
${var:-def} Returns def if var is empty or does not exist. It does not modify var
${var:=def} Returns def and also assigns def to var
${var:?msg} If var is empty, writes msg on stderr and aborts the script
${var:+val} Returns val only if var has a value (the opposite case)
${#var} Returns the length of the string
city=""
echo "${city:-all}"     # all  (city is still empty)
echo "${city:=all}"     # all  (and now city holds "all")
echo "${#city}"         # 3    (length of "all")
echo "${CSV_PATH:?the CSV path is missing}"   # aborts: bash: CSV_PATH: the CSV path...

${var:?} is the jewel of the family for production scripts: it turns a mandatory variable with no value into an immediate death with a clear message, instead of letting the script carry on with empty paths. Compare the two scenarios: without it, rm -rf "$TEMP_DIR/"* with the variable empty becomes rm -rf /*. With ${TEMP_DIR:?}, the script dies before getting there.

${var:+val} looks contrived, but it solves the construction of conditional options very well: filter="${city:+--city $city}" produces --city Bilbao if there is a city and an empty string if not. A nuance about the colon: ${var-def} (without the :) only applies the default value if the variable does not exist, whereas ${var:-def} also applies it if it exists but is empty; in practice you almost always want the version with the :.

The string-manipulation operations of the same family — ${var#pattern}, ${var/a/b}, ${var^^}, ${var:0:5} — are lesson 04-04.

  1. Command substitution and arithmetic expansion

You have been using $(command) since 03-02; here are the three nuances that were missing. Always quote it, for the same reason as variables: its result undergoes word splitting. And it nests without escapes, which is its great advantage over backticks:

files=$(ls /srv/veloz/data)
echo "$files"   # respects the newlines; without quotes it turns them into spaces
echo "Most recent log: $(basename "$(ls -t /var/log/veloz/*.log | head -1)")"

Notice that the inner double quotes on the last line work perfectly: inside $( ) a new context begins, so you can use double quotes again without escaping them. With `...` you would have to escape every level, and with two levels it already becomes unreadable.

Arithmetic expansion $(( )) needs no inner quotes but it does need outer ones if the result is used as an argument. Inside $(( )) there is no word splitting and no globbing — only arithmetic — so there the variables go without $ and without quotes at no risk at all: it is the one area of Bash where you can relax.

  1. IFS and word splitting

IFS (Internal Field Separator) is the variable that tells Bash which characters to split words on in step 6. Its default value is three of them: space, tab and newline (check it with printf '%q\n' "$IFS"). Changing it lets you split on another character, which is very useful with comma-separated data:

line="E-8821,2026-08-03,Bilbao,mgarcia,delivered,34.90"
IFS=',' read -r id date city courier status amount <<< "$line"
echo "Shipment $id by $courier in $city is $status"
# → Shipment E-8821 by mgarcia in Bilbao is delivered

That IFS=',' read is the canonical pattern for slicing a CSV line, and it has an important detail: by putting the assignment in front of the command, IFS only changes during that invocation (lesson 03-02) and restores itself afterwards. That is the safe usage. Modifying IFS globally — a bare IFS=',' line in the middle of the script — is dangerous, because it affects everything that comes after and you have to restore it by hand with IFS=$' \t\n'. If you forget to restore it, later commands that rely on splitting by spaces will behave inexplicably. The correct practice is to save the original value (ORIGINAL_IFS=$IFS) and put it back, or better still, use the single-invocation form.

  1. The disaster of for f in $(ls)

This antipattern deserves a section of its own because it is the most repeated error in beginners' scripts, and it brings together almost all the concepts of the lesson. Never write for f in $(ls /srv/veloz/data). What goes wrong, point by point:

  • Names with spaces get split. shipments july.csv produces two turns of the loop, with shipments and july.csv. And names with a newline (rare but legal) break any assumption.
  • Globbing is applied to the result. If a file is called *, $(ls) returns it and Bash expands it against the directory.
  • ls gives different formats depending on whether its output goes to a terminal or a pipe, and depending on the user's options and aliases. It is also one extra process, entirely unnecessary.

The correct solution is to use globbing directly, which Bash handles without splitting into words:

for f in /srv/veloz/data/*; do
    [[ -f "$f" ]] && wc -l "$f"
done

Each $f is a complete name even if it contains spaces, because globbing produces a list of already separated words, not a string that has to be sliced. Remember from 02-05 that if the pattern matches nothing, without nullglob the loop takes one turn with the literal pattern; hence the [[ -f "$f" ]] guard.

The same logic argues against for line in $(cat file): to walk through lines you use while IFS= read -r line; do ... done < file, which is lesson 04-01. That empty IFS= at the start, by the way, keeps read from trimming the spaces at the beginning and end of each line; together with -r, it forms the safest idiom for reading text in Bash.

  1. printf versus echo

echo is convenient but it is not predictable: its behavior with options and backslashes varies between shells, between versions and even according to the xpg_echo option. Does echo "-n" print -n or interpret it as an option? Does echo "a\tb" produce a tab or the literal text? It depends on the shell. printf is POSIX, uniform everywhere, and it also gives you control over the format:

Format Meaning
%s / %d / %.2f String / integer / decimal with two figures
%-20s String left-aligned in 20 characters
%% / \n Literal percent sign / newline (always interpreted)
printf '%-12s %6s %8s\n' "CITY" "SHIPMENTS" "AMOUNT"
printf '%-12s %6d %8.2f\n' "Bilbao" 148 3241.75    # → Bilbao          148  3241.75
printf '%-12s %6d %8.2f\n' "Valencia" 981 21470.30 # → Valencia        981 21470.30

Two very useful characteristics. printf reuses the format until it runs out of arguments, so printf '%s\n' "$@" prints each argument on its own line. And %q escapes the output so that it can be reused by the shell, which makes it the best tool for debugging values with spaces: printf 'value: %q\n' "$city" prints value: San\ Sebastián.

Practical rule: echo for simple, interactive messages, printf in serious scripts, and always printf when the format matters or the content might start with a hyphen.

  1. Final review of daily-report.sh

We close the module by reviewing the script with everything we have learned. These are the corrections the definitive version applies:

#!/usr/bin/env bash
# daily-report.sh - Daily summary for Veloz Envíos [--date D] [--city C] [-v]
readonly LOG_PATH="${VELOZ_APP_LOG:?the path to app.log must be defined}"
readonly CSV_PATH="${VELOZ_CSV:?the path to shipments.csv must be defined}"
readonly REPORT_DIR="${VELOZ_LOGS:-$HOME/veloz-ops/logs}"
readonly ERROR_THRESHOLD="${VELOZ_THRESHOLD:-50}"
usage() { printf 'Usage: %s [-d YYYY-MM-DD] [-c CITY] [-v] [-h]\n' "$(basename "$0")"; }

report_date="$(date +%F)"
city="${VELOZ_CITY:-all}"
verbose="no"
while [[ $# -gt 0 ]]; do
    case "$1" in
        -d|--date)    report_date="${2:?--date requires a value}"; shift 2 ;;
        -c|--city)    city="${2:?--city requires a value}";        shift 2 ;;
        -v|--verbose) verbose="yes"; shift ;;
        -h|--help)    usage; exit 0 ;;
        *) printf 'ERROR: unknown option %q\n' "$1" >&2; usage >&2; exit 2 ;;
    esac
done

[[ -r "$LOG_PATH" && -s "$CSV_PATH" ]] \
    || { printf 'ERROR: cannot read the source data\n' >&2; exit 3; }
mkdir -p "$REPORT_DIR" || exit 5
total_errors="$(grep -c "^$report_date .*ERROR" "$LOG_PATH")"
printf '%-18s %s\n' "VELOZ REPORT" "$report_date"
printf '%-18s %s (threshold %s)\n' "Errors for the day:" "$total_errors" "$ERROR_THRESHOLD"
[[ "$verbose" == "yes" ]] && grep "^$report_date .*ERROR" "$LOG_PATH" | tail -5
lines="$(grep ",$report_date," "$CSV_PATH")"
[[ "$city" != "all" ]] && lines="$(printf '%s\n' "$lines" | grep ",$city,")"
printf '%s\n' "$lines" | cut -d, -f5 | sort | uniq -c | sort -rn
exit 0

The changes with respect to the 03-05 version:

  • ${VELOZ_APP_LOG:?...} on the mandatory paths: if the configuration does not define them, the script dies instantly with a clear message instead of working on empty paths. And ${VELOZ_LOGS:-$HOME/veloz-ops/logs} on the optional ones, with a sensible default value.
  • ${2:?--date requires a value} inside the case, replacing the manual check from 03-05 with a single expression.
  • Quotes on absolutely every expansion, including "$(date +%F)" and "$(grep -c ...)".
  • printf instead of echo throughout the output, with %q in the error messages so that an option containing odd characters is shown exactly as it arrived. In particular, printf '%s\n' "$lines": if a CSV line started with -n or contained backslashes, echo might interpret it; printf '%s\n' never will.

That last point illustrates the mindset of the lesson: it is not about the script failing today, but about it not being able to fail the day an unexpected piece of data shows up. A printf instead of an echo costs four characters and eliminates an entire class of bugs.

Common Mistakes and Tips

  • Not quoting a variable. Paths with spaces that get split, empty variables that disappear, accidental globbing. It is the number one error.
  • Using double quotes in regular expressions or in awk. "$3" expands to nothing; use single quotes. And do not expect echo "\n" to print a newline: use printf '\n'.
  • Leaving a space after the line-continuation \. The command breaks and the error does not tell you why.
  • for f in $(ls). Use direct globbing: for f in dir/*.
  • Modifying IFS without restoring it. Prefer the IFS=',' read ... form, which only affects that invocation.
  • Confusing ${var:-x} with ${var:=x}. The first does not touch the variable; the second assigns it. And do not nest backticks: use $( ), which nests without escapes.

Exercises

Exercise 1 — Predict the output. With city="San Sebastián" and n=3, say exactly what each line prints and why: (a) echo $city; (b) echo "$city"; (c) echo '$city has $n letters'; (d) echo "$city has ${#city} letters"; (e) echo "Total: $(( n * 2 ))€".

Exercise 2 — Hardening a dangerous script. This fragment has six quoting and expansion problems; find them and rewrite it.

BACKUP_DIR=$1
TODAY=`date +%F`
for f in $(ls /srv/veloz/data); do cp $f $BACKUP_DIR/$f.$TODAY; done
echo "Copied `ls $BACKUP_DIR | wc -l` files"
rm -rf $BACKUP_DIR/tmp/*

Exercise 3 — Aligned report with printf. Read shipments.csv and produce a table with the city left-aligned in 12 characters, the number of shipments right-aligned in 6, and the percentage of the total with one decimal figure. Use printf and arithmetic expansion.

Solutions

Solution to Exercise 1

Line Output Reason
(a) and (b) San Sebastián In (a) it is split into two arguments, but echo joins them with a space; in (b) it is a single argument
(c) $city has $n letters Single quotes: nothing expands
(d) San Sebastián has 13 letters ${#city} counts 13 characters, space included
(e) Total: 6€ The arithmetic expands inside the double quotes

Case (a) is the didactic trap: with echo the result is identical to (b), and that is why many people conclude that quotes are optional. Swap echo for mkdir and (a) will create two directories.

Solution to Exercise 2

The six problems: (1) $1 with no quotes and no validation; (2) backticks instead of $( ), twice; (3) for f in $(ls ...); (4) all the cp variables unquoted; (5) $f contains only the name, not the path, so the cp fails unless you happen to be in that directory; (6) rm -rf $BACKUP_DIR/tmp/* with no quotes and no validation is a bomb, because if $BACKUP_DIR is empty it becomes rm -rf /tmp/*.

readonly BACKUP_DIR="${1:?Usage: $(basename "$0") DESTINATION_DIRECTORY}"
readonly TODAY="$(date +%F)"
[[ -d "$BACKUP_DIR" ]] || { printf 'ERROR: %q is not a directory\n' "$BACKUP_DIR" >&2; exit 2; }
for f in /srv/veloz/data/*; do
    [[ -f "$f" ]] || continue
    cp -a "$f" "$BACKUP_DIR/$(basename "$f").$TODAY"
done
printf 'Copied %d files\n' "$(find "$BACKUP_DIR" -maxdepth 1 -type f | wc -l)"
rm -rf "${BACKUP_DIR:?}/tmp/"*

The last line deserves special attention: "${BACKUP_DIR:?}" is the standard defensive idiom before any rm -rf, because if the variable were empty the script aborts with an error instead of running a catastrophic deletion at the root. It costs five characters and it has saved many servers.

Solution to Exercise 3. Watch out for one trap: Bash does not do decimals (03-02), so the integer part and the decimal part have to be computed separately.

total=$(tail -n +2 "$CSV_PATH" | wc -l)
printf '%-12s %6s %8s\n' "CITY" "SHIPMENTS" "PERCENT"
tail -n +2 "$CSV_PATH" | cut -d, -f3 | sort | uniq -c | sort -rn \
| while read -r n city; do
    printf '%-12s %6d %6d.%d%%\n' "$city" "$n" \
        "$(( n * 100 / total ))" "$(( n * 1000 / total % 10 ))"; done

The output aligns the columns: Valencia, 981, 78.6%; Bilbao, 148, 11.8%. Notice three things. The %% in the format prints a literal percent sign. The while read -r reads two fields per line, taking advantage of the fact that IFS splits on spaces exactly where uniq -c leaves them. And integer arithmetic forces that gymnastics of multiplying by 1000 and taking the modulus; in 06-01 you will see that awk does the same with printf "%.1f%%" and no acrobatics.

Conclusion

You have closed the gap that was left. You know in what order Bash expands a line, and that word splitting happens after substituting the variables — the fact from which almost every quoting bug derives. You tell precisely what each type of quote expands; you quote every variable as a matter of policy, knowing what breaks if you do not; you escape with \ without falling into the space-after-the-line-continuation trap; you handle ${var:-def}, ${var:=def}, ${var:?msg}, ${var:+val} and ${#var}; you nest $( ) without escapes; you understand IFS and change it only per invocation; you have banished for f in $(ls) in favor of direct globbing; and you use printf when the output must be predictable.

And with that, Module 3 comes to an end. Look back over the road: you started by saving in a file the pipelines you typed every morning, and daily-report.sh is today a command in ~/veloz-ops/bin with constants, environment validation, documented exit codes, --date and --city options, --help, configuration through environment variables and hardened expansions. It has grown over six lessons without any version breaking the previous one, which is exactly how real software grows. Its limits, mind you, are plain to see: the script repeats the same pipeline with small variations, if you wanted the report for every city you would have to invoke it four times by hand, and there is no way to reuse in another script the validation logic that cost you so much to write.

In Module 4 come the tools that solve that. Loops (04-01) will walk through cities, dates and files; functions (04-02) will package the validation and the formatting so they can be reused; arrays (04-03) will hold lists of couriers and per-city counters; string manipulation (04-04) will complete the ${var...} family you started today; case (04-05) will get the formal treatment you have only used in passing here; and arithmetic (04-06) will finally solve those percentages with decimals. daily-report.sh will stop being a linear script and become a program with structure.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved