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
- Overview: four families of operators
- Control-flow operators:
;,&&,||,! - Grouping:
{ ...; }versus( ... ) - The
testcommand and its three forms - Numeric comparison versus string comparison
- Strings: equality, order and emptiness
- File operators
- Logical operators inside
[[ ]] - Arithmetic as an operator:
(( ))and$(( )) - Precedence and parentheses
- 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.
- 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 resultThe 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.
- Grouping:
{ ...; } versus ( ... )
{ ...; } 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 |
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.
- The
test command and its three forms
test command and its three formsHere 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).
- 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" alphabeticallyThe 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.
- 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.
- 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" ]].
- 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.
- Arithmetic as an operator:
(( )) and $(( ))
(( )) 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 thresholdTwo 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 workedThe 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.
- 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 parenthesesThe 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-gtor, better still,(( )). - Using
-eqwith text.[[ "Valencia" -eq "Sevilla" ]]givesinteger expression expected. - Using
[ ]with unquoted variables. If the variable is empty,testreceives 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 || cthinking it is anif/else. Ifbfails,cruns too. - Trusting
(( ))with a zero result. It returns code 1 and can abort the script ifset -eis on. - Using
-aand-oinside[ ]. 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
- 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
