The toolkit runs on its own, but the question that closed Module 7 is still open: is it maintainable? Six months from now, at three in the morning, with the watchdog reporting that the backup failed, someone — probably you — will open backup.sh and will have two minutes to work out what line 47 does. If that line reads d=${f##*/}; e=${d%%.*}, they won't manage it. In Bash, readability is not aesthetics: it is the difference between fixing an incident and causing another one. This lesson turns that intuition into a concrete style guide and into a real refactor of code you have already written.
Contents
- Why readability is survival in Bash
- What "readable" means in measurable terms
- Names and namespaces
- Formatting: indentation, line length and continuation with
\ - Braces, double brackets and uniformity
- The canonical structure of a script
- Comments: the why, not the what
- Short functions and guard clauses
- A "not this / this instead" table and output messages
- The team style guide and a real refactor
- Why readability is survival in Bash
Every language rewards clear code, but Bash has three aggravating factors that turn it into an obligation:
- The syntax is dense by design.
${shipment##*/},2>&1,"${arr[@]}",<( ). You have learned all of those constructs in this course, but not one of them explains itself. - There is no compiler and no types to protect you. A misspelled name in Python blows up at run time; in Bash, a mistyped
$target_folderexpands to the empty string andrm -rf "$target_folder"/*wipes/*. The only defense mechanism is a human reading the code and spotting the mistake. - Operations scripts are read under pressure. Nobody opens
watchdog.shon a quiet Tuesday out of curiosity: it gets opened when something is broken, in a hurry and with no margin for error.
On top of that comes an uncomfortable reality: scripts live far longer than anyone expects. daily-report.sh started life as ten lines to get through the week, and today it publishes the report management reads every morning. Temporary code is the code that lasts longest.
- What "readable" means in measurable terms
"Readable" sounds subjective, but it comes down to criteria you can check in thirty seconds:
| Criterion | How it is measured | Practical threshold |
|---|---|---|
| Function length | Lines in the block | Fits on one screen: ≤ 40 |
| Nesting depth | Levels of if/for |
≤ 3 |
| Line length | Columns | ≤ 100, breaking with \ |
| One-letter names | Visual scan | Only i in short loops |
| Comments that explain the what | Reading | None: they are noise |
| Documentation header | Presence | Mandatory in scripts and public functions |
The definitive test is not a metric but a question: can somebody else on the team modify this function without asking you anything? If the answer is no, the code is not readable, however obvious it looks to you.
- Names and namespaces
A name is the comment that never goes stale:
target_city="Valencia" # variables: lowercase, descriptive
total_delivered_shipments=0
readonly VELOZ_DATA_DIR="/srv/veloz/data" # constants: UPPERCASE, readonly (03-02)
veloz_compute_percentage() { :; } # functions: verb + prefix (05-06)Three rules. Variables go in lowercase, because uppercase is reserved by convention for the environment and for constants; if you call your path variable PATH, you break the entire script. Functions start with a verb (veloz_compute_…, veloz_read_…, veloz_require), because a function does something; if you cannot find the verb, it probably does two things. And the veloz_ prefix from 05-06 is not decoration: when you source lib/common.sh, all those functions land in the shell's global namespace, and without a prefix your log would collide with anybody else's log. On length: i is fine in for i in {1..3}; f is not fine if it is used thirty lines further down. A name must grow with the distance between its definition and its last use.
- Formatting: indentation, line length and continuation with
\
\Indent with 4 spaces (what this course uses) or with 2, but pick one and never mix them. Tabs cause trouble inside here-documents and look different in every editor. This is not something to debate in each review: it is settled once and applied by a tool (shfmt, in 08-05).
tar --create --gzip --file "$target_archive" \
--exclude='*.tmp' --directory "$VELOZ_DATA_DIR" .
grep -F 'ERROR' /var/log/veloz/app.log \
| awk '{print $4}' \
| sort | uniq -c | sort -rnTwo details matter. The first is a classic trap: the backslash must be the last character on the line. If you leave a space after it, Bash escapes that space instead of the newline, the command splits in two and you get a baffling error along the lines of --file: command not found. It is invisible to the eye; ShellCheck catches it (SC1101). The second: in a pipeline you can do without the backslash if you break after the |, because a line ending in | already continues on its own. Putting the | at the start of each line, as in the example, is more readable because it lines the stages up vertically, but it requires the backslash.
- Braces, double brackets and uniformity
echo "$city" # correct and sufficient
echo "${city}_report" # HERE the braces are mandatory
echo "${shipments[2]}" "${path##*/}" # and in arrays and expansionsThere are two schools: braces always (absolute uniformity, more visual noise) or only when they clarify or are mandatory. This course follows the second; what is not optional is being uniform within a single file. For conditions the decision comes from 03-03: always use [[ ]]. Not only because it avoids word splitting and supports =~ and &&, but for uniformity: if 90% of the toolkit uses [[ ]], the remaining [ ] makes the reader stop and wonder why this one is different. Surprising the reader is a defect.
- The canonical structure of a script
Every script in the toolkit follows the same order. When you open fleet.sh you know the configuration is at the top and main is at the bottom, and you find what you are looking for without reading the whole file.
#!/usr/bin/env bash
#
# service-status.sh - Checks the health of veloz-api and warns if it is down
#
# Usage: service-status.sh [-v] [-t SECONDS] [HOST...]
#
# Options: -v Verbose output
# -t SECONDS Maximum wait per host (default 5)
#
# Exit codes: 0 all respond | 1 usage error | 69 service unavailable
#
# Author: Operations Team - Veloz Envios Created: 2026-03-11
set -euo pipefail
# --- Constants --------------------------------------------------------------
readonly VERSION="1.4.0"
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly VELOZ_DEFAULT_WAIT=5
# --- Libraries --------------------------------------------------------------
source "${SCRIPT_DIR}/../lib/common.sh"
# --- Functions --------------------------------------------------------------
usage() { sed -n '3,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
check_host() { local host="$1"; ...; }
main() { local wait="$VELOZ_DEFAULT_WAIT"; ...; }
main "$@" # --- Entry point: ALWAYS the last line of the fileFrom top to bottom: the portable shebang (03-01); the documentation header with usage, options, exit codes and authorship — the first thing anyone opening the file reads, and which usage() reuses with sed (06-02) so the text is not duplicated; set -euo pipefail with the caveats from 05-03; the constants grouped together and readonly; the source of the libraries using SCRIPT_DIR so it does not depend on the working directory (05-06); the functions; and main "$@" as the last line of the file, which guarantees nothing runs until the script has been read in full (04-02).
- Comments: the why, not the what
# BAD: repeats what the code already says
# Increment the counter by 1
(( counter++ ))
# GOOD: explains a decision the code cannot tell you
# We retry 3 times: the API takes up to 8 s to start after the nightly
# deployment and the first curl always fails (incident INC-2481).
veloz_wait_service 3Code says what it does; only a comment can say why. The valuable ones document a non-obvious decision, a limitation of an external tool, a reference to an incident or a warning ("do not change the order: flock must be acquired before creating the temp file"). And there is something worse than not commenting: the comment that lies. One saying "warns if the disk goes above 80%" next to code comparing against 90 is an active trap, because the reader trusts it and does not check. When you change the code, change the comment in the same edit or delete it. For the public functions in lib/common.sh, a header with a fixed format:
# veloz_percentage <part> <total>
# Computes the percentage of <part> over <total> with one decimal.
# Writes the result to standard output, without the % sign.
# Codes: 0 success | 1 non-numeric arguments | 2 total equal to zeroSignature, what it does, what it writes to stdout, what codes it returns. Four lines that save you opening the implementation.
- Short functions and guard clauses
A function must do one thing and its name must say which. The symptom that it does two is an "and" in its description: "reads the CSV and generates the report". The practical limit: if it does not fit on one screen, extract. Extracting is mechanical: (1) locate the coherent block, its inputs — they become arguments — and its output — it goes to stdout; (2) move it to a function with a verb name, declaring everything internal local (04-02); (3) run the script and confirm it still produces the same thing. Everybody skips the third step, and it is the only one that guarantees the refactor changed nothing; in 08-06 we will automate it with Bats.
The other enemy is nesting: from the third level on you have to scroll your eyes upward to know which branch you are in. Returning to the guard clauses from 03-04, this four-level block with the useful work buried at the bottom…
if [[ -f "$file" ]]; then
if [[ -r "$file" ]]; then
if [[ -s "$file" ]]; then
awk -F, -v c="$target" '$3 == c { print $1 }' "$file"
else veloz_log_error "empty file"; return 1; fi
else veloz_log_error "permission denied"; return 1; fi
else veloz_log_error "does not exist"; return 1; fi…turns into this, with a single level of indentation and errors that say which file it is:
[[ -f "$file" ]] || { veloz_log_error "does not exist: $file"; return 1; }
[[ -r "$file" ]] || { veloz_log_error "permission denied: $file"; return 1; }
[[ -s "$file" ]] || { veloz_log_error "empty file: $file"; return 1; }
awk -F, -v c="$target" '$3 == c { print $1 }' "$file"The pattern is: first everything that disqualifies the input, then the work.
- A "not this / this instead" table and output messages
Rewrites that come up over and over in Bash code reviews:
| Not this | This instead | Why |
|---|---|---|
command; if [ $? -eq 0 ] |
if command; then |
$? is lost with any intervening command (03-04) |
cat file | grep pattern |
grep pattern file |
One process fewer and less noise (02-04) |
for f in $(ls *.log) |
for f in *.log |
ls breaks on spaces; the glob never does (02-05) |
echo -e "a\tb" |
printf 'a\tb\n' |
echo -e is not portable (03-06) |
grep x f | wc -l |
grep -c x f |
grep already knows how to count |
if [ x$v = xabc ] |
if [[ $v == abc ]] |
The x trick is unnecessary with [[ ]] |
rm -rf $dir/* |
rm -rf "${dir:?}"/* |
If dir is empty, you wipe the root (03-06) |
T=$(date +%s) |
run_start=$(date +%s) |
Names, section 3 |
An operations script has two audiences and you have to serve both: the human wants to read and the machine — the watchdog, a grep, the journal — wants to parse. The solution is the one from 07-04: a line with a fixed structure and separated fields, which is still readable (2026-08-03T09:15:22+02:00 [INFO] daily-report: shipments processed: 1284). Three rules are worth formalizing: diagnostics go to stderr and data to stdout (02-04), so that data=$(script) does not capture warnings; errors say what failed, with what value and what to do ("cannot read /srv/veloz/data/shipments.csv: check the permissions", not "error"); and the exit code always accompanies the message (05-03), because whoever calls the script does not read text.
- The team style guide and a real refactor
Everything above fits in a STYLE.md inside the repository, next to the code — we will put it under Git in 08-04. One page with the decisions taken and, above all, with the justified exceptions the team has agreed on. It is a living document: when a discussion comes up for the second time in a review, the decision gets written down there and stops being discussed. As an external reference, the Google Shell Style Guide is the industry's most widely used document and a good base, though it is worth adopting with judgment: it settles useful things (local, main at the end, maximum length) and other debatable ones (2 spaces, 80 columns). What matters is not which guide you pick, but that one exists and that a tool applies it instead of a person: that is shfmt's job, in 08-05.
This function from daily-report.sh has not been touched since Module 4:
# BEFORE
gen() {
T=0; E=0
while IFS=, read -r a b c d e f; do
if [ "$a" != "shipment_id" ]; then
T=`expr $T + 1`
if [ $e = "delivered" ]; then E=`expr $E + 1`; fi
fi
done < $1
echo "Total: $T Delivered: $E `echo "scale=1;$E*100/$T" | bc`%"
} # 11 lines, 6 style defects and 2 processes for every shipment in the fileAnd this is how it ends up after applying the guide:
# AFTER
# summarize_deliveries <csv_file>
# Summarizes the totals of a shipments CSV (with header).
# Writes to stdout: "total delivered percentage", separated by spaces.
# Codes: 0 success | 1 the file cannot be read | 2 there are no data rows
summarize_deliveries() {
local csv_file="${1:?missing CSV file}"
[[ -r "$csv_file" ]] || { veloz_log_error "cannot read: $csv_file"; return 1; }
local total delivered
read -r total delivered < <(
awk -F, 'NR > 1 { total++; if ($5 == "delivered") delivered++ }
END { print total + 0, delivered + 0 }' "$csv_file"
)
(( total > 0 )) || { veloz_log_error "no data rows: $csv_file"; return 2; }
printf '%d %d %s\n' "$total" "$delivered" "$(veloz_percentage "$delivered" "$total")"
}Decision by decision: the name goes from gen to summarize_deliveries, verb first and unambiguous. The globals T/E become local with full names, which also avoids trampling script variables. The validation moves to the top as a guard clause, with ${1:?} (03-06) and a message that includes the path. The while read loop over six fields — five of them unused — is replaced by a single awk (06-01): shorter, faster (we will measure it in 08-02) and it does not break on empty fields. The obsolete `expr` disappears and the backticks give way to $( ), which nests and reads. The percentage is delegated to veloz_percentage, which already exists and is tested, instead of reimplementing it with bc. The output stops being a decorated sentence and becomes three fields the caller can format however it likes and read splits effortlessly. And the exit codes distinguish "cannot read" from "no data", which operationally are very different incidents.
Common Mistakes and Tips
- A space after the backslash. The most frustrating error in this lesson, because it is invisible. Configure your editor to highlight trailing whitespace and let ShellCheck (SC1101) hunt them down.
- Refactoring without being able to check. Rewriting by eye and deploying is as risky as not refactoring at all. Save the output first (
./daily-report.sh > /tmp/before.txt), refactor and compare withdiff. - Confusing "short" with "readable".
[[ -f $f ]]&&. $f||exit 1is extremely short and unreadable. The goal is clarity, not brevity: characters are free. - Tip: read your script out loud. If you reach a line and have to stop and decipher it, that line is missing a better name or a comment explaining the why.
- Tip: the best moment to refactor is when you are about to touch the code. Do not open a "clean up the toolkit" ticket; clean up the function you were already modifying.
Exercises
Exercise 1. Rewrite this function applying the guide: name, local, guard clauses, [[ ]], quoting, printf and a documentation header.
chk() {
if [ -d $1 ]; then
if [ -w $1 ]; then echo -e "OK\t$1"
else echo "not writable"; return 1; fi
else echo "does not exist"; return 1; fi
}Exercise 2. Find the five style defects in this fragment and fix them, explaining each one.
for f in `ls /var/log/veloz/*.log`; do
cat $f | grep ERROR | wc -l > /tmp/c
if [ $? -eq 0 ]; then echo "$f: `cat /tmp/c`"; fi
doneSolutions
Solution 1.
# check_writable_directory <path>
# Verifies that <path> is a directory the current user can write to.
# Writes to stdout a line "OK <path>" if everything is correct.
# Codes: 0 success | 1 does not exist or is not a directory | 2 exists but is not writable
check_writable_directory() {
local path="${1:?missing path}"
[[ -d "$path" ]] || { veloz_log_error "not a directory: $path"; return 1; }
[[ -w "$path" ]] || { veloz_log_error "no write permission: $path"; return 2; }
printf 'OK\t%s\n' "$path"
}A descriptive name with a verb; local with ${1:?} to fail early if the argument is missing; guards instead of nesting; [[ ]] with the variable quoted, because unquoted a path with spaces would break the test; printf instead of echo -e, which is not portable; errors to stderr via veloz_log_error so they do not pollute the useful output; two different codes because "does not exist" and "cannot write" are fixed in different ways; and a fixed header.
Solution 2.
for file in /var/log/veloz/*.log; do
[[ -f "$file" ]] || continue
errors=$(grep -c 'ERROR' "$file" || true)
printf '%s: %d\n' "$file" "$errors"
doneThe five defects: (1) for f in $(ls ...) iterates over the output of ls, which splits on spaces; the direct glob is correct and also creates no processes. (2) cat | grep | wc -l is three processes where grep -c is enough. (3) The temp file /tmp/c is unnecessary, is not unique — two simultaneous runs trample each other — and is insecure (we will see this in 08-03); a variable is enough. (4) if [ $? -eq 0 ] checks the code of the last command in the pipeline, which was wc, not grep: it always returns 0 and the condition checks nothing. (5) The unquoted variables. The || true is necessary because grep -c returns 1 when it finds nothing and with set -e that would abort the loop (05-03); the [[ -f ]] guard covers the case where the glob matches no file.
Conclusion
In Bash, readability is active defense: with no compiler and no types, the only filter between a mistyped $folder and an rm -rf / is somebody reading the code and understanding it. "Readable" is measurable: functions that fit on one screen, three levels of nesting at most, lines under a hundred columns and no comment repeating what the code already says. The toolkit's conventions are lowercase for variables, UPPERCASE readonly for constants, a verb at the start of every function and the veloz_ prefix so nothing collides on source. Formatting is settled once — 4 spaces, [[ ]] always, braces when they clarify, continuation with \ and no space after it — and a tool applies it, not an argument. Every script shares the same structure: shebang, documentation header with usage and exit codes, set -euo pipefail, constants, source of libraries, functions and main "$@" on the last line. Comments explain the why — the decision, the limitation, the incident — and are updated with the code, because a comment that lies is worse than none. Functions do a single thing, and when an "and" shows up in their description they get extracted; deep nesting dissolves into guard clauses that lift the errors to the top. The rewrite table sums up the rest: if command instead of $?, glob instead of ls, printf instead of echo -e, grep -c instead of grep | wc -l. And the summarize_deliveries refactor shows that readability and quality arrive together: the clear version is also shorter, faster and has better error codes.
And right there a reasonable doubt appears: we replaced a while read loop with an awk, claiming it is "faster". How much faster? Does it matter with 1,200 shipments? And with 500,000? That is not something you guess, you measure it. Lesson 08-02 teaches you to measure properly with time, SECONDS and date +%s%N, to identify the cost that dominates in Bash — creating processes — and to optimize only what really costs, starting by profiling daily-report.sh over half a million lines.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
