Every useful Bash script has to make decisions and repeat work. Is the disk full? Does the config file exist? Run this command on all ten servers. Skip the empty log files. That’s what conditionals and loops are for, and honestly, once you’re comfortable with them, about 80% of shell scripting is just gluing commands together with if and for.
This guide covers all of it in one place: if/else, nested ifs, case, the three loop types, break and continue, looping over files and arrays, plus the operators you need to write the conditions themselves (numbers, strings, files, permissions and math).
Every example was run on Ubuntu 26.04.1 LTS (Bash 5.3.9) and RHEL 10.2 (Bash 5.2.26). The output you see is real. Where the two systems behaved differently, I’ve pointed it out.
New to scripting? Start with your first Bash script and Bash fundamentals, then come back here.
Set up a practice folder
Save each example into its own file, make it executable, and run it. Something like this:
mkdir -p ~/flow && cd ~/flow
nano disk.sh # paste the script, save
chmod +x disk.sh
./disk.sh
Run your scripts with ./script.sh or bash script.sh, not sh script.sh. On Ubuntu, sh points to dash, and dash doesn’t understand [[ ]], arrays or (( )).
Bash if, else and elif statements
The basic shape looks like this:
if CONDITION; then
commands
elif OTHER_CONDITION; then
commands
else
commands
fi
Bash checks each condition from the top and runs the first block that matches. elif and else are both optional. And yes, the block ends with fi, which is just if backwards.
Here’s a practical one. It checks how full the root filesystem is and prints a different message depending on the number:
#!/usr/bin/env bash
usage=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if (( usage >= 90 )); then
echo "CRITICAL: root is ${usage}% full"
elif (( usage >= 75 )); then
echo "WARNING: root is ${usage}% full"
else
echo "OK: root is ${usage}% full"
fi
./disk.sh
OK: root is 1% full
Same result on both Ubuntu and RHEL. The tr -dc '0-9' bit strips the % sign and spaces so we’re left with a plain number we can compare.
One thing worth knowing early: if doesn’t actually care about brackets. It runs a command and checks its exit code. Zero means true, anything else means false. So this is perfectly valid:
if grep -q "^asif:" /etc/passwd; then
echo "user found"
fi
[[ ]], [ ] and (( )) are just commands (well, keywords) that return 0 or 1. That’s why you need spaces inside them. [[$x == 1]] fails with “command not found” because Bash reads [[$x as one word.
Bash nested if statements
A nested if is just an if inside another if. You use it when the second question only makes sense after the first one passes. For example, there’s no point asking whether a user has sudo rights if the user doesn’t exist.
#!/usr/bin/env bash
user="$1"
if id "$user" &>/dev/null; then
if [[ $(id -u "$user") -eq 0 ]]; then
echo "$user exists and is root"
else
if id -nG "$user" | grep -qwE 'sudo|wheel'; then
echo "$user exists and has sudo rights"
else
echo "$user exists, regular user"
fi
fi
else
echo "$user does not exist"
fi
./nested.sh root
root exists and is root
./nested.sh asif
asif exists and has sudo rights
./nested.sh nobody
nobody exists, regular user
./nested.sh bob
bob does not exist
Checking for both sudo and wheel is what makes this portable. Ubuntu puts admins in the sudo group, RHEL uses wheel. Same output on both.
A word of warning though. Three levels deep is about the limit before nested ifs get painful to read. If you’re going deeper, you can usually flatten it with elif, combine conditions with &&, or exit early when something fails.
Bash case statement with examples
When you’re comparing one value against a bunch of options, a case statement is much cleaner than a long chain of elifs. The classic use is handling script arguments:
#!/usr/bin/env bash
case "$1" in
start|up)
echo "Starting app..." ;;
stop|down)
echo "Stopping app..." ;;
restart)
echo "Restarting app..." ;;
status)
echo "App is running" ;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1 ;;
esac
./svc.sh restart
Restarting app...
./svc.sh down
Stopping app...
./svc.sh reload
Usage: ./svc.sh {start|stop|restart|status}
echo $?
1
A few things to notice:
|lets one branch match several values, sostopanddowndo the same thing.- Each branch ends with
;;. Forget it and you’ll get a syntax error. *)is the catch-all. Put it last, and use it to print usage and exit with a non-zero code.
Case patterns are globs, not plain strings, which makes them great for sorting files by extension:
#!/usr/bin/env bash
for f in report.PDF photo.jpg backup.tar.gz notes; do
case "$f" in
*.[pP][dD][fF]) echo "$f -> document" ;;
*.jpg|*.png) echo "$f -> image" ;;
*.tar.gz|*.zip) echo "$f -> archive" ;;
*) echo "$f -> unknown" ;;
esac
done
./ftype.sh
report.PDF -> document
photo.jpg -> image
backup.tar.gz -> archive
notes -> unknown
The [pP][dD][fF] trick matches the extension in any mix of upper and lower case.
Bash comparison operators explained
This is where a lot of beginners get bitten. Bash has two separate sets of comparison operators: one for numbers and one for strings. Use the wrong one and you won’t get an error. You’ll just get the wrong answer.
Numbers in [[ ]] |
Numbers in (( )) |
Strings in [[ ]] |
Meaning |
|---|---|---|---|
-eq |
== |
== |
equal |
-ne |
!= |
!= |
not equal |
-gt |
> |
> |
greater than (strings: sorts after) |
-ge |
>= |
n/a | greater than or equal |
-lt |
< |
< |
less than (strings: sorts before) |
-le |
<= |
n/a | less than or equal |
Here’s why the distinction matters:
#!/usr/bin/env bash
a=10 b=9
[[ $a -gt $b ]] && echo "-gt: $a is greater than $b"
(( a > b )) && echo "(( )): $a > $b"
[[ "$a" > "$b" ]] || echo "[[ > ]]: \"10\" sorts before \"9\" as text"
./cmp.sh
-gt: 10 is greater than 9
(( )): 10 > 9
[[ > ]]: "10" sorts before "9" as text
Inside [[ ]], the > sign compares text alphabetically, and “1” comes before “9”. So 10 is “less than” 9. Not what you wanted.
My rule: use (( )) for anything numeric. It reads like normal math, you don’t need the $ on variable names, and there’s no way to accidentally do a string comparison. Use [[ ]] for strings and files.
Bash string comparison and pattern matching
[[ ]] is where string checks live. It handles exact matches, empty checks, glob patterns and even full regular expressions:
#!/usr/bin/env bash
env="production"
host="web01.example.com"
empty=""
[[ $env == "production" ]] && echo "1. exact match"
[[ $env != "staging" ]] && echo "2. not staging"
[[ -z $empty ]] && echo "3. empty is empty"
[[ -n $env ]] && echo "4. env is set"
[[ $host == web* ]] && echo "5. glob: starts with web"
[[ $host == *.example.com ]] && echo "6. glob: example.com domain"
if [[ $host =~ ^([a-z]+)([0-9]+)\. ]]; then
echo "7. regex: role=${BASH_REMATCH[1]} number=${BASH_REMATCH[2]}"
fi
./str.sh
1. exact match
2. not staging
3. empty is empty
4. env is set
5. glob: starts with web
6. glob: example.com domain
7. regex: role=web number=01
The operators at a glance:
==and!=for equal and not equal (=works too)-ztrue if the string is empty,-ntrue if it isn’t== pattern*glob matching, as long as the right side is not quoted=~regex matching, with captured groups saved inBASH_REMATCH
That quoting rule catches people out. [[ $host == web* ]] is a pattern match. [[ $host == "web*" ]] looks for a literal asterisk. Same goes for =~: keep the regex unquoted, or store it in a variable first.
Want case-insensitive matching? Lower-case the value with ${var,,} before comparing. More expansion tricks like that are in Bash variables explained.
Bash logical operators: AND, OR and NOT
You’ll often need to test more than one thing at once. Bash gives you three operators:
&&(AND): both sides must be true||(OR): at least one side must be true!(NOT): flips the result
#!/usr/bin/env bash
port=8080
user=$(whoami)
if [[ $port -ge 1024 && $port -le 65535 ]]; then
echo "AND: $port is a valid unprivileged port"
fi
if [[ $user == "root" || $user == "admin" ]]; then
echo "OR: privileged user"
else
echo "OR: $user is not root or admin"
fi
if ! command -v nginx &>/dev/null; then
echo "NOT: nginx is not installed"
fi
mkdir -p /tmp/demo && echo "chain: /tmp/demo ready"
grep -q "^nobody:" /etc/passwd || echo "chain: no nobody user"
On RHEL, which doesn’t have Nginx installed:
./logic.sh
AND: 8080 is a valid unprivileged port
OR: asif is not root or admin
NOT: nginx is not installed
chain: /tmp/demo ready
On the Ubuntu box, Nginx was already installed from an earlier LEMP setup, so the NOT line didn’t print. Exactly what you’d expect.
The last two lines show && and || used outside of brackets, as a short way to chain commands. cmd1 && cmd2 runs cmd2 only if cmd1 succeeded. cmd1 || cmd2 runs cmd2 only if cmd1 failed. The grep found the nobody user, so nothing printed.
Older scripts use -a and -o inside single brackets for AND and OR. They’re deprecated and have weird edge cases. Stick with && and || inside [[ ]].
How to check if a file or directory exists in Bash
Probably the most common check in any script. Before you read a config, write a log or delete a folder, you should know it’s actually there. These are the test operators you’ll use most:
| Operator | True if the path… |
|---|---|
-e |
exists (any type) |
-f |
is a regular file |
-d |
is a directory |
-L |
is a symbolic link |
-s |
exists and is not empty |
file1 -nt file2 |
file1 is newer than file2 |
This script runs a handful of paths through them:
#!/usr/bin/env bash
for p in /etc/hosts /etc /etc/nothing ~/flow/logs/empty.log /etc/localtime; do
if [[ -L $p ]]; then
echo "$p -> symlink"
elif [[ -f $p && -s $p ]]; then
echo "$p -> regular file, not empty"
elif [[ -f $p ]]; then
echo "$p -> regular file, but empty"
elif [[ -d $p ]]; then
echo "$p -> directory"
else
echo "$p -> does not exist"
fi
done
./exists.sh
/etc/hosts -> regular file, not empty
/etc -> directory
/etc/nothing -> does not exist
/home/asif/flow/logs/empty.log -> regular file, but empty
/etc/localtime -> symlink
The -L check goes first on purpose. -f and -d follow symlinks, so /etc/localtime would also pass -f because it points to a real file. Check for the link before anything else if the difference matters to you.
To check that something does not exist, add !. This one-liner creates a backup folder only if it’s missing:
[[ ! -d /backup ]] && sudo mkdir -p /backup
Honestly, mkdir -p already does nothing if the folder exists, so you could skip the test there. But the pattern is handy for things like “exit if the config file is missing”.
How to check file permissions using Bash
Existence is one thing. Whether your script can actually use the file is another. These tests check permissions for whoever is running the script:
-rreadable-wwritable-xexecutable (for a directory, it means you can enter it)-Oyou own it
#!/usr/bin/env bash
for f in secret.key deploy.sh /etc/shadow; do
echo -n "$f: "
[[ -r $f ]] && echo -n "read " || echo -n "no-read "
[[ -w $f ]] && echo -n "write " || echo -n "no-write "
[[ -x $f ]] && echo -n "exec " || echo -n "no-exec "
[[ -O $f ]] && echo "(you own it)" || echo "(owned by $(stat -c %U "$f"))"
done
./perms.sh
secret.key: read write no-exec (you own it)
deploy.sh: read write exec (you own it)
/etc/shadow: no-read no-write no-exec (owned by root)
Identical on both distros. The real permissions on /etc/shadow are different though. Ubuntu uses -rw-r----- owned by root:shadow, while RHEL locks it down completely with ---------- owned by root:root. Your script doesn’t need to care. -r answers the only question that matters: can this user read it right now?
If you need the actual mode, say to enforce 600 on a private key, use stat:
stat -c '%A %a %U:%G %n' secret.key
-rw------- 600 asif:asif secret.key
mode=$(stat -c %a secret.key)
if [[ $mode != "600" ]]; then
echo "secret.key has mode $mode, fixing"
chmod 600 secret.key
fi
Keep in mind that root passes -r and -w on almost everything, so these checks are most useful in scripts run by normal users. For permissions beyond owner/group/other, see Linux ACLs with setfacl and getfacl.
Bash arithmetic operations and calculations
For integer math, use $(( )). It’s built in, fast, and supports everything you’d expect:
#!/usr/bin/env bash
a=17 b=5
echo "add: $(( a + b ))"
echo "subtract: $(( a - b ))"
echo "multiply: $(( a * b ))"
echo "divide: $(( a / b ))"
echo "modulo: $(( a % b ))"
echo "power: $(( 2 ** 10 ))"
(( a++ )); echo "a++ now: $a"
let "total = a * 2"; echo "let: $total"
echo "float: $(echo "scale=2; 17 / 5" | bc)"
On Ubuntu:
./math.sh
add: 22
subtract: 12
multiply: 85
divide: 3
modulo: 2
power: 1024
a++ now: 18
let: 36
float: 3.40
Notice that 17 divided by 5 gives 3, not 3.4. Bash only does whole numbers and just drops the decimal part. For decimals you need an external tool like bc.
And here’s an actual difference between the two systems. On a minimal RHEL 10 install, the last line failed:
./math.sh: line 11: bc: command not found
float:
bc isn’t installed there by default. Either install it, or use awk, which is on pretty much every Linux box:
sudo dnf install bc # RHEL
sudo apt install bc # Ubuntu, if it's missing
awk 'BEGIN { printf "%.2f\n", 17/5 }'
3.40
Two more things to remember. let and expr still work, but $(( )) is the modern way and doesn’t need quoting tricks. And dividing by zero kills the expression with an error, so check your divisor first:
echo $(( 10 / 0 ))
bash: 10 / 0 : division by 0 (error token is "0 ")
Bash for loop explained with practical examples
The for loop is the one you’ll write most. It comes in a few flavours:
#!/usr/bin/env bash
# 1. Loop over a list
for svc in nginx mysql redis; do
echo "Checking $svc"
done
# 2. Number range with brace expansion
for i in {1..3}; do echo -n "$i "; done; echo
# 3. Range with a step
for i in {0..10..5}; do echo -n "$i "; done; echo
# 4. C-style counter
for (( i=1; i<=3; i++ )); do echo "Retry $i of 3"; done
# 5. Loop over command output
for u in $(awk -F: '$3 >= 1000 && $3 < 65000 {print $1}' /etc/passwd); do
echo "Human user: $u"
done
./forloop.sh
Checking nginx
Checking mysql
Checking redis
1 2 3
0 5 10
Retry 1 of 3
Retry 2 of 3
Retry 3 of 3
Human user: asif
A couple of gotchas. Brace ranges like {1..$n} don’t work with variables because brace expansion happens before variables are filled in. Use the C-style form for that: for (( i=1; i<=n; i++ )).
Also, looping over $(command) splits on spaces, not just new lines. It’s fine for usernames, which never contain spaces, but it’s the wrong tool for reading files line by line. That’s a job for while.
Bash while loop explained
A while loop keeps going as long as its condition is true. Use it when you don’t know in advance how many times you’ll loop.
#!/usr/bin/env bash
count=1
while (( count <= 3 )); do
echo "Pass $count"
(( count++ ))
done
while read -r line; do
echo "line: $line"
done < logs/app.log
./whileloop.sh
Pass 1
Pass 2
Pass 3
line: error one
line: ok
The while read -r line ... done < file pattern is the right way to read a file line by line. The -r stops backslashes from being treated as escape characters, and the < at the end feeds the file into the loop. If redirection is still a bit fuzzy, pipes and redirection in Linux explains it properly.
Forget to increment count in the first loop and you’ve got an infinite loop. Press Ctrl+C to get out. Everyone does it at least once.
Sometimes an infinite loop is what you want, like a simple watcher that only stops when you tell it to:
while true; do
date +%T
sleep 5
done
Bash until loop with examples
until is the mirror image of while. It runs as long as the condition is false and stops the moment it becomes true. That makes it read naturally for “wait until something is ready” jobs.
In this example a background job creates a flag file after 3 seconds, and the loop waits for it:
#!/usr/bin/env bash
( sleep 3; touch /tmp/ready.flag ) &
tries=0
until [[ -f /tmp/ready.flag ]]; do
(( tries++ ))
echo "Waiting for ready.flag (try $tries)"
sleep 1
done
echo "Ready after $tries tries"
rm -f /tmp/ready.flag
./untilloop.sh
Waiting for ready.flag (try 1)
Waiting for ready.flag (try 2)
Waiting for ready.flag (try 3)
Ready after 3 tries
That’s the RHEL run. On Ubuntu it took 4 tries, simply because of timing. Which is a good reminder: in a real script, always add a limit so it can’t wait forever. Something like this, for waiting on a web server to come up:
tries=0
until curl -fs http://localhost >/dev/null; do
(( ++tries >= 30 )) && { echo "gave up after 30s"; exit 1; }
sleep 1
done
echo "web server is up"
Anything you can write with until can also be written as while !. Pick whichever reads better for the situation.
Break and continue statements in Bash
Two keywords give you control inside any loop:
continueskips the rest of the current pass and jumps to the next onebreakleaves the loop completely
#!/usr/bin/env bash
for n in {1..10}; do
(( n % 2 == 0 )) && continue
(( n > 7 )) && break
echo "odd: $n"
done
./breakcont.sh
odd: 1
odd: 3
odd: 5
odd: 7
Even numbers get skipped by continue. When the loop hits 9, break ends it, so 9 never prints.
In nested loops, plain break only exits the innermost loop. Use break 2 to jump out of two levels at once. continue 2 works the same way.
How to loop through files in Bash
The simplest way is a glob. Bash expands *.log into a list of matching file names, and spaces in names are handled correctly as long as you quote "$f":
#!/usr/bin/env bash
for f in logs/*.log; do
lines=$(wc -l < "$f")
echo "$f has $lines lines"
done
logs/app.log has 2 lines
logs/empty.log has 0 lines
logs/web server.log has 1 lines
See web server.log? One file, one loop pass, even with the space in its name. Don’t ever do for f in $(ls). That would split it into “web” and “server.log”.
Now the gotcha that bites everyone eventually. If a glob matches nothing, Bash leaves the pattern as it is:
for f in empty/*; do echo "got: $f"; done
got: empty/*
Your script then tries to process a file literally called empty/*. Fix it with nullglob, which makes an unmatched glob expand to nothing, so the loop simply doesn’t run:
shopt -s nullglob
for f in empty/*; do echo "got: $f"; done
shopt -u nullglob
(No output. That’s the point.)
For recursive searches, or when you need to filter by size or age, pair find with a while read loop. Using -print0 and read -d '' makes it safe for any file name, even ones with new lines:
find logs -type f -name '*.log' -print0 | while IFS= read -r -d '' f; do
echo "found: $f"
done
found: logs/app.log
found: logs/empty.log
found: logs/web server.log
The order of find results isn’t guaranteed, so pipe through sort -z if you need it. There’s a lot more find can do in 30 practical find examples.
How to loop through arrays in Bash
When you have a list of servers, users or packages, put them in an array and loop over "${array[@]}". The quotes are not optional. Look what happens without them:
#!/usr/bin/env bash
servers=("web01" "web02" "db server")
for s in "${servers[@]}"; do echo "ping $s"; done
echo "--- unquoted (wrong)"
for s in ${servers[@]}; do echo "ping $s"; done
ping web01
ping web02
ping db server
--- unquoted (wrong)
ping web01
ping web02
ping db
ping server
Three servers turned into four. Quote it every time.
Need the index as well as the value? "${!servers[@]}" gives you the indexes. And for key/value pairs, use an associative array with declare -A, then loop over the keys the same way:
for i in "${!servers[@]}"; do echo "$i: ${servers[$i]}"; done
declare -A port=([ssh]=22 [http]=80 [https]=443)
for name in "${!port[@]}"; do echo "$name -> ${port[$name]}"; done
0: web01
1: web02
2: db server
ssh -> 22
https -> 443
http -> 80
Notice the associative array came back in a different order than we defined it. Bash doesn’t keep insertion order for those. If order matters, keep a separate indexed array of keys, or pipe the output through sort.
Putting it together: a small log checker
Here’s a short script that uses most of what we’ve covered. It checks every log in a folder, skips empty ones, counts errors, and exits with a non-zero code if it found any:
#!/usr/bin/env bash
dir="${1:-logs}"
if [[ ! -d $dir ]]; then
echo "No such directory: $dir"
exit 2
fi
shopt -s nullglob
total=0
for f in "$dir"/*.log; do
[[ -r $f ]] || { echo "skip (unreadable): $f"; continue; }
[[ -s $f ]] || { echo "skip (empty): $f"; continue; }
errors=$(grep -ci "error" "$f")
case $errors in
0) echo "OK $f" ;;
*) echo "ERROR $f ($errors found)"; (( total += errors )) ;;
esac
done
(( total == 0 )) && echo "All clean" || { echo "Total errors: $total"; exit 1; }
./logcheck.sh
ERROR logs/app.log (1 found)
skip (empty): logs/empty.log
OK logs/web server.log
Total errors: 1
echo $?
1
./logcheck.sh /nope
No such directory: /nope
Directory check, permission check, file-size check, a for loop over files, continue, case, arithmetic, and a meaningful exit code. Drop it into cron and it’ll tell you when something’s wrong. To turn output like this into a proper report, have a look at building a report script with sort, uniq, tee and xargs.
Common mistakes to avoid
- Missing spaces in brackets.
[[ $a == 1 ]]works,[[$a==1]]doesn’t. - Using
>for numbers inside[[ ]]. It compares text. Use-gtor(( )). - Unquoted variables.
[ -f $file ]breaks on empty values and spaces. Use[[ ]]and quote"$f"in commands. - Quoting the pattern in
[[ == ]]."web*"matches a literal star, not “anything starting with web”. - Parsing
lsin a loop. Use globs orfind -print0. - Forgetting
;;in case statements or thefi,doneandesacthat close each block. - Loops with no exit. Every
untilorwhilethat waits on something external needs a retry limit.
Install shellcheck (sudo apt install shellcheck on Ubuntu, from EPEL on RHEL) and run it on your scripts. It catches nearly all of these before they cause trouble.
Frequently asked questions
What is the difference between [ ] and [[ ]] in Bash?
[ ] is the POSIX test command and works in any shell. [[ ]] is a Bash keyword that’s safer with empty variables and spaces, supports && and || inside the brackets, and adds glob and regex matching. In Bash scripts, use [[ ]] unless you specifically need POSIX sh compatibility.
How do I write an if else statement on one line in Bash?
Separate the parts with semicolons: if [[ -f app.log ]]; then echo yes; else echo no; fi. For short checks, [[ -f app.log ]] && echo yes || echo no also works, but be careful: if the command after && fails, the || part runs too.
When should I use case instead of if elif?
Use case when you’re checking one variable against several fixed values or patterns, like script arguments, file extensions or distro names. It’s shorter and easier to read. Use if when each branch tests something different, like a file in one branch and a number in another.
What is the difference between while and until loops in Bash?
A while loop runs as long as its condition is true. An until loop runs as long as its condition is false. They’re interchangeable, so until [[ -f x ]] does the same thing as while [[ ! -f x ]]. Pick the one that reads more naturally.
Can Bash do floating point math?
No. $(( )) only handles integers and throws away the decimal part, so $(( 17 / 5 )) gives 3. For decimals, use bc (echo "scale=2; 17/5" | bc) or awk. Note that bc isn’t installed by default on minimal RHEL systems.
How do I check if a file exists and is not empty?
Use -s, which is true only when the file exists and its size is greater than zero: if [[ -s report.txt ]]; then ... fi. Combine it with -f if you also want to make sure it’s a regular file and not a directory.
How do I loop through files with spaces in their names?
Use a glob such as for f in *.log and always quote "$f" when you use it. For recursive searches, use find ... -print0 | while IFS= read -r -d '' f. Avoid for f in $(ls) or $(find), because they split names on spaces.