health_check.sh already exists, but it carries the disk threshold written by hand in the body and the server IP just above it. The day Marta asks you to watch the test server too you will have to duplicate the file, and from that moment on you will have two scripts that drift apart little by little until one of them is lying. The cure is to take the values out of the body and put them into well-thought-out variables.

This lesson is about that, but above all it is about something deeper: in Bash everything is a string of text, and that single sentence explains 90% of the surprises coming your way. In exchange, Bash gives you an arsenal of string manipulation — parameter expansion — so powerful that it replaces basename, dirname, cut, tr and a good part of sed without launching a single process.

Contents

  1. Bash has no types: everything is a string
  2. Assignment: the three rules that catch everybody out
  3. Scope: script, subshell and environment
  4. declare and its attributes
  5. Parameter expansion in depth
  6. Arithmetic, and why Bash knows nothing about decimals
  7. Indexed arrays
  8. Associative arrays: bookings per house
  9. Strings and printf
  10. Application: a configurable health_check.sh

  1. Bash has no types: everything is a string

In Python or Java you declare the type and the language protects you. In Bash there are no types: every variable holds a string of bytes, and the "type" only appears when you use it, according to the context.

operator@srv-tramontana:~$ n=42; echo "${n} + 1 = $((n + 1))"
42 + 1 = 43
operator@srv-tramontana:~$ n=42abc; echo "$((n + 1))"
-bash: 42abc: value too great for base (error token is "42abc")

The same variable is a number inside $(( )) and text outside it. Nobody warned you when you assigned 42abc; the error arrived when you tried to add. Three consequences worth internalising today:

  • Nothing validates what you store. Validating is your job, and we will formalise it in 04-04 with [[ $v =~ ^[0-9]+$ ]].
  • Comparing numbers and comparing strings are different operations. "10" < "9" is true as text and false as a number; hence -lt and < existing separately.
  • There are no decimals. $(( 7842 / 25 )) gives 313, not 313.70 (section 6). The only partial exceptions to the rule are declare's attributes and arrays, which really are a genuine data structure.

  1. Assignment: the three rules that catch everybody out

operator@srv-tramontana:~$ version=3.2.1              # correct
operator@srv-tramontana:~$ version = 3.2.1
-bash: version: command not found
operator@srv-tramontana:~$ message=backup complete
-bash: complete: command not found
operator@srv-tramontana:~$ message="backup complete"   # correct
operator@srv-tramontana:~$ LC_ALL=C sort ~/data/houses.txt | head -1
cal-ferrer
operator@srv-tramontana:~$ echo "LC_ALL is still: '${LC_ALL:-empty}'"
LC_ALL is still: 'empty'
  • No spaces around the =. version = 3.2.1 is not an assignment: Bash reads it as the command version with the arguments = and 3.2.1. It is the rule most often broken by people coming from other languages, and the error message is no help at all.
  • Quotes if there are spaces. Without them the assignment ends at the first space. Working rule: always quote the value, whether it has spaces or not.
  • To use it, $; to assign it, no $. name=value to write, "$name" to read.

The last two lines of the example show something else: putting a variable in front of a command makes it exist only for that one execution, which is exactly what the course's LC_ALL=C convention does. On names: letters, digits and _, not starting with a digit; UPPERCASE for constants and the environment, lowercase for working variables. And avoid trampling on PATH, HOME, IFS, PWD, RANDOM or SECONDS, which already have an owner.

  1. Scope: script, subshell and environment

Picking up 03-01 again, now from inside a script:

Scope How it is created Who sees it
Shell VAR=value Only the current script, including its functions
Environment export VAR=value The script and every child process it launches
Local local var=value (in functions) Only the function and the ones it calls
operator@srv-tramontana:~$ cat /tmp/scope.sh
HOUSE="mas-figueres"; export RELEASE="3.2.1"
bash -c 'echo "child sees HOUSE=[${HOUSE:-}] RELEASE=[${RELEASE:-}]"'
( HOUSE="can-ventos"; echo "subshell changes HOUSE to $HOUSE" )
echo "the parent still has HOUSE=$HOUSE"
operator@srv-tramontana:~$ bash /tmp/scope.sh
child sees HOUSE=[] RELEASE=[3.2.1]
subshell changes HOUSE to can-ventos
the parent still has HOUSE=mas-figueres

