Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Bash conditions: if, tests and case by example

By updated

Improve this page

A Bash if does not test a boolean expression: it tests the exit code of a command. This page gives you the tests you will write every day in administration (does the config file exist, is the service running, is the disk full, is the argument valid), the complete table of operators on files, strings and numbers, and above all the quoting traps that make a [ $var = "x" ] blow up as soon as the variable is empty. Every example below was run on Ubuntu 24.04 with Bash 5.2 and checked with shellcheck.

What you will learn

  • Write if / elif / else blocks that react to the real state of the system
  • Test files, strings and integers with the right operator (-f, -z, -eq and the others)
  • Tell [ ], [[ ]] and (( )) apart and know which one to use when
  • Reproduce then fix the too many arguments and unary operator expected errors
  • Dispatch cleanly with case on the distribution or the argument received
  • Avoid the traps of set -e, the inverted exit code of (( )) and the fake && || ternary

A condition is an exit code

This is the mental model that unlocks everything else. In Bash, every command returns an exit code: 0 when it succeeded, a non-zero value when it failed. The if does nothing but run a command and look at that code. It is the opposite of the convention of most languages, where 0 means false.

The special variable $? holds the code of the last command. Look at what two very common administration commands return:

Terminal window
getent passwd bob > /dev/null; echo "getent passwd bob -> $?"
getent passwd deployer > /dev/null; echo "getent passwd deployer -> $?"
systemctl is-active ssh
systemctl is-active nginx
getent passwd bob -> 0
getent passwd deployer -> 2
active
inactive

The bob account exists so getent returns 0, the deployer account does not exist so 2. That is exactly what the if is going to use: no need to compare a text output, the exit code is enough.

Terminal window
if systemctl is-active --quiet ssh; then
echo "ssh is active, nothing to do"
else
echo "ssh is stopped, restart required"
fi
ssh is active, nothing to do

The --quiet option suppresses the active/inactive display: only the exit code is kept, which is precisely what we care about. Keep this reflex, it avoids useless grep calls on command output.

if / elif / else: the structure

The syntax requires two keywords that beginners forget all the time: then opens the block, fi closes it (it is if backwards, a joke from the authors of the Bourne shell that stayed in the language).

Terminal window
if CONDITION; then
# commands when the exit code is 0
elif OTHER_CONDITION; then
# commands when the first is false and this one true
else
# default commands
fi

The semicolon after the condition replaces a line break: if CONDITION; then and if CONDITION followed by then on the next line are strictly equivalent. Let's move straight to a real case: a configuration file can exist without being readable, and the two situations call for a different message.

Terminal window
conf="/etc/ssh/sshd_config"
if [[ -r "$conf" ]]; then
echo "OK: $conf readable, $(wc -l < "$conf") lines"
elif [[ -e "$conf" ]]; then
echo "WARNING: $conf exists but is not readable by $(id -un)"
else
echo "ERROR: $conf not found"
fi

Run with an unprivileged account, the result lands in the middle branch, because sshd_config is 600 root:root on Ubuntu:

WARNING: /etc/ssh/sshd_config exists but is not readable by bob

This -r then -e sequence is a pattern to memorise. A script that only tests existence then tries to read fails later, with an obscure message; a script that separates existence from readability tells you right away that a sudo is missing.

Six checks you will write every day

Conditions are not for boolean algebra exercises, they are for querying the state of a server. Here are the six questions that come back in almost every administration script, with the canonical form of each.

Is the service running?

systemctl is-active --quiet returns 0 when the unit is active. But a unit can also be unknown (package not installed), which is not the same as a stopped service. Handle the three cases separately if you want a usable diagnosis.

Terminal window
for svc in ssh docker cron postgresql; do
if systemctl is-active --quiet "$svc"; then
echo "$svc: active"
elif systemctl cat "$svc" &>/dev/null; then
echo "$svc: installed but stopped"
else
echo "$svc: unknown unit"
fi
done
ssh: active
docker: active
cron: active
postgresql: unknown unit

systemctl cat prints the unit file and fails when it does not exist: it is the shortest test to know whether the service is installed. If the diagnosis needs to go further, A service that will not start covers the reading of the journal.

Does the user exist?

Before running a chown on a service account, check that it exists. id -u returns 0 and the UID when the account is known, a non-zero code otherwise. The &>/dev/null redirection throws away both standard output and error, because only the exit code matters.

Terminal window
for u in bob www-data deployer; do
if id -u "$u" &>/dev/null; then
echo "$u: exists (uid $(id -u "$u"), shell $(getent passwd "$u" | cut -d: -f7))"
else
echo "$u: does not exist"
fi
done
bob: exists (uid 1000, shell /usr/bin/zsh)
www-data: exists (uid 33, shell /usr/sbin/nologin)
deployer: does not exist

The /usr/sbin/nologin shell of www-data marks a service account: it cannot open an interactive session. It is a piece of information your script may want to test in turn.

Is the disk above a threshold?

The fill rate comes out of df with a % stuck to the number. You must therefore strip the percentage before comparing, otherwise you compare a string. The ${pct%\%} expansion removes the trailing %; parameter expansions cover this syntax in detail.

Terminal window
threshold=75
while read -r _ _ _ _ pct mount; do
usage=${pct%\%}
if (( usage >= threshold )); then
echo "CRITICAL: $mount filled at $usage% (threshold $threshold%)"
else
echo "OK : $mount at $usage% (threshold $threshold%)"
fi
done < <(df -P / /boot | tail -n +2)
CRITICAL: / filled at 80% (threshold 75%)
OK : /boot at 11% (threshold 75%)

The -P option of df forces the POSIX format on a single line per filesystem: without it, a long device name causes a line break and breaks the reading. The comparison happens in (( )), the natural arithmetic form for integers.

Is the argument passed to the script valid?

$# holds the number of arguments received. An administration script always validates its inputs before acting, if only to print a usage message rather than an incomprehensible error three lines further down.

