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 --versionto check. - macOS: Apple ships an ancient Bash 3.2 (GPLv2 holdout) at
/bin/bash; install current viabrew install bash, get/opt/homebrew/bin/bashor/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 nameDefault 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 …; doneUse [[ ]] (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]}"; doneAlways 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"' EXITThe set quartet:
set -e— exit on any non-zero command. Has surprising exceptions (commands inif,&&,||,!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;waitblocks for all,wait -nfor any one (Bash 4.3+),wait $pidfor one specific.coproc name { commands; }opens a coprocess with bidirectional pipes (Bash 4+).xargs -P Nfor 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.shellcheck— the lint. Catches quoting bugs, unclear[[ ]], missing--, unused vars,set -etraps. 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/nullfans 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 mostawkaccumulator patterns. coproc:coproc PY { python -i; }; echo "print(2+2)" >&"${PY[1]}"; read line <&"${PY[0]}"— bidirectional pipe to a sidecar process.wait -ncompletes when any background child finishes, returning that child’s status. Building block for bounded-parallelism workers.- Trap subtleties:
trap '' SIGINTignores;trap - SIGINTresets to default.EXITruns once on shell exit (cleanup pattern).ERRtrap doesn’t fire inside&&/||chains, conditions, or the LHS of!— same exclusions asset -e.RETURNfires when a function/sourced-script returns;DEBUGfires 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, nogrepfork.set -e/-u/-o pipefailnuances:-eis 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 -eis suppressed insidefunc. local var=$(false)doesn’t fail under-ebecauselocalitself returned 0.set -umakes${arr[@]}fail on empty arrays in some Bash versions; use${arr[@]+"${arr[@]}"}defensively.
- ShellCheck integration:
shellcheck script.shand respect every warning. The# shellcheck disable=SC2034directive 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 extglobenables+(…),*(…),?(…),@(…),!(…)patterns — full glob power forcaseand pathname matching:case $x in @(yes|y)) … ;; esac.- Restricted shell (
bash -rorrbash): disablescd, redirections to fd,PATHmutation, 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 onIFSand glob-expanded — almost never what you want. #!/usr/bin/env bashfor portability over#!/bin/bash.set -euo pipefailIFS=$‘\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 ofecho "$var"for arbitrary strings (echo handles-n,-e, leading-inconsistently).mapfile -t lines < fileinstead oflines=( $(cat file) )— avoids word splitting and globbing.- Long options unsupported by
getopts. Usegetopt(1)from util-linux for long options, or roll a manualwhile [[ $#--gt-0-| -gt 0 ]]parser. - Naming:
lower_snake_casefor variables;UPPER_SNAKEfor env vars and constants;function_namefor funcs. - Linter: ShellCheck is mandatory.
- Formatter:
shfmt(frommvdan/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).
shellspecis a competitor. - Argument parsing:
getopts(built-in, short opts only),getopt(1)(long opts, util-linux), orargparse-bash. - Logging:
loggerfor syslog;printf '%(%Y-%m-%dT%H:%M:%S)T %s\n' -1 "msg"for ISO timestamps without forking date. - Templating:
envsubst(gettext) for$VARinterpolation 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,kubectlplugin scripts, Yocto/Buildroot recipes.
Gotchas
set -eis 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=…; doneare lost when the loop exits. Use< <(cmd)(process sub) orshopt -s lastpipe. $@vs$*vs"$@"vs"$*". Only"$@"preserves arguments as separate words. Always use"$@".[ $x = "y" ]fails when$xis empty (”[: =: unary operator expected”). Use[ "$x" = "y" ]or always[[ ]].for f in *.txt; do …; donewhen no.txtfiles exist iterates with the literal string*.txt(defaultnullgloboff).shopt -s nullglobfixes it;shopt -s failgloberrors 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. Usefor f in *instead — globs don’t split. readwithout-rmangles backslashes. Alwaysread -r.echo -n/echo -eare non-portable. Useprintf.$(())in arithmetic doesn’t need$for variables:(( count = count + 1 ))works;(( $count = $count + 1 ))is wrong (assignment to a value).local x=$(cmd)swallowscmd’s exit status becauselocalreturns 0. Split:local x; x=$(cmd).trap … EXITdoesn’t fire onkill -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 POSIXsh. - Tilde expansion only at start of word, unquoted.
cd "~/foo"doesn’t expand;cd ~/foodoes.
Citations
- GNU Bash Reference Manual — https://www.gnu.org/software/bash/manual/
- GNU Bash manual (single-page HTML) — https://www.gnu.org/software/bash/manual/bash.html
- Chet Ramey’s NEWS file (release history) — https://tiswww.case.edu/php/chet/bash/NEWS
- BashGuide (Greg Wooledge) — https://mywiki.wooledge.org/BashGuide
- BashFAQ — https://mywiki.wooledge.org/BashFAQ
- BashPitfalls — https://mywiki.wooledge.org/BashPitfalls
- Google Shell Style Guide — https://google.github.io/styleguide/shellguide.html
- ShellCheck wiki (per-warning explanations) — https://www.shellcheck.net/wiki/
- shfmt — https://github.com/mvdan/sh
- bats-core — https://github.com/bats-core/bats-core