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

  1. Syntax of if / then / fi
  2. The key idea: if evaluates an exit code
  3. else and elif
  4. One-line conditionals
  5. if on real commands
  6. Compound conditions, negation and input validation
  7. Nesting, guard clauses and early exit
  8. Different exit codes for different errors
  9. daily-report.sh validates its environment

  1. Syntax of if / then / fi

The 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:

if [[ -f "$CSV_PATH" ]]; then
    echo "The shipments file is available"
fi

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.

  1. The key idea: if evaluates an exit code

Here 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.

  1. else and elif

else 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).

  1. 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 compact

In 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.

  1. if on real commands

This 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"; fi

The 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.

  1. 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 test

Watch 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
fi

That 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.

  1. 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 out

The 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 -c

The 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.

  1. 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.

  1. daily-report.sh validates its environment

Putting 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 0

Run 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 issue

The 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 gives syntax error: unexpected end of file, pointing at the last line of the script instead of the guilty if.
  • Forgetting the ; before then. It produces syntax error near unexpected token 'then'.
  • Writing elseif or else if. In Bash it is elif. (else if works, but it opens a new if that needs its own fi.)
  • Believing that if needs 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 -gt or (( )).
  • Nesting three levels of if. Invert the conditions and use guard clauses.
  • Validating without warning. An if that detects the problem and neither prints anything nor exits with an error code leaves the script failing silently: every error goes to stderr with >&2 and with exit 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
fi

Exercise 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
fi

The 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"
fi

Because 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

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