daily-report.sh already keeps its paths in constants and its counts in variables, but it is still blind: it has an ERROR_THRESHOLD=50 that nobody compares with anything, and it takes for granted that shipments.csv exists. Before the script can start deciding, it needs a vocabulary: operators. In this lesson you will learn to chain commands according to their result, to check whether a file exists, to compare numbers and strings — and not to confuse the two, which is the most frequent mistake in Bash scripting. We will not write a single if yet: that is the next lesson. Here we build the pieces that will go inside it.

Contents

  1. Overview: four families of operators
  2. Control-flow operators: ;, &&, ||, !
  3. Grouping: { ...; } versus ( ... )
  4. The test command and its three forms
  5. Numeric comparison versus string comparison
  6. Strings: equality, order and emptiness
  7. File operators
  8. Logical operators inside [[ ]]
  9. Arithmetic as an operator: (( )) and $(( ))
  10. Precedence and parentheses

  1. Overview: four families of operators

Several systems of operators coexist in Bash; they look alike but they are not interchangeable. Telling them apart from the outset will save you hours of bewilderment:

Family Symbols Where they are written What they operate on
Control flow ; && || ! & Between commands Exit codes
Comparison (test) -eq -lt = -f -z Inside [ ] or [[ ]] Strings, numbers and files
Arithmetic + - * / % > < Inside (( )) or $(( )) Integers only
Expansion ${var:-x} $(cmd) In any word Text

The first three groups are the content of this lesson; the expansion family belongs to 03-06. The typical confusion is between rows 2 and 3: > inside [[ ]] compares text alphabetically, whereas > inside (( )) compares numbers. Same symbol, different semantics depending on the context.

  1. Control-flow operators: ;, &&, ||, !

Every command in Linux returns an exit code: 0 if it succeeded, nonzero if it failed (lesson 03-01). Control-flow operators chain commands using that code as the criterion.

Operator Name Behavior
; Sequence Runs the second one always, whatever happened with the first
&& Logical AND Runs the second one only if the first succeeded (code 0)
|| Logical OR Runs the second one only if the first failed (code ≠ 0)
! Negation Inverts the command's exit code
& Background Launches the command without waiting for it (02-06)
mkdir -p ~/veloz-ops/logs ; echo "we carry on no matter what"
mkdir -p ~/veloz-ops/logs && echo "directory ready"   # only if the mkdir went well
grep -q ERROR /var/log/veloz/app.log || echo "no errors today"

They are called lazy evaluation (or short-circuit) operators because Bash only runs the second command if it is needed to know the result: with &&, if the first one fails it already knows the whole thing fails; with ||, if the first one works it already knows the whole thing works.

The most valuable idiom in this section is critical_command || exit 1, which reads "do this or, if it fails, abort". It is the most compact way to write a validation:

cd /srv/veloz/data || exit 1      # if the cd fails, do not keep working blindly
grep -q ERROR "$LOG" && echo "there are errors" || echo "all clean"
! grep -q ERROR "$LOG" && echo "the log is clean"      # ! inverts the result

The first line matters: a cd that fails and a script that carries on is a recipe for disaster, because the following commands will run in the wrong directory.

Careful with the pattern on the second line: it is not a real if/else. If echo "there are errors" were to fail (for example, with a full disk), the || branch would run as well. It is fine for trivial cases; for real logic use if, which is 03-04.

  1. Grouping: { ...; } versus ( ... )

When the && or the || must affect several commands, you have to group them. Bash offers two forms with one crucial difference:

Form Where it runs Syntax
{ cmd1; cmd2; } In the current shell Needs spaces after { and a ; (or newline) before }
( cmd1; cmd2 ) In a subshell (child process) No special requirements
[[ -f "$CSV_PATH" ]] || { echo "Missing the shipments CSV" >&2; exit 3; }

