Introduction

Bash is everywhere. Any Linux or macOS machine has it, and it's the default glue language for sysadmin work, CI pipelines, Docker entrypoints, and deployment scripts. The question isn't whether you'll write Bash, you will, it's whether the scripts you write will be readable and reliable six months later.

That said, Bash isn't always the right tool. A good rule of thumb:

  • Use Bash when you're mostly gluing together other programs, running commands, moving files, piping output. Scripts under ~200 lines that live in a repo and don't need complex data structures are good candidates.
  • Use Python (or Go) when you need real data structures, HTTP calls, JSON parsing, or anything where the logic is the hard part rather than the I/O.

This article covers Bash from the basics up to patterns you'd use in a real production script. Each section is self-contained, so feel free to skip to whatever you need.

The Shebang & Script Basics

Every script should start with a shebang, the #! line that tells the kernel which interpreter to use.

#!/usr/bin/env bash

Prefer #!/usr/bin/env bash over #!/bin/bash. The env form searches your $PATH, which means the script works correctly when Bash is installed in a non-standard location (common on macOS with Homebrew, or in certain Docker images).

Making a script executable

chmod +x script.sh   # make executable
./script.sh          # run it directly

Three ways to run a script

  • ./script.sh โ€”> runs in a new subshell using the shebang interpreter. Most common.
  • bash script.sh โ€”> explicitly runs with bash, ignoring the shebang. Useful for testing.
  • source script.sh (or . script.sh) โ€”> runs in the current shell. Environment changes (variable exports, cd) affect your current session. Used for loading config or function libraries.

Comments and basic structure

#!/usr/bin/env bash
# This is a comment. Everything after # on a line is ignored.

# Inline comment โ€” avoid putting them mid-command; they harm readability.
echo "hello"  # this prints hello

# A minimal script usually looks like:
#   1. shebang
#   2. set flags (see error handling section)
#   3. global variables / constants
#   4. function definitions
#   5. main logic (often wrapped in a main() function)

Variables & Data Types

Bash variables are untyped strings by default. There's no int or bool, everything is a string, though arithmetic operators treat values as integers.

Assignment and referencing

name="Alice"        # No spaces around =
echo $name          # Alice
echo ${name}        # Alice (braces are clearer, required in some contexts)

greeting="Hello, ${name}!"
echo "$greeting"    # Hello, Alice!
Rule: Always quote your variable references: "$var" not $var. Unquoted variables are split on whitespace and glob-expanded, which causes subtle bugs with filenames or empty values.

Single vs double quotes

echo "My name is $name"   # double quotes: variables are expanded โ†’ My name is Alice
echo 'My name is $name'   # single quotes: no expansion โ†’ My name is $name

Command substitution

today=$(date +%Y-%m-%d)     # preferred: $()
files=$(ls *.sh)

# Old-style backticks work but don't nest well โ€” avoid them
today=`date +%Y-%m-%d`

Arithmetic

x=5
y=3
sum=$(( x + y ))      # 8
echo $(( x * y ))     # 15
echo $(( x % y ))     # 2 (modulo)
(( x++ ))             # increment x in place
echo $x               # 6

Special variables

  • $0 โ€” name of the script
  • $1 โ€ฆ $9 โ€” positional arguments
  • ${10}, ${11} โ€ฆ โ€” arguments beyond 9 need braces
  • $@ โ€” all arguments as separate words (quote it: "$@")
  • $* โ€” all arguments as a single word (rarely what you want)
  • $# โ€” number of arguments
  • $? โ€” exit status of the last command (0 = success)
  • $$ โ€” PID of the current shell
  • $! โ€” PID of the last background process

Variable modifiers

readonly PI=3.14159      # can't be reassigned
export DB_HOST="localhost"  # available to child processes

function greet() {
  local msg="hi"   # local: only visible inside this function
  echo "$msg"
}

Control Flow: if, case, loops

if / elif / else

Bash's if tests the exit code of a command, 0 is true, anything else is false.

if [[ "$1" == "hello" ]]; then
  echo "You said hello"
elif [[ "$1" == "bye" ]]; then
  echo "Goodbye"
else
  echo "I don't know what you said"
fi

