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/elseblocks that react to the real state of the system - Test files, strings and integers with the right operator (
-f,-z,-eqand the others) - Tell
[ ],[[ ]]and(( ))apart and know which one to use when - Reproduce then fix the
too many argumentsandunary operator expectederrors - Dispatch cleanly with
caseon 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:
getent passwd bob > /dev/null; echo "getent passwd bob -> $?"getent passwd deployer > /dev/null; echo "getent passwd deployer -> $?"systemctl is-active sshsystemctl is-active nginxgetent passwd bob -> 0getent passwd deployer -> 2activeinactiveThe 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.
if systemctl is-active --quiet ssh; then echo "ssh is active, nothing to do"else echo "ssh is stopped, restart required"fissh is active, nothing to doThe --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).
if CONDITION; then # commands when the exit code is 0elif OTHER_CONDITION; then # commands when the first is false and this one trueelse # default commandsfiThe 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.
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"fiRun 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 bobThis -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.
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" fidonessh: activedocker: activecron: activepostgresql: unknown unitsystemctl 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.
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" fidonebob: exists (uid 1000, shell /usr/bin/zsh)www-data: exists (uid 33, shell /usr/sbin/nologin)deployer: does not existThe /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.
threshold=75while 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%)" fidone < <(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.
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 2fiThe 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.
| Test | True when | Typical use |
|---|---|---|
-e path | the path exists (file, directory, link, socket…) | check before overwriting |
-f path | it is a regular file | read a config |
-d path | it is a directory | check a destination |
-L path | it is a symbolic link | detect a broken link |
-s path | it exists and its size is > 0 | reject an empty dump |
-r path | the current process can read it | anticipate a "Permission denied" |
-w path | the current process can write to it | check an output directory |
-x path | it is executable (or traversable for a directory) | validate a binary before calling it |
-O path | it belongs to the current user | check the ownership of a secret |
f1 -nt f2 | f1 is newer than f2 | rebuild a stale artefact |
f1 -ot f2 | f1 is older than f2 | detect a config not reloaded |
Existence, nature and links
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.
mkdir -p /tmp/lc-demoprintf 'listen 8080;\n' > /tmp/lc-demo/app.confln -sf /tmp/lc-demo/app.conf /tmp/lc-demo/link.confln -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.
: > /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 : trueKey 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.
sleep 1printf '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 : trueapp.conf -ot recent.log : trueTesting 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.
| Test | True 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" == pattern | the string matches the glob pattern (in [[ ]] only) |
"$var" =~ regex | the string matches the extended regex (in [[ ]] only) |
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 : trueabc < 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.
[[ "Zebre" < "abeille" ]] && echo "true" || echo "false" # locale fr_FR.UTF-8LC_ALL=C bash -c '[[ Zebre < abeille ]] && echo "true" || echo "false"' # strict ASCII orderfalsetrueIf 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.
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 : trueThe 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 operator | Equivalent 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 |
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.
a=5; b=10[ $a > $b ] && echo "5 is greater than 10?!"ls -l 105 is greater than 10?!-rw-rw-r-- 1 bob bob 0 Jul 12 11:31 10The 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:
[ "$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.
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 expectedbash: [[: 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.
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.
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.
answer=""[ $answer = "yes" ] && echo "confirmed"echo "exit code: $?"
service="nginx full"[ $service = "nginx full" ] && echo "match"echo "exit code: $?"bash: [: =: unary operator expectedexit code: 2bash: [: too many argumentsexit code: 2These 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.
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 quotesThe 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.
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.
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 0For 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.
| Form | Nature | Word splitting | Patterns / regex | When to use it |
|---|---|---|---|---|
[ ] | external command and builtin | yes, quoting mandatory | no | POSIX scripts (#!/bin/sh), BusyBox portability |
[[ ]] | shell keyword | no | == glob and =~ regex | everything else in Bash: files, strings |
(( )) | arithmetic evaluation | no | no | integers, 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.
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 obsoletetwo [ ] chained with && : the recommended POSIX form[[ && ]] : the Bash form, readable and safecase: 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 ;;esacPick 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.
. /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-getNote 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.
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:
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" ;; esacdonereport.csv: text filearchive.tar.gz: compressed archiveapp.log: logscript.sh: shell scriptnotes.txt: text fileOrder 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 bashset -uo pipefail
while true; do cat <<'MENU'
=== Server maintenance === 1) Disk space 2) Failed services 3) Latest journal errors q) QuitMENU 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" ;; esacdoneThe 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.
[[ -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.
[[ -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 deniedALERT: /etc/hosts cannot be foundThe 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:
if [[ -f /etc/hosts ]]; then cp /etc/hosts /var/backups/hosts.bakelse echo "ALERT: /etc/hosts cannot be found"ficp: cannot create regular file '/var/backups/hosts.bak': Permission deniedThe 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 bashset -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 conditionecho "4. this line will never show"1. false test in an if: the script carries on2. grep failing INSIDE the condition: the script carries on3. still aliveThe 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 bashset -euo pipefaili=0echo "before : i=$i"(( i++ )) # returns 1 because the value BEFORE incrementing is 0echo "after : i=$i" # never reachedbefore : i=0script exit code: 1Two 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 bashset -euo pipefaili=0(( ++i )) # returns 0 because the value AFTER incrementing is 1echo "with ++i : i=$i"j=0(( j++ )) || true # explicitly neutralise the exit codeecho "with || true : j=$j"with ++i : i=1with || true : j=1A 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 )) || usageservice="$1"conf="$2"
if [[ ! "$service" =~ ^[a-zA-Z0-9@._-]+$ ]]; then echo "ERROR: invalid service name: $service" >&2 exit 2fi
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 1fiecho "--> 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/fstabOK conf : /etc/fstab (14 lines)OK service : ssh activeINFO account : no user named ssh (not blocking)OK disk : / at 80% (threshold 90%)--> all checks passexit code: 0The 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.confKO conf : /etc/missing.conf not foundKO 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) failedexit code: 1And the call error, which returns 2 to stand apart from a failed business check:
$ bash check-service.sh sshUsage: check-service.sh <service> <conf-file>exit code: 2Troubleshooting
All the symptoms below were reproduced on Bash 5.2 while preparing this page. They cover almost every error message related to conditions.
| Symptom | Cause | Solution |
|---|---|---|
[: =: unary operator expected | empty unquoted variable in [ ]: the command receives = as its first argument | quote ([ "$var" = "x" ]) or switch to [[ $var = "x" ]] |
[: too many arguments | unquoted variable containing a space: [ receives one argument too many | same fix: quote, or [[ ]] |
[: : integer expression expected | -eq applied to an empty string | validate with [[ "$v" =~ ^[0-9]+$ ]] before comparing |
[[: 9.2: syntax error: invalid arithmetic operator | -eq on a decimal: Bash only does integers | compare as strings (=), or go through awk / bc |
[[ "$word" -eq 0 ]] returns true on text | in an arithmetic context, an unknown name is worth 0 | compare strings with =, never with -eq |
a file named 10 appears | [ $a > $b ]: > is a redirection, not a comparison | use -gt in [ ], or (( a > b )) |
the script stops on (( i++ )) | (( )) returns 1 when the result is 0, and set -e exits | write (( ++i )), (( i++ )) || true or i=$(( i + 1 )) |
| the "file not found" alert fires while the file exists | fake ternary cmd && a || b: b runs on failure of a | write 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 another | lexicographic comparison follows the locale | force LC_ALL=C at the top of the script |
case matches nothing and the script carries on | no default *) pattern | always end with *), failing explicitly if needed |
=~ never finds anything | the regex is in quotes, so treated literally | write the bare regex: [[ "$v" =~ ^[0-9]+$ ]] |
Key points
- An
iftests an exit code, not a boolean:0means true, everything else means false. [ ]is a command: the shell splits the line before calling it, hencetoo many argumentson 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 || bis not an if-then-else: whenafails,bruns 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. casebeats a stack ofelifas 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Bash loops: for, while and until by example
A new full quiz with random questions
Work again only on the questions you missed