Bash Scripting Tutorial for Linux

Short answer

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 $var splits the value on whitespace and expands wildcards. A file called My Documents becomes 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

VariableMeaning
$0Script 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
TestTrue when
-f path / -d pathRegular file / directory exists
-e pathAnything exists at the path
-r, -w, -x pathReadable, writable, executable by you
-s pathFile 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 -geInteger 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 -e stops at the first failing command. It is skipped inside if conditions and on the left of &&/||, which is what you want.
  • set -u catches typos like $DESTDIR vs $DEST_DIR instead of silently using an empty string. That matters most before commands like rm -rf "$dir/".
  • set -o pipefail makes curl ... | tar x fail when curl fails.
  • trap ... EXIT guarantees temporary files are removed.
Guard destructive commands

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.