Terminal window
usage() {
echo "Usage: $(basename "$0") <service> <conf-file>" >&2
exit 2
}
(( $# == 2 )) || usage
service="$1"
if [[ ! "$service" =~ ^[a-zA-Z0-9@._-]+$ ]]; then
echo "ERROR: invalid service name: $service" >&2
exit 2
fi

The usage message goes to >&2 (the error output), not the standard output: a script called in a pipeline must not pollute the data. The exit 2 code signals a usage error, distinct from an exit 1 that would signal a failed business check. The regex validation of the service name prevents a twisted argument from ending up concatenated into a systemctl command.

Testing files: the complete table

All file tests follow the same form: a unary operator followed by a path. They return true when the file exists and meets the requested property. A missing file therefore makes every test fail, which is the intended behaviour.

TestTrue whenTypical use
-e paththe path exists (file, directory, link, socket…)check before overwriting
-f pathit is a regular fileread a config
-d pathit is a directorycheck a destination
-L pathit is a symbolic linkdetect a broken link
-s pathit exists and its size is > 0reject an empty dump
-r paththe current process can read itanticipate a "Permission denied"
-w paththe current process can write to itcheck an output directory
-x pathit is executable (or traversable for a directory)validate a binary before calling it
-O pathit belongs to the current usercheck the ownership of a secret
f1 -nt f2f1 is newer than f2rebuild a stale artefact
f1 -ot f2f1 is older than f2detect a config not reloaded

The difference between -e, -f and -L shows on a broken link, a very common case after an uninstall. A link whose target has disappeared still exists as a directory entry, but -e answers false because it follows the link before testing.

Terminal window
mkdir -p /tmp/lc-demo
printf 'listen 8080;\n' > /tmp/lc-demo/app.conf
ln -sf /tmp/lc-demo/app.conf /tmp/lc-demo/link.conf
ln -sf /tmp/lc-demo/nosuchfile /tmp/lc-demo/broken-link.conf
[[ -f /tmp/lc-demo/app.conf ]] && echo "-f app.conf : true (regular file)"
[[ -f /tmp/lc-demo/link.conf ]] && echo "-f link.conf : true (link followed to a file)"
[[ -L /tmp/lc-demo/link.conf ]] && echo "-L link.conf : true (it is indeed a link)"
[[ -e /tmp/lc-demo/broken-link.conf ]] || echo "-e broken-link.conf : false (the target is gone)"
[[ -L /tmp/lc-demo/broken-link.conf ]] && echo "-L broken-link.conf : true (the link itself exists)"
-f app.conf : true (regular file)
-f link.conf : true (link followed to a file)
-L link.conf : true (it is indeed a link)
-e broken-link.conf : false (the target is gone)
-L broken-link.conf : true (the link itself exists)

A clean-up script that looks for broken links therefore combines both: [[ -L "$f" && ! -e "$f" ]]. It is the only way to catch them, and a classic of maintenance scripts for /etc/alternatives or a modules directory.

Size, permissions and freshness

-s is the test many backup scripts lack: a failing mysqldump often leaves a zero-size file, and the next script proudly archives it. Testing existence is not enough, you must test the content.

Terminal window
: > /tmp/lc-demo/empty.log # empty file
[[ -e /tmp/lc-demo/empty.log ]] && echo "-e empty.log : true (it exists)"
[[ -s /tmp/lc-demo/empty.log ]] || echo "-s empty.log : false (size 0)"
[[ -s /tmp/lc-demo/app.conf ]] && echo "-s app.conf : true (contains data)"
[[ -w /etc/passwd ]] || echo "-w /etc/passwd : false (non-root user)"
[[ -r /etc/shadow ]] || echo "-r /etc/shadow : false (permissions 640 root:shadow)"
[[ -x /usr/bin/systemctl ]] && echo "-x /usr/bin/systemctl : true"
-e empty.log : true (it exists)
-s empty.log : false (size 0)
-s app.conf : true (contains data)
-w /etc/passwd : false (non-root user)
-r /etc/shadow : false (permissions 640 root:shadow)
-x /usr/bin/systemctl : true

Key point: -r, -w and -x do not describe the file, they describe the current process. The same test returns true as root and false under a regular account. That is exactly what we want, since the question asked is "can I read this file", but it means that a test that passes in your terminal can fail in a cron job run under another account.

Finally, -nt and -ot compare the modification dates. It is the logic of make in one line: when the source is newer than the artefact, rebuild.

Terminal window
sleep 1
printf 'rotation\n' > /tmp/lc-demo/recent.log
[[ /tmp/lc-demo/recent.log -nt /tmp/lc-demo/app.conf ]] && echo "recent.log -nt app.conf : true"
[[ /tmp/lc-demo/app.conf -ot /tmp/lc-demo/recent.log ]] && echo "app.conf -ot recent.log : true"
recent.log -nt app.conf : true
app.conf -ot recent.log : true

Testing strings

Two unary tests and two binary tests cover 95 % of needs: -z (the string is empty), -n (it is not), = (equality) and != (difference). Bash also accepts == as a synonym of = in [[ ]]; both are correct, = remains the portable form.

TestTrue when
-z "$var"the string is empty (or the variable unset)
-n "$var"the string is not empty
"$a" = "$b"the two strings are identical
"$a" != "$b"they differ
"$a" < "$b"$a comes before $b in lexicographic order
"$a" > "$b"$a comes after $b in lexicographic order
"$var" == patternthe string matches the glob pattern (in [[ ]] only)
"$var" =~ regexthe string matches the extended regex (in [[ ]] only)
Terminal window
user="deployer"
empty=""
[[ -z "$empty" ]] && echo '-z $empty : true (empty string)'
[[ -n "$user" ]] && echo '-n $user : true (not empty)'
[[ "$user" = "deployer" ]] && echo '= deployer : true'
[[ "$user" != "root" ]] && echo '!= root : true'
[[ "abc" < "abd" ]] && echo 'abc < abd : true (lexicographic order)'
-z $empty : true (empty string)
-n $user : true (not empty)
= deployer : true
!= root : true
abc < abd : true (lexicographic order)

Watch out for a surprising detail: lexicographic comparison depends on the locale, not the ASCII table. On a system in fr_FR.UTF-8, case is ignored in the ordering; with LC_ALL=C, upper case comes before lower case. A script that sorts host names can therefore change behaviour depending on the environment that runs it.

Terminal window
[[ "Zebre" < "abeille" ]] && echo "true" || echo "false" # locale fr_FR.UTF-8
LC_ALL=C bash -c '[[ Zebre < abeille ]] && echo "true" || echo "false"' # strict ASCII order
false
true

If the result must be reproducible, force LC_ALL=C at the top of the script. It is the same rule as for sort, which follows exactly the same collation logic.

Patterns and regular expressions

In [[ ]], the == operator enables pattern matching (glob) when the right-hand side is not quoted. And =~ triggers a POSIX extended regular expression. Neither capability exists in [ ], and it is one of the best reasons to prefer double brackets.

Terminal window
host="web-prod-03"
[[ "$host" == web-* ]] && echo '== web-* : true (glob, right side unquoted)'
[[ "$host" == "web-*" ]] || echo '== "web-*" : false (quotes = literal comparison)'
[[ "$host" =~ ^web-(prod|preprod)-[0-9]+$ ]] && echo "=~ host pattern : true"
echo " capture BASH_REMATCH[1] = ${BASH_REMATCH[1]}"
ip="10.42.0.7"
[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && echo "=~ IPv4 format : true"
== web-* : true (glob, right side unquoted)
== "web-*" : false (quotes = literal comparison)
=~ host pattern : true
capture BASH_REMATCH[1] = prod
=~ IPv4 format : true

The BASH_REMATCH array retrieves the captured groups: index 0 holds the full match, 1 the first parenthesised group. It is a free mini-parser, with no call to sed or awk.

Testing numbers

Integers use six two-letter operators, a legacy of the test command: they do not read naturally, but they are unavoidable in [ ] as in [[ ]].

test operatorEquivalent in (( ))Meaning
-eq==equal
-ne!=not equal
-lt<strictly less than
-le<=less than or equal
-gt>strictly greater than
-ge>=greater than or equal
Terminal window
load=42
[[ $load -eq 42 ]] && echo "-eq 42 : true"
[[ $load -gt 10 ]] && echo "-gt 10 : true"
[[ $load -lt 100 ]] && echo "-lt 100 : true"
[[ $load -ne 0 ]] && echo "-ne 0 : true"
(( load > 10 )) && echo "(( load > 10 )) : true"
(( load == 42 )) && echo "(( load == 42 )) : true"

The question everyone asks: why not write [ 5 > 10 ]? Because [ ] is an ordinary command, and > keeps its usual meaning of output redirection there. Bash compares nothing at all: it runs [ 5 ] (true, the string 5 is not empty) and creates a file named 10 in the current directory.

Terminal window
a=5; b=10
[ $a > $b ] && echo "5 is greater than 10?!"
ls -l 10
5 is greater than 10?!
-rw-rw-r-- 1 bob bob 0 Jul 12 11:31 10

The test returns true, the absurd message shows up, and a stray file 10 appears. No error, no warning: the bug is silent, the worst case. The two correct forms:

Terminal window
[ "$a" -gt "$b" ] && echo "5 > 10" || echo "5 -gt 10 : false (correct)"
(( a > b )) && echo "5 > 10" || echo "(( 5 > 10 )) : false (correct)"
5 -gt 10 : false (correct)
(( 5 > 10 )) : false (correct)

The -eq trap on a string

-eq forces an arithmetic evaluation of both operands. On a variable holding text, the result ranges from an outright error to a dangerous false positive, depending on the context.

Terminal window
version=""
[ "$version" -eq 9 ] && echo "version 9"
version="9.2"
[[ "$version" -eq 9 ]] && echo "9.2 -eq 9 ?"
release="stable"
[[ "$release" -eq 0 ]] && echo "stable -eq 0 : TRUE (!!)"
bash: [: : integer expression expected
bash: [[: 9.2: syntax error: invalid arithmetic operator (error token is ".2")
stable -eq 0 : TRUE (!!)

The first two cases produce a visible error, good. The third one is poison: in an arithmetic context, Bash treats stable as a variable name, finds it unset, gives it the value 0, and concludes that 0 -eq 0 is true. Your "is the release zero" test answers yes on a string that has nothing numeric about it. Under set -u, the same test stops instead on stable: unbound variable (measured on Bash 5.2): the option closes this trap, one more reason to turn it on.

The fix fits in one rule: compare strings with =, numbers with -eq or (( )), never the other way round. When the value comes from outside (argument, file, API), check that it is numeric before comparing it.

Terminal window
if [[ "$version" =~ ^[0-9]+$ ]] && (( version >= 9 )); then
echo "supported major version"
else
echo "non-numeric or too old version: '$version'"
fi

[ ], [[ ]], (( )): which one to choose

This is the heart of the topic, and what separates a fragile script from a solid script. The three forms look alike but have nothing in common under the hood.

[ ] is a command, not syntax

Check it yourself: [ is a real executable, doubled by a shell builtin. It exists on disk, with permissions and a size.

Terminal window
type [
ls -l /usr/bin/[
[ is a shell builtin
-rwxr-xr-x 1 root root 55744 Apr 5 2024 /usr/bin/[

Two immediate consequences. First, spaces are mandatory: [$a glued together would not be recognised as the [ command followed by an argument. Then, and this is the decisive point, the shell splits the line into words before running the command. An unprotected variable that contains a space, or nothing at all, changes the number of arguments received by [.

The demonstration that hurts

Two textbook cases, both reproduced below. An empty variable: the [ command receives = as its first argument and understands nothing any more. A variable with a space: it receives one argument too many.

Terminal window
answer=""
[ $answer = "yes" ] && echo "confirmed"
echo "exit code: $?"
service="nginx full"
[ $service = "nginx full" ] && echo "match"
echo "exit code: $?"
bash: [: =: unary operator expected
exit code: 2
bash: [: too many arguments
exit code: 2

These two messages, unary operator expected and too many arguments, are the most frequent Bash errors on forums. You now know that they do not signal a bug in your logic, but an unquoted variable. The exit code 2 also separates the syntax error (2) from a plain "false" (1).

Two fixes exist, and they are not equivalent. The first is to quote systematically: it is mandatory if your script must run under #!/bin/sh (Dash, BusyBox), where [[ ]] does not exist.

Terminal window
answer=""
[ "$answer" = "yes" ] && echo "confirmed" || echo "no confirmation (no error)"
service="nginx full"
[ "$service" = "nginx full" ] && echo "match with quotes"
no confirmation (no error)
match with quotes

The second is to switch to [[ ]], which is a shell keyword and not a command: Bash parses it before word splitting, so there is neither word splitting nor glob expansion on the variables. The same code works without a single quote.

Terminal window
answer=""
[[ $answer = "yes" ]] && echo "confirmed" || echo "no confirmation (no error)"
service="nginx full"
[[ $service = "nginx full" ]] && echo "match without quotes in [[ ]]"
no confirmation (no error)
match without quotes in [[ ]]

(( )) and its inverted exit code

(( )) evaluates an arithmetic expression with the usual mathematical syntax: <, >, ==, +, %, and variables are written without $. But it applies a counter-intuitive rule inherited from the C language: the exit code is 0 (true) when the result is non-zero, and 1 (false) when the result is zero.

Terminal window
counter=0
(( counter )); echo "(( 0 )) -> exit code $?"
counter=3
(( counter )); echo "(( 3 )) -> exit code $?"
(( 5 > 10 )); echo "(( 5 > 10 )) -> exit code $?"
(( 10 > 5 )); echo "(( 10 > 5 )) -> exit code $?"
(( 0 )) -> exit code 1
(( 3 )) -> exit code 0
(( 5 > 10 )) -> exit code 1
(( 10 > 5 )) -> exit code 0

For comparisons, this behaviour is exactly what you expect: (( 5 > 10 )) is false, code 1. The trap appears when (( )) is used to compute rather than to test, because the result of the computation becomes an exit code. The section on set -e shows how this subtlety literally kills scripts.

The decision table

Let's sum up what precedes in a decision grid. The column that really matters is word splitting: it explains why [ ] demands rigorous quoting and why [[ ]] forgives an oversight. The nature column recalls the deeper reason: a command undergoes the shell expansions, a keyword precedes them.

FormNatureWord splittingPatterns / regexWhen to use it
[ ]external command and builtinyes, quoting mandatorynoPOSIX scripts (#!/bin/sh), BusyBox portability
[[ ]]shell keywordno== glob and =~ regexeverything else in Bash: files, strings
(( ))arithmetic evaluationnonointegers, counters, thresholds

The practical rule fits in one sentence: in Bash, [[ ]] for files and strings, (( )) for numbers, and [ ] only when the shebang imposes sh. The logical operators follow the same logic: && and || inside [[ ]], whereas [ ] uses the old -a and -o that POSIX now declares obsolete.

Terminal window
f=/etc/fstab
[ -f "$f" -a -r "$f" ] && echo "[ -a ] : still works, but obsolete"
[ -f "$f" ] && [ -r "$f" ] && echo "two [ ] chained with && : the recommended POSIX form"
[[ -f "$f" && -r "$f" ]] && echo "[[ && ]] : the Bash form, readable and safe"
[ -a ] : still works, but obsolete
two [ ] chained with && : the recommended POSIX form
[[ && ]] : the Bash form, readable and safe

case: clean dispatching

When you test the same variable against several values, a chain of elif quickly becomes unreadable. case compares an expression to a list of glob patterns (no regex here) and runs the first block that matches. Each block ends with ;;, and the *) pattern catches everything else.

case EXPRESSION in
PATTERN1)
commands ;;
PATTERN2|PATTERN3) # several patterns separated by |
commands ;;
*) # default case, always last
commands ;;
esac

Pick the package manager for the distribution

It is use case number one. The /etc/os-release file (present on every modern distribution) exposes an ID variable you can source directly, then dispatch on.

Terminal window
. /etc/os-release
case "$ID" in
ubuntu|debian) pm="apt-get" ;;
rhel|rocky|almalinux|fedora|centos) pm="dnf" ;;
opensuse*|sles) pm="zypper" ;;
alpine) pm="apk" ;;
*) echo "Unsupported distribution: $ID" >&2; exit 1 ;;
esac
echo "Detected ID: $ID ($PRETTY_NAME)"
echo "Package manager: $pm"
Detected ID: ubuntu (Ubuntu 24.04.2 LTS)
Package manager: apt-get

Note the opensuse* pattern: openSUSE identifiers vary (opensuse-leap, opensuse-tumbleweed), and the glob covers them all at once. And the default case fails explicitly instead of leaving $pm empty, which would produce a much more obscure error further on.

Parse the action passed as argument

The second classic use is the script that accepts start, stop, restart. case handles the empty value (no argument) and the unknown value in two separate branches, something an if would do much more heavily.

Terminal window
action="${1:-}"
case "$action" in
start) systemctl start nginx ;;
stop) systemctl stop nginx ;;
restart) systemctl restart nginx ;;
status) systemctl status nginx ;;
"") echo "Usage: deploy.sh {start|stop|restart|status}" >&2; exit 2 ;;
*) echo "Unknown action: $action" >&2; exit 2 ;;
esac

"${1:-}" provides an empty string when $1 does not exist, which avoids an error under set -u. The same pattern applies to sorting by extension, with broader globs:

Terminal window
for f in report.csv archive.tar.gz app.log script.sh notes.txt; do
case "$f" in
*.tar.gz|*.tgz) echo "$f: compressed archive" ;;
*.log) echo "$f: log" ;;
*.sh) echo "$f: shell script" ;;
*.csv|*.txt) echo "$f: text file" ;;
*) echo "$f: unknown type" ;;
esac
done
report.csv: text file
archive.tar.gz: compressed archive
app.log: log
script.sh: shell script
notes.txt: text file

