Bash — Reference

Source: https://www.gnu.org/software/bash/manual/

Bash

  • Created: 1989 by Brian Fox for the GNU Project (replacement for the Bourne shell, sh)
  • Latest stable: Bash 5.3 (released 2025); manual last updated 2025-07-04. (GNU Bash manual, Chet Ramey NEWS)
  • Paradigms: Imperative shell scripting; command-oriented; pipeline / dataflow via stdin/stdout
  • Typing: Untyped (everything is a string); arrays and associative arrays are first-class collections; arithmetic context coerces to integer
  • Memory: Per-process; no GC needed (short-lived processes); heredocs and large arrays held in memory
  • Compilation: Interpreted; parses and executes commands one at a time; some constructs cached as compound commands
  • Primary domains: Interactive shell on Linux/macOS, init scripts, build glue, CI pipelines, container entrypoints, system administration, ad-hoc text munging, “just enough” automation before reaching for Python
  • Notable runtimes: GNU bash (the reference). Cousins: dash (POSIX-only, faster startup), zsh (interactive superset), ksh (Korn shell, partial bash overlap), busybox sh (embedded)
  • Official docs: https://www.gnu.org/software/bash/manual/

At a glance

Bash (“Bourne-Again SHell”) is the GNU Project’s POSIX-compliant shell that became the de facto Linux/macOS interactive shell and scripting workhorse. It is a programming language whose primitives are processes: every command name resolves to either a builtin, a function, an alias, or an executable file invoked as a subprocess; values flow between them through file descriptors. Beyond simple command invocation, Bash provides arrays (positional and associative since 4.0), sophisticated parameter expansion, process substitution, traps, coprocesses, and arithmetic — enough to write nontrivial programs, with the perpetual caveat that Python or a real language is usually the right answer above ~100 lines.

Getting started

Install:

  • Linux: preinstalled on every distro; bash --version to check.
  • macOS: Apple ships an ancient Bash 3.2 (GPLv2 holdout) at /bin/bash; install current via brew install bash, get /opt/homebrew/bin/bash or /usr/local/bin/bash.
  • Windows: WSL (Windows Subsystem for Linux), Git Bash (msys2), or Cygwin.

Hello world:

#!/usr/bin/env bash
echo "Hello, world!"

chmod +x hello.sh && ./hello.sh. The shebang #!/usr/bin/env bash is portable; #!/bin/bash assumes path.

Project layout: No standard. A typical small project:

myscript/
  bin/myscript          # entrypoint, executable
  lib/util.sh           # sourced helpers
  tests/test_util.bats  # bats-core tests
  README.md

Package/build tool: Nothing native. bpkg and basher are community package managers; almost no one uses them — vendoring scripts or git submodule is more common. For testing: bats-core.

REPL: Just run bash. With set -o vi or set -o emacs for line editing. bash --posix for strict POSIX mode.

Basics

Types & literals. Everything is a string. Arithmetic via (( )) or $(( )) or let. Numeric literals: 42, 0x2a (hex), 010 (octal — careful), 2#1010 (base-N), 1_000 is not a separator (Bash doesn’t support underscore separators).

Variables & scoping.

name="alice"            # NO spaces around =
readonly PI=3.14
declare -i count=0      # integer
declare -a arr=(a b c)  # indexed array
declare -A map=([k]=v)  # associative array (Bash 4+)
local var=# function-local (only inside functions)
export PATH="$PATH:/x"  # exports to child processes
unset name

Default scope is global. local makes a variable function-scoped (and dynamically scoped — visible to called functions, unlike lexical scoping in most languages).

Control flow.

if [[ "$x" == "yes" ]]; then ... elif ... else ... fi
case "$cmd" in
    start) start_it ;;
    stop|halt) stop_it ;;
    *) usage ;;
esac
for f in *.txt; do echo "$f"; done
for ((i=0; i<10; i++)); do echo "$i"; done
while read -r line; do echo "$line"; done < file
until condition; do; done

Use [[ ]] (Bash conditional) over [ ] (POSIX test) — supports regex, &&/||, no word splitting on the LHS.

Functions.

greet() {
    local name="$1"
    echo "Hello, $name"
}
greet "world"

