skip to content

Bash — Shell Scripting Reference

Comprehensive Bash scripting reference covering variables, parameter expansion, control flow, functions, arrays, string manipulation, arithmetic, traps, process substitution, and the Bash 5.3 in-shell command substitution forms.

17 min read 107 snippets deep dive

Bash — Shell Scripting Reference#

What it is#

Bash (Bourne Again SHell) is the default interactive shell on most Linux distributions and macOS, and the de-facto standard scripting language for Unix system automation. It is maintained by the GNU Project and released under the GPL. Reach for Bash when you need to orchestrate other programs, automate file operations, or write portable glue scripts; for complex data manipulation, arithmetic-heavy logic, or anything that benefits from data structures, prefer Python or another full language. Redirection and pipes are covered separately in the Bash Redirection & Pipes cheat sheet.

The current stable release is Bash 5.3 (July 2025), which introduces fork-free in-shell command substitution (${ cmd; } and ${| cmd; }), the GLOBSORT variable, read -E with Readline completion, and C23 conformance. Examples below note the minimum required version where a feature post-dates Bash 4. macOS still ships Bash 3.2 by default; install Bash 5 via Homebrew (brew install bash) to use anything from Bash 4 onward.

Configuration#

Bash reads a different set of startup files depending on whether it is invoked as a login shell, an interactive non-login shell, or non-interactively (a script). The wrong file can run twice, or not at all, when a user logs in through a desktop manager versus a terminal — knowing the order is the first step in debugging “my alias / PATH / prompt isn’t loading” problems.

FileRead by
/etc/profileLogin shells (system-wide)
/etc/profile.d/*.shSourced from /etc/profile
/etc/bash.bashrcInteractive shells (Debian/Ubuntu only; not POSIX-standard)
~/.bash_profileLogin shells — preferred over .profile if present
~/.bash_loginLogin shells — fallback if .bash_profile is missing
~/.profileLogin shells — fallback if neither of the above exists
~/.bashrcInteractive non-login shells
~/.bash_logoutRead when a login shell exits
~/.inputrcReadline key-bindings and completion options
~/.bash_historyCommand history (path controlled by $HISTFILE)
$BASH_ENVNon-interactive shells source the file this points to
$ENVPOSIX-mode non-interactive shells source this file

Output: (none — file reference table)

# Common pattern: source ~/.bashrc from ~/.bash_profile so both
# login and interactive shells get the same environment.
# Put in ~/.bash_profile:
[[ -f ~/.bashrc ]] && source ~/.bashrc

# Inspect which startup files actually fired (Bash 5.0+)
bash -lic 'echo "$BASH_SOURCE"; shopt'

# Show current shell's effective options
shopt | grep on$
set -o

Output:

autocd          off
cdable_vars     off
checkwinsize    on
# History-related variables — usually set in ~/.bashrc
HISTSIZE=10000          # in-memory history entries
HISTFILESIZE=20000      # entries kept in ~/.bash_history
HISTCONTROL=ignoreboth  # ignore dups + commands starting with space
HISTTIMEFORMAT='%F %T ' # timestamp every history entry
shopt -s histappend     # append on exit instead of overwriting

Output: (none — configuration assignments)

# Useful ~/.inputrc settings (Readline, used by Bash interactively)
set completion-ignore-case on        # case-insensitive tab-completion
set show-all-if-ambiguous on         # list matches on first Tab
set colored-stats on                 # color completion list by file type
set bell-style none                  # silence the terminal bell
"\e[A": history-search-backward      # Up-arrow: search history by prefix
"\e[B": history-search-forward

Output: (none — Readline configuration)

Variables and assignment#

A variable assignment in Bash must have no spaces around the =. Variable names are case-sensitive. Unquoted variable references undergo word-splitting and glob expansion; always double-quote "$var" unless you explicitly want splitting.

name="Alice Dev"
count=42
is_ready=true      # Bash has no native boolean; this is just a string

echo "$name"
echo "${count}"
echo "Ready: $is_ready"

Output:

Alice Dev
42
Ready: true
# readonly — cannot be reassigned
readonly CONFIG_DIR="/etc/myapp"

# Declare with type attributes
declare -i num=10      # integer — arithmetic on assignment
declare -r LIMIT=100   # readonly
declare -l lower="ABC" # auto-lowercase
declare -u upper="abc" # auto-uppercase

echo "$lower $upper"

Output:

abc ABC
# Unset a variable
unset name

# Check if a variable is set
if [[ -v name ]]; then echo "set"; else echo "unset"; fi

Output:

unset

Parameter expansion#

Parameter expansion transforms a variable’s value inline, without invoking a subshell. The ${…} syntax provides defaults, substring extraction, search-and-replace, case conversion, and more.

url="https://example.com/path/file.tar.gz"

echo "${url#*/}"       # remove shortest prefix match */>
echo "${url##*/}"      # remove longest  prefix match — basename
echo "${url%.*}"       # remove shortest suffix match .* — strip last ext
echo "${url%%.*}"      # remove longest  suffix match — strip all dots onward
echo "${url/path/dest}" # replace first occurrence
echo "${url//e/E}"     # replace all occurrences
echo "${url:8:11}"     # substring: offset 8, length 11
echo "${#url}"         # string length