[ ] vs [[ ]] vs (( ))

  • [ ] โ€” POSIX test command. Works in all shells. Doesn't support &&, ||, regex, or unquoted empty variables safely.
  • [[ ]] โ€” Bash built-in. Safer, more powerful. Supports &&, ||, =~ (regex match), pattern matching. Prefer this in Bash scripts.
  • (( )) โ€” arithmetic evaluation. Returns 0 (true) if the expression is non-zero.
# String tests
[[ -z "$var" ]]       # true if empty
[[ -n "$var" ]]       # true if non-empty
[[ "$a" == "$b" ]]    # string equality
[[ "$a" != "$b" ]]    # string inequality
[[ "$str" =~ ^[0-9]+$ ]]  # regex match (no quotes on pattern)

# Numeric tests
[[ "$n" -eq 5 ]]      # equal
[[ "$n" -lt 10 ]]     # less than
[[ "$n" -ge 0 ]]      # greater than or equal
(( n > 0 ))           # arithmetic, also valid

# File tests
[[ -f "$path" ]]      # is a regular file
[[ -d "$path" ]]      # is a directory
[[ -e "$path" ]]      # exists (any type)
[[ -r "$path" ]]      # readable
[[ -x "$path" ]]      # executable
[[ -s "$path" ]]      # non-empty file

case

case "$action" in
  start)
    echo "Starting..."
    ;;
  stop|quit)          # multiple patterns with |
    echo "Stopping..."
    ;;
  restart)
    echo "Restarting..."
    ;;
  *)                  # default / fallthrough
    echo "Unknown action: $action"
    exit 1
    ;;
esac

for loops

# Iterate over a list
for fruit in apple banana cherry; do
  echo "I like $fruit"
done

# Iterate over files (glob)
for file in *.log; do
  echo "Processing $file"
done

# C-style for loop
for (( i = 0; i < 5; i++ )); do
  echo "i = $i"
done

# Iterate over an array
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
  echo "$fruit"
done

while and until

# while: loop while condition is true
count=0
while (( count < 5 )); do
  echo "$count"
  (( count++ ))
done

# Read lines from a file
while IFS= read -r line; do
  echo "Line: $line"
done < input.txt

# until: loop until condition becomes true
until ping -c1 example.com &>/dev/null; do
  echo "Waiting for network..."
  sleep 2
done

break and continue

for i in 1 2 3 4 5; do
  [[ "$i" -eq 3 ]] && continue  # skip 3
  [[ "$i" -eq 5 ]] && break     # stop at 5
  echo "$i"
done
# Output: 1 2 4

Functions

Functions let you name and reuse blocks of code. In Bash, functions must be defined before they're called.

Defining functions

# Two valid syntaxes โ€” pick one and be consistent:
greet() {
  echo "Hello, $1!"
}

function greet {   # 'function' keyword is optional
  echo "Hello, $1!"
}

greet "World"      # Hello, World!

Local variables

name="global"

say_name() {
  local name="local"   # shadows the global variable
  echo "Inside: $name"
}

say_name            # Inside: local
echo "Outside: $name"  # Outside: global
Always use local for variables inside functions. Without it, every assignment is global and will clobber variables in the calling scope.

Return values vs output capture

Bash functions can't return arbitrary values, return only sets an exit code (0โ€“255). To pass real data back, print it and capture with $().

# Exit code approach (for success/failure)
is_even() {
  (( $1 % 2 == 0 ))  # returns 0 if even, 1 if odd
}

if is_even 4; then echo "even"; fi

# Output capture approach (for data)
get_timestamp() {
  date +%Y%m%d_%H%M%S
}

ts=$(get_timestamp)
echo "Timestamp: $ts"

Recursive functions

factorial() {
  local n=$1
  if (( n <= 1 )); then
    echo 1
  else
    local sub
    sub=$(factorial $(( n - 1 )))
    echo $(( n * sub ))
  fi
}

factorial 5   # 120

Sourcing function libraries

# lib/utils.sh
log() {
  echo "[$(date '+%H:%M:%S')] $*"
}

# main.sh
source "$(dirname "$0")/lib/utils.sh"
log "Script started"

Argument Parsing

Manual parsing with shift and case

For simple scripts, manual parsing is often the clearest approach:

#!/usr/bin/env bash
usage() {
  echo "Usage: $0 [-v] [-o output_file] input_file"
  exit 1
}