Order matters: *.tar.gz must come before a possible *.gz, because case stops at the first pattern that matches. It is the major difference with a series of independent if statements.

A maintenance menu

Combined with a while loop and read, case builds an interactive menu in about fifteen lines. The script below passes shellcheck without a warning.

#!/usr/bin/env bash
set -uo pipefail
while true; do
cat <<'MENU'
=== Server maintenance ===
1) Disk space
2) Failed services
3) Latest journal errors
q) Quit
MENU
read -rp "Choice: " choice
case "$choice" in
1) df -h / | tail -1 ;;
2) systemctl --failed --no-legend --no-pager | head -3 ;;
3) journalctl -p err -n 3 --no-pager -q ;;
q|Q|quit) echo "Goodbye."; break ;;
"") echo "Empty choice, try again." ;;
*) echo "Invalid choice: $choice" ;;
esac
done

The q|Q|quit pattern accepts three spellings of the same intent, and read -rp prints the prompt without interpreting backslashes. Note set -uo pipefail without the -e: in a menu, a failing command (no failed service, for example) must not quit the program.

Chaining with && and ||

For a single action, && and || are more concise than a full if. cmd1 && cmd2 runs cmd2 only if cmd1 succeeds; cmd1 || cmd2 runs cmd2 only if cmd1 fails.