Output:

/example.com/path/file.tar.gz
file.tar.gz
https://example.com/path/file.tar
https://example
https://example.com/dest/file.tar.gz
https://ExamplE.com/path/filE.tar.gz
example.com
35
# Defaults and fallbacks
unset PORT
echo "${PORT:-8080}"       # use 8080 if PORT is unset or empty
echo "${PORT:=8080}"       # assign 8080 AND use it
echo "${PORT:?must be set}" # error and exit if unset/empty
echo "${PORT:+override}"   # use "override" only if PORT is set

Output:

8080
8080
8080
override
# Case conversion (Bash 4+)
word="hello world"
echo "${word^}"    # Capitalise first char
echo "${word^^}"   # All uppercase
echo "${word,}"    # Lowercase first char
echo "${word,,}"   # All lowercase

Output:

Hello world
HELLO WORLD
hello world
hello world

Special variables#

Special variables are set by the shell and give access to positional parameters, process state, and the last command’s status. They cannot be assigned directly (except $0 via exec).

$0    # Name / path of the current script
$1$9   # Positional parameters (script/function arguments)
${10}     # Positional parameters with index ≥ 10
$#    # Number of positional parameters
$@    # All positional parameters as separate words (quote-safe)
$*    # All positional parameters as a single word (joined by $IFS)
$?    # Exit status of the last foreground command
$$    # PID of the current shell
$!    # PID of the most recently backgrounded job
$_    # Last argument of the previous command
$IFS  # Internal field separator (default: space, tab, newline)
$LINENO  # Current line number in the script
$RANDOM  # Pseudo-random integer 0–32767 (reseeded each access)
$SECONDS # Seconds since the shell started
$BASH_VERSION  # Bash version string
$BASHPID  # PID of the current Bash process (differs from $$ in subshells)
$BASH_SOURCE  # Array of source files in the call stack
$FUNCNAME     # Array of function names in the call stack
$EPOCHSECONDS # Unix timestamp in seconds (Bash 5.0+)
$EPOCHREALTIME # Unix timestamp with microsecond precision (Bash 5.0+, improved 5.3)
$GLOBSORT     # Sort order for pathname expansion (Bash 5.3+: name/size/mtime/numeric/none)

Output: (none — variable reference table)

#!/usr/bin/env bash
# Example: show positional parameters
echo "Script: $0"
echo "Args  : $#"
echo "All   : $@"
echo "First : $1"

Output:

Script: ./myscript.sh
Args  : 3
All   : foo bar baz
First : foo

