Bash scripting for beginners: a practical introduction

Updated November 2026

A Bash script is a text file of the commands you would otherwise type by hand. This page takes you from a first script to a small, safe backup tool, and covers the parts beginners usually get wrong: quoting, exit codes and set -e. Every example runs as shown in Bash 5.2 and 5.3.

Which Bash do you have? Run bash --version. As of November 2026 the current upstream release is Bash 5.3. Debian 13 ships 5.2.37, Ubuntu 24.04 LTS ships 5.2.21, and Ubuntu 26.04 LTS ships 5.3. Nothing on this page needs more than Bash 4.4. The classic 2000 version of this HOWTO is kept by the LDP at tldp.org.

Your first script

Create a file called hello.sh:

#!/usr/bin/env bash
# hello.sh - greet someone
name="${1:-world}"
echo "Hello, $name."

The first line, the shebang, tells the kernel which interpreter to run. #!/usr/bin/env bash finds Bash on your PATH; #!/bin/bash works too on every mainstream Linux distribution. Do not write #!/bin/sh for a Bash script: on Debian and Ubuntu /bin/sh is dash, which does not understand [[ ]], arrays and other Bash features.

chmod +x hello.sh
./hello.sh
./hello.sh Ada

The output is Hello, world. and then Hello, Ada. The ${1:-world} means "the first argument, or world if it is missing or empty".

Variables and quoting

Assign with no spaces around = (name=Ada, not name = Ada, which runs a command called name). Read a variable with $name, and almost always inside double quotes:

file="My Report.txt"
touch "$file"
ls -l "$file"     # one argument: works
ls -l $file       # split into "My" and "Report.txt": fails

Without quotes, Bash splits the value on spaces and expands any * or ? in it as file name patterns. This is the most common bug in shell scripts. The rules:

greeting='Hello, $USER'    # single quotes: stored literally
echo "$greeting"           # prints: Hello, $USER
echo "Hello, $USER"        # prints: Hello, ada (your login name)
today=$(date +%F)          # command substitution
echo "Today is $today"     # e.g. Today is 2026-11-04
total=$(( 6 * 7 ))         # integer arithmetic
echo "$total"              # 42

Bash arithmetic is integers only: $(( 7 / 2 )) is 3. Use awk or bc for decimals.

Script arguments

#!/usr/bin/env bash
echo "Script name: $0"
echo "Number of arguments: $#"
echo "First argument: ${1-(none)}"
for arg in "$@"; do
    printf 'Argument: %s\n' "$arg"
done

"$@" expands to all arguments, each kept as a separate word, so ./args.sh "a b" c loops twice, not three times. Prefer printf to echo when printing data you did not write yourself: echo treats a value such as -n as an option.

Exit status and tests

Every command returns an exit status: 0 means success, anything from 1 to 255 means failure or "false". $? holds the status of the last command. grep, for example, returns 0 if it found a line, 1 if it found none and 2 on an error:

grep -q root /etc/passwd;        echo "status $?"   # 0
grep -q nosuchuser /etc/passwd;  echo "status $?"   # 1
grep -q root /no/such/file;      echo "status $?"   # 2 (plus an error message)

if runs a command and checks its status. The [[ … ]] command is Bash's test syntax; it returns 0 when the test is true.

if [[ -r /etc/os-release ]]; then
    . /etc/os-release
    echo "Running on $PRETTY_NAME"
else
    echo "No /etc/os-release here" >&2
fi

>&2 sends the message to standard error, where error messages belong. You can also test a command directly: if grep -q '^ada:' /etc/passwd; then ….

Common tests inside [[ ]]
TestTrue when
-e path, -f path, -d pathpath exists / is a regular file / is a directory
-r, -w, -x pathyou can read / write / execute it
-s pathfile exists and is not empty
-z "$s", -n "$s"string is empty / not empty
$a == "$b", $a != "$b"strings are equal / different (quote the right side, or it is a pattern)
$a == *.txtstring matches the glob pattern
$a =~ ^[0-9]+$string matches the extended regular expression (leave it unquoted)
a -eq b, -ne, -lt, -le, -gt, -geinteger comparisons

For numbers, (( … )) is usually easier to read:

read -r -p "How many files? " n
if [[ ! $n =~ ^[0-9]+$ ]]; then
    echo "Not a whole number: $n" >&2
    exit 1
elif (( n > 100 )); then
    echo "That is a lot."
else
    echo "OK, $n files."
fi

Your script ends with the status of its last command, or with the number you give to exit. Use exit 0 for success and a small non-zero number for each kind of failure, so other scripts and tools like systemd or cron can tell what happened.

case for choices

case "$1" in
    start)     echo "Starting" ;;
    stop)      echo "Stopping" ;;
    restart)   echo "Restarting" ;;
    -h|--help) echo "Usage: $0 {start|stop|restart}" ;;
    *)         echo "Unknown command: $1" >&2; exit 2 ;;
esac

Each branch is a glob pattern, and | separates alternatives. *) catches everything else.

Loops

# Over a list of words
for host in web1 web2 db1; do
    ping -c 1 -W 2 "$host" > /dev/null && echo "$host is up"
done

# Over files: let the glob do the work, never parse ls
for f in ./*.log; do
    [[ -e $f ]] || continue     # no match: the pattern stays as literal text
    gzip -- "$f"
done

# Counting
for (( i = 1; i <= 3; i++ )); do
    echo "Attempt $i"
done

# Line by line from a file
while IFS=: read -r user _ uid _; do
    if (( uid >= 1000 && uid < 60000 )); then
        echo "$user has UID $uid"
    fi
done < /etc/passwd

In the last loop, IFS=: splits each line on colons, read -r keeps backslashes as they are, and _ is a throwaway variable for fields you do not need; the last name gets the rest of the line. break leaves a loop early and continue jumps to the next round. There is also until, which loops while a command fails: until ping -c 1 -W 2 db1 > /dev/null; do sleep 5; done.

Functions

log() {
    printf '%s %s\n' "$(date +%T)" "$*" >&2
}

backup_file() {
    local src=$1
    local dest="$src.bak"
    cp -p -- "$src" "$dest" || return 1
    printf '%s\n' "$dest"
}

echo "some notes" > notes.txt
if copy=$(backup_file notes.txt); then
    log "Saved $copy"
else
    log "Backup failed"
fi

Inside a function, $1, $2 and "$@" are the function's own arguments. local keeps a variable inside the function. A function "returns" data by printing it (captured with $(…)) and success or failure with return N, which works like exit but only ends the function. The -- tells cp that no more options follow, so a file named -r cannot be mistaken for one.

set -euo pipefail, honestly

Many scripts start with set -euo pipefail. It means: exit when a command fails (-e), treat unset variables as errors (-u), and let a pipeline fail when any part of it fails (pipefail). It catches real mistakes, but -e in particular has rules that surprise people:

#!/usr/bin/env bash
set -e
count=0
(( count++ ))           # the old value is 0, which counts as "false": the script exits here
echo "never printed"
#!/usr/bin/env bash
set -eo pipefail
printf 'all good\n' > app.log
errors=$(grep ERROR app.log | wc -l)   # grep finds nothing, returns 1, the script exits
echo "Errors: $errors"                 # never printed
#!/usr/bin/env bash
set -e
f() {
    local out=$(false)   # the status of "local" (0) hides the failure
    echo "still running"
}
f

And -e is ignored in many places: in the test of an if or while, in every command of an && or || list except the last, and in commands negated with !. So a function called as if myfunc; then runs with no automatic error checking at all. The Bash manual lists all the exceptions under the set builtin.

A practical approach:

A complete example

This script makes a dated .tar.gz of a directory and keeps only the newest copies. It uses everything above, plus an array (explained in the advanced Bash HOWTO).

#!/usr/bin/env bash
# backup-dir.sh - archive a directory and keep the newest KEEP copies
set -uo pipefail

usage() {
    echo "Usage: ${0##*/} SOURCE_DIR DEST_DIR [KEEP]" >&2
    exit 2
}

(( $# >= 2 && $# <= 3 )) || usage
src=$1
dest=$2
keep=${3:-7}

[[ -d $src ]] || { echo "Not a directory: $src" >&2; exit 1; }
[[ $keep =~ ^[1-9][0-9]*$ ]] || { echo "KEEP must be a positive number" >&2; exit 1; }
mkdir -p -- "$dest" || exit 1

name=$(basename -- "$src")
archive="$dest/$name-$(date +%Y%m%d-%H%M%S).tar.gz"

if tar -czf "$archive" -C "$(dirname -- "$src")" -- "$name"; then
    echo "Created $archive"
else
    echo "tar failed; removing the partial archive" >&2
    rm -f -- "$archive"
    exit 1
fi

# The date stamp makes alphabetical order the same as age order.
shopt -s nullglob
archives=("$dest/$name"-????????-??????.tar.gz)
count=${#archives[@]}
if (( count > keep )); then
    for old in "${archives[@]:0:count-keep}"; do
        rm -f -- "$old" && echo "Removed $old"
    done
fi

Run it as ./backup-dir.sh ~/Documents /mnt/backup 5. Check it worked with ls -l /mnt/backup and tar -tzf on the newest archive, which lists its contents. shopt -s nullglob makes a pattern with no matches expand to nothing instead of itself.

Check scripts with ShellCheck

ShellCheck reads a script and points out quoting bugs, unused variables, typos and portability problems. Install the shellcheck package (sudo apt install shellcheck on Debian and Ubuntu, sudo dnf install ShellCheck on Fedora) and run:

shellcheck backup-dir.sh

Each warning has a code with a wiki page that explains it. You will meet these early: SC2086 ("Double quote to prevent globbing and word splitting"), SC2155 ("Declare and assign separately to avoid masking return values") and SC2164 ("Use cd ... || exit in case cd fails"). Most editors can run ShellCheck as you type. Before running a new script for real, bash -n script.sh checks the syntax without running anything.

Common beginner mistakes

Where to go next

Arrays, parameter expansion tricks, traps, getopts and debugging are covered in the advanced Bash scripting HOWTO. The Bash scripting guide suggests a learning path and maps topics to sections of the GNU Bash manual. For an interactive shell you enjoy using, see the Bash prompt HOWTO.

Sources