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 withbash, 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!
"$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
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 | truewould succeed.
-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$varis 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 = 5calls a command namedx. Assignments have no spaces:x=5. - Forgetting
set -euo pipefail: Without these, errors are silently swallowed and scripts continue in a broken state. - Using
lsto iterate files: Use globs (*.txt) orfind+while readfor robust file iteration.
Want to schedule and automate task using these scripts? Check out the article on cron job here