Control flow#

if / elif / else#

The if statement evaluates a command’s exit code; [[ … ]] is the preferred Bash test construct (vs. the POSIX [ … ]). Use -eq/-lt/-gt for integers and ==/!=/</> inside [[ ]] for strings.

score=85

if [[ $score -ge 90 ]]; then
    echo "A"
elif [[ $score -ge 80 ]]; then
    echo "B"
elif [[ $score -ge 70 ]]; then
    echo "C"
else
    echo "F"
fi

Output:

B
# File tests
file="/etc/hosts"

if [[ -f "$file" ]]; then
    echo "regular file"
elif [[ -d "$file" ]]; then
    echo "directory"
elif [[ -L "$file" ]]; then
    echo "symlink"
fi

# Common file test operators:
# -e  exists          -f  regular file   -d  directory
# -r  readable        -w  writable       -x  executable
# -s  size > 0        -L  symlink        -z  string is empty
# -n  string non-empty

Output:

regular file

case#

case matches a value against patterns using glob syntax. It is cleaner than a long if/elif chain when branching on a string or command output.

day=$(date +%u)   # 1=Mon … 7=Sun

case "$day" in
    1|2|3|4|5) echo "Weekday" ;;
    6)          echo "Saturday" ;;
    7)          echo "Sunday" ;;
    *)          echo "Unknown" ;;
esac

Output:

Weekday
# Match on file extension
file="archive.tar.gz"
case "$file" in
    *.tar.gz)  echo "gzipped tar" ;;
    *.tar.bz2) echo "bzip2 tar"  ;;
    *.zip)     echo "zip archive" ;;
    *)         echo "other"       ;;
esac

Output:

gzipped tar

Loops#

for loops#

The C-style for loop and the word-list for loop are the two main forms. Use {start..end} for integer sequences and $(command) for iterating over command output.

# Word-list for loop
for color in red green blue; do
    echo "$color"
done

Output:

red
green
blue
# Brace expansion sequence
for i in {1..5}; do
    echo "Step $i"
done

Output:

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

Output:

