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"

Comments#

Lines beginning with # are comments. There are no native block comments; the common workaround is a here-document fed to : (the no-op).

$ : <<'COMMENT'
$ block of commentary
$ that bash will ignore
$ COMMENT

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 (2**10 is 1024)

++ --

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 ([[ ]] or (( )))

Meaning

== (or =)

-eq

Equal

!=

-ne

Not equal

<

-lt

Less than

<=

-le

Less than or equal

>

-gt

Greater than

>=

-ge

Greater than or equal

-z X

String X is empty

-n X

String X is non-empty

=~

String matches regex (capture groups in BASH_REMATCH)

$ [[ "$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

-e PATH

Path exists (any type)

-f PATH

Regular file

-d PATH

Directory

-L PATH / -h PATH

Symlink (does not follow)

-r PATH / -w PATH / -x PATH

Caller has read / write / execute permission

-s PATH

File exists and is non-empty

-O PATH / -G PATH

Owned by effective UID / GID

-u PATH / -g PATH / -k PATH

SUID / SGID / sticky bit set

-N PATH

Modified since last read

A -nt B / A -ot B

File A is newer / older than B

A -ef B

A and B are the same file (same device + inode)

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

NAME=value

Bind NAME to a string

NAME+=value

Append to a string, or append to an indexed array

(( NAME = expr ))

Arithmetic evaluation, no $ needed

(( NAME += n ))

Compound arithmetic (-=, *=, /=, %=, <<=, >>=)

declare -i NAME

Make NAME integer-attributed; bare NAME=expr now evaluates

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

${var}

Plain expansion (braces protect adjacent characters)

${var:-default}

Use default if var is unset or empty

${var:=default}

Same, and assign default back to var

${var:?msg}

Error out with msg if var is unset

${var:offset:length}

Substring

${#var}

Length

${var#pat} / ${var##pat}

Strip shortest / longest prefix matching pat

${var%pat} / ${var%%pat}

Strip shortest / longest suffix matching pat

${var/pat/repl} / ${var//pat/repl}

Replace first / every occurrence

${var^} / ${var^^}

Uppercase first / all characters

${var,} / ${var,,}

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

[abc] / [a-z]

One character from the set

[!abc]

One character not in the set

**

Any path, recursively (only when shopt -s globstar)

?(p) *(p) +(p) @(p) !(p)

Extended globs (shopt -s extglob)

$ 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

{a,b,c}

a b c

{1..5}

1 2 3 4 5

{1..10..2}

1 3 5 7 9

{a..f}

a b c d e f

pre{x,y}post

prex post (concatenation)

$ 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

if cmd; then ... fi

Any command can drive an if; the exit code is the test.

if [[ cond ]]; then ... fi

String, glob, regex, and file tests. The default in Bash.

if (( expr )); then ... fi

Numeric / arithmetic tests. No $ needed inside.

cmd && next / cmd || next

One-line guard for a single step.

for x in LIST

Walk a fixed or globbed list of words.

for ((i=0;i<n;i++))

Arithmetic counter, C-style.

while cond

Loop while the test stays true; the standard form for read-driven loops.

until cond

Loop while the test stays false; retry / poll pattern.

case x in pat) ... esac

One value, many patterns. Glob patterns, first match wins.

select x in LIST

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 case. The default.

;&

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

$1, $2, …

Positional arguments

"$@"

All arguments (one element per token, with quoting)

$#

Argument count

$0

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'
  • -e exits on the first command that returns non-zero.

  • -u errors on unset variable references.

  • -o pipefail makes 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 .bashrc full 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 for awk or 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. HISTFILE is 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; the EXPANSION, REDIRECTION, CONDITIONAL EXPRESSIONS, and SHELL GRAMMAR sections 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.

  • Bash Hackers Wiki

  • Greg’s Wiki: BashFAQ / BashGuide

  • ShellCheck (lint).