Bash scripting for beginners: a practical introduction
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:
- Double quotes
"…"keep the text as one word but still expand$var,$(command)and$((arithmetic)). - Single quotes
'…'keep everything literally. Nothing is expanded. - A backslash escapes one character:
echo "Price: \$5".
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 ….
| Test | True when |
|---|---|
-e path, -f path, -d path | path exists / is a regular file / is a directory |
-r, -w, -x path | you can read / write / execute it |
-s path | file 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 == *.txt | string matches the glob pattern |
$a =~ ^[0-9]+$ | string matches the extended regular expression (leave it unquoted) |
a -eq b, -ne, -lt, -le, -gt, -ge | integer 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:
- Use
set -ufreely; give optional values a default with${VAR:-default}or${1-}. - Use
pipefailwhen a failure early in a pipeline matters, and add|| truewhere a "no match" is fine:errors=$(grep -c ERROR app.log || true). - Whether or not you use
-e, check the commands that matter explicitly:cd -- "$dir" || exit 1. - Write
count=$((count + 1))instead of(( count++ ))underset -e. - Declare and assign separately when the status matters:
local out; out=$(cmd) || return.
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
- Spaces in assignments or tests: write
x=1and[[ $x == 1 ]], with spaces inside the brackets. - Unquoted variables:
rm $filedeletes the wrong things when the name has spaces. Writerm -- "$file". - Parsing
ls: use globs (for f in ./*) orfind … -print0. - Windows line endings: a script saved with CRLF fails with
$'\r': command not found. Convert it withsed -i 's/\r$//' script.sh. - Running with
sh script.sh: that ignores the shebang and may run dash. Use./script.shorbash script.sh. - Expecting
cdto change your shell's directory: a script runs in its own process. Usesource script.sh(or a function in~/.bashrc) for that.
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
- Bash Reference Manual, edition 5.3, GNU Project
- Shell Parameter Expansion, Bash Reference Manual
- bash(1) manual page (GNU Bash 5.3), man7.org
- GNU Bash release downloads, ftp.gnu.org
- bash package versions, packages.debian.org
- bash package versions, packages.ubuntu.com
- grep(1) manual page, man7.org
- BashFAQ/105: Why doesn't set -e do what I expected?, Greg's Wiki
- ShellCheck and ShellCheck wiki