Args are $1, $2, …, $@ (all args, properly quoted as separate words when used as "$@"), $# (count). Return value: small integer status via return N. Returning data is by echo-ing to stdout and capturing with $(func).

Strings. Single-quoted 'literal' (no expansion). Double-quoted "with $vars and $(commands)". ANSI-C escapes via $'\t\n'. Length: ${#var}. Substring: ${var:offset:length}. Concat: just adjoin: "$a$b".

Collections.

arr=(a b c)
arr+=(d)            # append
echo "${arr[1]}"    # b
echo "${#arr[@]}"   # 4
echo "${arr[@]}"    # all
for x in "${arr[@]}"; do; done
 
declare -A m=([apple]=red [banana]=yellow)
echo "${m[apple]}"
for k in "${!m[@]}"; do echo "$k=${m[$k]}"; done

Always quote "${arr[@]}" to preserve elements with spaces. ${arr[*]} joins with first char of IFS; rarely what you want.

Intermediate

Type system depth. No types beyond “is it numeric?” via declare -i and ((arithmetic)). [[ $x -lt 10 ]] does numeric compare; [[ $x < $y ]] does string compare. Common bug.

Modules. source file.sh (alias . file.sh) executes the file in the current shell, importing functions and vars. No namespacing — by convention prefix functions: myproj::do_thing().

Error handling. Exit status is the universal mechanism: 0 success, non-zero failure. || and && chain on status. trap catches signals and the special ERR/EXIT/DEBUG/RETURN pseudosignals:

trap 'echo "error on line $LINENO" >&2' ERR
trap 'rm -f "$tmpfile"' EXIT

The set quartet:

  • set -e — exit on any non-zero command. Has surprising exceptions (commands in if, &&, ||, ! are exempt; functions don’t always inherit; subshells differ). Read the BashFAQ before relying on it.
  • set -u — error on undefined variable expansion.
  • set -o pipefail — pipeline returns the rightmost non-zero status (default is just the last command’s status).
  • set -x — print every command before executing (debug). The conventional script preamble: set -euo pipefail; IFS=$'\n\t'.

Concurrency.

  • & runs a job in the background; wait blocks for all, wait -n for any one (Bash 4.3+), wait $pid for one specific.
  • coproc name { commands; } opens a coprocess with bidirectional pipes (Bash 4+).
  • xargs -P N for naive process parallelism over a list.
  • Bash 5.3 added ${ command; } value-substitution (process substitution without the fork) for some patterns. (Chet Ramey NEWS)

I/O. Redirections: >file, >>file, <file, 2>file, &>file (both stdout and stderr), 2>&1 (merge). Per-fd: exec 3<>file opens fd 3 read-write. Heredoc: <<EOF. Here-string: <<<"text". Process substitution: <(cmd) produces a path that streams cmd’s stdout — diff <(sort a) <(sort b). Named pipes: mkfifo p; cmd1 > p & cmd2 < p.

Stdlib highlights. Bash’s “stdlib” is the set of GNU coreutils + builtins: printf (use over echo for portability), read -r (always -r), mapfile / readarray (read file to array), getopts (POSIX option parsing — limited; use getopt(1) from util-linux for long options), complete / compgen (programmable completion), compopt, bind (rebind readline keys).

Advanced

Memory. Each process has its own; large arrays and mapfiles are kept in shell memory until the process exits. Subshells (…) fork — they get a copy of all variables; modifications don’t propagate back. The classic anti-pattern: cat file | while read -r l; do count=$((count+1)); done; echo $count prints 0 because the while loop runs in a subshell from the pipe. Fix with < file while … or shopt -s lastpipe (Bash 4.2+, only in non-interactive shells).

Concurrency deep dive. Beyond & + wait, GNU parallel is the production tool for parallel pipelines. For producer/consumer: named pipes + flock for coordination. For high-concurrency, leave Bash — Python’s concurrent.futures or a real lang.

FFI. loadable builtins: compile a C .so and enable -f /path/to/foo.so foo adds a builtin command implemented in C, no fork. Bash 5.3 added kv and strptime as bundled loadable builtins. Otherwise, FFI = exec the C program.

Reflection. declare -f funcname prints a function’s body. compgen -A function lists functions. ${!prefix*} expands to variable names starting with prefix. ${!var} is indirect expansion (read variable whose name is in var). BASH_SOURCE array is the call stack (filenames); FUNCNAME array is the function name stack; BASH_LINENO the line numbers.

Performance tools.

  • time bash -c 'command' — wall/user/sys time.
  • set -x; PS4='+ $EPOCHREALTIME ${BASH_SOURCE}:${LINENO}: ' — instrument with timestamps + locations.
  • bash --debugger (with bashdb installed) for stepwise debugging.
  • shellcheckthe lint. Catches quoting bugs, unclear [[ ]], missing --, unused vars, set -e traps. Run on every script.
  • BASH_MONOSECONDS (5.3) — monotonic clock for benchmarking. (NEWS)

God mode

  • Process substitution <(...) and >(...) — turn a command’s stdout into a path: comm -23 <(sort a) <(sort b) does set difference on two unsorted files. tee >(cmd1) >(cmd2) > /dev/null fans out.
  • Parameter expansion transformations:
    • ${var^^} uppercase, ${var,,} lowercase, ${var^} first-char up
    • ${var//pattern/repl} replace all matches; ${var/pattern/repl} first only
    • ${var#prefix} strip shortest-match prefix; ${var##prefix} longest; ${var%suffix}/${var%%suffix} similarly for suffix
    • ${var:offset:length} substring
    • ${var:?message} error if unset; ${var:-default}, ${var:=default}, ${var:+alt}
    • ${var@Q} shell-quote, ${var@E} expand escapes, ${var@P} expand prompt-style, ${var@A} declare-style, ${var@a} attribute flags, ${var@u}/${var@U}/${var@L} case ops (Bash 5+)
  • Associative arrays (Bash 4+): declare -A. The single most underused feature; replaces parallel arrays and most awk accumulator patterns.
  • coproc: coproc PY { python -i; }; echo "print(2+2)" >&"${PY[1]}"; read line <&"${PY[0]}" — bidirectional pipe to a sidecar process.
  • wait -n completes when any background child finishes, returning that child’s status. Building block for bounded-parallelism workers.
  • Trap subtleties: trap '' SIGINT ignores; trap - SIGINT resets to default. EXIT runs once on shell exit (cleanup pattern). ERR trap doesn’t fire inside &&/|| chains, conditions, or the LHS of ! — same exclusions as set -e. RETURN fires when a function/sourced-script returns; DEBUG fires before every simple command (great for tracing).
  • File descriptor manipulation: exec 3>logfile; echo "hello" >&3; exec 3>&- (open, write, close). exec > >(tee log.txt) pipes the script’s own stdout through tee for the lifetime of the script.
  • Here-strings: bc <<<"2+2" is faster than echoing into a pipe (no fork for echo). read -r a b c <<<"$line" parses without a subshell.
  • $BASH_REMATCH: [[ $line =~ ^([a-z]+):([0-9]+)$ ]] && echo "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}". The whole regex toolkit, no grep fork.
  • set -e/-u/-o pipefail nuances:
    • -e is widely considered too unpredictable for production; many style guides (Wooledge BashFAQ #105) recommend explicit error checks.
    • In a function called from if func; then, set -e is suppressed inside func.
    • local var=$(false) doesn’t fail under -e because local itself returned 0.
    • set -u makes ${arr[@]} fail on empty arrays in some Bash versions; use ${arr[@]+"${arr[@]}"} defensively.
  • ShellCheck integration: shellcheck script.sh and respect every warning. The # shellcheck disable=SC2034 directive is rarely the right answer.
  • Debugging via set -x + PS4: PS4='+ ${BASH_SOURCE}:${LINENO} ${FUNCNAME[0]:-main}() ' makes the trace far more useful than the default +.
  • extglob: shopt -s extglob enables +(…), *(…), ?(…), @(…), !(…) patterns — full glob power for case and pathname matching: case $x in @(yes|y)) … ;; esac.
  • Restricted shell (bash -r or rbash): disables cd, redirections to fd, PATH mutation, etc. — sandbox primitive for trusted-but-not-root scripts.
  • Bash 5.3 ${ command; } value substitution: captures stdout without forking a subshell — eliminates a major perf footgun.

Idioms & style

  • Always quote "$var" and "${arr[@]}". Unquoted is word-split on IFS and glob-expanded — almost never what you want.
  • #!/usr/bin/env bash for portability over #!/bin/bash.
  • set -euo pipefail IFS=$‘\n\t’` at the top of scripts (with the caveats above).
  • Use [[ ]], not [ ]. [[ is a shell keyword that doesn’t word-split, supports =~ regex, &&/||.
  • $(cmd) not `cmd` for command substitution — nests properly, easier to read.
  • printf '%s\n' "$var" instead of echo "$var" for arbitrary strings (echo handles -n, -e, leading - inconsistently).
  • mapfile -t lines < file instead of lines=( $(cat file) ) — avoids word splitting and globbing.
  • Long options unsupported by getopts. Use getopt(1) from util-linux for long options, or roll a manual while [[ $#--gt-0-| -gt 0 ]] parser.
  • Naming: lower_snake_case for variables; UPPER_SNAKE for env vars and constants; function_name for funcs.
  • Linter: ShellCheck is mandatory.
  • Formatter: shfmt (from mvdan/sh) — opinionated, reliable.
  • Style guides: Google Shell Style Guide and the BashGuide at https://mywiki.wooledge.org/BashGuide are the references.
  • Switch to a real language at ~100 lines or when you need data structures beyond flat arrays/maps.

Ecosystem

  • Linting: ShellCheck. Non-negotiable.
  • Formatting: shfmt.
  • Testing: bats-core (TAP-output BDD). shellspec is a competitor.
  • Argument parsing: getopts (built-in, short opts only), getopt(1) (long opts, util-linux), or argparse-bash.
  • Logging: logger for syslog; printf '%(%Y-%m-%dT%H:%M:%S)T %s\n' -1 "msg" for ISO timestamps without forking date.
  • Templating: envsubst (gettext) for $VAR interpolation in templates.
  • Notable users: Every Linux distro init (legacy SysV scripts, modern systemd unit ExecStarts often), Docker entrypoints, GitHub Actions / GitLab CI / Jenkins shell steps, Homebrew formulae helpers, dotfiles management, kubectl plugin scripts, Yocto/Buildroot recipes.

Gotchas

  • set -e is a minefield. The above-mentioned exemptions break the mental model. Read BashFAQ/105 before relying on it.
  • Pipes spawn subshells. Variables set in cmd | while read …; do x=…; done are lost when the loop exits. Use < <(cmd) (process sub) or shopt -s lastpipe.
  • $@ vs $* vs "$@" vs "$*". Only "$@" preserves arguments as separate words. Always use "$@".
  • [ $x = "y" ] fails when $x is empty (”[: =: unary operator expected”). Use [ "$x" = "y" ] or always [[ ]].
  • for f in *.txt; do …; done when no .txt files exist iterates with the literal string *.txt (default nullglob off). shopt -s nullglob fixes it; shopt -s failglob errors instead.
  • Array length on unset is 0 but on a sparse array doesn’t equal max-index + 1. ${arr[@]} enumerates set elements, not indices.
  • Word splitting on unquoted command substitution. for f in $(ls) breaks on filenames with spaces. Use for f in * instead — globs don’t split.
  • read without -r mangles backslashes. Always read -r.
  • echo -n / echo -e are non-portable. Use printf.
  • $(()) in arithmetic doesn’t need $ for variables: (( count = count + 1 )) works; (( $count = $count + 1 )) is wrong (assignment to a value).
  • local x=$(cmd) swallows cmd’s exit status because local returns 0. Split: local x; x=$(cmd).
  • trap … EXIT doesn’t fire on kill -9. Nothing does. Plan accordingly.
  • macOS ships ancient Bash 3.2. Associative arrays, mapfile, ${var,,}, wait -n — none of those work on default macOS. Either install via Homebrew or target POSIX sh.
  • Tilde expansion only at start of word, unquoted. cd "~/foo" doesn’t expand; cd ~/foo does.

Citations