Bash Scripting Tutorial for Linux
A bash script is a text file of commands with a shebang line. Make it executable and run it:
printf '#!/usr/bin/env bash\nset -euo pipefail\necho "Hello, $USER"\n' > hello.sh
chmod +x hello.sh
./hello.sh
Your first script
Create backup-notes.sh in any editor:
#!/usr/bin/env bash
# Copy ~/notes to a dated folder
set -euo pipefail
src="$HOME/notes"
dest="$HOME/backups/notes-$(date +%F)"
mkdir -p "$dest"
cp -a "$src/." "$dest/"
echo "Backed up $src to $dest"
Then run it:
chmod +x backup-notes.sh
./backup-notes.sh
The first line, the shebang, tells the kernel which interpreter to use. Without it, or if you run sh script.sh, the script may run under dash on Debian and Ubuntu, where bash features like [[ ]] and arrays fail. If you see Permission denied, the execute bit is missing or the file is on a noexec mount; see bash: ./script.sh: Permission denied.
To run a script from anywhere, put it in ~/.local/bin (most distributions add this to PATH when it exists) and drop the .sh if you like.
Variables and quoting
name="world" # no spaces around =
echo "Hello, $name"
echo "Hello, ${name}!" # braces separate the name from following text
files=$(ls /etc | wc -l) # command substitution
echo "There are $files entries in /etc"
readonly CONFIG="/etc/myapp.conf"
export EDITOR=nano # visible to child processes
Quoting is the rule that prevents most bash bugs:
- Double quotes
"$var"expand variables but keep the value as one word, even if it contains spaces. - Single quotes
'$var'are literal: nothing expands. - No quotes
$varsplits the value on whitespace and expands wildcards. A file calledMy Documentsbecomes two arguments.
file="My Documents/report.txt"
rm $file # wrong: tries to remove "My" and "Documents/report.txt"
rm "$file" # right
Quote every variable expansion unless you specifically want splitting. Useful defaults:
echo "${1:-default}" # use "default" if $1 is unset or empty
echo "${DEST:?DEST must be set}" # abort with a message if unset
echo "${file%.txt}.bak" # strip suffix: report.bak
echo "${file##*/}" # basename: report.txt
echo "${#file}" # length of the string
Arguments and user input
| Variable | Meaning |
|---|---|
$0 | Script name as it was called |
$1 … $9, ${10} | Positional arguments |
$# | Number of arguments |
"$@" | All arguments, each kept as a separate word (use this one) |
$? | Exit status of the last command |
$$ | PID of the script |
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <directory> [more...]" >&2
exit 1
fi
for dir in "$@"; do
echo "$dir: $(du -sh "$dir" | cut -f1)"
done
For interactive input use read; -r stops backslashes being interpreted and -p prints a prompt:
read -rp "Delete old logs? [y/N] " answer
if [[ $answer == [yY] ]]; then
echo "Deleting..."
fi
For flags like -v or -o file, use the built-in getopts:
verbose=0 out=""
while getopts "vo:" opt; do
case $opt in
v) verbose=1 ;;
o) out=$OPTARG ;;
*) echo "Usage: $0 [-v] [-o file]" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1)) # "$@" now holds the remaining arguments
Conditions and tests
if [[ -f /etc/os-release ]]; then
. /etc/os-release
echo "Running on $NAME"
elif [[ -d /etc/apt ]]; then
echo "Debian-like system"
else
echo "Unknown system"
fi
| Test | True when |
|---|---|
-f path / -d path | Regular file / directory exists |
-e path | Anything exists at the path |
-r, -w, -x path | Readable, writable, executable by you |
-s path | File exists and is not empty |
-z "$s" / -n "$s" | String is empty / not empty |
"$a" == "$b", != | String equal / not equal (right side can be a glob pattern) |
"$s" =~ ^[0-9]+$ | Regex match (don't quote the regex) |
$a -eq $b, -ne -lt -le -gt -ge | Integer comparisons |
(( a > b )) | Arithmetic comparison with normal operators |
Any command can be a condition: if checks its exit status, where 0 means success.
if grep -q "^$USER:" /etc/passwd; then echo "local user"; fi
if ! command -v jq >/dev/null; then echo "please install jq" >&2; exit 1; fi
# short forms
[[ -d $dest ]] || mkdir -p "$dest"
ping -c1 -W2 example.com >/dev/null && echo "online"
case is cleaner than a chain of elifs when matching one value against patterns:
case "$ID" in
ubuntu|debian|linuxmint) sudo apt update ;;
fedora) sudo dnf upgrade --refresh ;;
arch) sudo pacman -Syu ;;
opensuse*) sudo zypper refresh ;;
*) echo "unsupported: $ID" >&2 ;;
esac
That's the core of the update-all-distros script.
Loops
# over a list
for pkg in curl git jq; do
echo "checking $pkg"
done
# over files: use a glob, never parse ls
for f in /var/log/*.log; do
[[ -e $f ]] || continue # glob matched nothing
echo "$f: $(wc -l < "$f") lines"
done
# C-style counter
for ((i = 1; i <= 5; i++)); do
echo "try $i"
done
# read a file line by line
while IFS= read -r line; do
echo "> $line"
done < /etc/hostname
# retry until a command succeeds
until ping -c1 -W2 1.1.1.1 >/dev/null; do
sleep 5
done
To loop over the output of find safely, including names with spaces or newlines, use null separators:
find ~/Downloads -name '*.iso' -mtime +30 -print0 |
while IFS= read -r -d '' iso; do
echo "old ISO: $iso"
done
More find patterns are in the find cheat sheet. Use break to leave a loop and continue to skip to the next item.
Functions, arrays and arithmetic
log() {
printf '%s %s\n' "$(date +%T)" "$*" >&2
}
backup_file() {
local src=$1 # local: not visible outside the function
local dest="${2:-$src.bak}"
cp -a -- "$src" "$dest" || return 1
log "copied $src to $dest"
}
backup_file /etc/hosts /tmp/hosts.bak
Functions take arguments the same way scripts do ($1, "$@") and return an exit status with return. To return data, print it and capture it with $(...).
# indexed arrays
pkgs=(curl git "visual studio code")
echo "${pkgs[0]}" # first element
echo "${#pkgs[@]}" # count
for p in "${pkgs[@]}"; do echo "$p"; done
pkgs+=(htop) # append
# associative arrays (bash 4+)
declare -A port=([ssh]=22 [http]=80)
echo "${port[ssh]}"
# arithmetic
count=$((count + 1))
(( total += size ))
Exit codes and error handling
Every command returns a status from 0 to 255; 0 is success. Your script should do the same: exit 0 on success, a non-zero code on failure, and error messages to standard error (>&2), so cron, systemd and other scripts can tell what happened.
#!/usr/bin/env bash
set -Eeuo pipefail
tmp=$(mktemp -d)
cleanup() { rm -rf -- "$tmp"; }
trap cleanup EXIT # runs on normal exit, error or Ctrl+C
trap 'echo "error on line $LINENO" >&2' ERR
curl -fsSL https://example.com/data.json -o "$tmp/data.json"
jq '.items | length' "$tmp/data.json"
set -estops at the first failing command. It is skipped insideifconditions and on the left of&&/||, which is what you want.set -ucatches typos like$DESTDIRvs$DEST_DIRinstead of silently using an empty string. That matters most before commands likerm -rf "$dir/".set -o pipefailmakescurl ... | tar xfail whencurlfails.trap ... EXITguarantees temporary files are removed.
If a variable can ever be empty, rm -rf "$dir"/* can turn into rm -rf /*. Use set -u, check the value ([[ -n $dir && -d $dir ]] || exit 1) and test new scripts with echo in front of the destructive command first.
Input, output and here-documents
cmd > out.txt # stdout to file (overwrite)
cmd >> out.txt # append
cmd 2> err.txt # stderr to file
cmd &> all.txt # stdout and stderr
cmd 2>/dev/null # discard errors
cmd1 | cmd2 # pipe stdout into cmd2
# here-document: multi-line input; quote 'EOF' to stop expansion
cat > ~/.config/myapp.conf <<EOF
user=$USER
created=$(date +%F)
EOF
# write a root-owned file (sudo doesn't apply to a redirect)
echo "vm.swappiness=10" | sudo tee /etc/sysctl.d/99-swappiness.conf
Text processing in scripts usually comes down to grep, sed and awk; see the grep, sed and awk cheat sheets.
Debugging, linting and scheduling
bash -n script.sh # syntax check only
bash -x script.sh # print each command as it runs
# or turn tracing on for one section
set -x
risky_command
set +x
Install ShellCheck, which catches quoting mistakes, unused variables and common traps before they bite:
sudo apt install shellcheck # Ubuntu, Debian, Mint
sudo dnf install ShellCheck # Fedora
sudo pacman -S shellcheck # Arch
sudo zypper install ShellCheck # openSUSE
shellcheck script.sh
Example output:
In script.sh line 4:
rm $file
^---^ SC2086 (info): Double quote to prevent globbing and word splitting.
Two more habits help. Scripts with Windows line endings fail with /usr/bin/env: 'bash\r': No such file or directory; fix them with sed -i 's/\r$//' script.sh. And if sudo or cron can't find a command your script uses, use full paths or set PATH at the top of the script.
Running scripts on a schedule
Once a script works by hand, run it with cron or a systemd timer. Remember that both run with a minimal environment: no ~/.bashrc, a short PATH and no terminal. Log output to a file or the journal and use absolute paths. Cron vs systemd timers explains which to choose, and the crontab generator builds the schedule line for you. A complete real-world example is the rsync home backup script.
Frequently asked questions
How do I run a bash script?
Make it executable with chmod +x script.sh and run it with ./script.sh, or run bash script.sh without changing permissions.
What does set -euo pipefail do?
-e exits on the first failing command, -u treats unset variables as errors, and -o pipefail makes a pipeline fail if any command in it fails, not just the last one.
What is the difference between [ ] and [[ ]] in bash?
[ is the POSIX test command and needs careful quoting. [[ ]] is bash syntax: it doesn't word-split variables, and supports &&, ||, pattern matching and =~ regexes. Use [[ ]] in bash scripts.
Should I use #!/bin/bash or #!/usr/bin/env bash?
Both work on mainstream distributions. #!/usr/bin/env bash finds bash on the PATH, which helps on systems where it isn't in /bin; #!/bin/bash is explicit and avoids PATH surprises in system scripts.
Spotted a mistake or a command that no longer works? Send a correction — pages are re-checked when reports come in.