That line is the validation pattern you will write most often: if the file does not exist, it prints an error on stderr (>&2, lesson 02-04) and exits with code 3. The braces are indispensable here: without them, the exit 3 would run every time.

The spaces around the braces are not optional: {echo hello;} is a syntax error because Bash reads {echo as a single command name, and the ; (or a newline) before the closing brace is required.

Parentheses create a subshell, with everything that implies according to 01-04: directory changes and variables defined inside do not escape. That is why ( cd /srv/veloz/data && wc -l shipments.csv ) does not alter your current directory. Practical rule: braces by default (they do not cost a process) and parentheses only when you want deliberate isolation.

  1. The test command and its three forms

Here comes the central piece. To ask "does this file exist?" or "is this number greater than that one?", Bash uses test. And test is a command, not special syntax: it takes arguments and returns 0 (true) or 1 (false).

There are three ways to invoke it, historically distinct: test -f "$csv" (the original form), [ -f "$csv" ] (the POSIX synonym) and [[ -f "$csv" ]] (a Bash keyword).

The fact that [ is a command and not a symbol explains the most bewildering beginner's error: [-f file] fails with command not found, because Bash looks for a program called [-f. The inner spaces are mandatory, and the ] is literally the last argument [ demands to receive.

Aspect [ ] (the test command) [[ ]] (Bash keyword)
Portability POSIX: works in sh, dash, everywhere Bash, Zsh, Ksh only
Unquoted variables Dangerous: if empty, syntax error Safe: there is no word splitting
Globbing of the value It expands, with surprises It does not expand
Internal && and || Not accepted; you must use -a / -o Accepted, with correct precedence
Pattern matching No Yes: [[ $city == Val* ]]
Regular expressions No Yes: [[ $l =~ ^ERROR ]] (05-04)
Numeric comparison with < No (it compares text) Not either: use -lt or (( ))

The most important practical difference is the second row. With city="", the expression [ $city = "Valencia" ] produces bash: [: =: unary operator expected, whereas [[ $city = "Valencia" ]] works and is simply false.

With [ ], the empty variable disappears before test ever sees it, and test receives [ = Valencia ], which makes no sense. With [[ ]], Bash does not expand words inside, so there is no problem. The fix with [ ] is to always quote ([ "$city" = "Valencia" ]), but it is a precaution you have to remember every single time.

Recommendation for this course: use [[ ]] whenever you are writing for Bash. Only fall back on [ ] when the script must work with plain sh (08-07).

  1. Numeric comparison versus string comparison

This is the section that prevents the most errors. Since in Bash everything is text (03-02), the shell cannot guess whether you want to compare numbers or words: you have to tell it with the operator.

Comparison Numeric String
Equal / not equal -eq / -ne = or == / !=
Less than / less or equal -lt / -le < / (does not exist)
Greater than / greater or equal -gt / -ge > / (does not exist)

The names are simply abbreviations and are easy to remember: equal, not equal, less than, less or equal, greater than, greater or equal.

And now the classic mistake, which is worth seeing with concrete numbers:

[[ $total_errors -gt $ERROR_THRESHOLD ]]  # more errors than we can tolerate?
[[ 100 -gt 9 ]]   ; echo $?    # 0 → true: 100 is greater than 9
[[ "100" > "9" ]] ; echo $?    # 1 → FALSE: "100" comes before "9" alphabetically

The last comparison is alphabetical: character by character, 1 comes before 9, so "100" is "less than" "9". It is exactly the same criterion a dictionary uses to place "house" before "shoe". If you compare numbers with > or <, your script will work by accident with figures of the same length and will fail the day the errors go from 9 to 100.

Rule: numbers with -eq -ne -lt -le -gt -ge; text with = == != < >. No exceptions. Two final warnings. Inside [ ], the symbols < and > must be written escaped (\<, \>), because otherwise Bash interprets them as redirections and creates a file called 9. Inside [[ ]] that is not needed. And the numeric operators demand that both sides be integers: if the variable is empty or contains text, you will get integer expression expected.

  1. Strings: equality, order and emptiness

Besides comparing, you often need to know whether a variable has content:

Operator True if…
-z "$var" / -n "$var" The string has zero length / is not empty
$a = $b / $a != $b They are identical (in [[ ]], == is a synonym) / they differ
$a < $b $a comes first alphabetically (according to the locale)
[[ -n $city ]] || { echo "You must specify a city" >&2; exit 2; }
[[ $city == Val* ]] && echo "starts with Val"
[[ ${city,,} == "valencia" ]] && echo "matches, ignoring case"

The second line shows something only [[ ]] allows: the right-hand side without quotes is treated as a globbing pattern (02-05), so Val* matches any city starting with "Val". If you quote it ("Val*"), it becomes literal text and will not match.

A detail about alphabetical order: it depends on LC_COLLATE. In a Spanish locale, [[ "año" < "banco" ]] may give a different result than under the C locale. For deterministic comparisons, the usual practice is to set LC_ALL=C.

  1. File operators

This is the family that turns daily-report.sh into a robust script: check before you work.

Operator True if the path…
-e path Exists (whatever it is)
-f path Exists and is a regular file
-d path Exists and is a directory
-s path Exists and is not empty (size > 0)
-r path Is readable by the current user
-w path Is writable
-x path Is executable (or a traversable directory)
-L path Is a symbolic link (05-01)
f1 -nt f2 / f1 -ot f2 f1 is newer / older than f2
[[ -r "$LOG_PATH" ]]   || echo "no read permission on the log" >&2
[[ -s "$CSV_PATH" ]]   || echo "warning: the CSV is empty" >&2
[[ -d "$REPORT_DIR" ]] || mkdir -p "$REPORT_DIR"         # "make sure it exists"

The distinction between -e, -f and -s matters more than it seems. -e only says the path exists: a directory called shipments.csv also satisfies it. -f guarantees it is a real file. And -s adds that it has content, which is what you really want to know: an empty CSV exists, is readable and produces a report with everything at zero without raising any error. That is the silent failure -s prevents.

-nt and -ot have a very specific use in operations: knowing whether a report is up to date with respect to its source data, with [[ "$CSV_PATH" -nt "$report" ]].

  1. Logical operators inside [[ ]]

To combine conditions, [[ ]] accepts && and || inside the brackets:

[[ -f "$CSV_PATH" && -r "$CSV_PATH" ]] && echo "exists and I can read it"
[[ -z $city || $city == "all" ]] && echo "analyze every city"

The [ ] command does not accept them; it uses -a (and) and -o (or), as in [ -f "$CSV_PATH" -a -r "$CSV_PATH" ]. Avoid -a and -o. They are marked as obsolete in the POSIX standard itself because their parsing is ambiguous: test receives a flat list of arguments and has to guess where each expression starts and ends, which produces incorrect results with certain values. On top of that, they do not short-circuit: [ -f "$f" -a -r "$f" ] always evaluates both parts.

There is another notable practical difference: inside [[ ]], && does short-circuit, which lets you write dependent checks safely. In [[ -f "$f" && $(wc -l < "$f") -gt 0 ]], if the file does not exist the wc never runs and no spurious error message appears. With -a you would have no such guarantee.

  1. Arithmetic as an operator: (( )) and $(( ))

Bash offers an arithmetic context where the operators recover their usual mathematical meaning. There are two variants:

Syntax Returns Used for
(( expr )) An exit code Comparing and evaluating as a condition
$(( expr )) A value Computing and substituting the result
total_errors=37; ERROR_THRESHOLD=50

(( total_errors > ERROR_THRESHOLD )) && echo "WARNING: too many errors"
percentage=$(( total_errors * 100 / ERROR_THRESHOLD ))
echo "You are at ${percentage}% of the threshold"     # → You are at 74% of the threshold

Two notable advantages. First: inside (( )) the $ is optional on variable names, because anything that is not a number is interpreted as one. Second: you can use <, >, <=, >=, == and != with their natural numeric meaning, which reads far better than -lt and company.

There is a surprising detail that produces silent failures: (( )) returns 1 (failure) when the result of the expression is 0, following the C convention, which is inverted with respect to the shell's exit codes.

(( 0 ))  ; echo $?    # 1 → "false"
(( 5 ))  ; echo $?    # 0 → "true"
counter=0; (( counter++ )) ; echo $?    # 1, even though the operation worked

The last line is the real trap: if you have set -e active (05-03), a (( counter++ )) with the counter at zero will abort the script. The idiomatic solution is (( counter++ )) || true.

The complete arithmetic repertoire — %, **, increments, number bases, decimals with bc — is lesson 04-06. Here we are interested in (( )) only as a comparison operator.

  1. Precedence and parentheses

When you mix operators, Bash applies an established precedence: && is evaluated before || inside [[ ]], just as in most languages. But between commands, && and || have the same precedence and are evaluated left to right, which is a classic source of surprises.

[[ -f "$CSV_PATH" || -f "$ALT_PATH" ]] && [[ -r "$CSV_PATH" ]]   # explicit
[[ ( -f "$f" || -d "$f" ) && -r "$f" ]]                          # inner parentheses

The professional advice is simple: do not trust precedence, write parentheses. Inside [[ ]] you write them as they are, with spaces around them; inside [ ] you have to escape them (\\( and \\)), one more reason to prefer [[ ]].

With all this, the validations daily-report.sh needs can already be expressed in full:

[[ -f "$CSV_PATH" && -s "$CSV_PATH" ]] || { echo "CSV missing or empty" >&2; exit 3; }
[[ -r "$LOG_PATH" ]] || { echo "Cannot read the log" >&2; exit 4; }
(( total_errors > ERROR_THRESHOLD )) && echo "WARNING: $total_errors errors today"

Three lines that turn a trusting script into a defensive one. In 03-04 we will give them the if shape they deserve and integrate them into the file.

Common Mistakes and Tips

  • Forgetting the inner spaces of the brackets. [[-f $f]] is not syntax: [ and [[ are words that need spaces on both sides.
  • Comparing numbers with > or <. [[ "100" > "9" ]] is false. Use -gt or, better still, (( )).
  • Using -eq with text. [[ "Valencia" -eq "Sevilla" ]] gives integer expression expected.
  • Using [ ] with unquoted variables. If the variable is empty, test receives fewer arguments than it expects and fails with a cryptic message.
  • Writing < or > inside [ ] without escaping. Bash reads it as a redirection and creates a file with that name.
  • Chaining a && b || c thinking it is an if/else. If b fails, c runs too.
  • Trusting (( )) with a zero result. It returns code 1 and can abort the script if set -e is on.
  • Using -a and -o inside [ ]. Obsolete and ambiguous: use && and || inside [[ ]].

Exercises

Exercise 1 — Translate into operators. Write, without using if, the line corresponding to each sentence: (a) if shipments.csv does not exist, print an error on stderr and exit with code 3; (b) create ~/veloz-ops/logs only if it does not exist; (c) if the number of issues exceeds 100, print an alert; (d) if the variable city is empty, assign it Valencia (using operators, not parameter expansion).

Exercise 2 — Find the five faults. This block has five operator errors. Identify them and rewrite it with the best practices from the lesson.

errors=`grep -c ERROR /var/log/veloz/app.log`
if [-f /srv/veloz/data/shipments.csv]
[ $errors > 50 ] && echo "too many errors"
[ -f $csv -a -r $csv ] && echo "readable"
[[ $city -eq "Valencia" ]] && echo "it is Valencia"

Exercise 3 — Full validation. Write the block of preliminary validations for daily-report.sh using operators only. It must check, with a different exit code for each failure: that the log exists and is readable (code 3), that the CSV exists and is not empty (code 4), that the reports directory exists or can be created (code 5), and that there is free space on /srv (code 6). Every error must go to stderr.

Solutions

Solution to Exercise 1

[[ -f "$CSV_PATH" ]] || { echo "Cannot find $CSV_PATH" >&2; exit 3; }    # (a)
[[ -d "$REPORT_DIR" ]] || mkdir -p "$REPORT_DIR"                         # (b)
(( issues > 100 )) && echo "ALERT: $issues issues today"                 # (c)
[[ -z $city ]] && city="Valencia"                                        # (d)

In (a) the braces are indispensable: they group the message and the exit into a single unit for the ||. In (c) we use (( )) instead of [[ $issues -gt 100 ]]; both are correct, but the arithmetic reads better and cannot be confused with a textual comparison. In (d), the real Bash idiom is city=${city:-Valencia}, which you will see in 03-06: it does the same thing in a single expression.

Solution to Exercise 2

The five faults: (1) backticks instead of $( ); (2) [-f ...] with no inner spaces and with the if left unclosed — the ; then is missing too; (3) > compares text and on top of that creates a file called 50; you have to use -gt or (( )); (4) obsolete -a and unquoted variables inside [ ]; (5) -eq applied to text, which will give integer expression expected.

errors=$(grep -c ERROR /var/log/veloz/app.log)
[[ -f /srv/veloz/data/shipments.csv ]] && echo "the CSV exists"
(( errors > 50 )) && echo "too many errors"
[[ -f $csv && -r $csv ]] && echo "readable"
[[ $city == "Valencia" ]] && echo "it is Valencia"

Fault (3) deserves a closer look: with [ $errors > 50 ], Bash interprets > as a redirection, evaluates [ $errors ] (true if the variable is not empty) and creates an empty file called 50 in the current directory. The script appears to work — it always says "too many errors" — and keeps leaving junk around the filesystem. It is the perfect example of why operators matter.

Solution to Exercise 3

[[ -f "$LOG_PATH" && -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; }

[[ -d "$REPORT_DIR" ]] || mkdir -p "$REPORT_DIR" \
  || { echo "ERROR: cannot create $REPORT_DIR" >&2; exit 5; }

free=$(df --output=avail -m /srv | tail -1)
(( free > 100 )) || { echo "ERROR: less than 100 MB free on /srv" >&2; exit 6; }

Three details worth highlighting. Using -s in addition to -f in the second check is what avoids the silent all-zeros report. The third line chains || twice: if the directory does not exist it tries to create it, and if that also fails, it aborts; this is correct because the second || catches the mkdir failure. And the distinct exit codes (3, 4, 5, 6) let whoever invokes the script — cron, another script, a monitoring dashboard — know what failed without reading the messages, which is exactly the contract we talked about in 03-01.

Conclusion

You now have the vocabulary a script reasons with. You tell the four families of operators apart and you know that > means different things depending on the context; you chain commands with ;, && and || taking advantage of short-circuiting, and you have mastered the command || exit 1 idiom; you group with { ...; } and with ( ... ) when you want isolation; you know the three forms of test and why [[ ]] is the right choice in Bash; you separate numeric comparison from textual comparison, which is beginners' most expensive mistake; you check files with -f, -r, -s, -d and -nt, understanding why -s prevents silent failures; you combine conditions inside [[ ]] instead of using the obsolete -a and -o; and you use (( )) with due care about its zero result.

And, above all, the three validations daily-report.sh needed are already written. But written as loose lines chained with ||, a style that holds up well with two conditions and becomes unreadable with five.

In lesson 03-04 we give them their definitive form. You will discover that if in Bash does not evaluate a boolean but an exit code — which explains why if grep -q ERROR ... works without brackets — you will learn to structure validations with guard clauses instead of nesting, and daily-report.sh will start checking its environment before working and warning when the errors exceed the threshold.

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