CoreMesh

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.

Bash Script Examples for Everyday Tasks That Fail Safely

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#

bash
#!/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 env finds bash on the PATH instead of hard-coding /bin/bash.
  • set -euo pipefail turns on three safety options. -e exits on the first failing command, -u makes an unset variable an error, and pipefail makes 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 to world when it's missing. Without the fallback, -u would 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.

bash
#!/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:

python
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.

bash
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:

bash
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:

bash
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:

bash
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.

bash
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:

bash
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.

bash
#!/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 -e stops the script at the first failing command, so you don't carry on with half-finished state. Commands tested directly by if, while, until, ||, or && are exempt, which is what you want.
  • set -u turns 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 pipefail makes cmd1 | cmd2 fail when cmd1 fails. Without it, only the last command's status counts.
  • shopt -s inherit_errexit closes a gap in set -e. By default, command substitution runs in a subshell with errexit unset, so result="$(failing_command)" can quietly produce an empty string. With this option set, the subshell inherits the errexit setting.
  • trap cleanup EXIT runs 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:

bash
#!/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:

bash
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":

  1. Unquoted variables. rm $file with a filename containing a space deletes the wrong things. Quote every expansion unless you specifically want word splitting.
  2. Spaces around =. count = 5 runs a command named count with two arguments. Write count=5.
  3. Using [ ] with an empty variable. [ $name == bob ] becomes [ == bob ] when name is empty and errors out. Use [[ ]], or quote inside [ ].
  4. Globs that match nothing. Without nullglob your loop processes a literal *.log. Set shopt -s nullglob or shopt -s failglob at the top.
  5. Parsing ls output. Filenames can contain spaces and newlines. Loop over a glob instead.
  6. Changing variables inside a pipeline. In cat file | while read -r line; do count=$((count+1)); done, the loop runs in a subshell and count is unchanged afterward. Redirect the file into the loop with < file, or set shopt -s lastpipe, which runs the last command of a pipeline in the current shell when job control is off, as it is in scripts.
  7. Relying on echo for escapes. Whether echo interprets \n depends on the xpg_echo option and on which shell is running. printf '%s\n' "$value" behaves the same everywhere.
  8. Running with sh. sh script.sh ignores the shebang and may use a shell where [[, arrays, and ${var,,} don't exist. Use ./script.sh or bash script.sh.
  9. Shifting past the end. shift with no arguments left fails without printing anything. shopt -s shift_verbose makes 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.

javascript
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 bash and set -euo pipefail. Add shopt -s inherit_errexit if 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 nullglob before looping over globs. Add globstar for recursive patterns and dotglob for hidden files.
  • Read files with while IFS= read -r line; do ...; done < file.
  • Use local inside functions, and use return only for status. Print data instead.
  • Send errors to stderr with >&2 and exit non-zero on failure.
  • Clean up temporary files with trap cleanup EXIT.
  • Put -- before filenames in rm, mv, and cp.
  • 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.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

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

`code` and ``` blocks work

No account needed. Comments are read before they show up, usually the same day. Your email is never asked for.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS