Learn BASH Scripting - Conditionals & Test Operators
Episode 9 of 27

Learn BASH Scripting - Conditionals & Test Operators

Giving your scripts the ability to make decisions: the if/elif/else/fi structure based on exit status, the difference between test [ ] (POSIX) and [[ ]] (bash), test operators for strings, numbers, and files, plus && and || short-circuiting along with ready-to-use monitoring script patterns.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

In episode 8 we covered arithmetic operations — computing with $(( )) and bc. In this episode we close out this foundational layer with the structure that makes all that computation meaningful: conditionals. Arithmetic without decisions is just numbers; decisions without arithmetic are just assumptions. Combine the two, and your scripts evolve from merely executing commands to thinking.

Since episode 5, you've already seen glimpses of many idioms we'll dissect now: [ $# -ne 2 ] for argument validation, [ -z "$nama" ] for checking empty input, (( total >= 1000000 )) for thresholds. This episode ties them all together: understanding why bash's if structure works on exit status, why [[ ]] is safer than [ ], and how test operators for strings, numbers, and files can catch almost every real-world condition.

Main Discussion

if, elif, else, fi — Decisions Based on Exit Status

Bash's if structure differs from other programming languages. In Python or JavaScript, if expects a boolean value. In bash, if expects a command — and it makes its decision based on that command's exit status: 0 means "true", anything non-zero means "false".

Basic anatomy of if
#!/usr/bin/env bash
if command_yang_dijalankan; then
    echo "Berhasil (exit 0)"
elif perintah_lain; then
    echo "Kondisi kedua terpenuhi"
else
    echo "Semua kondisi gagal"
fi

Notice the complete structure: if ... then ... elif ... then ... else ... fi. fi is the reverse of if — the block closer. The then keyword is required after the condition. And since the foundation is exit status, anything that returns an exit status can be a condition — not just a test. The most concrete example:

if directly using a command's exit status
#!/usr/bin/env bash
if grep -q "error" /var/log/app.log; then
    echo "ADA baris error di log — perlu investigasi."
else
    echo "Tidak ada error ditemukan."
fi

grep -q prints nothing (the -q flag = quiet), and returns 0 if the pattern is found, 1 if not. Since if only reads the exit status, you don't need to write if [ $(grep ...) ]; then — just hand over the command directly. This is what makes bash so elegant: a condition is a command, not a data type.

Why the if [ ... ]; then Form?

If if is command-based, then what is [ ... ]? The answer: [ is a command — another name for the test program. It evaluates an expression and returns an exit status. So if [ "$a" = "b" ]; then reads as: run the test command with arguments "$a", =, "b", then use its exit status. This explains many of bash's syntax quirks — like why spaces inside [ ] are mandatory:

Spaces are mandatory — [ ] is a command
[ "$a" = "b" ]   # benar — argumen terpisah: [, "$a", =, "b", ]
[ "$a" = "b"]    # salah — "b]" menjadi satu argumen, error "unexpected token"
[ "$a"="b" ]     # salah — seluruh ekspresi menjadi satu argumen

Because [ is a command, every part of the expression must be a separate argument separated by spaces. This is also why test and [ are interchangeable: test "$a" = "b" is exactly the same as [ "$a" = "b" ]. The final ] is just a closing argument that makes the syntax feel "like parentheses".

[ ] vs [[ ]]: Two Different Worlds

This is one of the most important design decisions you'll make as a bash script writer: use [ ] (POSIX test) or [[ ]] (bash extension). They look similar, but their behavior differs fundamentally.

[ ] — POSIX, Portable, But Fragile

[ ] is an external/builtin command that follows the POSIX standard — meaning it's portable: it works in sh, dash, and every shell. But this portability comes at a price:

  • Word splitting still occurs on unquoted variables.
  • No regex support — only simple string comparison.
  • && and || are not recognized inside it (the shell splits them off as command operators).

[[ ]] — Bash Extension, More Powerful and Safer

[[ ]] is a bash keyword — not an external command. Because of that, it understands syntax in a "smarter" way:

  • No word splitting or globbing — variables don't need quoting (though quoting is still good practice).
  • && and || work inside as expression logical operators.
  • Regex with =~ — you've seen a glimpse of this in episode 6.

Compare:

Behavior when a variable is empty or contains spaces
#!/usr/bin/env bash
var=""   # variabel kosong
 
# [ ] — Wajib kutip; tanpa kutip, error "unary operator expected"
if [ "$var" == "x" ]; then echo "x"; fi
 
# [[ ]] — Lebih toleran dan lebih aman
if [[ $var == "x" ]]; then echo "x"; fi
Why [ ] errors without quotes
bash: [: x: unary operator expected

When $var is empty and written without quotes, [ $var == "x" ] becomes [ == "x" ] — and test is confused because its first argument is the == operator, not a value. Meanwhile [[ $var == "x" ]] treats $var as a single token no matter what it contains — without needing quotes. This is why professional scripting teams set the rule: use [[ ]] in bash scripts and save [ ] only for scripts that must be portable to sh.

Important

Choose [[ ]] for bash scripts (shebang #!/usr/bin/env bash) — it's safer (no word splitting), supports regex =~, and allows &&/|| operators inside. Use [ ] only if you're writing scripts that truly must run in a pure POSIX shell (sh). This consistency eliminates a whole class of bugs that most often plague beginners.

Example: && and || Inside [[ ]]

Because [[ ]] understands logical operators, combining multiple conditions becomes natural:

Combining conditions inside [[ ]]
#!/usr/bin/env bash
user="root"
env="produksi"
 
if [[ $user == "root" && $env == "produksi" ]]; then
    echo "Akses DIBERIKAN — kombinasi berbahaya, tetapi valid."
elif [[ $user == "root" || $env == "staging" ]]; then
    echo "Akses dengan peringatan."
else
    echo "Akses ditolak."
fi
Output
Akses DIBERIKAN — kombinasi berbahaya, tetapi valid.

With [ ], the same expression requires chained writing and extra quoting: [ "$user" = "root" ] && [ "$env" = "produksi" ]. Both work, but [[ ]] is far more readable — and readability is a feature, not a luxury.

Test Operators: String, Number, and File

Now we get to the hardware of conditionals: the test operators. All three work in both [ ] and [[ ]] (with the small differences already discussed).

String Operators

String tests: empty, non-empty, equal, different
#!/usr/bin/env bash
nama="Arman"
 
if [[ -z "$nama" ]]; then echo "String kosong."; fi
if [[ -n "$nama" ]]; then echo "String tidak kosong."; fi
if [[ "$nama" == "Arman" ]]; then echo "Sama persis."; fi
if [[ "$nama" != "Budi" ]]; then echo "Berbeda dengan 'Budi'."; fi
Output
String tidak kosong.
Sama persis.
Berbeda dengan 'Budi'.
OperatorTrue if...Counter-intuitive?
-z "$var"the string is empty (zero-length)Yes — -z = "zero length", not "has a value"
-n "$var"the string is not emptyYes — the intuitive opposite of -z
$a == $btwo strings are equalNo
$a != $btwo strings are differentNo

Numeric Operators

For numbers, don't use > or < inside [ ] — they're shell redirection operators, not comparisons! Bash provides special word operators:

Numeric tests: -eq -ne -gt -ge -lt -le
#!/usr/bin/env bash
pemakaian=87
 
if (( pemakaian >= 90 )); then
    echo "KRITIS: pemakaian $pemakaian%."
elif (( pemakaian >= 80 )); then
    echo "PERINGATAN: pemakaian $pemakaian%."
else
    echo "Normal: pemakaian $pemakaian%."
fi
OperatorMeaning
-eqequal — sama dengan
-nenot equal — tidak sama
-gtgreater than — lebih besar
-gegreater or equal — lebih besar atau sama
-ltless than — lebih kecil
-leless or equal — lebih kecil atau sama

Notice the example above uses (( )) from episode 8 — when only comparing integers, (( )) is a cleaner choice than [ "$pemakaian" -ge 90 ]. But you'll often encounter the [ "$x" -ge 90 ] form in other scripts, so mastering both syntaxes matters. And when the value can be fractional (from bc), remember episode 8's lesson: let bc do the comparing.

File and Directory Operators

This is what makes bash great for automation: bash can query file properties directly, without needing to call another program.

File tests: exists? directory? readable?
#!/usr/bin/env bash
file="/etc/nginx/nginx.conf"
 
if [[ -f "$file" ]]; then
    echo "Ada file: $file"
elif [[ -d "$file" ]]; then
    echo "Ini direktori, bukan file biasa."
fi
 
if [[ -r "$file" ]]; then
    echo "File bisa dibaca."
fi
OperatorTrue if...Real-World Scenario
-e paththe path exists (file, dir, symlink, anything)Check existence before access
-f paththe path is a regular file-f = "file" — configuration keys
-d paththe path is a directoryCheck before cd or a glob loop
-r paththe file can be readValidate permissions before reading
-w paththe file can be writtenCheck before writing logs
-x paththe file can be executedCheck a binary/script before running
-s paththe file exists and is not empty-s = "size > 0" — empty logs

Tip

Many beginners use -e and -f interchangeably — but they're different. -e is correct for any path (including symlinks and directories); -f is specifically for regular files. When you write if [ -f "$dir" ] for a variable that's actually a directory, that condition will always be false. Ask yourself: what exactly do I want to verify? then pick the matching operator — this precision is what makes scripts trustworthy.

Short-Circuit && and ||

Besides if, bash has a compact way to make decisions: short-circuit evaluation. In a pipeline, cmd1 && cmd2 runs cmd2 only if cmd1 succeeds; cmd1 || cmd2 runs cmd2 only if cmd1 fails.

&& and || as decision makers
#!/usr/bin/env bash
mkdir -p /tmp/backup && echo "Direktori dibuat."
 
[ -d /tmp/backup ] || echo "Direktori tidak ada — buat dulu!"
Output
Direktori dibuat.
Direktori tidak ada — buat dulu!

Why is this called short-circuit? Because bash stops evaluating once the result is already determined. In A && B, if A fails, bash doesn't need to run B (the result is already known to be false). In A || B, if A succeeds, B is skipped (the result is already known to be true). This is why the idiom is efficient — and why operand order matters so much.

When && / || and When if?

Both styles can rewrite each other, but each has its best context:

Concise style vs explicit style
#!/usr/bin/env bash
# Ringkas — untuk aksi satu baris: "kalau berhasil, lakukan ini"
systemctl start nginx && echo "nginx berjalan"
 
# Eksplisit — untuk logika bercabang yang butuh penjelasan
if systemctl start nginx; then
    echo "nginx berjalan"
else
    echo "Gagal memulai nginx" >&2
    exit 1
fi

The rule of thumb: &&/|| for short, clear sequences of actions; if for logic that branches, comments, or behaves differently on failure. Production scripts more often need if — because when a failure happens, we almost always need to do more than print a single line (for example, exit 1 and logging). But there's one mandatory exception you must always remember — see pitfall number 4 below.

Practice: Disk Space & File Monitoring Script

Time to combine all the test operators into a realistic monitoring script — a blend of episode 5 (arguments), episode 8 (arithmetic), and this episode (conditionals):

cek_disk.sh — threshold monitoring + file check
#!/usr/bin/env bash
set -euo pipefail
 
threshold="${1:-80}"
log_file="/var/log/cek_disk.log"
 
if ! [[ "$threshold" =~ ^[0-9]+$ ]]; then
    echo "Error: ambang batas harus angka." >&2
    exit 1
fi
 
pemakaian=$(df --output=pcent / | tail -1 | tr -d ' %')
 
if [[ -f "$log_file" ]]; then
    if [[ -w "$log_file" ]]; then
        echo "[$(date +%F)] pemakaian=$pemakaian%" >> "$log_file"
    else
        echo "PERINGATAN: $log_file tidak bisa ditulis." >&2
    fi
fi
 
if (( pemakaian >= threshold )); then
    echo "KRITIS: pemakaian disk ${pemakaian}% melebihi ambang ${threshold}%."
    exit 2
else
    echo "OK: pemakaian disk ${pemakaian}% di bawah ambang ${threshold}%."
    exit 0
fi
Monitoring script combining string, number, and file tests
Running it
./cek_disk.sh 90
Sample output
OK: pemakaian disk 37% di bawah ambang 90%.

Let's break down how each type of test contributes:

LineTest UsedPurpose
${1:-80}Parameter expansion with a defaultScript runs without arguments, default threshold 80
[[ "$threshold" =~ ^[0-9]+$ ]]String regex with ! (negation)Reject thresholds that aren't pure numbers
[[ -f "$log_file" ]]File: whether the log file existsDon't append to a file that doesn't exist yet
[[ -w "$log_file" ]]File: whether it's writableWarn instead of failing silently
(( pemakaian >= threshold ))Number with (( ))The main decision: past the threshold or not

Notice how all three test types (string, file, number) work side by side for a single goal. This script can be installed in cron to run every 5 minutes — and because exit 0/exit 2 are used, callers (for example, a monitoring system) can distinguish "all good" from "there's a problem" just from the exit status. This is the power of conditionals designed with full awareness.

Common Pitfalls: Conditional Traps

1. Missing spaces inside [ ]

[ "$a" = "b" ] is correct; [ "$a" = "b"] errors with "missing ]"; [ "$a"="b" ] errors with "unary operator expected". Because [ is a command, every token must be space-separated. This isn't just style — it's syntax.

2. Using > / < as numeric comparison

Inside [ ] and [[ ]], > and < are string comparisons (alphabetical order), not numeric! [[ "9" > "10" ]] evaluates to true because "9" is lexicographically greater than "1". For numbers, use -gt, -lt, etc. (or (( ))).

3. Forgetting to quote variables inside [ ]

[ $var == "x" ] with empty $var becomes [ == "x" ]"unary operator expected" error. Inside [ ], always quote. Inside [[ ]], quoting becomes optional (but still recommended).

4. cmd1 && cmd2 || cmd3 — the dangerous three-way pattern

This pattern is tempting but dangerous: if cmd2 itself fails, bash will also run cmd3 — because cmd1 && cmd2 yields "false" when cmd2 fails, then || cmd3 fires too. The result: the "success" path can still trigger the error branch. Don't write three-way logic with &&/|| — use if/else, which is unambiguous.

5. [ $var == "x" ] vs [[ $var == "x" ]] — real differences

Besides the quoting issue, there's another subtle trap: in [[ ]], == and = are both string comparisons. In POSIX [ ], the official operator is === might work in bash, but in sh/dash its behavior isn't guaranteed. If the script must be portable, write [ "$var" = "x" ]. If bash-only, use [[ ]].

6. Forgetting ; or then on the same line

if [ ... ] then — without ; before then — errors. Two valid styles: if [ ... ]; then (one line) or if [ ... ] then a new line with then. Pick one and stay consistent.

Conclusion

In this episode 9, we've completed the decision-making foundation: the if/elif/else/fi structure that works on a command's exit status; the fundamental difference between [ ] (a POSIX command fragile against word splitting) vs [[ ]] (a safer bash keyword that supports regex and logical operators); a catalog of test operators for strings (-z, -n, ==, !=), numbers (-eq, -ne, -gt, -ge, -lt, -le), and files (-f, -d, -e, -r, -w, -x, -s); plus &&/|| short-circuiting — complete with a disk monitoring script that blends it all.

Key takeaways:

  • if reads exit status, not booleans — a condition is a command.
  • Use [[ ]] for bash scripts: safer, regex, &&/|| inside.
  • Strings: -z -n == !=; Numbers: -eq -ne -gt -ge -lt -le; Files: -f -d -e -r -w -x -s.
  • Don't use >/< for numbers — those are string comparisons.
  • Quote variables inside [ ]; avoid three-way &&/|| patterns.

You now have four core bash foundations: data (arguments & input), file lists (globbing), calculation (arithmetic), and decisions (conditionals). Their combination can already build real automation scripts. The next step is refining the decision-making itself. In the next episode, episode 10, we'll cover Advanced Conditionals (Case Statement & Regex) — matching a single variable against many patterns at once with case ... esac, and validating inputs like email, IP addresses, and numbers using regular expressions with the =~ operator. See you in the next episode!