i=0
i=1
i=2
# Iterate over files
for f in /etc/*.conf; do
    echo "Config: $f"
done

Output:

Config: /etc/hosts.conf
Config: /etc/nsswitch.conf
# Bash 5.3+: control glob sort order via $GLOBSORT
# Format: [+-]key   where + is ascending (default) and - is descending
# Keys: name, size, mtime, atime, ctime, blocks, numeric, none
GLOBSORT=-mtime              # newest files first
for f in *.log; do echo "$f"; done

GLOBSORT=size                # smallest first
ls -- *.log                  # built-in expansion is also sorted by $GLOBSORT

unset GLOBSORT               # restore default (alphabetical by name)

Output:

2026-05-25.log
2026-05-24.log
2026-05-23.log

while and until#

while runs while its condition is true; until runs until its condition is true (i.e. while it is false). Both support break and continue.

count=0
while [[ $count -lt 3 ]]; do
    echo "count=$count"
    ((count++))
done

Output:

count=0
count=1
count=2
# Read lines from a file
while IFS= read -r line; do
    echo ">> $line"
done < /etc/hostname

Output:

>> myhost
# Read lines from a command
while IFS= read -r line; do
    echo "user: $line"
done < <(getent passwd | cut -d: -f1 | head -3)

Output:

user: root
user: daemon
user: bin

Functions#

A Bash function is a named block of commands that can accept positional parameters and return an integer exit code via return. Functions share the parent shell’s environment unless local is used. Variables declared local are scoped to the function and its callees.

greet() {
    local name="$1"
    local greeting="${2:-Hello}"
    echo "$greeting, $name!"
}

greet "Alice Dev"
greet "Alice Dev" "Hi"

Output:

Hello, Alice Dev!
Hi, Alice Dev!
# Return a value via stdout capture
add() {
    echo $(( $1 + $2 ))
}

result=$(add 3 7)
echo "Sum: $result"

Output:

Sum: 10
# Return success/failure for use in if
file_exists() {
    [[ -f "$1" ]]
}

if file_exists "/etc/hosts"; then
    echo "found"
fi

Output:

found
# Variadic function using $@
sum_all() {
    local total=0
    for n in "$@"; do
        (( total += n ))
    done
    echo "$total"
}

sum_all 1 2 3 4 5

Output:

15

Arrays#

Bash supports both indexed arrays (array[0], array[1], …) and associative arrays (dict[key]). Associative arrays require declare -A. Arrays are zero-indexed; negative indices count from the end (Bash 4.3+).

# Indexed array
fruits=("apple" "banana" "cherry")

echo "${fruits[0]}"      # first element
echo "${fruits[-1]}"     # last element
echo "${fruits[@]}"      # all elements
echo "${#fruits[@]}"     # number of elements
echo "${!fruits[@]}"     # all indices

Output:

apple
cherry
apple banana cherry
3
0 1 2
# Append, modify, delete
fruits+=("date")
fruits[1]="blueberry"
unset fruits[2]

echo "${fruits[@]}"

Output:

apple blueberry date
# Array slice: ${array[@]:offset:length}
nums=(10 20 30 40 50)
echo "${nums[@]:1:3}"

Output:

20 30 40
# Associative array (Bash 4+)
declare -A config
config[host]="myhost"
config[port]="5432"
config[db]="appdb"

echo "${config[host]}:${config[port]}/${config[db]}"
echo "Keys  : ${!config[@]}"
echo "Values: ${config[@]}"

Output:

myhost:5432/appdb
Keys  : host port db
Values: myhost 5432 appdb

String manipulation#

Bash provides built-in string operations through parameter expansion. For complex regex matching and capture, use =~ inside [[ ]] and access matches via $BASH_REMATCH.

str="  Hello, World!  "

# Trim leading whitespace
echo "${str#"${str%%[! ]*}"}"

# Length
echo "${#str}"

# Substring: ${var:offset:length}
echo "${str:2:5}"

# Replace
echo "${str/World/Alice Dev}"

# Upper/lowercase (Bash 4+)
echo "${str^^}"
echo "${str,,}"

Output:

Hello, World!  
18
Hello
  Hello, Alice Dev!  
  HELLO, WORLD!  
  hello, world!  
# Regex match with capture groups
text="2026-05-04"
if [[ "$text" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then
    echo "Year : ${BASH_REMATCH[1]}"
    echo "Month: ${BASH_REMATCH[2]}"
    echo "Day  : ${BASH_REMATCH[3]}"
fi

Output:

Year : 2026
Month: 05
Day  : 04
# Split a string into an array using IFS
IFS=',' read -ra parts <<< "one,two,three"
echo "${parts[1]}"
echo "${parts[@]}"

Output:

two
one two three

Arithmetic#

Bash integer arithmetic uses (( … )) for statements and $(( … )) for expressions. Floating-point requires an external tool like bc or awk.

a=10
b=3

echo $(( a + b ))
echo $(( a - b ))
echo $(( a * b ))
echo $(( a / b ))    # integer division (truncates)
echo $(( a % b ))    # modulo
echo $(( a ** b ))   # exponentiation (right-associative)

Output:

13
7
30
3
1
1000
# Increment / decrement
(( a++ ))
(( b-- ))
(( a += 5 ))

echo "$a $b"

Output:

16 2
# Conditional arithmetic: (( expr )) returns 0 (true) if expr != 0
if (( a > 10 )); then echo "big"; fi

Output:

big
# Floating-point with bc
result=$(echo "scale=4; 22/7" | bc)
echo "$result"

Output:

3.1428

Process substitution#

Process substitution (<(command)) presents the stdout of a command as a temporary file path, allowing commands that require file arguments to consume live command output. It differs from a pipe in that both sides run concurrently and you can use it in file-position arguments. >(command) is the write-side variant.

# diff two command outputs without temp files
diff <(sort /etc/passwd) <(sort /etc/group)

Output:

(diff output between sorted passwd and group files)
# Read from a command in a while loop (avoids a subshell, unlike pipe)
while IFS= read -r line; do
    echo ">> $line"
done < <(ls /etc/*.conf 2>/dev/null)

Output:

>> /etc/hosts.conf
>> /etc/nsswitch.conf
# Tee to multiple files and a command simultaneously
echo "log line" | tee >(gzip > log.gz) >(wc -c > size.txt)

Output:

log line

In-shell command substitution (Bash 5.3+)#

Classic $(command) and backtick `command` substitution always forks a subshell. Bash 5.3 (July 2025) adds two new forms that execute in the current shell — no fork, no pipe — so variable assignments, cd, and other side effects persist after the substitution completes. The variant with output capture (${ cmd; }) is dramatically faster than $(cmd) in tight loops, and the REPLY variant (${| cmd; }) is ideal for helper functions that “return” a value without the noise of a subshell.

# Output-capturing form: ${ command; } — note the leading space and trailing ;
# Captures stdout of command, just like $(command), but in the current shell.
count=${ wc -l < /etc/passwd; }
echo "users: $count"

Output:

users: 42
# REPLY form: ${| command; } — command runs in the current shell and
# expands to whatever it leaves in $REPLY. No stdout capture, no fork.
get_user() {
    REPLY="alice"
}
echo "Hello, ${| get_user; }"

Output:

Hello, alice
# Side effects persist — unlike $(cd /tmp && pwd), this cd actually moves the
# parent shell. Useful for shell-level helpers; use with care in scripts.
pwd
result=${ cd /tmp && pwd; }
echo "result: $result"
pwd

Output:

/home/alice
result: /tmp
/tmp
# Performance win: tight-loop fork avoidance
# $(date +%s) forks once per iteration; ${ … ; } does not.
for i in {1..1000}; do
    now=${ printf '%(%s)T\n' -1; }
done
echo "$now"

Output:

1721059200

Traps and signals#

trap registers a command to run when the shell receives a signal or exits. Use EXIT to guarantee cleanup code runs regardless of how the script terminates — normal exit, error, or signal.

# Cleanup on exit
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT

echo "working with $tmpfile"
# tmpfile is always deleted when the script exits

Output:

working with /tmp/tmp.XXXXXX
# Catch Ctrl+C (SIGINT)
trap 'echo "Interrupted!"; exit 130' INT

for i in {1..10}; do
    echo "step $i"
    sleep 1
done

Output:

step 1
step 2
Interrupted!
# ERR trap — run on any non-zero exit code
set -euo pipefail
trap 'echo "Error on line $LINENO (exit $?)"' ERR

false   # triggers ERR

Output:

Error on line 4 (exit 1)
# Common signal names
trap 'handler' EXIT     # script exit (any cause)
trap 'handler' INT      # Ctrl+C
trap 'handler' TERM     # kill / system shutdown
trap 'handler' HUP      # terminal disconnect
trap 'handler' ERR      # non-zero exit (with set -e)
trap '' INT             # ignore Ctrl+C
trap - INT              # restore default INT behaviour

Output: (none — trap definitions)

Script safety options#

set -e (exit on error), set -u (treat unset variables as errors), and set -o pipefail (propagate pipe failures) are the standard “safe mode” options for production scripts.

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

# -e : exit immediately on error
# -u : treat unset variables as errors
# -o pipefail : pipeline fails if any stage fails

echo "Running safely"
# Unset variable reference would now cause an error:
# echo "$UNDEFINED"   # → would exit with "unbound variable"

Output:

Running safely
# Enable xtrace for debugging
set -x        # print each command before executing
ls /tmp
set +x        # turn off xtrace

Output:

+ ls /tmp
<list of files>

Common pitfalls#

  1. Unquoted variablesrm $file splits on spaces and expands globs; always write rm "$file".
  2. [ ] vs [[ ]][[ ]] is Bash-specific and safer (no word-splitting, supports &&/||/=~); use [ ] only when you need POSIX portability.
  3. == vs -eq== compares strings; -eq compares integers. [[ "10" == "10.0" ]] is false; (( 10 == 10 )) is true.
  4. Pipe subshells — variables set inside a pipe stage are lost after the pipe: echo "x" | read val; echo "$val" prints nothing. Use process substitution or lastpipe option instead.
  5. Integer division$(( 7 / 2 )) is 3, not 3.5; use bc or awk for floats.
  6. $* vs $@ — always use "$@" to forward arguments; $* joins all args into one string split by $IFS.
  7. set -e and functionsset -e does not trigger inside an if condition; a function called in an if can fail silently.
  8. Associative arrays require Bash 4 — macOS ships with Bash 3.2; install Bash 5 via Homebrew if you need declare -A. The same applies to ${ cmd; } (Bash 5.3+), GLOBSORT (5.3+), EPOCHSECONDS (5.0+), ${var^^} (4.0+), and negative array indices (4.3+).
  9. #!/usr/bin/env bash vs #!/bin/bash/bin/bash is 3.2 on macOS and may not exist on Alpine/BusyBox systems. Use #!/usr/bin/env bash to pick up whichever Bash is first in $PATH (e.g. the Homebrew 5.x build at /opt/homebrew/bin/bash).

Real-world recipes#

Robust script header#

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

Output: (none — header boilerplate; sets up safety options and cleanup)

Parse flags with getopts#

usage() { echo "Usage: $0 [-v] [-o output] file" >&2; exit 1; }

verbose=false
output="result.txt"

while getopts ":vo:" opt; do
    case "$opt" in
        v) verbose=true ;;
        o) output="$OPTARG" ;;
        :) echo "Option -$OPTARG requires an argument" >&2; usage ;;
        ?) echo "Unknown option: -$OPTARG" >&2; usage ;;
    esac
done
shift $(( OPTIND - 1 ))

[[ $# -lt 1 ]] && usage
echo "Processing $1$output (verbose=$verbose)"

Output:

Processing input.txt → result.txt (verbose=false)

Retry with backoff#

Run a command up to N times with exponential backoff between attempts.

retry() {
    local attempts=$1 delay=1
    shift
    for ((i=1; i<=attempts; i++)); do
        "$@" && return 0
        echo "Attempt $i/$attempts failed. Retrying in ${delay}s…" >&2
        sleep "$delay"
        (( delay *= 2 ))
    done
    return 1
}

retry 4 curl -sf "https://example.com/api/health"

Output:

Attempt 1/4 failed. Retrying in 1s…
Attempt 2/4 failed. Retrying in 2s…
(succeeds on attempt 3 — returns 0)

Find and process files safely#

Use find with -print0 and read -d '' to handle filenames with spaces or special characters.

while IFS= read -r -d '' file; do
    echo "Processing: $file"
    wc -l "$file"
done < <(find /var/log -name "*.log" -size +1M -print0)

Output:

Processing: /var/log/syslog
  42387 /var/log/syslog
Processing: /var/log/kern.log
   8904 /var/log/kern.log

Parallel jobs with wait#

Run multiple background jobs and collect all their exit codes.

pids=()

for host in myhost web1 web2; do
    ping -c1 -W1 "$host" &>/dev/null && echo "$host up" || echo "$host down" &
    pids+=("$!")
done

failed=0
for pid in "${pids[@]}"; do
    wait "$pid" || (( failed++ ))
done

echo "Done. Failures: $failed"

Output:

myhost up
web1 down
web2 up
Done. Failures: 1

Sources#