Bash looks odd at first. Spaces break things, if ends with fi, and a missing pair of quotes can turn one filename into two. Get past that and most scripts are built from the same small set of parts, which is what this guide covers: variables, everyday commands, arguments, exit codes, decisions and loops.
You can read it top to bottom or jump to whatever you’re stuck on. I ran every script here on Ubuntu 26.04.1 LTS (Bash 5.3.9) and RHEL 10.2 (Bash 5.2.26), and where the two disagreed, you’ll see it. Haven’t written a script at all yet? Do your first Bash script first. It takes ten minutes.
Before you start
Make a practice folder, save each example to a file, then run it:
mkdir -p ~/basics && cd ~/basics
nano vars.sh # paste the script, save
chmod +x vars.sh
./vars.sh
Use ./script.sh or bash script.sh, never sh script.sh. The reason showed up in testing. On Ubuntu sh is dash, and a one-line [[ ]] test died with this:
shtest.sh: 1: [[: not found
On RHEL, sh points to Bash, so the same file ran fine. A script can pass on one box and blow up on the other, all because of how it was launched.
Bash variables
#!/usr/bin/env bash
name="web01"
count=3
today=$(date +%F)
echo "Server: $name"
echo "Checks: $count"
echo "Date: $today"
echo "Next: $((count + 1))"
./vars.sh
Server: web01
Checks: 3
Date: 2026-11-04
Next: 4
No spaces around =. Write name = "web01" and Bash goes looking for a command called name. You only use $ when reading a value, and when you do, wrap it in double quotes. $(date +%F) stores a command’s output, $(( )) does the math. There’s a lot more to variables, like defaults and validation, in Bash variables explained.
Basic Bash commands for scripts
Typing commands at a prompt and using them in a script are different jobs. Nobody’s watching the output, so the script has to act on it. These are the commands that come up constantly.
echo, printf and read
echo is fine for a plain status line. Once you want columns or anything else lined up, switch to printf. read goes the other way and takes input.
#!/usr/bin/env bash
printf '%-8s %5s\n' "disk" "81%"
printf '%-8s %5s\n' "memory" "42%"
read -rp "Service name: " svc
echo "You entered: $svc"
./io.sh
disk 81%
memory 42%
Service name: app-prod
You entered: app-prod
printf doesn’t add a newline unless you ask for one. Annoying for a day, then you’re glad of it.
pwd, basename, dirname and realpath
Scripts get run from all sorts of directories, so don’t assume. basename and dirname just chop up a path as text, and the file doesn’t even have to exist. realpath checks the filesystem and follows symlinks.
#!/usr/bin/env bash
log=/var/log/nginx/access.log
echo "file: $(basename "$log")"
echo "dir: $(dirname "$log")"
echo "real: $(realpath mylogs)"
echo "here: $(pwd)"
./paths.sh
file: access.log
dir: /var/log/nginx
real: /home/asif/basics/logs
here: /home/asif/basics
mkdir, cp, mv, touch and rm
#!/usr/bin/env bash
dest=backups/$(date +%F)
mkdir -p "$dest"
cp app.conf "$dest/app.conf.bak"
mv "$dest/app.conf.bak" "$dest/app.conf.old"
ls "$dest"
touch scratch.tmp
rm -f scratch.tmp
[[ -e scratch.tmp ]] || echo "scratch.tmp is gone"
./files.sh
app.conf.old
scratch.tmp is gone
mkdir -p won’t complain if the folder is already there, so the script is safe to run twice. Be careful with rm, though. Before a variable goes anywhere near rm -rf "$dir"/*, make sure it isn’t empty, because an empty $dir makes that path /*.
head, tail, wc and grep
#!/usr/bin/env bash
head -n 1 app.log
tail -n 1 app.log
echo "lines: $(wc -l < app.log)"
echo "errors: $(grep -c ERROR app.log)"
./readlog.sh
2026-11-04 10:01:00 INFO request handled in 45ms
2026-11-04 10:10:00 INFO request handled in 41ms
lines: 10
errors: 1
Skip less in scripts. It sits and waits for a key press, and under cron nobody’s there to press one. For digging through bigger logs, see how to search Linux logs.
Script arguments
$1 is the first argument, $# is how many you got, and "$@" is all of them with each one kept separate.
#!/usr/bin/env bash
if (( $# < 1 )); then
echo "Usage: $0 <name> [more names...]" >&2
exit 1
fi
echo "First arg: $1"
echo "Arg count: $#"
for arg in "$@"; do
echo " - $arg"
done
shift
echo "After shift: $*"
./args.sh
Usage: ./args.sh <name> [more names...]
./args.sh web01 "db server" cache01
First arg: web01
Arg count: 3
- web01
- db server
- cache01
After shift: db server cache01
"db server" stays in one piece because of those quotes around $@. And copy that usage check at the top. >&2 sends the message to stderr, so it won’t end up mixed into piped output.
Exit codes
Every command finishes with a number. 0 means it worked, anything else means it didn’t, and $? holds the last one.
#!/usr/bin/env bash
ls /etc/hostname > /dev/null
echo "ls existing file: $?"
ls /nope 2> /dev/null
echo "ls missing file: $?"
grep -q "^root:" /etc/passwd && echo "root user found"
id ghost &> /dev/null || echo "user ghost does not exist"
nosuchcmd 2> /dev/null
echo "unknown command: $?"
./exitcodes.sh
ls existing file: 0
ls missing file: 2
root user found
user ghost does not exist
unknown command: 127
127 means command not found. 126 is its cousin: found it, but couldn’t run it, usually a missing chmod +x. && runs the next command on success and || runs it on failure. Your own scripts should follow the same rules, because cron and CI have no other way of knowing something broke.
Bash if, else and elif statements
if runs a command and looks at its exit code. That’s really all it does. [[ ]] and (( )) are just things that return 0 or 1, so if grep -q root /etc/passwd is perfectly valid too. Here’s a disk check with three outcomes:
#!/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
The spaces inside brackets are required. [[$x == 1]] fails with “command not found” since Bash reads [[$x as one word.
Nested if statements
Nest when the second question only makes sense after the first one passes. No point checking sudo rights for a user who doesn’t exist.
#!/usr/bin/env bash
user="$1"
if id "$user" &>/dev/null; then
if id -nG "$user" | grep -qwE 'sudo|wheel'; then
echo "$user exists and has sudo rights"
else
echo "$user exists, regular user"
fi
else
echo "$user does not exist"
fi
./nested.sh asif
asif exists and has sudo rights
./nested.sh nobody
nobody exists, regular user
./nested.sh bob
bob does not exist
Checking both groups is what makes it portable, as Ubuntu admins sit in sudo and RHEL admins in wheel. Much deeper than this and you’re better off exiting early.
Bash case statement
One value, lots of possible matches? Use case and skip the long elif chain. Patterns are globs, so *.conf works.
#!/usr/bin/env bash
case "$1" in
start|up) echo "Starting app..." ;;
stop|down) echo "Stopping app..." ;;
*.conf) echo "Using config file $1" ;;
*)
echo "Usage: $0 {start|stop|FILE.conf}"
exit 1 ;;
esac
./svc.sh down
Stopping app...
./svc.sh app.conf
Using config file app.conf
./svc.sh reload
Usage: ./svc.sh {start|stop|FILE.conf}
| gives one branch several values, ;; closes a branch, and *) catches whatever’s left.
Bash comparison operators
Numbers and strings use different operators, and picking the wrong one gives no error. You simply get a wrong answer.
#!/usr/bin/env bash
a=10 b=9
(( a > b )) && echo "(( )): $a is bigger than $b"
[[ $a -gt $b ]] && echo "-gt: $a is bigger than $b"
[[ "$a" > "$b" ]] || echo '[[ > ]]: "10" sorts before "9" as text'
./cmp.sh
(( )): 10 is bigger than 9
-gt: 10 is bigger than 9
[[ > ]]: "10" sorts before "9" as text
Inside [[ ]], > compares alphabetically. So I use (( )) for numbers, where >, <= and == mean what they look like, and [[ ]] for strings and files. Old-style -eq, -ne, -gt, -ge, -lt and -le still work inside brackets.
String comparison and pattern matching
#!/usr/bin/env bash
env="production"
host="web01.example.com"
empty=""
[[ $env == "production" ]] && echo "exact match"
[[ -z $empty ]] && echo "empty is empty"
[[ $host == web* ]] && echo "glob: starts with web"
[[ $host == "web*" ]] || echo 'quoted "web*": no match'
if [[ $host =~ ^([a-z]+)([0-9]+)\. ]]; then
echo "regex: role=${BASH_REMATCH[1]} number=${BASH_REMATCH[2]}"
fi
./str.sh
exact match
empty is empty
glob: starts with web
quoted "web*": no match
regex: role=web number=01
See the fourth line? Quotes turn the pattern into literal text, so "web*" hunts for an actual asterisk. -z tests for empty, -n for not empty. Regex captures land in BASH_REMATCH.
Logical operators: AND, OR and NOT
#!/usr/bin/env bash
port=8080
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 nosuchtool &>/dev/null; then
echo "NOT: nosuchtool is not installed"
fi
mkdir -p /tmp/demo && echo "chain: /tmp/demo ready"
./logic.sh
AND: 8080 is a valid unprivileged port
OR: asif is not root or admin
NOT: nosuchtool is not installed
chain: /tmp/demo ready
You’ll still run into -a and -o in old scripts. Don’t copy them. && and || inside [[ ]] do the same job with fewer surprises.
Check if a file or directory exists in Bash
-e means something exists at that path, -f a regular file, -d a directory, -L a symlink and -s a file with something in it.
#!/usr/bin/env bash
for p in /etc/hosts /etc /etc/nothing logs/empty.log mylogs; 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
logs/empty.log -> regular file, but empty
mylogs -> symlink
-L has to go first. -f and -d follow links, so mylogs would otherwise report as a directory. Testing that something’s missing just takes a !, as in [[ ! -d /backup ]].
Check file permissions
-r, -w and -x answer for whoever’s running the script, and -O tells you if they own the file.
#!/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)
Output matched on both systems even though /etc/shadow has different modes on each. That’s the nice part: the test tells you what this user can do, so the script doesn’t care how it got that way. Need the exact mode? stat -c %a file prints it. One caveat, root passes -r and -w on nearly everything.
Bash arithmetic
#!/usr/bin/env bash
a=17 b=5
echo "add: $(( a + b ))"
echo "multiply: $(( a * b ))"
echo "divide: $(( a / b ))"
echo "modulo: $(( a % b ))"
echo "decimal: $(awk 'BEGIN { printf "%.2f", 17/5 }')"
./math.sh
add: 22
multiply: 85
divide: 3
modulo: 2
decimal: 3.40
17 divided by 5 gives 3, because Bash only does whole numbers. For decimals I’d use awk here rather than bc, which wasn’t on my minimal RHEL install. Dividing by zero stops the expression with division by 0, so check the divisor if it comes from outside.
Bash for loop
#!/usr/bin/env bash
for svc in nginx mysql redis; do
echo "Checking $svc"
done
for i in {1..3}; do echo -n "$i "; done; echo
for (( i=1; i<=2; i++ )); do echo "Retry $i of 2"; done
./forloop.sh
Checking nginx
Checking mysql
Checking redis
1 2 3
Retry 1 of 2
Retry 2 of 2
Lists, brace ranges, C-style counters. Note that {1..$n} doesn’t work, as braces expand before variables do, so use the C-style form when the limit’s in a variable.
Bash while loop
This pattern is worth memorising, since it’s how you read a file line by line:
#!/usr/bin/env bash
while IFS=: read -r user _ uid _ _ _ shell; do
if (( uid >= 1000 && uid < 65000 )); then
echo "$user uses $shell"
fi
done < /etc/passwd
./whileloop.sh
asif uses /bin/bash
IFS=: splits each line on colons, -r leaves backslashes alone, and _ throws away fields you don’t need. Don’t try for line in $(cat file). That splits on spaces too, so a three-word line becomes three passes.
Bash until loop
until runs while its condition is false, so it reads nicely for waiting on something.
#!/usr/bin/env bash
( sleep 3; touch /tmp/ready.flag ) &
tries=0
until [[ -f /tmp/ready.flag ]]; do
(( ++tries > 10 )) && { echo "gave up"; exit 1; }
echo "Waiting for ready.flag (try $tries)"
sleep 1
done
echo "Ready"
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
See the gave up line? Any loop waiting on something outside your script needs a limit, or one day it waits forever.
Break and continue
#!/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
continue skips the evens and break ends things at 9. In nested loops, break 2 gets you out of two levels.
Loop through files and arrays
Use a glob for files. Never $(ls).
#!/usr/bin/env bash
for f in logs/*.log; do
echo "$f has $(wc -l < "$f") lines"
done
for f in empty/*; do echo "no nullglob: $f"; done
shopt -s nullglob
for f in empty/*; do echo "with nullglob: $f"; done
echo "done"
./filesloop.sh
logs/app.log has 2 lines
logs/empty.log has 0 lines
logs/web server.log has 1 lines
no nullglob: empty/*
done
web server.log came through as one file. Now look at the no nullglob line. An unmatched glob stays as literal text, so your loop runs once on a file that doesn’t exist. shopt -s nullglob fixes that, which is why the second loop printed nothing. For recursive work, combine this with find.
Arrays need their quotes too:
#!/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
./arrayloop.sh
ping web01
ping web02
ping db server
--- unquoted (wrong)
ping web01
ping web02
ping db
ping server
Three servers became four. The trickier loop bugs, like the last line of a file going missing, are covered in Bash loops in practice.
Bash functions
Pasted the same lines twice? Make a function. It gets its own $1, and it returns the status of its last command, so this works inside an if as is:
#!/usr/bin/env bash
is_running() {
systemctl is-active --quiet "$1"
}
for svc in cron crond; do
if is_running "$svc"; then
echo "$svc: running"
else
echo "$svc: not running"
fi
done
Here you can see a real difference between the distros. Ubuntu names the service cron, RHEL names it crond.
# Ubuntu 26.04
cron: running
crond: not running
# RHEL 10.2
cron: not running
crond: running
Return values, local, associative arrays, getopts and trap all have their own guide: Bash functions, arrays, getopts and trap.
Putting it together: a small log checker
This one takes a folder, skips empty files and exits non-zero when it finds errors, so cron can act on it.
#!/usr/bin/env bash
dir="${1:-logs}"
if [[ ! -d $dir ]]; then
echo "No such directory: $dir" >&2
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
if (( total == 0 )); then
echo "All clean"
else
echo "Total errors: $total"
exit 1
fi
./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
echo $?
2
Same results on Ubuntu and RHEL. To turn this kind of output into a proper report, try this script using sort, uniq, tee and xargs. Then run shellcheck over your work (sudo apt install shellcheck on Ubuntu, or from EPEL on RHEL). It catches most of what’s above before it bites.
Frequently asked questions
What are the basics of Bash scripting?
Variables, a handful of commands, arguments, exit codes, if and case, loops and functions. Most scripts are some mix of those.
What is the difference between [ ] and [[ ]] in Bash?
[ ] is the POSIX test command. [[ ]] is a Bash keyword that copes with empty variables and adds glob and regex matching. In Bash scripts, use [[ ]].
Should I use echo or printf in scripts?
echo for a simple line. printf for anything with formatting, since it behaves the same way across shells.
What does exit code 127 mean?
Command not found. Usually a typo, a missing package or a $PATH problem. 126 means it was found but couldn’t be run.
When should I use case instead of if?
When one value gets checked against several fixed options or patterns, like arguments or file extensions. If each branch tests something different, stay with if.
What is the difference between while and until in Bash?
while runs as long as its condition is true, until as long as it’s false. Pick whichever reads better.
Can Bash do floating point math?
Not on its own. $(( 17 / 5 )) gives 3. Use awk or bc, but bc may not be there on a minimal RHEL system.
How do I check if a file exists and is not empty?
if [[ -s report.txt ]]; then ... fi. -s is true only when the file exists and has something in it.
How do I loop through files with spaces in their names?
Use a glob, for f in *.log, and quote "$f". Avoid looping over $(ls) or $(find).