Bash Scripting Basics: A Practical Guide for Developers
Write reliable Bash scripts: shebang and strict mode, variables, quoting, conditionals, loops, functions, arguments and exit codes, with a complete example script.
· 7 min read
Contents
- Your first script
- Strict mode: the three lines that prevent most bugs
- Variables and quoting
- Always quote your variables
- Default values
- Conditionals
- case for multiple options
- Loops
- Functions
- Arguments
- Exit codes and error handling
- Clean up with trap
- Redirection cheat sheet
- A complete example: a safe backup script
- Lint your scripts with ShellCheck
- When not to use Bash
- FAQ
- What is the difference between sh and bash?
- Why does my script work in the terminal but not in cron?
- How do I debug a Bash script?
- Further reading
Every developer ends up writing shell scripts: a deploy step, a backup job, a setup script for new teammates. Bash is available on almost every Linux server and macOS machine, and in Windows through WSL or Git Bash. A little knowledge goes a long way — and a few habits prevent the bugs that make shell scripts infamous.
This guide covers the essentials, ending with a complete, production-style script you can adapt.
Your first script
Create hello.sh:
#!/usr/bin/env bash
echo "Hello, $USER! Today is $(date +%A)."
Make it executable and run it:
chmod +x hello.sh
./hello.sh
The first line, the shebang, tells the system which interpreter to use. #!/usr/bin/env bash finds Bash wherever it is installed, which is more portable than hard-coding /bin/bash.
Strict mode: the three lines that prevent most bugs
By default Bash keeps going after errors, treats undefined variables as empty strings and hides failures inside pipelines. Start every script with:
#!/usr/bin/env bash
set -euo pipefail
| Option | Effect |
|---|---|
-e | Exit immediately if a command fails |
-u | Treat undefined variables as errors |
-o pipefail | A pipeline fails if any command in it fails, not just the last one |
Without -u, a typo like rm -rf "$BUILD_DIRR/" expands to rm -rf "/". With it, the script stops with an "unbound variable" error.
set -e has some edge cases (it does not trigger inside if conditions or commands joined with ||), so it is a safety net, not a replacement for checking important commands.
Variables and quoting
name="world" # no spaces around =
echo "Hello, $name"
echo "Hello, ${name}!" # braces when text follows the variable
files_count=$(ls | wc -l) # command substitution
today=$(date +%F)
Always quote your variables
This is the most important rule in Bash:
file="my report.pdf"
rm $file # ❌ tries to delete "my" and "report.pdf"
rm "$file" # ✅ deletes "my report.pdf"
Unquoted variables are split on spaces and expanded as wildcards. Use double quotes around every variable unless you specifically want splitting. Single quotes prevent all expansion: echo '$HOME' prints $HOME literally.
Default values
env="${1:-staging}" # first argument, or "staging" if missing
port="${PORT:-8080}" # environment variable with a fallback
: "${API_KEY:?API_KEY must be set}" # exit with an error if not set
Conditionals
if [[ -f "config.yaml" ]]; then
echo "Config found"
elif [[ -d "config" ]]; then
echo "Config directory found"
else
echo "No config" >&2
exit 1
fi
Use [[ ... ]] (Bash's improved test) rather than the older [ ... ] — it handles empty variables and special characters more safely.
Common tests:
| Test | True if… |
|---|---|
-f path | A regular file exists |
-d path | A directory exists |
-e path | Anything exists at that path |
-z "$var" | String is empty |
-n "$var" | String is not empty |
"$a" == "$b" | Strings are equal |
$x -eq 5, -lt, -gt | Numeric comparisons |
For arithmetic, use double parentheses: if (( count > 10 )); then ....
case for multiple options
case "$1" in
start) echo "Starting" ;;
stop) echo "Stopping" ;;
*) echo "Usage: $0 start|stop" >&2; exit 1 ;;
esac
Loops
# Over a list
for env in dev staging prod; do
echo "Deploying to $env"
done
# Over files (safe with spaces in names)
for file in ./logs/*.log; do
[[ -e "$file" ]] || continue # skip if the glob matched nothing
gzip "$file"
done
# Over lines of a file
while IFS= read -r line; do
echo "Host: $line"
done < hosts.txt
# Counting
for i in {1..5}; do
echo "Attempt $i"
done
Avoid for line in $(cat file): it splits on spaces, not lines.
Functions
log() {
echo "[$(date +%T)] $*" >&2
}
backup_file() {
local src="$1"
local dest="${2:-$src.bak}"
cp "$src" "$dest"
log "Backed up $src to $dest"
}
backup_file "nginx.conf"
Use local for variables inside functions so they do not leak into the rest of the script. $* and $@ hold all arguments; "$@" (quoted) passes them on unchanged.
Arguments
| Variable | Meaning |
|---|---|
$0 | Script name |
$1, $2… | Positional arguments |
$# | Number of arguments |
"$@" | All arguments, each kept separate |
$? | Exit code of the last command |
if (( $# < 1 )); then
echo "Usage: $0 <environment>" >&2
exit 1
fi
Exit codes and error handling
Every command returns an exit code: 0 means success, anything else means failure. Your scripts should do the same — exit 1 on errors.
if ! command -v docker >/dev/null 2>&1; then
echo "docker is not installed" >&2
exit 1
fi
curl -fsS https://example.com/health || { echo "Health check failed" >&2; exit 1; }
Clean up with trap
trap runs code when the script exits — even after an error:
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
Redirection cheat sheet
command > out.txt # stdout to file (overwrite)
command >> out.txt # stdout to file (append)
command 2> err.txt # stderr to file
command > all.txt 2>&1 # both to the same file
command 2>/dev/null # discard errors
command1 | command2 # pipe stdout into the next command
A complete example: a safe backup script
#!/usr/bin/env bash
# backup.sh — archive a directory and keep only the newest N backups.
# Usage: ./backup.sh <source_dir> [backup_dir] [keep]
set -euo pipefail
log() { echo "[$(date '+%F %T')] $*" >&2; }
if (( $# < 1 )); then
echo "Usage: $0 <source_dir> [backup_dir] [keep]" >&2
exit 1
fi
src="$1"
dest="${2:-$HOME/backups}"
keep="${3:-7}"
[[ -d "$src" ]] || { log "Source $src does not exist"; exit 1; }
mkdir -p "$dest"
name="$(basename "$src")-$(date +%F_%H%M%S).tar.gz"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
log "Archiving $src"
tar -czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
mv "$tmp" "$dest/$name"
log "Created $dest/$name ($(du -h "$dest/$name" | cut -f1))"
# Delete old backups beyond the newest $keep
mapfile -t old < <(ls -1t "$dest"/"$(basename "$src")"-*.tar.gz | tail -n +"$((keep + 1))")
for f in "${old[@]}"; do
rm -- "$f"
log "Removed old backup $f"
done
It uses strict mode, validates input, writes to a temporary file first (so a failed run never leaves a half-written backup), cleans up with trap and logs to stderr. Schedule it with cron — see Cron Expressions Explained.
Lint your scripts with ShellCheck
ShellCheck catches quoting bugs, unsafe patterns and portability problems. Install it with sudo apt install shellcheck or brew install shellcheck and run:
shellcheck backup.sh
Most editors have a ShellCheck extension that underlines problems as you type.
When not to use Bash
Bash is great for gluing commands together. Switch to Python (or another language) when a script needs complex data structures, JSON processing beyond what jq handles comfortably, error handling across many steps, or grows past a couple of hundred lines.
FAQ
What is the difference between sh and bash?
sh is the POSIX shell standard; on many systems it is a minimal shell like dash. Bash supports everything in POSIX sh plus extras such as [[ ]], arrays and {1..5}. If your script uses Bash features, use a Bash shebang.
Why does my script work in the terminal but not in cron?
Cron runs with a minimal environment and a short PATH. Use absolute paths or set PATH at the top of the script.
How do I debug a Bash script?
Run it with bash -x script.sh (or add set -x) to print each command before it runs.
Further reading
Keep reading
Makefiles for Modern Projects: A Practical Tutorial
Use a Makefile as your project's command menu: targets, phony targets, variables, dependencies, a self-documenting help target and a real-world example.
Cron Expressions Explained: Syntax, Examples and Common Mistakes
Learn to read and write cron expressions with clear examples, a field-by-field breakdown, and the mistakes that make scheduled jobs silently fail.
SSH Keys Explained: Generate, Add and Use Them Safely
Create an SSH key with ssh-keygen, add it to a server and to GitHub, use ssh-agent and a config file, and fix the most common SSH permission errors.