Terminal window
[[ -f /etc/nginx/nginx.conf ]] && echo "Nginx is configured"
[[ -d /opt/myapp ]] || echo "Directory /opt/myapp missing, installation required"

On the other hand, the cmd && action_if_true || action_if_false form you will meet everywhere is not an if-then-else. It is one of the costliest mistakes in Bash, because it passes code reviews: it looks like a ternary, it is not one. If action_if_true fails, both branches run.

Demonstration with a realistic scenario: back up /etc/hosts when it exists, alert otherwise. The file does exist, but the user has no right to write in /var/backups.

fake-ternary.sh
[[ -f /etc/hosts ]] && cp /etc/hosts /var/backups/hosts.bak || echo "ALERT: /etc/hosts cannot be found"
cp: cannot create regular file '/var/backups/hosts.bak': Permission denied
ALERT: /etc/hosts cannot be found

The alert is entirely false. /etc/hosts exists, the real problem is a write permission, but the script blames a missing file. An administrator who receives this alert will look in the wrong place. The if/else version tells the truth and returns the right exit code:

real-if.sh
if [[ -f /etc/hosts ]]; then
cp /etc/hosts /var/backups/hosts.bak
else
echo "ALERT: /etc/hosts cannot be found"
fi
cp: cannot create regular file '/var/backups/hosts.bak': Permission denied

