Bash Script Examples for Everyday Tasks That Fail Safely
Copy-paste bash script examples for variables, arguments, if and loops, functions and exit codes, plus the safe defaults that stop scripts failing quietly.

A bash script is a plain text file that starts with a #!/usr/bin/env bash line, gets executable permission with chmod +x, and runs its commands from top to bottom. The examples below cover what almost every script needs: variables, arguments, if and case, loops over files and lines, functions, exit codes, and a short block of safe defaults at the top. Each one is small enough to paste into a file and adapt.
Everything here targets Bash 5.x as described in the Bash Reference Manual, with the bash(1) man page as the definitive reference when the two disagree. Most of it also works on Bash 4, but several constructs don't exist in plain sh, so keep the shebang pointed at bash and run scripts with ./script.sh rather than sh script.sh.
The smallest bash script that does something useful#
#!/usr/bin/env bash
set -euo pipefail
name="${1:-world}"
echo "Hello, $name"
Save it as hello.sh, run chmod +x hello.sh, then try ./hello.sh and ./hello.sh Indra.
Four things are happening:
- The shebang tells the kernel which interpreter to use. Going through
envfinds bash on the PATH instead of hard-coding/bin/bash. set -euo pipefailturns on three safety options.-eexits on the first failing command,-umakes an unset variable an error, andpipefailmakes a pipeline fail if any stage fails instead of only the last one. More on these below."${1:-world}"reads the first argument and falls back toworldwhen it's missing. Without the fallback,-uwould abort the script when no argument is given.- Double quotes around every expansion keep values with spaces intact.
That's the template. Every other example here assumes those first two lines are present.
Variables and arguments in bash script examples#
Assignment has no spaces around the equals sign, and you read a variable with $name or ${name}. Positional parameters hold the arguments: $1 is the first, $# is the count, $@ is all of them, and $0 is the script name.
#!/usr/bin/env bash
set -euo pipefail
if (( $# < 1 )); then
echo "usage: $0 <file> [more files...]" >&2
exit 2
fi
for file in "$@"; do
echo "processing $file"
done
>&2 redirects the message to standard error. The manual describes this as duplicating a file descriptor: [n]>&word makes descriptor n (standard output when n is omitted) a copy of the descriptor named by word, so >&2 sends the output wherever stderr is going. Usage messages and warnings belong there so they don't pollute output another program might consume.
A few expansions worth memorizing:
| Expansion | Meaning |
|---|---|
${var:-default} |
Use default if var is unset or empty |
${var:?message} |
Exit with message if var is unset or empty |
${var#prefix} |
Remove the shortest matching prefix |
${var%suffix} |
Remove the shortest matching suffix |
${var//old/new} |
Replace every old with new |
$(command) |
Replace with the command's output |
$(( 2 + 3 )) |
Arithmetic |
The quoting rule that trips up the most people is "$@" versus bare $@ or $*. Quoted "$@" keeps each argument as one word even if it contains spaces. Here's the same effect shown with Python's shlex, which splits a command line using shell rules, so you can see exactly what the shell would hand to a program:
import shlex
filename = "Q3 Report final.txt"
# Unquoted $file: the shell splits the value on whitespace first
unquoted = shlex.split(f"rm {filename}")
# Quoted "$file": the value stays as a single argument
quoted = shlex.split(f'rm "{filename}"')
print("unquoted argv:", unquoted)
print("quoted argv: ", quoted)
The unquoted version would try to delete three files, none of them the one you meant. Quote your variables.
If statements, tests, and case#
Bash has two test syntaxes. [[ ... ]] is the bash-specific one and is easier to get right: no word splitting inside it, and it supports pattern matching plus && and ||. [ ... ] is the POSIX one and behaves like a regular command, so every variable inside it has to be quoted.
config="${CONFIG_FILE:-./app.conf}"
if [[ ! -f "$config" ]]; then
echo "missing config: $config" >&2
exit 1
elif [[ ! -r "$config" ]]; then
echo "cannot read config: $config" >&2
exit 1
fi
if (( $(id -u) == 0 )); then
echo "running as root"
fi
Common file tests: -f regular file, -d directory, -e exists, -r readable, -x executable, -s non-empty. String tests: -z empty, -n non-empty, == equal, != not equal. For numbers, (( ... )) reads naturally: (( count > 10 )).
case is the right tool when you're matching one value against several patterns, such as a subcommand:
case "${1:-}" in
start) echo "starting" ;;
stop) echo "stopping" ;;
-h|--help) echo "usage: $0 start|stop" ;;
"") echo "missing command" >&2; exit 2 ;;
*) echo "unknown command: $1" >&2; exit 2 ;;
esac
Patterns in case and [[ are case-sensitive by default. If you want START and start to hit the same branch, shopt -s nocasematch turns on case-insensitive matching for both constructs. shopt -s extglob enables extended patterns when a plain glob isn't enough. The full list of these switches lives in the manual's shopt section.
Loops over files, lines, and arguments#
The for loop over a glob is the workhorse:
shopt -s nullglob
for log in ./*.log; do
echo "compressing $log"
gzip -- "$log"
done
That shopt -s nullglob line matters. By default, a pattern that matches nothing is left as-is, so an empty directory makes the loop run once with the literal string ./*.log. With nullglob set, a pattern with no matches expands to nothing and the loop body never runs. If you'd rather have the script stop with an error in that situation, shopt -s failglob does that instead.
Two related options: shopt -s globstar makes ** match files in subdirectories recursively, so ./**/*.log walks the whole tree, and shopt -s dotglob includes names starting with a dot, which globs otherwise skip.
To process a file line by line, use while read:
while IFS= read -r host; do
[[ -z "$host" ]] && continue
echo "checking $host"
done < hosts.txt
IFS= keeps leading and trailing whitespace, and -r stops backslashes from being interpreted. Together they make read return each line exactly as written. For a counted loop, the C-style form for (( i = 1; i <= 5; i++ )); do ...; done works the way you'd expect.
Functions, local variables, and return values#
Functions let you name a block of commands and call it with arguments. Inside a function, $1, $@ and $# refer to the function's own arguments, and local keeps variables from leaking into the rest of the script.
log() {
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2
}
require_cmd() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log "missing required command: $cmd"
return 1
fi
}
require_cmd git
require_cmd rsync
log "all dependencies found"
return sets the function's exit status, a number from 0 to 255, not a value. To hand back data, print it and capture the output with command substitution:
slugify() {
local text="$1"
text="${text,,}" # lowercase
text="${text// /-}" # spaces to dashes
printf '%s\n' "$text"
}
slug="$(slugify "Bash Script Examples")"
echo "$slug"
Define functions before you call them. Bash reads the file top to bottom, so a call placed above the definition fails with "command not found".
Exit codes and safe defaults#
Every command returns an exit status. Zero means success, anything else means failure, and $? holds the status of the last command. Your script should follow the same convention: exit 0 on success and a non-zero code on failure, so whoever calls it, whether a person, cron, or a CI runner, can tell what happened.
#!/usr/bin/env bash
set -euo pipefail
shopt -s inherit_errexit
workdir="$(mktemp -d)"
cleanup() {
rm -rf -- "$workdir"
}
trap cleanup EXIT
cp -- "$1" "$workdir/input"
echo "working in $workdir"
# ... do the real work here ...
This is the shape I'd start every non-trivial script from:
set -estops the script at the first failing command, so you don't carry on with half-finished state. Commands tested directly byif,while,until,||, or&&are exempt, which is what you want.set -uturns a typo in a variable name into an immediate error instead of a silent empty string. Pair it with${var:-default}wherever empty is acceptable.set -o pipefailmakescmd1 | cmd2fail whencmd1fails. Without it, only the last command's status counts.shopt -s inherit_errexitcloses a gap inset -e. By default, command substitution runs in a subshell witherrexitunset, soresult="$(failing_command)"can quietly produce an empty string. With this option set, the subshell inherits theerrexitsetting.trap cleanup EXITruns the cleanup function whenever the script exits, on success, on error, or on Ctrl-C, so temporary files don't pile up.
When you expect a command might fail and want to continue anyway, say so explicitly: grep -q pattern file || echo "not found", or some_command || true.
Redirections round this out. 2>&1 makes stderr a copy of wherever stdout currently points, so command >out.log 2>&1 captures both streams in one file. Order matters. command 2>&1 >out.log duplicates stderr onto the terminal first and only then redirects stdout, which is almost never what you meant.
Shell script examples for automation#
Two patterns I reach for constantly. First, a batch rename with a dry-run switch, so you can see what would happen before it happens:
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
dry_run="${DRY_RUN:-0}"
for f in ./*.jpeg; do
target="${f%.jpeg}.jpg"
if [[ "$dry_run" == 1 ]]; then
echo "would rename $f to $target"
else
mv -- "$f" "$target"
fi
done
Run DRY_RUN=1 ./rename.sh to preview and ./rename.sh to do it. The -- after mv marks the end of options so a filename starting with a dash can't be mistaken for a flag.
Second, a retry wrapper for commands that fail intermittently, such as deploys or downloads:
retry() {
local attempts="$1"
shift
local n=1
until "$@"; do
if (( n >= attempts )); then
echo "giving up after $n attempts: $*" >&2
return 1
fi
echo "attempt $n failed, retrying in ${n}s" >&2
sleep "$n"
n=$(( n + 1 ))
done
}
retry 3 ./deploy.sh production
The until "$@" line runs whatever command you passed, arguments intact, and keeps looping while it fails. Because until tests the command directly, set -e doesn't abort the script on the failed attempts.
Common mistakes in bash scripts#
These account for most of the bugs in scripts that "worked on my machine":
- Unquoted variables.
rm $filewith a filename containing a space deletes the wrong things. Quote every expansion unless you specifically want word splitting. - Spaces around
=.count = 5runs a command namedcountwith two arguments. Writecount=5. - Using
[ ]with an empty variable.[ $name == bob ]becomes[ == bob ]whennameis empty and errors out. Use[[ ]], or quote inside[ ]. - Globs that match nothing. Without
nullglobyour loop processes a literal*.log. Setshopt -s nullgloborshopt -s failglobat the top. - Parsing
lsoutput. Filenames can contain spaces and newlines. Loop over a glob instead. - Changing variables inside a pipeline. In
cat file | while read -r line; do count=$((count+1)); done, the loop runs in a subshell andcountis unchanged afterward. Redirect the file into the loop with< file, or setshopt -s lastpipe, which runs the last command of a pipeline in the current shell when job control is off, as it is in scripts. - Relying on
echofor escapes. Whetherechointerprets\ndepends on thexpg_echooption and on which shell is running.printf '%s\n' "$value"behaves the same everywhere. - Running with
sh.sh script.shignores the shebang and may use a shell where[[, arrays, and${var,,}don't exist. Use./script.shorbash script.sh. - Shifting past the end.
shiftwith no arguments left fails without printing anything.shopt -s shift_verbosemakes it print an error, which helps when debugging argument parsing.
Here's a toy check for mistake number one. It flags a $var that sits outside double quotes on a line. It's deliberately naive; the real tool for this job is ShellCheck, which catches all nine of the mistakes above and many more.
const lines = [
'if [ $count -gt 3 ]; then echo many; fi',
'if [ "$count" -gt 3 ]; then echo many; fi',
'cp $src $dest',
'cp "$src" "$dest"',
'for f in "$@"; do echo "$f"; done',
];
function hasBareVariable(line) {
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') inQuotes = !inQuotes;
const next = line[i + 1] || '';
if (ch === '$' && !inQuotes && /[A-Za-z_@{]/.test(next)) return true;
}
return false;
}
for (const line of lines) {
console.log((hasBareVariable(line) ? 'WARN' : 'ok ') + ' ' + line);
}
Quick reference#
- Start with
#!/usr/bin/env bashandset -euo pipefail. Addshopt -s inherit_errexitif you use command substitution. - Quote every expansion:
"$var","$@","$(cmd)". - Default missing values with
${var:-default}. Require them with${var:?message}. - Prefer
[[ ]]for tests and(( ))for arithmetic. - Put
shopt -s nullglobbefore looping over globs. Addglobstarfor recursive patterns anddotglobfor hidden files. - Read files with
while IFS= read -r line; do ...; done < file. - Use
localinside functions, and usereturnonly for status. Print data instead. - Send errors to stderr with
>&2and exit non-zero on failure. - Clean up temporary files with
trap cleanup EXIT. - Put
--before filenames inrm,mv, andcp. - Run ShellCheck before you commit.
Quick answers
Where can I find bash script examples on GitHub?
Search GitHub for install scripts, dotfiles repos, and project Makefiles that call shell scripts; they show real-world argument handling and error checks. Read anything before you run it, and run ShellCheck on it first.
What's the difference between a shell script and a bash script?
A shell script is any script run by a Unix shell. A bash script is one written for Bash specifically, which adds features like [[ ]], arrays, and ${var,,} that plain sh doesn't have. If your shebang says bash, use Bash features freely.
Which shell script examples should I practice for an interview?
Be able to write, from memory, a script that validates arguments, loops over files safely with nullglob, reads a file line by line, and exits non-zero on failure. Expect questions about why set -e doesn't catch a failing command inside $(...) and how quoting affects word splitting.
How do I practice shell scripting with real automation tasks?
Pick a task you repeat by hand, such as renaming files, rotating logs, or checking that required tools are installed, and script it with a dry-run flag. Start from the safe-defaults template above and add one feature at a time.
Is there a PDF of shell script examples I can download?
I'd skip PDFs and bookmark the GNU Bash Reference Manual instead, since it's updated with each Bash release and covers every construct used here. For quick lookups, the bash(1) man page is the definitive reference.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note