verbose=false
output=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose)
      verbose=true
      shift
      ;;
    -o|--output)
      output="$2"
      shift 2
      ;;
    -h|--help)
      usage
      ;;
    -*)
      echo "Unknown option: $1" >&2
      usage
      ;;
    *)
      input="$1"
      shift
      ;;
  esac
done

[[ -z "$input" ]] && { echo "Error: input_file required" >&2; usage; }

$verbose && echo "Verbose mode on"
echo "Input: $input, Output: ${output:-stdout}"

getopts (built-in, short flags only)

while getopts ":vo:h" opt; do
  case "$opt" in
    v) verbose=true ;;
    o) output="$OPTARG" ;;
    h) usage ;;
    :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
    \?) echo "Unknown option: -$OPTARG" >&2; exit 1 ;;
  esac
done
shift $(( OPTIND - 1 ))   # remove parsed options, leaving positional args

getopts is POSIX-portable and handles option arguments via $OPTARG. It does not support long options like --output. For those, use the manual case approach above or the external getopt command.

Error Handling & set Flags

By default, Bash blithely continues past errors. A typo'd command or a failing pipeline just produces a non-zero exit code that gets silently ignored. These set flags change that.

The essential trio

set -euo pipefail
  • -e (errexit) โ€” exit immediately if any command exits non-zero. Not perfect (see note), but catches most mistakes.
  • -u (nounset) โ€” treat unset variables as an error. Catches typos in variable names.
  • -o pipefail โ€” a pipeline's exit status is the exit status of the last command that failed, not the last command. Without this, false | true would succeed.
Note on -e: it doesn't trigger inside if conditions, &&/|| chains, or ! negations โ€” those are intentionally tested. To allow a command to fail without exiting, append || true: rm -f "$tmp" || true.

set -x (trace mode)

set -x   # print each command and its expanded arguments before running
# ... commands ...
set +x   # turn off tracing

Use set -x at the top of a script while debugging, or wrap just a block of interest. Each line is printed prefixed with +.

trap: cleanup on exit

#!/usr/bin/env bash
set -euo pipefail

TMPDIR=$(mktemp -d)

cleanup() {
  rm -rf "$TMPDIR"
  echo "Cleaned up."
}

trap cleanup EXIT          # runs on any exit (normal or error)
trap cleanup INT TERM      # also catches Ctrl-C and kill signals

# ... do work in $TMPDIR ...
echo "Work done"
# cleanup() runs automatically here

Custom error function

die() {
  echo "Error: $*" >&2
  exit 1
}

[[ -f "$config" ]] || die "Config file not found: $config"

Checking exit codes explicitly

if ! cp "$src" "$dest"; then
  echo "Copy failed" >&2
  exit 1
fi

# Or capture and check
rsync -av "$src/" "$dest/" || { echo "rsync failed"; exit 1; }

Input, Output & Redirection

File descriptors

Every process has three standard streams: stdin (fd 0), stdout (fd 1), and stderr (fd 2). Redirection operators let you control where these streams go.

Redirection operators

command > file        # stdout to file (overwrite)
command >> file       # stdout to file (append)
command 2> file       # stderr to file
command 2>&1          # redirect stderr to wherever stdout goes
command &> file       # stdout and stderr to file (Bash shorthand)
command > /dev/null   # discard stdout
command &> /dev/null  # discard both stdout and stderr
command < file        # stdin from file

Reading user input

read -p "Enter your name: " name
echo "Hello, $name"

# Read with a timeout
read -t 10 -p "Enter value (10s timeout): " val || echo "Timed out"

# Read a password (no echo)
read -s -p "Password: " password
echo  # newline after hidden input

Here-documents (heredoc)

# Write a multi-line string to a command or file
cat > config.yaml <

Pipes and tee

# Pipeline: stdout of one command โ†’ stdin of the next
cat access.log | grep "ERROR" | sort | uniq -c | sort -rn | head -20

# tee: write to a file AND pass through to stdout
./build.sh 2>&1 | tee build.log

String Operations & Parameter Expansion

Bash has a surprisingly powerful set of string manipulation operators built right into variable expansion, no need to call awk or sed for simple cases.

Length, substring

str="Hello, World"