The script ends here with exit code 1 from cp, without a misleading message. Rule: a one-line && || is acceptable when action_if_true is an echo that cannot fail. As soon as it is a real command (cp, mv, systemctl), write a real if.

set -e and conditions

set -e tells Bash to exit as soon as a command fails. Many think that a false test in an if will therefore kill the script. It is wrong, and that is fortunate: a command evaluated in a condition (if, while, &&, ||, !) is explicitly exempted from set -e. Without that exemption, no if would be usable.

#!/usr/bin/env bash
set -euo pipefail
if [[ -f /etc/keepalived/keepalived.conf ]]; then
echo "Keepalived is configured"
else
echo "1. false test in an if: the script carries on"
fi
if grep -q "^admin:" /etc/passwd; then
echo "the admin account exists"
else
echo "2. grep failing INSIDE the condition: the script carries on"
fi
echo "3. still alive"
grep -q "^admin:" /etc/passwd # same grep, but OUTSIDE a condition
echo "4. this line will never show"
1. false test in an if: the script carries on
2. grep failing INSIDE the condition: the script carries on
3. still alive

The script exits with code 1 right after line 3, and line 4 never shows. The same grep, with the same exit code, is harmless in an if and fatal outside. It is the key to understanding scripts that "stop without a word": look for the first command outside a condition that can return a non-zero code.

