In the previous lesson you wrote the validations daily-report.sh needed, but chained with || and braces: a style that holds up well with two checks and becomes unreadable with five. Now we give them their definitive form with if. And along the way you will discover the idea Bash courses most often explain badly: if does not evaluate a boolean, it evaluates an exit code. Understanding that completely changes the way conditions are written, and it explains why if grep -q ERROR file — with no brackets at all — is the most idiomatic construct in the language.
Contents
- Syntax of
if/then/fi - The key idea:
ifevaluates an exit code elseandelif- One-line conditionals
ifon real commands- Compound conditions, negation and input validation
- Nesting, guard clauses and early exit
- Different exit codes for different errors
daily-report.shvalidates its environment
- Syntax of
if / then / fi
if / then / fiThe basic structure is if condition, then then with the commands and fi to close. It is usually written with then on the same line, separated by a semicolon:
Three details cause most of the initial errors: fi closes the block (it is if backwards, a convention inherited from the Bourne shell, just like case/esac); the ; before then is mandatory if they are on the same line, because otherwise Bash reads then as one more argument of the condition; and indentation is purely cosmetic to Bash, but indispensable for whoever reads it — four spaces in this course.
- The key idea:
if evaluates an exit code
if evaluates an exit codeHere is the concept you have to internalize. In most languages, if receives a boolean expression. In Bash, if receives a command, runs it and looks at its exit code. If it is 0, it runs the then block; if it is nonzero, it does not. This has a consequence that is disconcerting when you come from other languages: 0 means true, the opposite of what is usual. The reason is that in Unix 0 is "no errors" and there are many ways to fail, each with its own number (lesson 03-01).
And it has another, far more important consequence: if if runs any command at all, then [[ ... ]] is not special if syntax, but simply the command you usually put there. Check it for yourself:
Run [[ -f /srv/veloz/data/shipments.csv ]] followed by echo $? and you will get 0 if the file exists and 1 if it does not. That [[ ]] works perfectly outside any if, because it is a command in its own right. That is why if [[ -f "$CSV_PATH" ]] and if test -f "$CSV_PATH" are identical to Bash.
Hold on to this sentence: any command can go in an if. It is the doorway to section 5, which is where if unfolds its full power.
else and elif
else and elifelse covers the opposite case, and elif (a contraction of else if) chains alternative conditions:
if (( total_errors == 0 )); then
echo "No errors in veloz-api today"
elif (( total_errors < ERROR_THRESHOLD )); then
echo "Errors within tolerance: $total_errors"
elif (( total_errors < ERROR_THRESHOLD * 2 )); then
echo "WARNING: $total_errors errors, above the threshold"
else
echo "CRITICAL: $total_errors errors, more than double the threshold"
fi # with total_errors=73 → WARNING: 73 errors...Bash evaluates the conditions in order and stops at the first true one. That is why the order matters: if you put total_errors < ERROR_THRESHOLD * 2 first, that branch would also capture the normal cases. Always order from the most specific to the most general. You can chain as many elif as you like, but when you go past four or five comparing the same value against fixed values, the right tool is case (04-05).
- One-line conditionals
When the body is a single short command, it all fits on one line using semicolons. The alternative, already familiar from 03-03, is && and ||:
if [[ -z $city ]]; then city="Valencia"; fi # one ; before then, another before fi
[[ -z $city ]] && city="Valencia" # equivalent, more compactIn the long form, each ; stands in for a newline. Which one to use? The table settles the doubt:
| Situation | Recommended style |
|---|---|
| One short action, no alternative | condition && action |
| An action only if something fails | condition || action |
| A validation that aborts | condition || { message; exit N; } |
| Two branches, or more than one command in the body | if ... else ... fi |
| Three or more cases | if/elif or case (04-05) |
The underlying rule is one of readability: && and || for one-line reflexes, if for logic. And never use a && b || c as a substitute for if/else, for the reason you already saw in 03-03: if b fails, c runs anyway.
if on real commands
if on real commandsThis is the most important section of the lesson. Since if evaluates exit codes, you can put the command you care about directly without brackets, which is shorter, faster and more expressive:
if grep -q ERROR "$LOG_PATH"; then # idiomatic
echo "There are errors logged today"
fi
n=$(grep -c ERROR "$LOG_PATH") # clumsy alternative
if [[ $n -gt 0 ]]; then echo "There are errors"; fiThe first version is better for three reasons: grep -q stops reading as soon as it finds the first match (in a one-gigabyte log the difference is enormous), it does not create a subshell to capture the output, and it expresses the intent directly. Use the second one only when you genuinely need the number.
The idiomatic uses you will write most often are these:
if command -v jq >/dev/null 2>&1; then # is the tool installed?
echo "jq available, it will be used for the JSON output"
else
echo "WARNING: jq not installed, plain text output" >&2
fi
ping -c1 -W2 srv-veloz-01 >/dev/null 2>&1 && echo "server reachable"
systemctl is-active --quiet veloz-api && echo "veloz-api running"Pay attention to command -v jq >/dev/null 2>&1: it is the correct and portable way to check whether a program exists. You will see which jq out there, but which is an external program that is not present on every system and whose exit code is not reliable; command -v is a Bash builtin (lesson 01-04) and always works. The redirection to /dev/null — the 02-04 pattern applied to conditions — silences the output in all three cases, because here we only care about the exit code; without it you would see the ping statistics in the middle of the report.
- Compound conditions, negation and input validation
To combine conditions, the natural thing in Bash is to use && and || inside [[ ]], as you saw in 03-03: if [[ -f "$CSV_PATH" && -r "$CSV_PATH" && -s "$CSV_PATH" ]] asks in one go whether the CSV exists, is readable and has content.
You can also combine whole commands with && in the if condition, something less well known but perfectly valid, as in if command -v jq >/dev/null && [[ -f "$JSON_PATH" ]]; then.
Negation is written with !, and it can be applied both inside and outside the brackets:
if ! grep -q ERROR "$LOG_PATH"; then # negating an external command
echo "Clean day: no errors logged"
fi
[[ ! -f "$CSV_PATH" ]] && echo "The shipments file is missing" >&2 # inside the testWatch out for a style detail: if ! [[ -f "$f" ]] and if [[ ! -f "$f" ]] are equivalent, but the second reads better because it keeps the negation right next to what it negates. And if ! command is the only option when what you are negating is an external command.
The file operators from 03-03 find their natural home here. A serious script never assumes its input data is where it should be:
if [[ ! -e "$CSV_PATH" ]]; then echo "ERROR: $CSV_PATH does not exist" >&2; exit 4
elif [[ ! -r "$CSV_PATH" ]]; then echo "ERROR: cannot read it" >&2; exit 4
elif [[ ! -s "$CSV_PATH" ]]; then echo "ERROR: it is empty" >&2; exit 4
fiThat block is didactic but overdone: distinguishing each cause with its own message is excellent for diagnosis and excessive for a small script. The condensed version you will use in practice groups the three into if [[ ! -r "$CSV_PATH" || ! -s "$CSV_PATH" ]]. The criterion for choosing between the two: the further away the person reading the error is, the more specific the message must be. A script you run yourself can afford generic messages; one that runs from cron at seven in the morning and leaves a trace only in a log needs to say exactly what failed.
Do not forget to validate the variables too, not just the files: [[ -z $city ]] && { echo "ERROR: no city specified" >&2; exit 2; }. This check will make full sense in 03-05, when the city arrives as a command-line argument and may perfectly well not arrive at all.
- Nesting, guard clauses and early exit
An if can contain another if, but nesting becomes unreadable very quickly:
if [[ -f "$CSV_PATH" ]]; then # the useful logic ends up three levels deep
if [[ -r "$CSV_PATH" ]]; then
if [[ -s "$CSV_PATH" ]]; then
tail -n +2 "$CSV_PATH" | cut -d, -f5 | sort | uniq -c
fi
fi
fi # ...and if the file does not exist, nobody finds outThe problem is not merely cosmetic. In that form, if the file does not exist the script says nothing and finishes as if everything had gone well, because there is no branch handling the failure. It is a textbook silent failure. The professional solution is called a guard clause: invert each condition, handle the bad case first and exit immediately, leaving the useful code at the end and unindented.
[[ -f "$CSV_PATH" ]] || { echo "ERROR: $CSV_PATH does not exist" >&2; exit 4; }
[[ -r "$CSV_PATH" ]] || { echo "ERROR: cannot read $CSV_PATH" >&2; exit 4; }
[[ -s "$CSV_PATH" ]] || { echo "ERROR: $CSV_PATH is empty" >&2; exit 4; }
tail -n +2 "$CSV_PATH" | cut -d, -f5 | sort | uniq -cThe second version wins on four fronts: it goes from three levels of indentation to none, it says what failed with a specific message, it returns a nonzero exit code instead of faking success, and it accommodates a new check by adding a line rather than another level.
The rule, applicable to any language: validate and exit at the beginning; leave the real work for the end. If you find yourself with more than two levels of if, there is almost always a guard waiting to be extracted.
- Different exit codes for different errors
You already know that exit N is the contract with whoever calls you (03-01), and conditionals are the mechanism that lets you honor it precisely: every failure reason gets its own number. For daily-report.sh we settle on this table, which we will document in the file header:
| Code | Meaning | Code | Meaning |
|---|---|---|---|
0 |
Report correct | 3 |
app.log cannot be read |
1 |
Unexpected error | 4 |
shipments.csv missing, unreadable or empty |
2 |
Incorrect usage (03-05) | 5 |
Report destination not writable |
The advantage is concrete: whoever invokes the script will be able to react differently to each failure without parsing text messages — for example, with a case $? that alerts the data team only when the code is 4. That case is a preview of 04-05. There is also a tool that aborts the script automatically on any failing command, set -e, together with set -u and trap: they are the complete error-handling arsenal and are studied in 05-03. For now, explicit guards give you more control and force you to think about what should happen in each case.
daily-report.sh validates its environment
daily-report.sh validates its environmentPutting it all together; this version can already go into production without fear:
#!/usr/bin/env bash
# daily-report.sh - Daily activity summary for Veloz Envíos
# Author : Joan Costa <[email protected]> · Usage: daily-report.sh
# Codes : 0 success | 3 unreadable log | 4 invalid CSV | 5 destination not writable
# --- Constants --------------------------------------------------------
readonly LOG_PATH="/var/log/veloz/app.log"
readonly CSV_PATH="/srv/veloz/data/shipments.csv"
readonly REPORT_DIR="$HOME/veloz-ops/logs"
readonly ERROR_THRESHOLD=50
readonly SERVER="srv-veloz-01"
# --- Preliminary validations (guard clauses) --------------------------
[[ -r "$LOG_PATH" ]] || { echo "ERROR: cannot read $LOG_PATH" >&2; exit 3; }
[[ -f "$CSV_PATH" && -s "$CSV_PATH" ]] \
|| { echo "ERROR: $CSV_PATH does not exist or is empty" >&2; exit 4; }
mkdir -p "$REPORT_DIR" || { echo "ERROR: cannot create $REPORT_DIR" >&2; exit 5; }
[[ -w "$REPORT_DIR" ]] || { echo "ERROR: $REPORT_DIR not writable" >&2; exit 5; }
# --- Data and report --------------------------------------------------
total_errors=$(grep -c ERROR "$LOG_PATH")
total_shipments=$(tail -n +2 "$CSV_PATH" | wc -l)
echo " DAILY REPORT - VELOZ ENVIOS ($SERVER) · $(date '+%F %T')"
echo "-- Errors in app.log --"
if (( total_errors == 0 )); then
echo "No errors logged. Clean day."
elif (( total_errors < ERROR_THRESHOLD )); then
echo "$total_errors errors (threshold: $ERROR_THRESHOLD). Within the normal range."
else
echo "WARNING: $total_errors errors, above the threshold of $ERROR_THRESHOLD."
echo "Last 3 errors logged:"
grep ERROR "$LOG_PATH" | tail -3
fi
echo "-- Shipments by status ($total_shipments in total) --"
tail -n +2 "$CSV_PATH" | cut -d, -f5 | sort | uniq -c | sort -rn
# --- Data quality -----------------------------------------------------
grep -q ',delivered,' "$CSV_PATH" \
|| echo "ATTENTION: no shipment delivered today. Incomplete data?" >&2
exit 0Run on a day with issues:
DAILY REPORT - VELOZ ENVIOS (srv-veloz-01) · 2026-08-03 07:00:04
-- Errors in app.log --
WARNING: 73 errors, above the threshold of 50.
Last 3 errors logged:
2026-08-03 06:41:02 [ERROR] timeout querying delivery route id=88213
2026-08-03 06:44:57 [ERROR] failed to geocode address in Bilbao
2026-08-03 06:52:11 [ERROR] 500 response from the routing provider
-- Shipments by status (1247 in total) --
981 delivered
148 in_transit
118 issueThe qualitative leap is real. The script now checks its environment before working and aborts with a specific code if something fails; it interprets the error count instead of just printing it; it adds useful context (the last three errors) only when needed, so as not to fill quiet days with noise; and it detects a data anomaly no operator would have noticed while reading figures. That is no longer a pipeline saved in a file: it is an operations tool.
Common Mistakes and Tips
- Forgetting
fi. Bash givessyntax error: unexpected end of file, pointing at the last line of the script instead of the guiltyif. - Forgetting the
;beforethen. It producessyntax error near unexpected token 'then'. - Writing
elseiforelse if. In Bash it iselif. (else ifworks, but it opens a newifthat needs its ownfi.) - Believing that
ifneeds brackets.if grep -q ...is valid and idiomatic: the brackets are just another command. - Confusing 0 with false. In Bash 0 is success, that is, true for an
if. - Comparing numbers with
>inside[[ ]]. That is a textual comparison (03-03). Use-gtor(( )). - Nesting three levels of
if. Invert the conditions and use guard clauses. - Validating without warning. An
ifthat detects the problem and neither prints anything nor exits with an error code leaves the script failing silently: every error goes to stderr with>&2and withexit N.
Exercises
Exercise 1 — From nesting to guards. Rewrite this block with guard clauses, messages on stderr and different exit codes (3 for the directory, 4 for the file, 5 for the permission).
if [[ -d /srv/veloz/data ]]; then
if [[ -f /srv/veloz/data/shipments.csv ]]; then
if [[ -r /srv/veloz/data/shipments.csv ]]; then
wc -l /srv/veloz/data/shipments.csv
fi
fi
fiExercise 2 — Checking tools up front. Write a fragment for daily-report.sh that verifies that grep, cut, sort and uniq exist before starting, warns on stderr about which one is missing and exits with code 6. In addition, if column exists, it must set a variable table_format="yes" to present the report aligned; if not, leave it at "no" and carry on without aborting.
Exercise 3 — Classifying the day. Based on the variable issues, print "excellent day" if it is 0, "normal day" between 1 and 50, "review routes" between 51 and 150, and "escalate to management" from 151 upwards. Explain why you do not need to write the lower bound of each range.
Solutions
Solution to Exercise 1
readonly DATA_DIR="/srv/veloz/data"
readonly CSV_PATH="$DATA_DIR/shipments.csv"
[[ -d "$DATA_DIR" ]] || { echo "ERROR: $DATA_DIR does not exist" >&2; exit 3; }
[[ -f "$CSV_PATH" ]] || { echo "ERROR: $CSV_PATH does not exist" >&2; exit 4; }
[[ -r "$CSV_PATH" ]] || { echo "ERROR: cannot read $CSV_PATH" >&2; exit 5; }
wc -l "$CSV_PATH"Besides removing the three levels of indentation, this version fixes a serious flaw in the original: the nested block finished with code 0 even though it had done nothing. A cron running it would have considered a day fine on which the data file did not even exist. Note too that the paths have been extracted into constants, applying 03-02.
Solution to Exercise 2
for tool in grep cut sort uniq; do
command -v "$tool" >/dev/null 2>&1 \
|| { echo "ERROR: the tool '$tool' is missing" >&2; exit 6; }
done
if command -v column >/dev/null 2>&1; then
table_format="yes"
else
table_format="no"; echo "WARNING: 'column' not available" >&2
fiThe for is a preview of 04-01, but it fits naturally here; without it you would have to repeat the same if four times. What matters is the design criterion: grep, cut, sort and uniq are hard dependencies — without them the script cannot work, so it aborts — whereas column is an optional enhancement — its absence degrades the presentation but does not prevent generating the report, so it only warns. Telling the two apart is one of the decisions that separate a robust script from a fragile one.
Solution to Exercise 3
if (( issues == 0 )); then echo "Excellent day: no issues at all"
elif (( issues <= 50 )); then echo "Normal day: $issues"
elif (( issues <= 150 )); then echo "Review routes: $issues"
else echo "Escalate to management: $issues"
fiBecause the conditions are evaluated top to bottom and stop at the first true one, the lower bounds are implicit: the <= 150 branch is only reached if the two previous ones failed, so we already know the value is greater than 50. Writing (( issues > 50 && issues <= 150 )) would be correct but redundant, and it would add one more opportunity to get it wrong when adjusting the thresholds.
Conclusion
Your script now thinks. You have mastered the syntax of if/then/elif/else/fi with its punctuation traps; and above all you understand the central idea of the chapter, that if evaluates an exit code and not a boolean, which turns [[ ]] into just another command and opens the door to if grep -q ... or if command -v ..., which is how Bash is written by people who have been doing it for years. You know when a one-line && is more readable than a three-line if; you negate with ! unambiguously; you validate files and variables before using them; and you have swapped nesting for guard clauses that leave the useful logic unindented, with its own exit code for each failure.
daily-report.sh validates its environment, interprets the threshold, adds context only when needed and detects anomalies in the data. But it still has one fundamental limitation: it always does exactly the same thing. It analyzes the current day and every city, and there is no way to ask it for last Monday's report or only Bilbao's without editing the file. In that sense, it is still as rigid as the alias we left behind in Module 2.
In lesson 03-05 that comes to an end. You will learn to receive information from outside: positional parameters $1 and $@, the critical difference between "$@" and "$*", named arguments such as --date and --city, getopts, default values and interactive input with read. By the end, daily-report.sh --date 2026-07-28 --city Bilbao will be a real command, --help and all.
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