echo ${#str}           # 12  (length)
echo ${str:7}          # World  (from offset 7)
echo ${str:7:5}        # World  (offset 7, length 5)
echo ${str: -5}        # World  (last 5 chars; note the space)

Prefix and suffix stripping

path="/home/alice/notes.txt"

echo ${path#*/}         # home/alice/notes.txt  (strip shortest prefix matching */)
echo ${path##*/}        # notes.txt             (strip longest prefix โ†’ basename)
echo ${path%.*}         # /home/alice/notes     (strip shortest suffix matching .*)
echo ${path%%/*}        # (empty โ€” strips everything after first /)

Find and replace

str="foo bar foo baz"

echo ${str/foo/qux}     # qux bar foo baz  (replace first match)
echo ${str//foo/qux}    # qux bar qux baz  (replace all matches)
echo ${str/#foo/qux}    # qux bar foo baz  (replace only if at start)
echo ${str/%baz/end}    # foo bar foo end  (replace only if at end)

Case conversion (Bash 4+)

str="Hello World"
echo ${str^^}   # HELLO WORLD  (uppercase)
echo ${str,,}   # hello world  (lowercase)
echo ${str^}    # Hello World  (uppercase first char)
echo ${str,}    # hELLO wORLD  (lowercase first char)

Default values

# Use a default if var is unset or empty
echo "${name:-"stranger"}"     # prints "stranger" if $name is empty/unset

# Assign and use a default
echo "${name:="stranger"}"     # assigns "stranger" to $name, then prints it

# Abort with error if var is unset
echo "${name:?"name is required"}"  # exits with error if $name is empty/unset

Arrays & Associative Arrays

Indexed arrays

# Declaration and assignment
fruits=("apple" "banana" "cherry")
declare -a colors       # explicit declaration (optional)
colors[0]="red"
colors[1]="green"
colors+=("blue")        # append

# Access
echo "${fruits[0]}"       # apple
echo "${fruits[-1]}"      # cherry (last element)
echo "${fruits[@]}"       # all elements
echo "${#fruits[@]}"      # 3 (length)
echo "${fruits[@]:1:2}"   # banana cherry (slice: offset 1, length 2)

# Iterate
for fruit in "${fruits[@]}"; do
  echo "$fruit"
done

# Iterate with index
for i in "${!fruits[@]}"; do
  echo "$i: ${fruits[$i]}"
done

# Delete an element
unset 'fruits[1]'

Associative arrays (Bash 4+)

declare -A user
user["name"]="Alice"
user["age"]="30"
user["city"]="Kathmandu"

echo "${user["name"]}"    # Alice
echo "${!user[@]}"        # all keys: name age city
echo "${user[@]}"         # all values

# Iterate key-value pairs
for key in "${!user[@]}"; do
  echo "$key = ${user[$key]}"
done

# Check if key exists
[[ -v user["email"] ]] || echo "email not set"

mapfile / readarray

# Read a file into an array (one element per line)
mapfile -t lines < input.txt
echo "${#lines[@]} lines read"
echo "${lines[0]}"  # first line

# Read command output into an array
mapfile -t users < <(cut -d: -f1 /etc/passwd)

Process Substitution & Subshells

Subshells

Commands inside ( ) run in a subshell, a child process with a copy of the parent's environment. Changes to variables inside don't propagate back.

x=1
( x=99; echo "inside: $x" )   # inside: 99
echo "outside: $x"             # outside: 1  (unchanged)

# Useful for temporary directory changes
( cd /tmp && ls )   # back in original dir after the subgroup

Process substitution

Process substitution lets you treat the output of a command as if it were a file:

# <(cmd) provides cmd's output as a file path (for reading)
diff <(sort file1.txt) <(sort file2.txt)   # compare sorted versions

# Compare two command outputs without temp files
comm <(ls dir1 | sort) <(ls dir2 | sort)

# >(cmd) provides a file path that writes into cmd's stdin
tee >(gzip > output.gz) >(wc -l) > /dev/null < input.txt

Pipeline variable scope trap

A subtle gotcha: in a pipeline, each segment runs in a subshell. Variables set in the last segment of a pipe won't be visible outside.

# This does NOT work โ€” count is set in a subshell
count=0
echo "a b c" | while read word; do (( count++ )); done
echo $count   # still 0!

# Fix: use process substitution instead of a pipe
while read word; do (( count++ )); done < <(echo "a b c")
echo $count   # 3

Script Patterns & Templates

Production script boilerplate

#!/usr/bin/env bash
set -euo pipefail

# โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

# โ”€โ”€ Cleanup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
TMPDIR=""
cleanup() {
  [[ -n "$TMPDIR" ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT

# โ”€โ”€ Logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
log()  { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]  $*"; }
warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]  $*" >&2; }
die()  { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; exit 1; }

# โ”€โ”€ Usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
usage() {
  cat <

Options:
  -o, --output FILE   Output file (default: stdout)
  -v, --verbose       Enable verbose output
  -h, --help          Show this help

Example:
  $SCRIPT_NAME -o out.txt input.txt
EOF
  exit "${1:-0}"
}

# โ”€โ”€ Argument Parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
verbose=false
output=""
input=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    -o|--output)  output="$2"; shift 2 ;;
    -v|--verbose) verbose=true; shift ;;
    -h|--help)    usage 0 ;;
    --)           shift; break ;;
    -*)           die "Unknown option: $1" ;;
    *)            input="$1"; shift ;;
  esac
done

[[ -z "$input" ]] && { warn "No input specified"; usage 1; }

# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
main() {
  TMPDIR=$(mktemp -d)
  log "Starting $SCRIPT_NAME"
  $verbose && log "Verbose mode enabled"

  log "Processing: $input"
  # ... actual work here ...

  log "Done."
}

main "$@"

Idempotent scripts

An idempotent script can be run multiple times safely โ€” it checks state before acting:

# Instead of:
mkdir /opt/myapp

# Do:
[[ -d /opt/myapp ]] || mkdir /opt/myapp

# Instead of:
useradd appuser

# Do:
id appuser &>/dev/null || useradd --system appuser

File locking with flock

# Prevent concurrent runs of the same script
exec 9>/var/lock/myscript.lock
flock -n 9 || die "Another instance is already running"

# ... rest of script ...
# Lock is released automatically when the script exits

Color output

# Only use colors when writing to a terminal
RED=''  GREEN=''  YELLOW=''  RESET=''
if [[ -t 1 ]]; then
  RED='\033[0;31m'
  GREEN='\033[0;32m'
  YELLOW='\033[1;33m'
  RESET='\033[0m'
fi

echo -e "${GREEN}Success${RESET}"
echo -e "${YELLOW}Warning${RESET}"
echo -e "${RED}Error${RESET}"

Debugging Tips

Syntax check without running

bash -n script.sh   # parse and check syntax, but don't execute

Trace mode

bash -x script.sh   # run with trace: each command printed before execution

# Or enable/disable around a specific section:
set -x
cp "$src" "$dest"
set +x

Output lines prefixed with + show executed commands with variables already expanded. Lines with ++ are from inside subshells.

shellcheck

shellcheck is a static analysis tool for shell scripts. It catches quoting errors, deprecated syntax, common anti-patterns, and portability issues. Run it before committing anything.

# Install
brew install shellcheck            # macOS
apt-get install shellcheck         # Debian/Ubuntu

# Use
shellcheck script.sh

# In CI (GitHub Actions example)
- name: Lint shell scripts
  run: shellcheck scripts/*.sh

Selective debug output

# Only print debug info when DEBUG is set
debug() {
  [[ "${DEBUG:-false}" == "true" ]] && echo "[DEBUG] $*" >&2
}

debug "About to process $file"

# Run with debugging:
DEBUG=true ./script.sh

Common pitfalls

  • Word splitting: for f in $(ls) breaks on filenames with spaces. Use globs instead: for f in *.
  • Unquoted variables: if [ $var == "x" ] breaks if $var is empty โ€” the test becomes [ == "x" ]. Always quote: "$var".
  • Pipe subshells: Variables set inside a pipeline aren't visible outside. Use process substitution <() to avoid the subshell.
  • Spaces in assignments: x = 5 calls a command named x. Assignments have no spaces: x=5.
  • Forgetting set -euo pipefail: Without these, errors are silently swallowed and scripts continue in a broken state.
  • Using ls to iterate files: Use globs (*.txt) or find + while read for robust file iteration.

Want to schedule and automate task using these scripts? Check out the article on cron job here