(( i++ )) kills your script

Here is the most vicious consequence of the exit code of (( )). The post-increment operator i++ returns the value before incrementing. When i is 0, (( i++ )) therefore returns 0, which in Bash arithmetic means false, hence an exit code 1. With set -e, the script dies on a simple counter increment.

#!/usr/bin/env bash
set -euo pipefail
i=0
echo "before : i=$i"
(( i++ )) # returns 1 because the value BEFORE incrementing is 0
echo "after : i=$i" # never reached
before : i=0
script exit code: 1

Two fixes. The pre-increment (( ++i )) returns the value after incrementing, so 1, so true. Or you explicitly neutralise the exit code with || true, which has the merit of being readable for the reviewer.

#!/usr/bin/env bash
set -euo pipefail
i=0
(( ++i )) # returns 0 because the value AFTER incrementing is 1
echo "with ++i : i=$i"
j=0
(( j++ )) || true # explicitly neutralise the exit code
echo "with || true : j=$j"
with ++i : i=1
with || true : j=1

A third option, often the clearest in a counting script: errors=$(( errors + 1 )). It is an assignment, not a test command, so its exit code is always 0.

A complete script, validated by shellcheck

Let's bring it all together: argument validation, configuration test, service state, account existence, disk threshold, and a verdict with a distinct exit code depending on the kind of failure. This script passes shellcheck 0.11 without a single warning.