Three lessons in six lines. The child does not see HOUSE because it is not exported, but it does see RELEASE. The subshell — the parentheses — does see everything from the parent because it is a copy of it, but its changes do not come back: the same wall you met with cd, and the one that will make a while read inside a pipeline lose its variables (04-03). As for local, it can only be used inside a function and it is a preview of 04-05, but the rule is so important that we state it now: every variable created inside a function must be local; without it, it is global, and one function can silently trample another one's counter.

  1. declare and its attributes

declare (or its synonym typeset) creates variables with attributes, the closest thing to a type that Bash has.

Attribute Effect
-r / -x Read-only (= readonly) / exported (= export)
-i Integer: assignments are evaluated as arithmetic
-a / -A Indexed / associative array — the latter must be declared
-l / -u Converts to lowercase / uppercase on assignment
-p Does not declare: prints how a variable is declared
operator@srv-tramontana:~$ declare -r ACTIVE_RELEASE="3.2.1"; ACTIVE_RELEASE="3.3.0"
-bash: ACTIVE_RELEASE: readonly variable
operator@srv-tramontana:~$ declare -i errors=0; errors+=14; errors+=5; echo "$errors"
19
operator@srv-tramontana:~$ declare -u code="ok"; echo "$code"; declare -p errors
OK
declare -i errors="19"

Look at errors+=14: with the -i attribute it adds, whereas without it, it would concatenate and leave 014. That different behaviour depending on an invisible attribute is why many people prefer explicit arithmetic with $(( )); we will use -i only for obvious counters. readonly we will use a lot: it turns a logic error — reassigning a constant by accident — into an immediate, noisy error, always better than a silent one.

  1. Parameter expansion in depth

This is where Bash stops looking like a toy language. Everything that follows happens inside the shell, without launching processes. We start from a real name from our backups:

operator@srv-tramontana:~$ f="/srv/tramontana/backups/outgoing/bookings-2026-08-18.csv.gz"
operator@srv-tramontana:~$ base="${f##*/}"; echo "$base"   # after the last /
bookings-2026-08-18.csv.gz
operator@srv-tramontana:~$ echo "${f%/*}"                 # before the last /
/srv/tramontana/backups/outgoing
operator@srv-tramontana:~$ echo "${base%%.*}"             # up to the first dot
bookings-2026-08-18
operator@srv-tramontana:~$ echo "${base#*-}"              # after the first hyphen
2026-08-18.csv.gz

That is basename and dirname without basename or dirname. The mnemonic is visual: on a keyboard, # is to the left of $ and strips from the left; % is to the right and strips from the right. Doubling the symbol makes the pattern greedy, like the greediness from 03-02. Careful: these are globbing patterns (*, ?, [...]), not regular expressions.

Expansion What it returns
${VAR:-value} / ${VAR:=value} value if VAR is empty or unset; the second form also assigns
${VAR:?message} The value, or aborts the script with that message if it is empty
${VAR:+value} value only if VAR has content; otherwise, the empty string
${#VAR} / ${VAR:off:len} Length in characters / substring from off (0-based)
${VAR#pattern} / ${VAR##pattern} Removes the shortest / longest matching prefix
${VAR%pattern} / ${VAR%%pattern} Removes the shortest / longest matching suffix
${VAR/pat/new} / ${VAR//pat/new} Replaces the first occurrence / all of them
${VAR^^} / ${VAR,,} Everything to uppercase / to lowercase

The first four are the basis of default configuration, and :+ is the least known: it adds something only if there is something to add.

operator@srv-tramontana:~$ datestamp="2026-08-18"; level="warn"; filepath="/houses/el-molí"; verbose=""
operator@srv-tramontana:~$ echo "Year ${datestamp:0:4}, month ${datestamp:5:2}, length ${#datestamp}"
Year 2026, month 08, length 10
operator@srv-tramontana:~$ echo "${filepath//í/i} → [${level^^}]"
/houses/el-moli → [WARN]
operator@srv-tramontana:~$ echo "curl -s ${verbose:+-v} ${URL:?the URL to check is missing}"
-bash: URL: the URL to check is missing

The ${filepath//í/i} resolves, without calling sed, the accent the application puts into the links to /houses/el-molí and which causes the four 404s in access.log. The ${verbose:+-v} adds the option only if the variable has content, which is how conditional command lines are built. And ${VAR:?message}, already seen in 03-01, is the cheapest way to demand a mandatory value.

  1. Arithmetic, and why Bash knows nothing about decimals

Bash has two ways of doing sums and both work only with signed 64-bit integers.

operator@srv-tramontana:~$ echo "$(( (14 + 5 + 4) * 100 / 412 ))"     # % of errors
5
operator@srv-tramontana:~$ i=0; (( i++ )); (( i += 10 )); echo "$i"
11

$(( )) substitutes the result, so you use it wherever you want the value. (( )) evaluates and prints nothing: it is for modifying variables or for use as a condition, returning 0 (success) if the result is non-zero. Inside both, variables go without $ and you have + - * / % **, comparisons, && || !, ++ --, += and the ternary a ? b : c.

Two warnings: a number with a leading zero is interpreted as octal: $(( 08 )) is a syntax error, which ruins any calculation involving months (08, 09); force it with $(( 10#08 )). And (( i++ )) with i at 0 returns 1, because the value of the expression is the one before the increment, and with set -e that aborts the script; it is a trap from 04-06, and for now use (( ++i )) or i=$(( i + 1 )).

For decimals you have to leave Bash:

operator@srv-tramontana:~$ echo "scale=2; 7842.50 / 25" | bc
313.70
operator@srv-tramontana:~$ LC_ALL=C awk -F';' 'NR>1 { s += $6; n++ } END { printf "%.2f\n", s/n }' \
    /home/operator/data/bookings.csv
313.70

Tramontana's average amount per booking is 313.70 €. You have to tell bc the scale (scale=2) because by default it truncates to zero decimals when dividing; bc -l loads the maths library and raises it to 20. But look at the second example: if the data is already in a file, awk does the reading and the sums at once. The course's rule: bc for a one-off calculation, awk as soon as there is a file. And the LC_ALL=C is not decorative: under a locale that uses the comma, printf '%.2f' would print 313,70, and that comma breaks any later comparison.

  1. Indexed arrays

An array is an ordered list of strings with a numeric index starting at 0. It solves "storing several things in one variable" without resorting to space-separated strings, which break as soon as an element contains one.

operator@srv-tramontana:~$ releases=(3.1.0 3.2.0 3.2.1); releases+=(3.3.0)
operator@srv-tramontana:~$ echo "${releases[0]} ... ${releases[-1]}  (total ${#releases[@]})"
3.1.0 ... 3.3.0  (total 4)
operator@srv-tramontana:~$ echo "${releases[@]:1:2}"
3.2.0 3.2.1
operator@srv-tramontana:~$ unset 'releases[0]'; echo "${!releases[@]}"
1 2 3

It is created with parentheses, extended with +=(...), [-1] is the last one, ${#array[@]} counts elements and ${array[@]:1:2} is a slice. The last command reveals something important: unset leaves a hole, the indices are not renumbered; that is why ${!array[@]} exists, giving you the indices and not the values.

${array[@]} versus ${array[*]}

The difference that causes the most bugs, and it only shows up inside double quotes:

operator@srv-tramontana:~$ files=("august report.pdf" "bookings.csv")
operator@srv-tramontana:~$ for f in "${files[@]}"; do echo -n "[$f] "; done; echo
[august report.pdf] [bookings.csv]
operator@srv-tramontana:~$ for f in "${files[*]}"; do echo -n "[$f] "; done; echo
[august report.pdf bookings.csv]
operator@srv-tramontana:~$ for f in ${files[@]}; do echo -n "[$f] "; done; echo
[august] [report.pdf] [bookings.csv]

"${array[@]}" produces one argument per element, respecting spaces: it is what you want 99% of the time. "${array[*]}" produces a single argument with the elements joined by the first character of IFS, useful only for printing. And without quotes Bash splits on spaces, and "august report.pdf" becomes two files that do not exist. Burn it in: arrays always with "${array[@]}".

  1. Associative arrays: bookings per house

An associative array indexes by string instead of by number: it is what awk gave you in 03-05, now inside Bash. It has to be declared with declare -A before use; otherwise Bash treats it as indexed and all the keys collapse into index 0.

#!/usr/bin/env bash
set -euo pipefail
CSV="/home/operator/data/bookings.csv"
declare -A house_nights

# tail -n +2 skips the header. IFS=';' splits each line on the CSV
# separator; read -r assigns each field to a variable (04-03 details it).
# ${var:-0} gives 0 the first time a house appears, with set -u active.
while IFS=';' read -r _id _date house _guest nights _amount; do
    house_nights["$house"]=$(( ${house_nights["$house"]:-0} + nights ))
done < <(tail -n +2 "$CSV")

# ${!array[@]} are the KEYS; sort leaves them in a reproducible order.
for house in $(printf '%s\n' "${!house_nights[@]}" | LC_ALL=C sort); do
    printf '%-14s %3d nights\n' "$house" "${house_nights[$house]}"
done
operator@srv-tramontana:~$ bash /tmp/per_house.sh
cal-ferrer      11 nights      can-ventos      17 nights
el-moli          8 nights      la-solana       13 nights
mas-figueres    21 nights

The 70 known nights, distributed (shown here in two columns to save space). Two new pieces: < <(tail -n +2 "$CSV") is a process substitution that stops the while running in a subshell and losing the arrays when it finishes — 04-03 details it — and _id, _date, _guest are the convention for "I read this field but I do not use it", which also keeps shellcheck quiet. An honest note: this report fits in one line of awk. Associative arrays shine when what you accumulate is not tabular text — exit codes per service, paths already processed, a cache; with a CSV, awk still wins. The rule is formalised in 04-04.

  1. Strings and printf

Concatenating means writing one thing after another, and += appends at the end:

operator@srv-tramontana:~$ host="10.0.2.15"; port=8080
operator@srv-tramontana:~$ url="http://${host}:${port}"; url+="/health"; echo "$url"
http://10.0.2.15:8080/health

The braces in ${host} are not optional: without them $host: would work by luck, but $portX would look for a variable called portX. Use braces whenever the variable touches adjoining text. For output, printf replaces echo in everything serious: echo varies between shells and does odd things with -n and backslashes, whereas printf is precisely defined and reuses the format while arguments remain, which turns it into a table generator.

Directive Meaning
%s / %d String / decimal integer
%.2f Floating point with two decimals
%-20s String left-aligned in 20 columns
%10.2f Float in 10 columns, right-aligned
%% / \n / \t A literal % / newline / tab
operator@srv-tramontana:~$ LC_ALL=C printf '%-14s %6d %10.2f\n' \
    mas-figueres 21 2450 can-ventos 17 1980 la-solana 13 1425.50
mas-figueres       21    2450.00
can-ventos         17    1980.00
la-solana          13    1425.50

A single call, three rows: the format was reused three times. That is the table for Marta's report, and the reason echo is now left only for one-line messages with no formatting.

  1. Application: a configurable health_check.sh

The underlying change: no value is nailed into the body any more. They are all constants at the top, and the ones it makes sense to adjust from outside are read from the environment with ${VAR:-default}.

#!/usr/bin/env bash
#
# health_check.sh - Quick health check for Tramontana Bookings.
#
# Author : Systems operator <operator@srv-tramontana>
# Date   : 2026-08-18
# Usage  : health_check.sh
# Env    : TRAMONTANA_HOST, TRAMONTANA_PORT, TRAMONTANA_DISK_THRESHOLD,
#          TRAMONTANA_ERROR_LOG

set -euo pipefail

# --- Configuration (environment > default value) --------------------------
readonly HOST="${TRAMONTANA_HOST:-10.0.2.15}"
readonly PORT="${TRAMONTANA_PORT:-8080}"
readonly DISK_THRESHOLD="${TRAMONTANA_DISK_THRESHOLD:-80}"
readonly ERROR_LOG="${TRAMONTANA_ERROR_LOG:-/var/log/tramontana/errors.log}"
readonly MAX_WAIT=5
readonly HEALTH_URL="http://${HOST}:${PORT}/health"

# --- Body -----------------------------------------------------------------
LC_ALL=C printf '=== Tramontana Bookings health (%s) ===\n' "$(date '+%F %T')"
printf 'Server : %s   Target: %s\n\n' "$(hostname)" "$HEALTH_URL"

http_code=$(curl -s -o /dev/null -w '%{http_code}' \
    --max-time "$MAX_WAIT" "$HEALTH_URL" || true)
disk_usage=$(df --output=pcent / | tail -1 | tr -dc '0-9')
free_kb=$(df --output=avail / | tail -1 | tr -dc '0-9')
# An integer of KiB says nothing to a human: we turn it into GiB with one decimal.
free_gb=$(LC_ALL=C awk -v k="$free_kb" 'BEGIN { printf "%.1f", k/1048576 }')
today=$(date '+%Y-%m-%d')
errors_today=$(grep -c "^$today" "$ERROR_LOG" || true)

LC_ALL=C printf '%-14s %s\n' \
    "HTTP:"   "$http_code" \
    "Disk:"   "${disk_usage}% used, ${free_gb} GiB free (threshold ${DISK_THRESHOLD}%)" \
    "Errors:" "$errors_today today"
exit 0
operator@srv-tramontana:~$ ~/scripts/health_check.sh
=== Tramontana Bookings health (2026-08-18 09:41:02) ===
Server : srv-tramontana   Target: http://10.0.2.15:8080/health

HTTP:          200
Disk:          30% used, 15.4 GiB free (threshold 80%)
Errors:        6 today
operator@srv-tramontana:~$ TRAMONTANA_PORT=9090 ~/scripts/health_check.sh | sed -n 2p
Server : srv-tramontana   Target: http://10.0.2.15:9090/health

Without touching the file, the script points at another port. That is the first step of the precedence chain arguments > environment > configuration file > defaults that we will complete in 04-03 and close in 04-07. It still compares nothing against DISK_THRESHOLD: that arrives in 04-04.

Common Mistakes and Tips

  • Spaces around the =. VAR = value assigns nothing: it is error number one. Number two is using $VAR without quotes: if the value contains spaces or a *, Bash splits it or expands it against the disk. SC2086 is exactly this, and it is not a pedantic warning: it is a failure waiting for its file with a space in the name.
  • Forgetting declare -A. Without it, data[mas-figueres]=1 evaluates the key as arithmetic, turns it into 0 and all the houses trample each other, with no error whatsoever. And do not confuse ${VAR:-x} with ${VAR:=x}: the first does not modify the variable, the second does.
  • ${array[*]} in a loop. It iterates over a single gigantic element; always use "${array[@]}". And beware of leading zeros: $(( 08 + 1 )) fails because of octal, so write 10#$month.
  • Expecting decimals from $(( )). $(( 7842 / 25 )) gives 313 and the remainder is lost without warning. For money, awk or bc with LC_ALL=C.
  • Tip: declare -p VAR tells you what a variable really is — array, integer, exported — when something does not add up. It is the stat of variables.
  • Tip: prefer parameter expansion to basename, dirname, cut or tr for simple things: in a loop of a thousand iterations that is a thousand processes you save. And declare every constant readonly.

Exercises

Exercise 1. Given f="/srv/tramontana/backups/pre-deploy/app-3.2.1-2026-08-11.tar.gz", obtain using parameter expansion only (no basename, no cut, no sed): the directory, the file name, the name without the extension, the version 3.2.1 and the date 2026-08-11. Explain each expansion.

Exercise 2. Write ~/scripts/bookings_summary.sh which, reading bookings.csv, prints an aligned table with house, bookings, nights and amount, plus a totals line. Use an associative array and printf. It must match the known data: 25 bookings, 70 nights, 7,842.50 €.

Exercise 3. Luis proposes this to calculate the percentage of POST requests out of the 412 in access.log. Explain the three flaws and rewrite it.

posts = 114
total = 412
pct=$(($posts/$total*100))
echo "POST: $pct %"

Solutions

Solution 1.

operator@srv-tramontana:~$ f="/srv/tramontana/backups/pre-deploy/app-3.2.1-2026-08-11.tar.gz"
operator@srv-tramontana:~$ base="${f##*/}"; name="${base%%.tar.gz}"; noapp="${name#app-}"
operator@srv-tramontana:~$ printf '%s\n%s\n%s\n%s\n%s\n' "${f%/*}" "$base" "$name" \
    "${noapp%%-*}" "${name##*-[0-9].[0-9].[0-9]-}"
/srv/tramontana/backups/pre-deploy
app-3.2.1-2026-08-11.tar.gz
app-3.2.1-2026-08-11
3.2.1
2026-08-11
  • ${f%/*} removes the shortest suffix starting with /: that is dirname. ${f##*/} removes the longest prefix ending in /: that is basename.
  • ${base%%.tar.gz} trims the compound extension. Here %% and % give the same result because the pattern is literal, but %%.* would have been dangerous: it would also cut at the dot in 3.2.1, leaving app-3.
  • The version comes out in two steps: #app- removes the prefix and %%-* trims from the first remaining hyphen, the one before the date. For the date, ##*-[0-9].[0-9].[0-9]- uses a globbing pattern — not a regex — that consumes everything up to just past the version number.

Solution 2.

#!/usr/bin/env bash
#
# bookings_summary.sh - Table of bookings, nights and amount per house.
# Author : Systems operator <operator@srv-tramontana>  Date: 2026-08-18

set -euo pipefail
readonly CSV="${TRAMONTANA_CSV:-/home/operator/data/bookings.csv}"
declare -A n_bookings n_nights amounts
declare -i total_bookings=0 total_nights=0
all_amounts=""
# The amounts are accumulated as space-separated text, because Bash cannot
# add decimals; the sum is done by awk with LC_ALL=C for the decimal point.
sum() { LC_ALL=C awk '{ s=0; for (i=1;i<=NF;i++) s+=$i; printf "%.2f", s }' <<<"$1"; }

while IFS=';' read -r _id _date house _guest nights amount; do
    n_bookings["$house"]=$(( ${n_bookings["$house"]:-0} + 1 ))
    n_nights["$house"]=$((   ${n_nights["$house"]:-0} + nights ))
    amounts["$house"]="${amounts["$house"]:-}${amount} "
    all_amounts+="${amount} "
    total_bookings+=1; total_nights+=nights
done < <(tail -n +2 "$CSV")

LC_ALL=C printf '%-14s %9s %8s %12s\n' HOUSE BOOKINGS NIGHTS AMOUNT
for house in $(printf '%s\n' "${!n_bookings[@]}" | LC_ALL=C sort); do
    LC_ALL=C printf '%-14s %9d %8d %12s\n' \
        "$house" "${n_bookings[$house]}" "${n_nights[$house]}" "$(sum "${amounts[$house]}")"
done
LC_ALL=C printf '%-14s %9d %8d %12s\n' TOTAL "$total_bookings" "$total_nights" "$(sum "$all_amounts")"

exit 0
operator@srv-tramontana:~$ ~/scripts/bookings_summary.sh
HOUSE           BOOKINGS   NIGHTS       AMOUNT
cal-ferrer             4       11      1120.00
can-ventos             6       17      1980.00
el-moli                3        8       867.00
la-solana              5       13      1425.50
mas-figueres           7       21      2450.00
TOTAL                 25       70      7842.50

It matches the known data. The sum function is a preview of 04-05, but it illustrates why functions are inevitable: the same awk line is used in two places.

Solution 3. The three flaws:

  1. Spaces around the =. posts = 114 tries to run the command posts and the script dies on the first line.
  2. The order of operations truncates the result. $posts/$total*100 first calculates 114/412, which in integer arithmetic is 0, and then 0*100 = 0. Always multiply before dividing.
  3. The $ signs are unnecessary inside $(( )) — noise, not an error — and above all a percentage calls for a decimal that Bash cannot give.
posts=114
total=412
# Multiplying before dividing gives the correct integer: 27
pct_int=$(( posts * 100 / total ))
# And with one decimal, going out to awk with a fixed locale:
pct=$(LC_ALL=C awk -v p="$posts" -v t="$total" 'BEGIN { printf "%.1f", p*100/t }')
LC_ALL=C printf 'POST: %s of %s (%s %% integer, %s %% exact)\n' \
    "$posts" "$total" "$pct_int" "$pct"
# -> POST: 114 of 412 (27 % integer, 27.7 % exact)

Notice the %% in the format: inside printf, a literal % is written doubled.

Conclusion

You no longer depend on values nailed into the code, and along the way you have picked up the part of Bash that gives the most productivity per line written.

  • You know that in Bash everything is a string and that the "type" is supplied by the context: nothing is validated, comparing numbers is not comparing text, and there are no decimals. You assign with no spaces around the =, you always quote the value, you distinguish the shell, environment and subshell scopes knowing that the child's changes never come back to the parent, and you use declare with its -r, -i, -a, -A, -x and -p attributes.
  • You have mastered the whole of parameter expansion: :-, :=, :? and :+, lengths, substrings, trims with #, ##, %, %%, replacements with / and //, and case changes. You no longer need basename or cut for the simple things.
  • You do integer arithmetic with $(( )) and (( )), you know the octal trap and the (( i++ )) one, and you go out to bc or awk with LC_ALL=C as soon as a decimal appears: Tramontana's average amount is 313.70 €.
  • You handle indexed arrays — always "${array[@]}", never ${array[*]} — you count by key with associative arrays after the indispensable declare -A, you format with printf (which already produces the table for Marta's report), and health_check.sh reads its configuration from the environment with default values.

But the script is still mute: you cannot ask it for help, you cannot tell it "use threshold 60" without exporting a variable, and there is no way for it to read the month for a report. In the next lesson, Script Input, Output and Arguments, we give it a real interface: positional parameters and the critical difference between $@ and $*, a help function, getopts for short options, the -h, -v, --dry-run and -- conventions that make a script look professional, read in all its variants and the canonical pattern for reading a file line by line, the golden rule of stdout for results and stderr for humans, and exit codes with meaning. By the end of it, health_check.sh will accept options and bookings_report.sh will be born.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved