Bash Scripting From Zero to a Script You Can Trust
A complete Bash scripting tutorial: variables, quoting, tests, loops, functions, arrays, exit codes, traps and a finished backup script you can trust.

A Bash script you can trust has five properties: it names its interpreter, it quotes what it expands, it checks what it was given, it cleans up after itself, and it tells its caller whether it worked. Everything in this guide builds toward those five, in the order a beginner meets them. The examples assume Bash 5.2 on Debian 12, and each one says where a different shell or an older Bash behaves differently.
The guide has six parts. The first gets a script running. The second covers variables and the quoting rules that decide whether a script is safe. The third covers tests, loops and functions. The fourth covers input, output and exit codes. The fifth covers the safety settings and cleanup. The last assembles a complete backup script from every earlier piece. If the target is a script that runs unchanged on Alpine or BusyBox, read the guide to portable shell scripts after this one, because several features below are Bash only.
What a shell script is
A shell script is a text file of commands that the shell reads from top to bottom. Nothing is compiled. The shell parses one command line at a time, expands it, runs it and moves on. That explains most of what surprises beginners: a typo on line 40 does not stop line 1 from running, a variable holds text and nothing else, and a command that fails does not stop the script unless something tells it to.
Bash is both an interactive shell and a scripting language. Everything you type at a prompt works in a script, which is why the best way to learn is to build the script out of commands you already run by hand. The reference for every feature is the GNU Bash Reference Manual, and the portable subset is defined in the POSIX Shell Command Language.
Write and run a first script
Create a file called hello.sh with an editor and put these lines in it.
#!/usr/bin/env bash
echo "Hello from $(hostname)"
date +%FThe first line is the shebang. When the kernel is asked to execute the file, it reads those two bytes, sees #! and starts the named program with the file as its argument. Using /usr/bin/env bash finds Bash wherever it is installed, which matters on macOS and BSD systems where it is not at /bin/bash. The $(...) form is command substitution: the shell runs the command and replaces the expression with its output.
chmod +x hello.sh
./hello.shHello from build01
2026-11-05
Without the executable bit, ./hello.sh fails with Permission denied. You can still run it as bash hello.sh, which ignores the bit and the shebang. When a script behaves differently from the command line, the two ways of starting it are the first thing to compare.
Variables are text
Assign with no spaces around the equals sign and read with a dollar sign. Spaces around = make Bash treat the name as a command, which produces command not found.
name="release notes"
count=3
echo "$name has $count items"
echo "${name}_v2"release notes has 3 items
release notes_v2
The braces in ${name}_v2 mark where the name ends. Without them Bash would look for a variable called name_v2. Every variable is a string; count=3 is the character 3 until an arithmetic context reads it as a number. Names are case sensitive, and the convention is lowercase for variables inside a script and uppercase for environment variables that other programs read, such as PATH and HOME.
Quote every expansion
This is the rule that separates working scripts from scripts that work until a filename contains a space. After expanding an unquoted variable, Bash splits the result on whitespace and then treats each piece as a glob pattern. That is called word splitting and pathname expansion, and it is almost never what you want.
file="my notes.txt"
touch "$file"
rm $filerm: cannot remove 'my': No such file or directory
rm: cannot remove 'notes.txt': No such file or directory
The unquoted $file became two arguments. With a variable that held * or a path with a leading dash, the damage is worse than an error message. Double quotes keep the value as one word and still allow expansion. Single quotes prevent all expansion and are right for literal text such as a regular expression. The full treatment of how quoting breaks and how attackers use it is in the shell script security guide.
The special parameters
Bash gives every script a handful of built-in values. $0 is the script name. $1 to $9, and ${10} onward, are the arguments. $# is how many arguments there are. $? is the exit status of the last command. $$ is the process ID of the shell. $@ and $* are all the arguments, and the difference between them matters.
#!/usr/bin/env bash
printf 'count=%s\n' "$#"
printf 'arg: [%s]\n' "$@"$ ./args.sh one "two words" three
count=3
arg: [one]
arg: [two words]
arg: [three]
"$@" expands to each argument as its own quoted word, so the argument with a space survives. "$*" joins them into one string. Use "$@" whenever you forward arguments to another command, which is nearly always. The unquoted forms of both behave the same and both are wrong.
Default values and required values
Parameter expansion has operators for the cases scripts meet constantly. ${var:-default} substitutes a default when the variable is unset or empty. ${var:=default} also assigns it. ${var:?message} aborts with a message when the variable is missing, which is the shortest way to demand a required input.
#!/usr/bin/env bash
target="${1:?usage: deploy.sh TARGET [BRANCH]}"
branch="${2:-main}"
echo "deploying $branch to $target"$ ./deploy.sh
./deploy.sh: line 2: 1: usage: deploy.sh TARGET [BRANCH]
$ ./deploy.sh staging
deploying main to staging
Other operators trim text. ${path##*/} removes the longest prefix matching */, which leaves the file name. ${file%.txt} removes the shortest suffix, which strips an extension. ${var^^} upper cases and ${var,,} lower cases. These run inside the shell with no extra process, so they are faster than calling basename or tr in a loop.
Command substitution and arithmetic
Use $(command) to capture output and $(( expression )) for integer arithmetic. Prefer $(...) over backticks because it nests and does not need escaping. Trailing newlines are stripped from the captured output.
today=$(date +%F)
files=$(ls | wc -l)
echo "$today: $files entries, next is $(( files + 1 ))"
(( files > 10 )) && echo "busy directory"Arithmetic is integer only. Division truncates, and (( )) returns success when the result is non-zero, so it doubles as a numeric test. For decimals, hand the work to awk or bc; the grep, sed and awk guide shows the awk idiom. Counting entries with ls | wc -l miscounts names that contain newlines, which is why later sections use arrays and globs instead of parsing ls.
Exit status is the return value
Every command returns a number from 0 to 255 when it finishes. Zero means success. Anything else means some kind of failure, and the meaning is defined by the command. grep returns 0 for a match, 1 for no match and 2 for an error, so "no match" is not an error condition unless you decide it is.
grep -q root /etc/passwd; echo "exit=$?"
grep -q nosuchuser /etc/passwd; echo "exit=$?"
grep -q root /etc/missing; echo "exit=$?"exit=0
exit=1
grep: /etc/missing: No such file or directory
exit=2
Your own scripts should follow the same convention. End with exit 0 for success and a small positive number for each distinct failure you want a caller to tell apart. Avoid numbers above 125, which the shell reserves: 126 means the command was found but not executable, 127 means not found, and 128 plus N means the process died from signal N. A caller reading $? is the entire interface between a script and cron, a CI job or another script.
Test conditions with if
if runs a command and branches on its exit status. The command is very often [[, which evaluates a condition and returns 0 or 1. In Bash, prefer the double-bracket form because it does not word split, accepts pattern and regex matching, and does not need quotes on the left side.
if [[ -f "$config" && -r "$config" ]]; then
echo "using $config"
elif [[ -z "$config" ]]; then
echo "no config given"
else
echo "cannot read $config" >&2
exit 1
fiThe file tests you will use constantly are -e exists, -f regular file, -d directory, -r readable, -w writable, -x executable, -s non-empty, and -L symlink. The string tests are -z empty, -n non-empty, == and !=. The numeric tests are -eq, -ne, -lt, -le, -gt and -ge, because < inside [[ ]] compares strings and "10" < "9" is true. For numbers, use (( a < b )) instead.
Single bracket versus double bracket
The single bracket [ is a real program-like builtin that receives its arguments after expansion. That makes it portable and also fragile: an empty unquoted variable disappears, and [ $x = y ] becomes [ = y ], a syntax error. The double bracket is parsed before expansion, so none of that applies.
| Feature | [ ] | [[ ]] |
|---|---|---|
| Works in POSIX sh and dash | Yes | No |
| Needs quotes around variables | Yes | No |
Pattern match with == | No | Yes |
Regex with =~ | No | Yes |
&& and || inside | Use -a and -o, which are ambiguous | Yes |
Choose by the shebang. A Bash script gets [[ ]]. A script that must run under #!/bin/sh gets [ ] with every variable quoted and uses case for pattern matching.
Match patterns and regular expressions
Inside [[ ]], an unquoted right-hand side of == is a glob pattern, and the right-hand side of =~ is an extended regular expression. Captured groups land in the BASH_REMATCH array. Keep the regex in a variable to avoid quoting problems.
version="release-2.14.7"
re='^release-([0-9]+)\.([0-9]+)\.([0-9]+)$'
if [[ $version =~ $re ]]; then
echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}"
fi
[[ $version == release-* ]] && echo "looks like a release"major=2 minor=14 patch=7
looks like a release
Quoting the regex variable on the right side turns it into a literal string match, which is the most common reason a working regex stops matching. The full dialect differences between grep, sed, awk and Bash are covered in the guide to regular expressions in shell tools.
Choose between many branches with case
case matches one value against several glob patterns and is clearer than a ladder of elif. Each branch ends with ;;, and patterns can be joined with |. It is also portable to every POSIX shell, so it is the right tool for argument parsing in scripts that cannot assume Bash.
case "$1" in
start|up) echo "starting" ;;
stop) echo "stopping" ;;
status) echo "checking" ;;
*) echo "usage: $0 {start|stop|status}" >&2; exit 2 ;;
esacThe final *) branch is the default and should always exist in a script that takes input, because falling off the end of a case silently is how a typo in an argument becomes a no-op that exits 0.
Loop over a list
A for loop iterates over words. Give it a quoted array or a glob, never the output of ls. A glob that matches nothing stays as the literal pattern unless nullglob is set, so test for it.
shopt -s nullglob
for f in /var/log/*.log; do
size=$(stat -c %s "$f")
printf '%8d %s\n' "$size" "$f"
doneThe C-style loop for ((i = 0; i < 5; i++)) is useful for counters. A while loop runs while a command succeeds, and until runs until one does. Use break to leave a loop and continue to skip to the next pass. Both accept a number to leave several nested loops at once, though a function is usually a clearer way to escape deep nesting.
Read a file line by line
The reliable idiom for reading lines is while IFS= read -r line. Clearing IFS keeps leading and trailing whitespace, and -r stops read from treating a backslash as an escape. Feed the loop with a redirection, not a pipe, so the loop runs in the current shell and its variables survive.
count=0
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue
count=$(( count + 1 ))
echo "entry $count: $line"
done < hosts.txt
echo "total $count"entry 1: web01
entry 2: web02
entry 3: db01
total 3
Written as cat hosts.txt | while read ..., the loop runs in a subshell and count is still 0 afterwards. This is among the most reported Bash surprises, and the fix is the redirection above or, when the data comes from a command, process substitution: done < <(some_command). A last line with no trailing newline is skipped by read; add || [[ -n $line ]] to the loop condition if the file may lack one.
Functions
A function groups commands under a name. Arguments arrive as $1, $2 inside it, separate from the script's own. A function returns an exit status with return, and outputs text by printing it, which the caller captures with $(...). Variables are global by default, so declare working variables with local.
log() {
local level="$1"; shift
printf '%s [%s] %s\n' "$(date +%T)" "$level" "$*" >&2
}
require() {
command -v "$1" >/dev/null 2>&1 || { log ERROR "missing tool: $1"; return 1; }
}
require rsync || exit 1
log INFO "rsync found"14:02:11 [INFO] rsync found
Notice that log writes to standard error. Diagnostics belong there, so that the standard output of a script stays clean enough to pipe into the next command. Functions must be defined before the line that calls them runs, so put them at the top and the main logic at the bottom.
Return values from functions
A function has two channels: its exit status and its output. Use the status for success or failure, as above, and use output for data. Do not smuggle data through a global variable when output will do, because command substitution is easier to read and test. The one trade-off is that $(...) runs the function in a subshell, so side effects such as setting a variable do not escape. When a function must set variables in the caller, use declare -n name references, available since Bash 4.3.
split_host() {
local -n _host="$2" _port="$3"
_host="${1%%:*}"
_port="${1##*:}"
}
split_host "db01.internal:5432" h p
echo "host=$h port=$p"host=db01.internal port=5432
Indexed arrays
An array holds a list of separate strings, and it is the correct answer to "how to store several things without word splitting". Assign with parentheses, read one element with an index, and expand the whole list with "${arr[@]}", always quoted.
servers=(web01 web02 "db 01")
servers+=(cache01)
echo "${#servers[@]} servers, first is ${servers[0]}"
for s in "${servers[@]}"; do
printf '[%s]\n' "$s"
done
echo "${servers[@]:1:2}"4 servers, first is web01
[web01]
[web02]
[db 01]
[cache01]
web02 db 01
The element with a space stays one element because of the quotes. Building a command in an array and running it as "${cmd[@]}" is the safe replacement for assembling a command in a string and passing it to eval. Arrays do not exist in POSIX sh, so a script that must run under dash cannot use them.
Associative arrays
Bash 4 added associative arrays, which map string keys to values. Declare them with declare -A; Bash 3.2, still shipped by macOS, does not have them.
declare -A port=([http]=80 [https]=443 [ssh]=22)
port[postgres]=5432
for name in "${!port[@]}"; do
printf '%-10s %s\n' "$name" "${port[$name]}"
done | sorthttp 80
https 443
postgres 5432
ssh 22
Keys come back in no particular order, so sort when the order matters. Use [[ -v port[ssh] ]] to test whether a key exists, which is different from the value being non-empty.
Input and output redirection
Every process starts with three open files: standard input (0), standard output (1) and standard error (2). Redirection rewires them. > truncates and writes, >> appends, < reads, 2> redirects errors, and 2>&1 sends errors to wherever output currently goes. Order matters: cmd > file 2>&1 captures both, while cmd 2>&1 > file sends errors to the terminal.
./job.sh > job.log 2>&1
./job.sh >> job.log 2> errors.log
./job.sh &> job.log
./job.sh < input.txt > /dev/nullThe &> form is a Bash shortcut for the first line. A here document supplies multiline input without a temporary file: cat <<'EOF' with a quoted delimiter disables expansion inside, while an unquoted delimiter expands variables. The same redirections are the foundation of the debugging techniques in the guide to debugging shell scripts.
Pipelines
A pipe connects the output of one command to the input of the next, and every stage runs at the same time. This is what makes small tools composable. The pipeline's exit status is, by default, the status of the last command only, so a failure in the middle is invisible.
false | true; echo "status=$?"
set -o pipefail
false | true; echo "status=$?"status=0
status=1
With pipefail the pipeline returns the status of the rightmost command that failed. The PIPESTATUS array holds the status of every stage if you need to know which one broke. One quirk to learn early: grep -q exits as soon as it finds a match, which can close the pipe and kill the producer with SIGPIPE, so a producer that is not finished can report failure under pipefail even though the search succeeded.
Safety settings
Three options change a script from permissive to careful. set -u makes an unset variable an error, which catches typos like $fiel. set -o pipefail is the pipeline behavior above. set -e exits when a command fails. The first two are almost always a good idea. The third is more complicated than its name suggests.
#!/usr/bin/env bash
set -euo pipefail
echo "start"
false && echo "not reached"
echo "after the AND list"
if false; then :; fi
echo "after the if"
false
echo "never printed"start
after the AND list
after the if
set -e ignores a failure that is part of an && or || list (except the last command), a failure in an if or while condition, and a command preceded by !. It also does not fire inside a command substitution used as an argument in some contexts, and a failure inside a function called from a condition is ignored. The consequence is that a script can have set -e and still print "done" after a failure. Pair it with explicit checks on the commands that matter, and read the full list of exceptions in the manual's description of the set builtin.
Handle errors on purpose
Where a failure is expected, say so. || true declares that a command may fail. || followed by a block handles the failure with a message. Define a die helper once and use it everywhere so every failure has the same shape.
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
cd "$workdir" || die "cannot enter $workdir"
grep -q "$pattern" "$file" || echo "pattern absent, continuing"
mkdir -p "$out" || die "cannot create $out"The pattern cd dir || die deserves a habit of its own. A failed cd followed by rm -rf * is a classic way to delete the wrong directory, and ShellCheck flags the unguarded form as SC2164.
Clean up with a trap
A trap runs a command when the shell receives a signal or exits. The EXIT pseudo-signal fires on normal exit, on exit called after an error, and on SIGINT or SIGTERM once the handler is installed for them. It is the right place to delete temporary files.
tmp=$(mktemp -d) || exit 1
cleanup() {
rm -rf -- "$tmp"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
echo "working in $tmp"
sleep 30Pressing Ctrl-C sends SIGINT, the trap calls exit 130, and the EXIT trap runs cleanup. Always make the temporary directory with mktemp, never with a fixed name like /tmp/work, which another user or process can pre-create. SIGKILL cannot be trapped, so a script killed with kill -9 leaves its files behind; design cleanup so that a second run can recover from that.
Parse options with getopts
getopts is a builtin for short options and works in POSIX sh. The option string lists the letters, and a colon after a letter means the option takes a value. A leading colon turns on silent error handling so you can print your own messages.
verbose=0; out="backup.tar.gz"
while getopts ":vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) out="$OPTARG" ;;
h) echo "usage: $0 [-v] [-o file] dir..."; exit 0 ;;
:) echo "option -$OPTARG needs a value" >&2; exit 2 ;;
\?) echo "unknown option -$OPTARG" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
echo "verbose=$verbose out=$out dirs=$#"$ ./pack.sh -v -o /tmp/x.tgz src docs
verbose=1 out=/tmp/x.tgz dirs=2
After the loop, shift $(( OPTIND - 1 )) removes the parsed options so "$@" holds only the remaining arguments. getopts does not understand long options such as --output. For those, write a while [[ $# -gt 0 ]] loop with a case, or use the external getopt from util-linux, which is not available on every system.
Work with files and paths safely
Most destructive scripting accidents involve a path. A few habits remove most of the risk. Put -- before filenames to stop a name beginning with a dash being read as an option. Use mktemp for temporary files. Resolve the script's own location rather than assuming the current directory. Prefer globs over parsing ls, and use find ... -print0 with read -d '' when names might contain newlines.
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
rm -f -- "$tmp/partial.tar"
find "$dir" -type f -name '*.log' -mtime +30 -print0 |
while IFS= read -r -d '' f; do
echo "old: $f"
doneDeleting with a variable that might be empty, as in rm -rf "$dir/"*, removes everything under the root when dir is unset. The expansion "${dir:?}" aborts instead, and set -u does the same job for the whole script.
Run commands in the background and wait
Add & to run a command in the background, record its PID in $! and use wait to collect its exit status. A bounded number of parallel jobs is a safer pattern than launching hundreds at once.
pids=()
for host in web01 web02 web03; do
ssh -o BatchMode=yes "$host" uptime > "out.$host" 2>&1 &
pids+=($!)
done
fail=0
for pid in "${pids[@]}"; do
wait "$pid" || fail=$(( fail + 1 ))
done
echo "$fail host(s) failed"Waiting on each PID separately is what lets the script see each exit status. A bare wait with no arguments returns 0 regardless of the jobs. For real fan-out with a concurrency limit, xargs -P is simpler than hand-rolled job control. SSH specifics such as BatchMode are in the SSH guide.
Scripts that run unattended
A script started by cron or a CI runner has no terminal, a minimal PATH and often a different working directory, user and locale. Set what you depend on at the top, log to a file with timestamps, and use a lock to stop two copies running at once. flock from util-linux does that in one line.
#!/usr/bin/env bash
set -euo pipefail
PATH=/usr/local/bin:/usr/bin:/bin
exec 9>/var/lock/nightly.lock
flock -n 9 || { echo "already running" >&2; exit 0; }
cd "$(dirname "$0")"
./sync.shHolding file descriptor 9 open keeps the lock until the script exits, and the kernel releases it even if the script is killed. Everything about scheduling such a script is in the guide to cron and systemd timers.
Check the script with ShellCheck
ShellCheck reads a script without running it and reports the mistakes this guide has described: unquoted variables (SC2086), a missing guard after cd (SC2164), reading ls output (SC2012) and useless use of cat (SC2002). On Debian 12 the package is shellcheck 0.9.0.
shellcheck deploy.shIn deploy.sh line 7:
rm -rf $target/*
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Run it in your editor and in CI. When a warning is wrong for a specific line, silence that one line with a disable comment that carries a reason, rather than switching the check off for the file. The testing workflow around it, including Bats, is in the guide to testing shell scripts.
Build a backup script from the pieces
This script uses every earlier idea: a shebang, strict settings, a required argument, a default, option parsing, quoting, a temporary directory with a trap, a function, a lock, logging to standard error and meaningful exit codes. It archives a directory, checks the archive, and rotates old ones.
#!/usr/bin/env bash
# backup.sh [-k KEEP] [-d DEST] SOURCE
set -euo pipefail
keep=7
dest="$HOME/backups"
log() { printf '%s %s\n' "$(date +%T)" "$*" >&2; }
die() { log "error: $*"; exit 1; }
while getopts ":k:d:" opt; do
case "$opt" in
k) keep="$OPTARG" ;;
d) dest="$OPTARG" ;;
*) die "usage: $0 [-k KEEP] [-d DEST] SOURCE" ;;
esac
done
shift $(( OPTIND - 1 ))
source_dir="${1:?usage: $0 [-k KEEP] [-d DEST] SOURCE}"
[[ -d $source_dir ]] || die "not a directory: $source_dir"
[[ $keep =~ ^[0-9]+$ ]] || die "KEEP must be a number"
mkdir -p -- "$dest"
exec 9>"$dest/.lock"
flock -n 9 || die "another backup is running"
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
name="$(basename -- "$source_dir")-$(date +%Y%m%d-%H%M%S).tar.gz"
log "archiving $source_dir"
tar -czf "$tmp/$name" -C "$(dirname -- "$source_dir")" -- "$(basename -- "$source_dir")"
tar -tzf "$tmp/$name" >/dev/null || die "archive failed verification"
mv -- "$tmp/$name" "$dest/$name"
log "wrote $dest/$name"
mapfile -t old < <(ls -1t -- "$dest"/*.tar.gz | tail -n +"$(( keep + 1 ))")
for f in "${old[@]}"; do
[[ -n $f ]] || continue
log "removing $f"
rm -f -- "$f"
done$ ./backup.sh -k 3 -d /tmp/bk ~/project
10:41:07 archiving /home/dev/project
10:41:07 wrote /tmp/bk/project-20261105-104107.tar.gz
The temporary archive is built outside the destination and moved in only after verification, so a crash mid-write never leaves a half-finished file where the next restore will look for a good one. The final rotation uses ls -t on files whose names the script itself created, which is one of the few safe uses of parsing ls. For names you do not control, use find with -print0.
Environment variables and export
A plain assignment creates a shell variable that only the current shell can see. export copies it into the environment, which every child process inherits. This is why PATH works for every program you start and why a variable you set in a script does not appear in the terminal that launched it: the child's environment is a copy, and nothing flows back to the parent.
name=local-only
export shared=visible
bash -c 'echo "name=[$name] shared=[$shared]"'
LC_ALL=C sort names.txtname=[] shared=[visible]
Prefixing a command with VAR=value sets the variable for that one command only, which is the clean way to change the locale or a tool's configuration without leaking it into the rest of the script. Scripts that read configuration from the environment should document each variable at the top and give it a default with ${VAR:-default}, so that running with an empty environment still does something sensible.
Subshells and scope
A subshell is a child copy of the shell. Parentheses create one explicitly, as does a pipeline stage and a command substitution. Changes made inside it, including variable assignments and cd, vanish when it ends. That is useful when you want a temporary directory change without undoing it afterwards.
pwd
( cd /tmp && pwd )
pwd
total=0
printf '1\n2\n3\n' | while read -r n; do total=$(( total + n )); done
echo "total=$total"/home/dev
/tmp
/home/dev
total=0
The last two lines repeat the pipeline surprise from the file-reading section in a form you can run in ten seconds. Braces, { ...; }, group commands without creating a subshell, which is how the || { log ERROR "..."; return 1; } pattern earlier groups two commands under one condition. Bash 4.2 and later also offer shopt -s lastpipe, which runs the last stage of a pipeline in the current shell when job control is off, as it is in scripts.
Here documents and here strings
A here document feeds literal multiline text to a command's standard input. With an unquoted delimiter, variables and command substitutions expand inside it. With a quoted delimiter, nothing does. A leading dash on the operator, <<-, strips leading tabs so the block can be indented with the surrounding code.
user=deploy
cat <<EOF > /tmp/unit.conf
[service]
User=$user
ExecStart=/usr/local/bin/app --home=\$HOME
EOF
grep -c service <<< "$(cat /tmp/unit.conf)"The <<< operator is a here string, which passes one string as standard input without the echo "$x" | pipe. Because it avoids a pipeline, the receiving command runs in the current shell when it is a builtin such as read. Use a quoted delimiter, <<'EOF', whenever the text contains dollar signs that must stay literal, such as a generated script or a systemd unit with shell syntax in it.
Process substitution
The form <(command) runs a command and gives its output a file name that other commands can open. It lets you compare two commands' output without temporary files, and it keeps a loop in the current shell while reading from a command.
diff <(sort a.txt) <(sort b.txt)
while IFS= read -r user; do
echo "login shell for $user"
done < <(cut -d: -f1 /etc/passwd | head -3)The file name is typically /dev/fd/63, a pipe, so the consumer reads it once and cannot seek. A tool that needs to read its input twice or measure its size will not work with it. Process substitution is a Bash, Zsh and Ksh feature; dash and BusyBox ash do not have it.
Globbing and brace expansion
Two different expansions share similar syntax. Braces generate strings before anything else happens: {a,b,c}, {1..5} and {01..10} expand whether or not matching files exist. Globs match existing files: *, ? and [a-c]. With shopt -s extglob you also get patterns like !(*.bak) and +(digit), and with shopt -s globstar, ** descends into directories.
mkdir -p /tmp/site/{css,js,img}
touch /tmp/site/{a,b}.html
shopt -s globstar nullglob
for f in /tmp/site/**/*.html; do echo "$f"; done
cp config.yml{,.bak}The last line expands to cp config.yml config.yml.bak, a handy idiom for a quick backup. Remember that a glob that matches nothing is passed through unchanged by default, so rm *.tmp in an empty directory reports an error about a file literally named *.tmp. Set nullglob to make it expand to nothing, and check the array length before using the result.
Read input from the user
read takes a line from standard input and splits it into variables. A prompt with -p, a silent mode with -s for secrets and a timeout with -t cover interactive scripts. A script that may also run unattended should check whether it has a terminal first.
if [[ -t 0 ]]; then
read -r -p "Delete $target? [y/N] " answer
else
answer=n
fi
[[ $answer == [yY]* ]] || { echo "aborted"; exit 1; }
read -r -s -p "Token: " token; echoThe test -t 0 is true when standard input is a terminal. Defaulting to "no" when there is none stops a destructive step from running from cron on a guess. Never put a secret on the command line as an argument, because it is visible to every user through the process list; read it from a prompt, a file with tight permissions or an environment variable instead.
Strings and substrings
Bash can slice and edit strings without calling external programs. ${#var} is the length, ${var:2:5} is five characters from offset two, and ${var/old/new} replaces the first match while ${var//old/new} replaces all of them. For lists of words, set IFS and use read -ra to split into an array.
s="alpha,beta,gamma"
echo "${#s} ${s:6:4} ${s//,/ | }"
IFS=, read -ra parts <<< "$s"
printf '%s\n' "${parts[@]}"16 beta alpha | beta | gamma
alpha
beta
gamma
Assigning IFS=, in front of read applies only to that command. Changing IFS globally is a common source of bugs in the lines that follow it, so avoid it unless you restore the old value.
Source a library of functions
When two scripts need the same helpers, put them in a file and load it with source (or the portable .). The file runs in the current shell, so its functions and variables become available. Locate it relative to the script, so it works from any directory.
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib/common.sh
source "$script_dir/lib/common.sh"
log INFO "library loaded"The comment above the source line is a directive for ShellCheck, which otherwise cannot follow a path built from a variable. A library should only define functions and constants, not run commands, and should avoid set -e or exit of its own, because those would change the behavior of whichever script loads it.
Dates, time and durations
GNU date formats and parses times. date +%F prints an ISO date, date -d '2 days ago' +%F does date arithmetic, and date +%s prints seconds since the epoch, which is the easiest form for measuring a duration. Bash itself has printf '%(%F)T' -1 for formatting the current time without starting a process, and a built-in SECONDS variable that counts since the shell started.
start=$(date +%s)
sleep 2
elapsed=$(( $(date +%s) - start ))
echo "took ${elapsed}s, run at $(date -d @"$start" +%FT%T%z)"
printf 'now: %(%F %T)T\n' -1BSD and macOS date use -v instead of -d for arithmetic, so a script that computes dates is not portable between them without a wrapper. Store timestamps in UTC with an explicit offset when a script's output is read by other systems.
Style that keeps a script readable
Scripts live longer than the afternoon they were written in. A few conventions cost nothing and make a script easier to change safely. Put set -euo pipefail and configuration at the top. Define functions next, then a main function that holds the flow, and end the file with main "$@", so the script reads top to bottom as a table of contents. Use lowercase for locals and uppercase for constants and environment. Indent with two spaces. Keep lines under 80 characters by breaking at a pipe or &&, with the operator at the end of the line so Bash knows the command continues.
Write comments that explain why. A comment that says "loop over files" above a loop over files is noise, while one that says "tar exits 1 when a file changes during the read, which is fine here" saves the next person an hour. Name variables for what they hold, such as backup_dir instead of d. When a function is longer than a screen, split it. When a script passes 200 lines, ask whether the next section of this guide applies.
Make a script safe to run twice
A script that fails halfway will be run again, by you, by cron or by a retry in a CI system. If the second run breaks because the first one already created a directory, added a line to a file or moved a file, the failure is in the script. The property you want is idempotence: running it twice leaves the system in the same state as running it once. Use mkdir -p instead of mkdir, ln -sfn instead of ln -s, and rm -f where absence is fine. Before appending a line to a file, check whether it is already there with grep -qxF.
line='export PATH="$HOME/bin:$PATH"'
grep -qxF -- "$line" ~/.profile || printf '%s\n' "$line" >> ~/.profile
ln -sfn "$release_dir" /srv/app/currentThe second command swaps a symlink to a new release directory. Swapping the link is close to atomic on the same filesystem, which is why deploy scripts use it. Combine that with the lock from the unattended-scripts section and a second run either waits or exits cleanly.
Debug while you write
Run bash -n script.sh to check syntax without executing anything. Run bash -x script.sh to print every command after expansion with a leading plus sign, which is the fastest way to see what a quoted or unquoted variable became. The trace prefix is controlled by PS4.
bash -n backup.sh && echo "syntax ok"
bash -x backup.sh -k 3 /tmp/demosyntax ok
+ keep=7
+ dest=/home/dev/backups
To see the file and line next to each command, assign PS4 inside the script, for example PS4='+ ${BASH_SOURCE##*/}:${LINENO}: ' before set -x. On Bash 5.2.15 in Debian 12, exporting PS4 in the environment before launching bash -x did not change the prefix, while assigning it inside the script did, so set it in the script.
You can also turn tracing on for part of a script with set -x and off with set +x. For problems that tracing cannot show, such as a command hanging or a file that is never opened, the next tools are strace and a debugger-style approach, covered in the debugging guide. Until then, an echo before a destructive command that prints what it is about to do costs nothing and catches most mistakes.
Common mistakes and their fixes
A short catalogue of what beginners hit, each paired with the fix.
- Spaces around the equals sign.
x = 1runs a command calledx. Writex=1. - Unquoted expansions.
rm $fsplits and globs. Writerm -- "$f". - Testing the wrong thing.
if [ $? -eq 0 ]after a command is noise. Writeif command; then. - Piping into
whileand expecting variables to survive. The loop is a subshell. Redirect from a file or use process substitution. - Using
for f in $(ls). It breaks on spaces. Usefor f in *. - Comparing numbers with
<in[[ ]]. That compares strings. Use(( a < b ))or-lt. - Forgetting that
cdcan fail. Writecd dir || exit 1. - Relying on the current directory. Resolve paths from
BASH_SOURCEinstead.
Most of these are caught by ShellCheck. The ones that are not, such as the subshell loop, are the reason to understand the model rather than only follow rules.
When a script should not be a Bash script
Bash is the right tool when the work is mostly running other programs, moving files and connecting outputs to inputs. It is the wrong tool when the script needs real data structures, structured parsing of JSON or XML, a long-lived process, numeric work beyond integers, or tests that take a day to write. A rough marker is a script past 200 lines that is growing nested arrays and string escaping, or one that needs to run on Windows. In those cases a Python or Go program is cheaper to maintain, and the move is a staged rewrite, not a leap. The Linux command line guide covers the commands a script leans on, and it is worth reading before deciding that a script needs replacing.
Which Bash features need which version
A script that runs on your laptop and fails on a server is often using a feature the server's Bash does not have. Knowing roughly when each feature arrived saves a debugging session. Associative arrays and mapfile arrived in Bash 4.0 in 2009, the &>> and |& shortcuts and coproc in 4.0 as well, declare -n name references in 4.3, ${var@Q} and related transformations in 4.4, EPOCHREALTIME and SRANDOM in 5.0, and wait -p in 5.1. macOS ships Bash 3.2, which predates all of them, because later versions use the GPL version 3 license. Red Hat Enterprise Linux 7 shipped 4.2, and current Debian and Ubuntu ship 5.1 or 5.2.
The practical rule is to decide the minimum version once, write it at the top of the script, and check it. A three-line guard turns a confusing syntax error halfway through the run into a clear message at the start. Put the guard before any syntax that older versions cannot parse, since a parse error happens before any line runs. Syntax that appears inside a function body is parsed when the function is read, so keep new-version constructs out of the first lines of the file.
#!/usr/bin/env bash
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) )); then
echo "bash 4.4 or newer is required, found $BASH_VERSION" >&2
exit 1
fiThe array BASH_VERSINFO holds the major and minor numbers as integers, which makes the comparison reliable where comparing the text of $BASH_VERSION is not. When you cannot control the target, the choice is between writing to the oldest Bash you must support, requiring a newer one and installing it, or writing POSIX sh, as described in the portable shell guide.
Configuration files without eval
Scripts need settings, and the quick answer is to put them in a file of shell assignments and source it. That works, and it means the file is code: anyone who can edit it can run commands as the script's user. For a file only you write, that is fine. For a file that other people or other programs write, parse it instead of running it. A simple key and value format is easy to read safely with read, and the loop below rejects names that are not plain identifiers.
declare -A conf=()
while IFS='=' read -r key value; do
[[ -z $key || $key == \#* ]] && continue
[[ $key =~ ^[a-z_][a-z0-9_]*$ ]] || { echo "bad key: $key" >&2; exit 1; }
conf[$key]=$value
done < app.conf
echo "host is ${conf[host]:-unset}"Because the value is stored as data and never expanded as code, a line such as host=$(rm -rf ~) is stored as that literal text and does nothing. Defaults come from the :- operator at the point of use, and required settings use ${conf[host]:?host is required}. Environment variables can override the file: check the environment first, then the file, then the default, and document the order. That layering is how most command line tools behave, and users will expect it.
Reading JSON and CSV from a script
Bash has no data structures for structured text, and parsing JSON with sed and grep works until the day a value contains a quote. Use jq for JSON. It is a single binary, it streams, and it handles escaping correctly. Ask it for exactly the fields you need, in a shape the shell can consume, and use the -r flag so strings come out without quotes.
curl -fsS https://api.example.org/v1/servers |
jq -r '.items[] | select(.state == "active") | [.name, .ip] | @tsv' |
while IFS=$'\t' read -r name ip; do
printf 'checking %s at %s\n' "$name" "$ip"
doneThe @tsv filter prints tab-separated values with tabs and newlines inside fields escaped, so each record is one line. For simple CSV with no quoted commas, IFS=, read -r a b c in a loop is enough. For real CSV, use a tool that understands quoting, such as Miller or csvkit, or do the job in Python. The rule underneath is to hand structured formats to a program that knows the format and keep the shell for gluing the pieces together.
Logging that you can use later
A script that prints a line when it starts and a line when it ends is already better than silence, and a few small habits make the output searchable. Give every message a timestamp and a level, send diagnostics to standard error so that standard output stays clean for data, and keep the format stable so that grep and awk can read it. If the script runs under cron or systemd, the scheduler captures both streams, so the script need not manage log files itself.
LOG_LEVEL="${LOG_LEVEL:-info}"
declare -A rank=([debug]=0 [info]=1 [warn]=2 [error]=3)
log() {
local level=$1; shift
(( rank[$level] >= rank[$LOG_LEVEL] )) || return 0
printf '%s %-5s %s\n' "$(date -u +%FT%TZ)" "${level^^}" "$*" >&2
}
log info "starting backup of $src"
log debug "tar options: $opts"2026-11-05T10:41:07Z INFO starting backup of /srv/data
Setting LOG_LEVEL=debug in the environment turns on the extra lines without editing the script. Calling date for every line starts a process each time; on Bash 4.2 and later printf '%(%FT%TZ)T' -1 formats the time without one, which matters in scripts that log thousands of lines. Never log secrets, and be careful with whole command lines, since they often contain them.
Colors and terminal awareness
Colored output helps people reading a terminal and ruins logs and pipes. Print color only when standard output is a terminal and the user has not asked for plain text. The NO_COLOR environment variable is a widely respected convention: if it is set to anything, do not emit color. Test for a terminal with [[ -t 1 ]] and use tput to ask the terminal database for the right codes, not hard-coded escape sequences.
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then
red=$(tput setaf 1); green=$(tput setaf 2); reset=$(tput sgr0)
else
red=''; green=''; reset=''
fi
printf '%sFAILED%s: %s\n' "$red" "$reset" "$msg"Defining empty strings in the plain case keeps every later printf the same. The same test, -t 0 for standard input, tells a script whether it may prompt, which matters when it runs from cron or a pipeline. A script that decides on a terminal, colors, prompts and progress bars from these checks behaves well interactively and quietly when automated, without a separate mode for each.
Designing exit codes and error messages
The exit code is the script's return value, and callers depend on it. Decide what each failure means and keep it stable. A small table works: 0 success, 1 a runtime failure, 2 a usage error, and a few higher numbers for conditions callers might handle differently, such as 3 for "nothing to do" or 4 for "lock held". Keep to 1 through 125 so you do not collide with the shell's reserved codes. Document the table in the usage text, since a script that returns seven different codes nobody knows about is no better than one that returns one.
Error messages are for the person reading them at the worst moment. A good one says what failed, with what input, and what to try. Compare "error" and "cannot read /etc/app.conf: permission denied (run as root or add yourself to the app group)". Prefix messages with the script name so that when scripts call scripts, the origin is clear, write them to standard error, and exit immediately after a fatal one. A die helper that does all three keeps the habit effortless.
Performance: builtins against external programs
Bash is slow at starting programs and fast at its own builtins, and that difference drives almost every optimization. Starting an external command costs a fork and an exec, on the order of a few hundred microseconds on a modern machine, while a builtin runs in microseconds. In a loop over ten thousand items, calling basename, cut or expr on each is the difference between a second and ten. Replace them with parameter expansion, [[ ]] and $(( )).
for f in "${files[@]}"; do
base=${f##*/} # instead of $(basename "$f")
ext=${base##*.} # instead of $(echo "$base" | sed 's/.*\.//')
stem=${base%.*}
(( n += 1 )) # instead of n=$(expr $n + 1)
doneReading a file line by line with while read is itself slow for big files, because each iteration is interpreted. For a million lines, a single awk or sort is far faster, and the section on that trade-off in the text processing guide includes measurements. Measure before and after any change; a script that runs once a night for ten seconds does not need tuning.
Organizing a script that grows
Scripts grow past one screen, and a little structure keeps them readable. Put configuration and constants at the top, then helper functions, then a main function that reads like an outline, then one line calling it. Keep each function short enough to see at once and give it one job. Pass values as arguments rather than reading globals, and return results by printing them or setting a clearly named variable. When two scripts share helpers, move them to a library file that is sourced, and give the library no side effects on load.
At some size, the right move is to split by responsibility into several small scripts or subcommands, as git does with git-commit and git-log. A dispatcher script reads the first argument and runs the matching function or executable. The tests for each unit stay small, as the testing guide shows, and each piece can be debugged on its own with the tools in the debugging guide.
main() {
local cmd="${1:-help}"; shift || true
case "$cmd" in
backup) cmd_backup "$@" ;;
restore) cmd_restore "$@" ;;
list) cmd_list "$@" ;;
help|-h|--help) usage ;;
*) usage; exit 2 ;;
esac
}
main "$@"The pattern gives a tool with subcommands in a single file, a good size for operations scripts that a team shares. Each cmd_* function parses its own options, so adding a subcommand does not disturb the others.
Where this does not apply
Alpine, BusyBox and minimal containers. These images ship ash, with no arrays, no [[, no local in some builds and no process substitution. Write POSIX sh, use case for matching, and read the portable shell guide linked above.
macOS. The system Bash is 3.2, from 2007, so associative arrays, mapfile, ${var^^} and name references do not exist there. Install a current Bash with Homebrew and use #!/usr/bin/env bash so the newer one is found, or avoid those features.
Scripts that handle untrusted input. Anything that processes data from users, uploads or the network needs the extra care in the security guide, since quoting alone does not stop injection through eval, option smuggling or unsafe temporary files.
Large data work. A loop that reads a million lines with read is slow because each iteration is interpreted. Use awk, sort or a real language for bulk text.
What to read next
With scripts running, the next skills are understanding the commands they call, finding why they misbehave and proving they keep working. Start with the Linux command line guide for the toolset, then the debugging guide for set -x, PS4 and strace, and the testing guide for putting a safety net under a script before you change it.
Primary sources used here: the Bash Reference Manual, the POSIX shell language specification, the parameter expansion section, the conditional constructs section, the flock manual page, the mktemp manual page, the signal manual page, the GNU coreutils manual and the ShellCheck wiki.