#!/usr/bin/env bash
# check-service.sh: checks that a service is ready to run.
set -euo pipefail
DISK_THRESHOLD=90
usage() {
echo "Usage: $(basename "$0") <service> <conf-file>" >&2
exit 2
}
(( $# == 2 )) || usage
service="$1"
conf="$2"
if [[ ! "$service" =~ ^[a-zA-Z0-9@._-]+$ ]]; then
echo "ERROR: invalid service name: $service" >&2
exit 2
fi
errors=0
if [[ ! -e "$conf" ]]; then
echo "KO conf : $conf not found"
errors=$(( errors + 1 ))
elif [[ ! -r "$conf" ]]; then
echo "KO conf : $conf exists but is not readable by $(id -un)"
errors=$(( errors + 1 ))
elif [[ ! -s "$conf" ]]; then
echo "KO conf : $conf is empty"
errors=$(( errors + 1 ))
else
echo "OK conf : $conf ($(wc -l < "$conf") lines)"
fi
if systemctl is-active --quiet "$service"; then
echo "OK service : $service active"
else
echo "KO service : $service inactive (systemctl is-active returns $(systemctl is-active "$service" || true))"
errors=$(( errors + 1 ))
fi
if id -u "$service" &>/dev/null; then
echo "OK account : user $service exists (uid $(id -u "$service"))"
else
echo "INFO account : no user named $service (not blocking)"
fi
usage_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
if (( usage_pct >= DISK_THRESHOLD )); then
echo "KO disk : / filled at ${usage_pct}% (threshold ${DISK_THRESHOLD}%)"
errors=$(( errors + 1 ))
else
echo "OK disk : / at ${usage_pct}% (threshold ${DISK_THRESHOLD}%)"
fi
if (( errors > 0 )); then
echo "--> ${errors} check(s) failed"
exit 1
fi
echo "--> all checks pass"

Three runs, three distinct outcomes. The nominal case, where every check passes and the script returns 0:

$ bash check-service.sh ssh /etc/fstab
OK conf : /etc/fstab (14 lines)
OK service : ssh active
INFO account : no user named ssh (not blocking)
OK disk : / at 80% (threshold 90%)
--> all checks pass
exit code: 0

The degraded case, where the errors counter lets the script report everything at once rather than exit at the first problem:

$ bash check-service.sh nginx /etc/missing.conf
KO conf : /etc/missing.conf not found
KO service : nginx inactive (systemctl is-active returns inactive)
INFO account : no user named nginx (not blocking)
OK disk : / at 80% (threshold 90%)
--> 2 check(s) failed
exit code: 1

And the call error, which returns 2 to stand apart from a failed business check:

$ bash check-service.sh ssh
Usage: check-service.sh <service> <conf-file>
exit code: 2

Troubleshooting

All the symptoms below were reproduced on Bash 5.2 while preparing this page. They cover almost every error message related to conditions.

SymptomCauseSolution
[: =: unary operator expectedempty unquoted variable in [ ]: the command receives = as its first argumentquote ([ "$var" = "x" ]) or switch to [[ $var = "x" ]]
[: too many argumentsunquoted variable containing a space: [ receives one argument too manysame fix: quote, or [[ ]]
[: : integer expression expected-eq applied to an empty stringvalidate with [[ "$v" =~ ^[0-9]+$ ]] before comparing
[[: 9.2: syntax error: invalid arithmetic operator-eq on a decimal: Bash only does integerscompare as strings (=), or go through awk / bc
[[ "$word" -eq 0 ]] returns true on textin an arithmetic context, an unknown name is worth 0compare strings with =, never with -eq
a file named 10 appears[ $a > $b ]: > is a redirection, not a comparisonuse -gt in [ ], or (( a > b ))
the script stops on (( i++ ))(( )) returns 1 when the result is 0, and set -e exitswrite (( ++i )), (( i++ )) || true or i=$(( i + 1 ))
the "file not found" alert fires while the file existsfake ternary cmd && a || b: b runs on failure of awrite a real if / else
[: unexpected operator under sh[[ ]] or == used with a #!/bin/sh shebang (Dash)switch the shebang to #!/usr/bin/env bash, or use only [ ] and =
the sort test (<) changes result from one machine to anotherlexicographic comparison follows the localeforce LC_ALL=C at the top of the script
case matches nothing and the script carries onno default *) patternalways end with *), failing explicitly if needed
=~ never finds anythingthe regex is in quotes, so treated literallywrite the bare regex: [[ "$v" =~ ^[0-9]+$ ]]

Key points

  • An if tests an exit code, not a boolean: 0 means true, everything else means false.
  • [ ] is a command: the shell splits the line before calling it, hence too many arguments on an unquoted variable.
  • [[ ]] is a keyword: no word splitting or glob on variables, plus &&, ||, patterns and =~. It is the form to prefer in Bash.
  • (( )) for integers, with mathematical syntax and without $. Its exit code is inverted: (( 0 )) returns 1.
  • Files: -e (exists), -f (file), -d (directory), -L (link), -s (not empty), -r / -w / -x (permissions of the current process), -nt / -ot (freshness).
  • Strings: -z (empty), -n (not empty), =, !=. Numbers: -eq, -ne, -lt, -le, -gt, -ge. Never cross the two families.
  • cmd && a || b is not an if-then-else: when a fails, b runs too and alerts you about the wrong problem.
  • Under set -e, a false test in a condition does not stop the script, but a command failing outside a condition does.
  • case beats a stack of elif as soon as you test the same variable: dispatch by distribution, by action, by extension.
  • Have shellcheck review every script before committing it.

Knowledge check

Check that the essentials of this guide stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn