Overview#
Bash is a command interpreter. Programs are written as a sequence of commands separated by newlines or semicolons. The interpreter reads each command, expands variables and globs, runs it, captures the exit status, and moves on.
As a language, Bash is closer to a shell-oriented DSL than to a general-purpose programming language. Strings are the only real data type, control flow is built on exit codes, and functions return integers, not values. What it gives the operator in return is a uniform interface to every other tool on the box. The right mental model is glue, not application.
The page is grouped by stage so the reader can build a mental model in one pass. Foundation is the lexical surface (syntax). Operations is what the operator does to values (operators, strings). Flow is how a script makes decisions (control, functions). Wiring is how programs connect (I/O, subshells). Failure covers exit codes, strict mode, and traps. Re-use is sourcing modules. Runtime is what bash is underneath (runtime model, standard library). The last two sections are battle-drill tasks and references.
For the variable types (string, integer, indexed array, associative array, plus the readonly, exported, case-folded, nameref, and local attributes) see Structures. They live with the operational patterns the operator builds on top of them.
Syntax#
A script begins with a shebang and is made executable.
$ #!/usr/bin/env bash
$ echo "hello"
Keywords#
Reserved words include:
if then elif else fi case esac for select while until do done
function in time { } [[ ]] ! return break continue
Identifiers#
A variable name is a letter or underscore followed by any mix of
letters, digits, and underscores. Names are case-sensitive. The
shell has no separate namespace for variables, functions, and
aliases; the most recent binding of a name shadows any other.
Sigils mark the use site, not the declaration site (var=value
declares; $var reads).
$ MY_VAR=1 # valid
$ _hidden=secret # valid
$ 2nd=bad # invalid: cannot start with a digit
Literals#
Bash has only string literals, but several syntaxes produce them.
Single quotes are byte-for-byte (no expansion), double quotes
expand $var, $(cmd), and backslash, and unquoted words are
subject to word-splitting and globbing. $'...' is the C-style
escape form (\n, \t, \x41). Numeric literals are
written as decimal by default; 0x is hex, 0 is octal, and
base#digits is an arbitrary radix inside (( )).
$ echo 'literal $HOME' # literal $HOME
$ echo "expanded $HOME" # /home/operator
$ echo $'tab\there' # tab + here
$ echo $(( 0xff )) # 255
$ echo $(( 2#1010 )) # 10
Expressions#
A simple command is a sequence of words separated by spaces; the
first word is the command, the rest are arguments. Commands end at a
newline, a semicolon, an & (background), or one of the list
operators && / || / |. A compound command groups
several inside { ... ; } (current shell) or ( ... ) (subshell).
$ echo a; echo b # two commands on one line
$ true && echo ok # second runs only if first succeeded
$ false || echo fallback # second runs only on failure
$ { echo a; echo b; } > log # group in current shell
$ ( cd /tmp; pwd ) # group in subshell
Operators#
Bash uses different operator dialects in different contexts.
Arithmetic in (( )) or $(( )) follows C-like rules. String,
file, and regex tests live in [[ ]] (Bash builtin; preferred).
The legacy POSIX [ ... ] / test form still works but is
quirkier around quoting and operator names.
$ (( a = 2 + 3 )) # arithmetic, no $ needed inside
$ [[ "$x" == "y" ]] # string equality
$ [[ -f /etc/hosts ]] # file exists and is regular
$ [[ "$s" =~ ^[0-9]+$ ]] # regex match
Arithmetic#
Inside (( )) or $(( )), bash evaluates a C-like expression.
Variables expand without a leading $. Result is an integer; bash
has no native floating point (use bc or awk).
Operator |
Effect |
|---|---|
|
Add, subtract, multiply, integer-divide, remainder |
|
Exponent ( |
|
Pre- / post-increment, decrement |
|
Compound assignment |
|
Bitwise and, or, xor, not, left shift, right shift |
|
Ternary |
$ (( x = (1 + 2) * 3 )) # 9
$ (( x > 5 ? (y=1) : (y=0) )) # ternary, returns truthy
$ printf '%s\n' "$y"
Comparison#
Inside [[ ]], string and numeric tests use different operators.
Reaching for the wrong family is the most common bash bug.
String ( |
Numeric ( |
Meaning |
|---|---|---|
|
|
Equal |
|
|
Not equal |
|
|
Less than |
|
|
Less than or equal |
|
|
Greater than |
|
|
Greater than or equal |
|
String |
|
|
String |
|
|
String matches regex (capture groups in |
$ [[ "$count" -gt 10 ]] && echo "many" # numeric
$ [[ "$name" == operator* ]] && echo "match" # glob pattern
$ (( count > 10 )) && echo "many" # same numeric test in (( ))
Tests#
Inside [[ ]], unary file-test operators ask the kernel about
a path. The most common.
Test |
True when |
|---|---|
|
Path exists (any type) |
|
Regular file |
|
Directory |
|
Symlink (does not follow) |
|
Caller has read / write / execute permission |
|
File exists and is non-empty |
|
Owned by effective UID / GID |
|
SUID / SGID / sticky bit set |
|
Modified since last read |
|
File |
|
|
Logical#
Outside [[ ]], bash chains commands by exit code: && (run
if previous succeeded), || (run if previous failed). Inside
[[ ]], && / || combine tests. ! negates.
$ true && echo ok
$ false || echo fallback
$ [[ -f f && -r f ]] && cat f # both inside one test
$ [[ ! -f f ]] && echo missing
Assignment#
NAME=VALUE (no spaces around =) attaches a value to a
name. Compound forms are arithmetic-only and live in (( )).
Form |
Effect |
|---|---|
|
Bind |
|
Append to a string, or append to an indexed array |
|
Arithmetic evaluation, no |
|
Compound arithmetic ( |
|
Make |
Strings#
Bash strings interpolate $var and ${expr}. The braces let
the operator add an expansion operator that modifies the string
on the fly. Together these are parameter expansion, the
feature that replaces what other languages need a regex or library
for.
$ name="alice"
$ echo "hello, $name" # interpolation
$ echo "hi, ${name^}" # capitalise first letter
$ echo "${PATH//:/$'\n'}" # split colons to newlines
Common parameter-expansion forms.
Form |
Effect |
|---|---|
|
Plain expansion (braces protect adjacent characters) |
|
Use |
|
Same, and assign |
|
Error out with |
|
Substring |
|
Length |
|
Strip shortest / longest prefix matching |
|
Strip shortest / longest suffix matching |
|
Replace first / every occurrence |
|
Uppercase first / all characters |
|
Lowercase first / all characters |
Command substitution captures another command’s stdout as a string.
$ today=$(date +%F)
$ shell=$(basename "$SHELL")
Quoting controls expansion. Double quotes preserve whitespace
and allow $var and $(...) to expand; single quotes
suppress all expansion.
$ echo "$HOME" # /home/operator
$ echo '$HOME' # $HOME
Regex#
Inside [[ ]] the =~ operator runs a POSIX ERE match. Capture
groups land in the BASH_REMATCH array, indexed from 0 (the whole
match) upward. Quote literal parts of the regex; an unquoted space
or special character changes the meaning. Quote the variable, not
the regex; bash treats a quoted pattern as a literal string.
$ s="user42@example.com"
$ if [[ "$s" =~ ^([a-z]+)([0-9]+)@(.+)$ ]]; then
$ printf '%s\n' "user: ${BASH_REMATCH[1]}" \
$ "id: ${BASH_REMATCH[2]}" \
$ "host: ${BASH_REMATCH[3]}"
$ fi
user: user
id: 42
host: example.com
Globbing#
Globs are filename patterns expanded by the shell before the
command runs. They are not regular expressions. Defaults are
conservative; shopt flips on the more useful behaviors.
Pattern |
Matches |
|---|---|
|
Any string (zero or more characters), except a leading dot |
|
Exactly one character |
|
One character from the set |
|
One character not in the set |
|
Any path, recursively (only when |
|
Extended globs ( |
$ shopt -s globstar nullglob extglob
$ ls **/*.log # recursive
$ ls !(*.bak) # everything except .bak
$ for f in *.txt; do echo "$f"; done # nullglob: no iteration if empty
Braces#
Brace expansion is not a glob; it produces a literal list of strings regardless of whether the resulting names exist. Useful for repeated names with small variations.
Form |
Expands to |
|---|---|
|
|
|
|
|
|
|
|
|
|
$ mkdir -p logs/{web,db,cache}/{2024,2025}
$ cp config.yaml{,.bak} # quick backup
$ for i in {1..3}; do echo "host$i"; done
Control#
Bash builds branching and looping on exit codes. Every
condition is a command; the shell runs it and reads its exit
status. 0 is truthy, anything else falsy. The test is any
command, the most common being the [[ ]] keyword (string and
file tests) and (( )) (arithmetic). [/test is the
older POSIX form, kept for portability and avoided in Bash code
because it needs every variable double-quoted to be safe.
Form |
When the operator reaches for it |
|---|---|
|
Any command can drive an if; the exit code is the test. |
|
String, glob, regex, and file tests. The default in Bash. |
|
Numeric / arithmetic tests. No |
|
One-line guard for a single step. |
|
Walk a fixed or globbed list of words. |
|
Arithmetic counter, C-style. |
|
Loop while the test stays true; the standard form for
|
|
Loop while the test stays false; retry / poll pattern. |
|
One value, many patterns. Glob patterns, first match wins. |
|
Numbered interactive menu on stdin. |
If#
if runs a command and branches on its exit status. The
minimal form is one condition, one then block, one fi.
The condition can be [[ ]] (the default), (( ))
(arithmetic), or any command (grep -q, test, an
exit-coded helper). 0 is truthy, anything else falsy.
$ if [[ -f config.toml ]]; then
$ echo "found"
$ fi
flowchart LR
C{cond?} -->|true| T[then block] --> X([fi])
C -->|false| X
Add else to handle the false branch.
$ if [[ -f config.toml ]]; then
$ echo "found"
$ else
$ echo "missing"
$ fi
flowchart LR
C{cond?} -->|true| T[then block] --> X([fi])
C -->|false| E[else block] --> X
Chain elif to test another condition only when the previous
one was false. Each elif is another diamond on the false
branch with its own block joining the same fi.
$ if [[ -z "$1" ]]; then
$ echo "no argument"
$ elif [[ "$1" == "help" ]]; then
$ echo "usage: ..."
$ else
$ echo "got $1"
$ fi
flowchart LR
C1{cond 1?} -->|true| T1[then block] --> X([fi])
C1 -->|false| C2{cond 2?}
C2 -->|true| T2[elif block] --> X
C2 -->|false| E[else block] --> X
Three test dialects sit on top of the same syntax. Pick by what is being compared.
$ if [[ "$user" == "root" ]]; then echo "root"; fi # string test
$ if (( count > 10 )); then echo "many"; fi # arithmetic
$ if grep -q ERROR app.log; then echo "found"; fi # any command
One-line guard idioms. && runs the right side when the left
succeeded, || when it failed; both stop on the first decisive
result.
$ [[ -f config.toml ]] || { echo "missing config" >&2; exit 1; }
$ command -v jq >/dev/null && jq -r .name input.json
Quote the variable, not the literal, when using [[ ]].
Right-hand strings are treated as glob patterns by default;
quoting forces a literal comparison.
$ name="alice"
$ [[ $name == al* ]] # true: glob match
$ [[ $name == "al*" ]] # false: literal compare
For#
for runs the body once per word in a list. Three list forms
cover almost every case the operator hits.
$ for f in *.txt; do # glob
$ echo "$f"
$ done
$ for h in web01 db01 cache01; do # literal list
$ ssh "$h" uptime
$ done
$ for arg; do # implicit "$@" (positional args)
$ printf 'arg: %s\n' "$arg"
$ done
flowchart LR
Q{next item?} -->|yes| B[body] --> Q
Q -->|no| X([done])
The C-style for (( init; cond; step )) walks an arithmetic
counter instead of an explicit list.
$ for ((i=0; i<10; i++)); do
$ echo "$i"
$ done
flowchart LR
I[init] --> C{cond?}
C -->|true| B[body] --> S[step] --> C
C -->|false| X([done])
Iterate an array. Always quote "${arr[@]}" so each element
stays one word, even if it contains spaces.
$ targets=(web01 "db prod" cache01)
$ for t in "${arr[@]:-}"; do echo "[$t]"; done # safe with set -u
Pitfall. for f in $(ls) and for f in $(find ...) split
on whitespace and re-glob the results, breaking on filenames with
spaces, newlines, or glob metacharacters. Prefer a glob, a null-
delimited read, or mapfile.
$ for f in *.log; do echo "$f"; done # safe
$ mapfile -t files < <(find . -name '*.log') # safe
$ for f in "${files[@]}"; do echo "$f"; done
$ while IFS= read -r -d '' f; do # null-delimited
$ echo "$f"
$ done < <(find . -name '*.log' -print0)
While#
while runs the body for as long as the test stays true. The
standard reach for read-driven input loops, polling, and any
loop whose iteration count is not known in advance.
$ while IFS= read -r line; do
$ echo "$line"
$ done < input.txt
flowchart LR
C{cond?} -->|true| B[body] --> C
C -->|false| X([done])
IFS= and -r are the read-line discipline. IFS= keeps
leading and trailing whitespace; -r stops backslashes
escaping characters inside the line.
Pitfall (the single biggest bash gotcha). Each stage of a
pipeline runs in a subshell, so variables changed inside a
while on the right of a pipe vanish when the pipe ends.
$ count=0
$ printf 'a\nb\nc\n' | while read -r line; do
$ count=$((count + 1))
$ done
$ echo "$count" # prints 0, not 3
Two standard fixes. Process substitution keeps the loop in the
current shell. ``shopt -s lastpipe`` runs the last stage of a
pipeline in the current shell (interactive shells need
set +m first).
$ count=0
$ while read -r line; do count=$((count + 1)); done < <(printf 'a\nb\nc\n')
$ echo "$count" # 3
$ shopt -s lastpipe; set +m
$ count=0
$ printf 'a\nb\nc\n' | while read -r line; do count=$((count + 1)); done
$ echo "$count" # 3
until is the inverse polarity and has its own section below.
Until#
until runs the body while the condition is false and stops
once the condition becomes true. Same loop structure as while,
polarity flipped. The standard reach for retry / wait-for-ready
patterns where the loop should run until something works.
$ until ping -c1 -W1 host >/dev/null 2>&1; do
$ sleep 1
$ done
flowchart LR
C{cond?} -->|false| B[body] --> C
C -->|true| X([done])
Add a deadline so the loop cannot spin forever.
$ deadline=$(( $(date +%s) + 30 ))
$ until curl -fsS https://target/healthz >/dev/null; do
$ (( $(date +%s) >= deadline )) && { echo "timeout" >&2; exit 1; }
$ sleep 1
$ done
Case#
case tests one value against a sequence of glob patterns,
not regular expressions. The first match wins; ;; closes the
branch.
$ case "$1" in
$ start) echo "starting" ;;
$ stop|halt) echo "stopping" ;; # multi-pattern with |
$ [0-9]*) echo "numeric: $1" ;; # glob class
$ *.log) echo "log file" ;;
$ "") echo "empty" ;;
$ *) echo "unknown" ;; # default
$ esac
flowchart LR
P{match P?} -->|yes| H[handler] --> X([esac])
P -->|no| N[try next pattern]
Three branch terminators control flow at the bottom of each handler.
Terminator |
Effect |
|---|---|
|
End this branch and exit the |
|
Fall through unconditionally into the next handler. |
|
Continue evaluating later patterns; rare, useful for “match multiple, run each”. |
$ case "$verb" in
$ get|head) echo "read-only" ;;& # also try the next pattern
$ get|put|post) echo "method: $verb" ;;
$ esac
Select#
select builds an interactive numbered menu from a list and
reads the operator’s choice on stdin. PS3 is the menu prompt.
The reply lands in the loop variable; the raw input is in
$REPLY. Loops until break or EOF (Ctrl-D).
$ PS3="pick a host: "
$ select host in web01 db01 cache01 quit; do
$ case "$host" in
$ quit) break ;;
$ "") echo "invalid: $REPLY" ;;
$ *) echo "chose $host"; break ;;
$ esac
$ done
flowchart LR
P[print menu] --> R[read choice]
R --> M{valid?}
M -->|yes| B[body] --> P
M -->|no| P
Loop Control#
break exits the innermost loop; break N exits N levels.
continue skips to the next iteration of the innermost loop;
continue N skips N levels.
$ for f in *.log; do
$ [[ -s "$f" ]] || continue # skip empty files
$ grep -q ERROR "$f" && break # stop on first hit
$ echo "$f"
$ done
Multi-level break out of a nested search.
$ for host in "${hosts[@]}"; do
$ for path in "${paths[@]}"; do
$ if ssh "$host" "test -f $path"; then
$ echo "found $path on $host"
$ break 2 # exit both loops
$ fi
$ done
$ done
``return`` vs ``exit``. return N ends a function with exit
status N and resumes the caller; exit N ends the whole
script. Reach for return inside functions, exit at the top
level. Using exit inside a sourced library kills the operator’s
shell.
Functions#
Bash functions take positional arguments through $1,
$2, and so on, and return string output via echo
captured by command substitution. The numeric return
value is an exit status, not a return value, a common
trap for newcomers from other languages.
$ add() {
$ local a="$1" b="$2"
$ echo $((a + b))
$ }
$ result=$(add 2 3)
I/O#
Three streams (stdin 0, stdout 1, stderr 2) are the
universal interface. read pulls a line off stdin; echo and
printf write to stdout; redirection operators rewire any of
the three. The minimal forms below cover what most language-level
prose needs; the full surface (every redirection operator,
heredocs, pipelines, process substitution, named pipes) lives in
I/O and Pipelines.
$ read -r line < input.txt
$ printf '%s\n' "hello" # to stdout
$ printf 'oops\n' >&2 # to stderr
$ cmd > out.txt 2> err.txt # split each stream
$ cmd <<< "one-line stdin"
Arguments and environment are accessed through positional
parameters and export-ed variables.
Form |
Meaning |
|---|---|
|
Positional arguments |
|
All arguments (one element per token, with quoting) |
|
Argument count |
|
Script name |
|
Own process ID |
|
Exit status of the last command |
|
PID of the last background command |
$ export PATH="$HOME/bin:$PATH"
$ printenv | head
Subshells#
( ... ) runs a group of commands in a forked subshell.
Variable and working-directory changes inside the parens do not
leak back into the parent shell. The standard reach when the
operator needs a temporary cd or a scoped variable mutation
without writing a function.
$ ( cd /tmp; pwd ) # prints /tmp
$ pwd # parent cwd unchanged
$ x=1
$ ( x=99 ) # change happens in a subshell
$ echo "$x" # still 1
Subshells also appear implicitly. Each stage of a pipeline is a
subshell; command substitution $( ... ) runs in one; a
backgrounded job & is one. For the pipelines, redirection, and
process-substitution side of subprocess work, see I/O and Pipelines.
Errors#
Every command returns an exit status (0 for success, non-zero
for failure) available in $?. The conditional operators &&
and || chain on it.
$ cmd && echo ok || echo failed
Strict mode at the top of a script turns silent failures loud.
$ set -euo pipefail
$ IFS=$'\n\t'
-eexits on the first command that returns non-zero.-uerrors on unset variable references.-o pipefailmakes a pipeline fail if any stage fails.
Traps run a handler when the shell receives a signal or exits.
$ tmp=$(mktemp)
$ trap 'rm -f "$tmp"' EXIT
$ trap 'echo interrupted; exit 130' INT TERM
Modules#
Bash has no formal module system. source FILE (or its POSIX
synonym .) reads and executes a file in the current shell,
so every function, variable, and trap it defines stays in
scope after the file returns. The convention is to keep shared
helpers under lib/ and source them from the script’s
entry-point.
$ source ./lib/common.sh # current shell
$ . ./lib/common.sh # POSIX equivalent
$ bash ./lib/common.sh # subprocess; nothing leaks back
A defensive idiom checks the source path once and exposes functions only on a fresh source.
$ # lib/common.sh
$ [[ -n "${COMMON_LOADED:-}" ]] && return 0
$ COMMON_LOADED=1
$ log() { printf '[%s] %s\n' "$(date +%FT%T)" "$*" >&2; }
Runtime#
Bash is a single-process, single-threaded interpreter. The
bash binary parses each command, expands it, then either calls
a built-in directly or fork(2) + exec(2) an external
program. No bytecode, no JIT, no garbage collector. Memory is
managed by malloc for internal structures (variables, the
hash table of names, the line-edit buffer); the operator never
sees it.
Practical implications.
Startup is fast because there is no compile step; everything the script needs is read and run line by line. A
.bashrcfull of slow expansions is what makes interactive startup feel slow, not bash itself.Concurrency costs a process.
&and pipelines fork; each stage is a separate OS process. For tight inner loops this matters; reach forawkor a real language when the per-line cost dominates.Variables die with the shell. Nothing persists to disk on its own; the operator has to write it.
HISTFILEis the exception (interactive command history).No tail-call optimization. Deep recursion blows the stack; prefer iteration in bash.
Library#
Bash’s “standard library” is split in two. Built-ins live
inside the bash binary and have no fork cost (echo, read,
printf, cd, test / [, [[ ]], declare,
mapfile, getopts, trap, shopt). Coreutils is
the external GNU package that ships ls, cp, cut,
sort, head, tail, cat, and the rest of the
toolchain every script assumes is on $PATH.
For the cataloging and category split, see
Coreutils. help is the built-in lookup
when a name has no man page (help cd, help read).
$ help # list every built-in
$ help read # docs for one built-in
$ enable -p # list which are enabled
$ type -a echo # built-in vs external?
echo is a shell builtin
echo is /usr/bin/echo
Tasks#
Iterate over the lines of a file safely.
$ while IFS= read -r line; do
$ echo "$line"
$ done < input.txt
Read a file into an array (Bash 4+).
$ mapfile -t lines < input.txt
$ printf '%s\n' "${lines[0]}"
Parse short and long flags with ``getopts`` (POSIX, no long opts).
$ while getopts ":hvf:" opt; do
$ case "$opt" in
$ h) echo "usage: ..."; exit 0 ;;
$ v) verbose=1 ;;
$ f) file="$OPTARG" ;;
$ \?) echo "bad option: -$OPTARG" >&2; exit 2 ;;
$ esac
$ done
$ shift $((OPTIND - 1))
Trap a signal and clean up.
$ tmp=$(mktemp -d)
$ trap 'rm -rf "$tmp"' EXIT
$ trap 'echo interrupted >&2; exit 130' INT TERM
Run a step with a timeout.
$ timeout 30s curl -fsSL https://target
Background work and wait for all jobs.
$ for h in web01 db01 cache01; do
$ ssh "$h" 'uptime' &
$ done
$ wait
Compare two pipelines without temp files.
$ diff <(sort a.txt) <(sort b.txt)
Capture both stdout and exit code without losing either.
$ out=$(cmd; rc=$?; echo "__rc=$rc")
$ rc=${out##*__rc=}
$ out=${out%__rc=*}
References#
man 1 bash(the standard reference; theEXPANSION,REDIRECTION,CONDITIONAL EXPRESSIONS, andSHELL GRAMMARsections cover most of this page in detail).man 7 bash-builtins,help(the in-shell built-in docs).man 1 dash,man 1 sh(the POSIX neighbor for portability checks).Bash for the surrounding Bash section (Setup, init files, line editing, completion).
Patterns for strict mode, traps, and the safer-script idioms.
Tools for the surrounding toolchain (
shellcheck,shfmt,bats).The Terminal for the terminal-level view of how bash is driven and how scope nests.
Coreutils for the GNU utilities every bash script reaches for.
ShellCheck (lint).
Comments#
Lines beginning with
#are comments. There are no native block comments; the common workaround is a here-document fed to:(the no-op).