Everything the adaptive question bank can teach and test in Bash & Shell, from foundations through advanced practice. Work through it in order, or start practicing and let the questions find your level.
#! has to be the first two characters of the file. A Windows CRLF ending glues a carriage return onto the interpreter name, so the launch fails with bad interpreter or env's bash\r: No such file or directory. More than one argument after the interpreter is not portable, so put set -e in the body. With no shebang the launching shell decides: bash 5.3 runs the file itself, zsh hands it to /bin/sh.
The child gets a copy of your exported variables and takes its changes with it when it exits, so a script that runs cd or sets a variable leaves your session untouched. A sourced file acts as if typed at your prompt: its cd, assignments and even exit hit the current shell, so leave a sourced file early with return. . is the POSIX spelling; source is a bash synonym that dash lacks.
./script asks the kernel to run the file, which requires the execute bit; without it you get Permission denied and status 126. bash script opens the file as input to a new bash, so read permission is enough, and the shebang is just a comment, which also means bash runs it even when the shebang names some other interpreter.
A word starts at the beginning of a line, after unquoted whitespace, or right after an operator such as ; or |. So echo a#b prints a#b, while echo a #b; echo c prints only a: the comment swallows the ; and the second command too. An interactive shell honors comments only while interactive_comments is on, which is the default.
Words and operators are found from the raw text and its quoting before any $ is expanded, so the structure of the command is fixed first. That is why an operator inside a variable is just text: with cmd='ls | wc', running $cmd hands | and wc to ls as file names instead of building a pipeline. Keywords and redirections are recognized at the same early stage.
An interactive login shell reads /etc/profile, then the first of ~/.bash_profile, ~/.bash_login or ~/.profile; an interactive non-login shell reads ~/.bashrc. A script reads neither, only the file named in BASH_ENV. cron also runs the line under /bin/sh with a sparse environment of its own, so set PATH and anything else you need inside the script.
type -t prints a single word and type -a lists every match in lookup order, so you can spot a function shadowing /bin/ls. which is an external program that only searches PATH, so it cannot see functions or builtins. In a script, aliases are off unless expand_aliases is set, and type then reports an alias name as not found.
Before PATH is consulted, bash checks for a function of that name, then a builtin, so a function named ls beats /bin/ls. In PATH the first directory holding an executable match wins, so a directory you prepend shadows the system copy. An empty entry, such as a leading colon or ::, means the current directory. A name with a slash skips the builtin and PATH lookups entirely.
After the first PATH search, bash reuses the remembered path. Move or delete that binary and the next call fails with No such file or directory and status 127, even when another copy sits later in PATH (checked in bash 5.3 and 3.2). hash -r empties the table, assigning PATH empties it too, and shopt -s checkhash makes bash search again when a remembered file is gone.
B. Quoting
Nothing is special between them, not even a backslash, so '\n' is two characters. To include a single quote, close the string, add an escaped quote, and reopen: 'it'\''s' is three adjacent pieces that the shell joins into one word. In bash, $'it\'s' also works, because ANSI-C quoting accepts \'.
$var, $(...) and $((...)) still expand inside double quotes, but the result stays one word and any * in it stays literal. "$@" and "${arr[@]}" are the deliberate exceptions, giving one word per element. At an interactive prompt ! also triggers history expansion inside double quotes; history expansion is off in scripts.
Outside quotes, a backslash before a newline is a line continuation and both vanish; before any other character it strips that character's special meaning. Inside double quotes a backslash before anything else is kept, so "a\qb" stays a\qb while "a\$b" gives a$b. Inside single quotes a backslash is never special.
With f='my file.txt', rm $f receives two arguments, my and file.txt. A value holding * turns into the names of files in the current directory, and an empty variable disappears entirely, so [ $v = x ] becomes [ = x ] and fails with unary operator expected. When you need several arguments from one variable, use an array.
Only quotes you typed are removed. Quotes that arrive inside an expansion are ordinary characters, so after q='"hi"', echo $q prints the quotes too, and storing -name "*.txt" in a string can never group arguments. A typed "" survives as one empty argument, while an unquoted empty variable produces none.
Bash decodes the escapes once, then treats the result like single-quoted text, so no $ expansion follows. It is the readable way to write a tab or newline, as in IFS=$'\n'. POSIX added $'...' only in its 2024 edition, and the dash shipped with macOS prints it literally, so keep it out of #!/bin/sh scripts.
Bash parses $(...) as a fresh command, so "$(grep "$pat" "$file")" needs no escaping: the inner quotes protect the inner expansions and the outer pair keeps the output as one word. Escaping the inner quotes out of habit backfires: with x=q, "$(echo \"$x\")" prints "q" with literal quote marks.
find . -name *.txt works only by luck: with one match in the current directory the shell rewrites it to -name a.txt, and with two, find gets a stray argument and fails (macOS find says unknown primary or operator). Quote the pattern, as in '*.txt', whenever the command rather than the shell should do the matching.
An open quote keeps the command going across lines. At a prompt bash shows the secondary prompt, PS2, which defaults to > , until you close it, and the newline you typed becomes a character inside the argument. If that value is later expanded unquoted, it splits at the newline, because newline is in the default IFS.
C. Simple commands, pipelines and lists
Assignments before the name and redirections anywhere in the line are set aside first, so >out echo hi is legal. An unquoted expansion that comes out empty disappears and the next word becomes the command: $e ls runs ls, while "$e" ls tries an empty command name and fails with status 127. A line left with only assignments changes the current shell.
Error messages skip the pipe and still reach the terminal, which is why cmd | grep error can miss them. cmd 2>&1 | grep error merges the streams first, and |& is bash shorthand for exactly that. It arrived in bash 4.0, so the bash 3.2 that macOS ships rejects it as a syntax error, and POSIX sh has no |& either.
false | true reports 0, so grep x missing.log | sort succeeds even though grep failed, and set -e never notices. set -o pipefail makes a pipeline return the status of the rightmost stage that failed, or 0 if none did. POSIX added pipefail in its 2024 edition; the dash shipped with macOS rejects it.
After a | b | c, ${PIPESTATUS[0]} to ${PIPESTATUS[2]} hold each stage's status. The next command, even an echo or a test, replaces the whole array, so copy it immediately with s=("${PIPESTATUS[@]}"). A plain simple command also sets it, to a single element. It is a bash array with no equivalent in POSIX sh.
echo hi | read x sets x in a child that exits at once, so x is unchanged afterward, and a counter bumped inside cmd | while read is lost the same way. bash 4.2 added shopt -s lastpipe, which runs the last stage in the current shell, but only while job control is off, as it is in a script.
a && b || c is not if-else: c also runs when a succeeds and b then fails. Because the two operators group from the left, a || b && c runs c after either a or b succeeds. The list's status is that of the last command that ran, and set -e ignores a failure anywhere except the command after the final operator.
Because cmd & always reports 0, a failure surfaces only when you wait: cmd & pid=$!; wait "$pid" returns cmd's real exit status. $! changes with each new background job, so save it right away. In a script, where job control is off, a background command reads from /dev/null unless you redirect its input.
Without the space, !false is looked up as a command named !false, which fails with status 127 in a script and triggers history expansion at an interactive prompt. A negated command never trips set -e, whichever way it turns out, so ! grep -q x f cannot abort the script. ! applies to the whole pipeline that follows it.
As a reserved word, time wraps the entire pipeline and can time functions and builtins, which an external /usr/bin/time cannot. Its report goes to the shell's stderr, not the command's, so time cmd 2>log still prints on the terminal; capture it with { time cmd; } 2>log. The TIMEFORMAT variable sets the layout.
D. The expansion order, and word splitting
The order explains three classic surprises: braces cannot see variables, a $(cmd) result is still split and globbed unless quoted, and only quotes you typed disappear because quote removal runs last. Tilde, parameter, arithmetic and command substitution share one left-to-right pass. Only braces, splitting and globbing turn one word into several, besides "$@" and "${a[@]}".
With a='$HOME', echo $a prints the literal text $HOME, because a value is inserted once and never read as code. ${!x} gives one controlled level of indirection. eval re-parses the entire line, so a ; or $(...) inside the value runs as a command, which makes eval on untrusted input dangerous. declare -n, added in bash 4.3, is safer.
Text you type is never split by IFS: with IFS=,, the literal word a,b stays one argument, but $v holding a,b becomes two. The right side of an assignment, the words inside [[ ]] and the word after case are not split either, so x=$y is safe unquoted. With IFS unset, splitting acts as if it held space, tab and newline.
With the default IFS, ' a b ' splits into exactly two fields. With IFS=,, a,,b gives three fields with an empty middle one, yet a trailing delimiter adds no empty last field, so a,,b, is still three. With IFS=' ,', spaces next to a comma merge into that delimiter, so ' a , b ' gives two clean fields.
Run on its own line, IFS= stops splitting for every later expansion until you unset IFS or restore the old value. As a prefix it does not touch the command's own arguments, because they are expanded first; it matters only to code that runs during the command, such as read or a function body. Even with IFS empty, an unquoted empty expansion still vanishes from the argument list.
The prefix works because read consults IFS while it runs, and the assignment ends with that command, so IFS is back to normal on the next line. IFS= read -r line uses the same trick to keep leading and trailing spaces. It cannot change expansion: in IFS=, printf '%s\n' $v, $v was already split with the old IFS.
Braces are expanded before $n is replaced, so the shell sees {1..$n} as an invalid sequence, leaves it alone, and you get the literal text {1..3}. Use for ((i=1; i<=n; i++)) or seq 1 "$n"; eval "echo {1..$n}" works but re-parses the whole line. A ${ also blocks brace expansion until its closing brace.
Both ends are included and the step defaults to 1 or -1, so {10..1..3} gives 10 7 4 1. Once either end starts with 0, every term is padded to the same width. Letter ranges follow C-locale character order, so {a..E} runs through punctuation. In the bash 3.2 that macOS ships, the stepped form stays literal and {01..10} loses its padding.
echo file{1,2}.txt prints both names even when neither exists, in the order you wrote them, while echo file*.txt lists only existing matches, sorted, or the pattern itself when nothing matches. So braces suit creating and renaming, as in mkdir -p src/{lib,bin} or mv a.{txt,bak}, and globs suit selecting. POSIX sh has no brace expansion.
A quoted tilde, as in "~/x", or one in mid-word stays literal, so write "$HOME/x" when you need quotes. Assignments add an exception: bash also expands a tilde right after = or :, which is why PATH=$PATH:~/bin works. ~+ is PWD and ~- is OLDPWD. An unknown user name leaves the word unchanged.
Forward arguments with "$@", which keeps 'a b' c as two arguments. "$*" builds one string joined by the first IFS character, a space by default and nothing when IFS is empty, which suits a message rather than forwarding. Unquoted, $@ and $* behave the same and re-split every argument.
E. Parameter expansion operators
A variable name runs as long as letters, digits and underscores continue, so "$file_bak" reads a variable named file_bak, usually unset and silently empty unless set -u is on. Braces end the name: "${file}_bak". They are also required past $9 and for every operator form, such as ${v:-x}.
Use ${v:-word} when an empty value should also get the default, and ${v-word} when an explicitly empty value means something and must be kept. Neither form changes v. word is expanded only when it is needed, so ${v:-$(slow_cmd)} never runs the command while v has a value.
: "${v:=word}" is the idiom: the : builtin does nothing, so the line exists only to assign. The colon form treats empty as missing, while ${v=word} assigns only when v is unset. On $1 or any positional or special parameter it fails with cannot assign in this way, so copy arguments into named variables first.
Leave the message out and bash prints a stock one, parameter null or not set. A non-interactive shell then exits with a nonzero status; an interactive one only skips that command. Without the colon, ${v?msg} rejects unset but lets an empty value through. It is a one-line guard for required input: : "${API_KEY:?set API_KEY}".
The colon form needs v set and non-empty; ${v+word} also fires when v is set but empty. Only word is substituted, never the value of v, so cmd ${verbose:+--verbose} adds the flag when verbose holds anything. Leave it unquoted: "${v:+x}" passes an empty argument when the flag is off, while the bare form passes none.
${#v} counts characters as the locale defines them: in a UTF-8 locale a string with one accented letter counts one less than its bytes, while LC_ALL=C makes it count bytes (checked in bash 5.3). For an array, ${#arr[@]} is the element count, and ${#arr} measures only element 0.
Offsets start at 0 and length counts characters, so ${s:1:3} of abcdef is bcd. Without the space, ${s:-2} is the default-value operator and returns all of abcdef; write ${s: -2} or ${s:(-2)} for ef. A negative length, added in bash 4.2, counts back from the end. POSIX sh has no substring expansion.
With p=archive.tar.gz, ${p#*.} is tar.gz and ${p##*.} is gz. The pattern must match at the very start; when it does not, nothing is removed and the value comes back unchanged. A memory aid: on a US keyboard # sits left of $ and % sits right of it, so # trims the front.
With p=archive.tar.gz, ${p%.*} is archive.tar, the usual way to drop one extension, while ${p%%.*} gives archive, which also chops a dotted name such as v1.2.notes down to v1. A pattern that matches no suffix leaves the value unchanged. Both forms are POSIX, so they work in sh too.
They agree with the commands on ordinary paths and differ at the edges. For c.txt, ${p%/*} returns c.txt where dirname prints .. For /a/b/, ${p##*/} is empty where basename prints b. For /, both expansions are empty while both commands print /. Strip trailing slashes first, or call the commands when paths may be odd.
The match is the longest possible, so with s=a.b.c, ${s/a*b/Q} gives Q.c. ${v/#pat/rep} anchors at the start and ${v/%pat/rep} at the end, and an empty replacement deletes, as in ${v//[[:space:]]/}. bash 5.2 added patsub_replacement and shipped it switched on, so an unquoted & in the replacement inserts the matched text.
A . is a literal dot and * is any run of characters, so ${v%.*} drops an extension and ${v//[0-9]/} deletes every digit. Regex repetition such as + or {2} does not exist unless extglob adds +(...) and its relatives. Quote a pattern held in a variable to match it literally: with prefix='*', ${v#"$prefix"} removes a real *.
With no pattern every character qualifies; add one to restrict it, so ${v^^[aeiou]} uppercases only vowels, and ${v^} capitalizes just the first letter. Bash 4.0 introduced these forms, and macOS's /bin/bash, still version 3.2, stops on them with bad substitution; tr '[:lower:]' '[:upper:]' is the portable route.
With name=PATH, ${!name} returns PATH's value. The ! must come right after {, and ${!prefix*} and ${!arr[@]} are different operators that list variable names and array keys. Indirection only reads; to assign through a name, use printf -v "$name" '%s' value or a nameref, which needs bash 4.3.
F. Parameters, variables and attributes
x = 1 runs a command named x with the arguments = and 1, which usually fails with command not found. x= 1 is subtler: it runs a command named 1, with x set to empty in that command's environment. Both lines are valid syntax, so nothing warns you until they run.
Only exported variables are copied into a child's environment, so y=1; bash -c 'echo $y' prints an empty line until you export y. The copy is one-way: a child can never change its parent's variables, which is why a script cannot set variables in the shell that started it unless you source it. Variables inherited at startup are already exported.
The value exists only in that command's environment and is gone on the next line. Expansion happens before the assignment applies, so FOO=3 echo "$FOO" prints the old value, not 3. Before a shell function, the variable is local to that call. The common use is tuning one program, as in LC_ALL=C sort file.
${v+x} gives x when v is set, even to an empty string, and nothing when it is unset, so [ -n "${v+x}" ] tests existence. [ -z "$v" ] cannot tell the two apart. Under set -u, reading an unset variable is fatal but reading an empty one is fine. bash 4.2 added [[ -v v ]] for the same test.
$10 is read as $1 followed by a literal 0, so with arguments a through k it prints a0. Positional parameters cannot be assigned with =; set -- new args replaces them all. Inside a function they are the function's own arguments, and the script's are hidden until it returns.
shift 2 drops $1 and $2 and lowers $# by 2. Asking for more than $# changes nothing and returns status 1, which ends a script running under set -e. A common parsing loop is while (($#)); do case $1 in ...; esac; shift; done, which consumes one argument per pass until none are left.
$0 is the script name and is never counted, so a script run with no arguments has $# of 0. The trap is bash -c: the first word after the command string becomes $0, not $1, so bash -c 'echo $1' one prints an empty line. Pass a placeholder first, as in bash -c 'echo $1' _ one.
Every command resets it, including echo, a test and a plain assignment, so cmd; echo failed; exit $? exits 0. Save it at once with rc=$?, or test the command directly with if ! cmd; then. Values run from 0 to 255: 126 means not executable, 127 not found, and 128+N killed by signal N.
Inside ( ... ) or $(...), $$ still reports the parent shell's pid, which matters when you build temp file names or signal a process from a subshell. $BASHPID, added in bash 4.0, is the pid of the process actually evaluating it; macOS's stock bash 3.2 lacks it, so there it expands to nothing.
Attributes change later assignments: with -i, n='2+3' stores 5, and a word like abc is evaluated as a variable name, often giving 0. -r cannot be undone, not even with unset. Inside a function, declare makes the variable local unless you add -g. Associative -A arrays need bash 4.0, which rules out the 3.2 that Apple installs as /bin/bash.
A nameref lets a function fill in a variable whose name the caller passes, as in f() { declare -n out=$1; out=done; }; f result. If the caller passes the nameref's own name, bash warns about a circular name reference, so give namerefs names callers will not use. They arrived in bash 4.3 and are missing from the bash 3.2 that macOS ships.
G. Command and process substitution
In the old backtick form, a backslash before $, a backtick or another backslash is consumed before the inner command runs, so echo '\\' captured with backticks yields one backslash and with $(...) yields two (bash 3.2 and 5.3 agree). Nested backticks need escaping; $(...) nests as written. The manual calls backticks deprecated.
That is why x=$(echo hi) holds hi without a newline, but it also drops blank lines at the end of a file you meant to copy exactly. Newlines in the middle survive. To keep the trailing ones, append a sentinel and strip it: x=$(cmd; printf .); x=${x%.}.
for f in $(ls) breaks names containing spaces into pieces, and an output line of * becomes every file in the directory. Write "$(cmd)" when you want the output as one argument, and process output line by line with while IFS= read -r line; do ...; done < <(cmd) rather than for.
$(...) runs in a copy of the shell, so assignments, cd and exit inside it vanish when it ends; only its standard output comes back. Print the value you need and assign it outside, as in x=$(compute). bash 5.3 added ${ cmd; }, which captures output while running in the current shell, so its side effects persist.
In a bare assignment, x=$(cmd) leaves cmd's status in $?, so if x=$(cmd); then works. Wrap it in another command and that command's status wins: local x=$(cmd), declare and export all return 0 and hide the failure. Declare first, then assign on its own: local x; x=$(cmd).
The inner command runs alongside yours, connected by a pipe that appears as a filename, /dev/fd/63 on macOS, or a named pipe on systems without /dev/fd. That lets diff <(sort a) <(sort b) compare two outputs with no temp files. No space may separate < from (, and it is a bash extension, not POSIX sh.
cat <(exit 7) reports 0 because the status is cat's own, and neither set -e nor pipefail sees the inner failure. bash 4.4 began putting the last process substitution's pid in $!, so a wait $! right afterward recovers its status, 7 here (checked in bash 5.3).
With the loop on the right of a pipe, the whole while runs in a child and its counters die with it. Feeding it with done < <(cmd) keeps the loop in your shell, so totals survive. Write the reader as while IFS= read -r line to keep backslashes and edge spaces, and use done < file when the input is already a file.
H. Arithmetic evaluation
A bare name inside is evaluated as an expression, while $x is pasted in as text first, and the two can differ: with x='2+3', $(( x*2 )) is 10 but $(( $x*2 )) is 8. The result is a decimal string. $((...)) itself is POSIX, but ** is a bash extension that dash rejects.
The status follows C truth, so if (( count > 10 )) reads naturally: a zero result gives status 1, any other result gives status 0, negative ones included. Inside ((...)), < and > compare numbers, unlike in [ ]. It is a bash command, so a #!/bin/sh script writes [ "$n" -gt 10 ] instead.
$(( 7/2 )) is 3 and $(( -7/2 )) is -3, because the fraction is dropped rather than rounded down. A decimal such as 1.5 is a syntax error, dividing by zero is an error, and overflow wraps silently, so $(( 2**63 )) comes out negative. For fractions, scale to integers, such as cents, or use bc or awk.
It follows from a == (a/b)*b + a%b: -7/3 truncates to -2, so the remainder must be -1, and 7 % -3 is 1. So n % 2 == 1 misses negative odd numbers; test n % 2 != 0, or use ((n % m + m) % m) when you need a value from 0 to m-1.
i++ yields the old value, and an arithmetic command whose value is 0 returns status 1, so the first ((i++)) from 0 fails and set -e ends the script at a counter. Bash 4.1 is what put (( under set -e; the 3.2 at /bin/bash on macOS still returns 1 but keeps going. Use ((++i)) when counting up from 0, i=$((i+1)), or ((i++)) || true.
From 0, $(( i++ )) substitutes 0 and leaves i at 1, while $(( ++i )) substitutes the new value. Both change the variable as a side effect, even inside $(( )), unlike $(( i+1 )), which leaves i alone. They need a variable to act on: $(( 5++ )) is a syntax error.
Zero-padded values such as dates and times break arithmetic: $(( 08 )) fails with value too great for base, and 010 silently means 8. Force decimal with 10#$n, so n=08 gives 8. Bases run from 2 to 64, so 2#101 is 5 and 16#ff is 255.
A misspelled name therefore gives a quiet 0 instead of an error, as does an empty string. set -u turns a reference to an unset name into an unbound variable error, even inside $(( )). A non-numeric value is itself treated as a name, so with x=abc you get the value of abc, usually 0.
Two rankings surprise people. Unary minus binds tighter than **, so $(( -2**2 )) is 4, not -4. Inside (( )), && binds tighter than ||, so 1 || 0 && 0 is 1, while the shell's own command && and || have equal precedence. Shifts sit between + - and the comparisons, bitwise & ^ | between == and &&; parenthesize when unsure.
Bash evaluates an index such as ${arr[$i]} as an expression, so if i holds text like x[$(cmd)], the shell runs cmd while working out the index, and $(( i )) does the same (true of both 3.2 and 5.3). Validate input before it reaches an index, for example with [[ $i =~ ^[0-9]+$ ]].
I. Filename expansion and pattern matching
In a regex, a* means zero or more a characters; as a glob it means a followed by anything. A . is a plain dot in a glob, and ? is exactly one character rather than an optional one. Inside [[ ]] the two languages sit side by side: == *.txt is a glob, while =~ \.txt$ is a regex.
A bracket expression always matches exactly one character. Bash also accepts [^a], but POSIX specifies only [!a] for negation. To include ], put it first; to include -, put it first or last. bash 5.0 turned globasciiranges on by default, so a range like [a-z] follows plain character order rather than the locale's.
The class needs its own brackets inside the outer pair: [[:digit:]] matches one digit, while [:digit:] alone is a set of the characters :, d, i, g and t, so it matches d but not 5 (checked in bash 5.3). Classes follow the locale, and bash adds [[:word:]], which covers letters, digits and underscore.
A . at the start of a name, or right after a /, must be matched by a literal dot, so * skips .env. .* or shopt -s dotglob brings such files in, though . and .. still need a pattern that starts with a dot. bash 5.2's globskipdots, on by default, keeps them out of .* too, but the bash 3.2 on macOS returns both.
In filename expansion every / must appear literally in the pattern, so *.c finds files in the current directory only and */*.c exactly one level down. The rule is specific to filenames: in case and [[ == ]], * matches across slashes, so [[ a/b == a*b ]] is true. For any depth use globstar or find.
With it on, **/*.c finds .c files at any depth, the current directory included, and **/ alone matches only directories. Without the option, ** behaves like *. Hidden directories stay skipped unless dotglob is also set. The option is new in bash 4.0, so macOS's stock 3.2 answers shopt -s globstar with invalid shell option name.
By default for f in *.log runs once with f holding the literal *.log when nothing matches. nullglob removes the word, which fixes that loop but turns ls *.log into a bare ls that lists everything. failglob prints no match, sets status 1 and abandons the rest of that line, or the whole function or compound command around it. Both options are bash extensions.
The option changes how bash parses parentheses, so it must be on before the line holding the pattern is read: shopt -s extglob earlier on the same line is too late and gives a syntax error. !(*.txt) matches everything except text files. bash 4.1 made the right side of [[ == ]] accept these patterns without the option; bash 3.2 rejects them there.
Results come back sorted by the locale's collation, so one directory can list differently on two machines: in one UTF-8 test A3 landed between a1 and ab, while LC_ALL=C put the capital first. Set LC_ALL=C when a script depends on the order. bash 5.3 added GLOBSORT, which can sort by name, size or mtime.
One pattern language covers filenames, case labels, [[ $x == pat ]] and the #, % and / operators. Outside filename matching, * crosses / and a leading dot needs no special match. In case and [[ ]], quoting the pattern makes it literal: [[ $x == "a*" ]] checks for the two characters a*.
J. Redirection and file descriptors
The number must touch the operator: cmd 2>err redirects stderr, but cmd 2 >err passes 2 as an argument and redirects stdout. >&2 is short for 1>&2 and sends a message to stderr, where diagnostics belong. The read-write operator <> also defaults to descriptor 0.
Truncation happens before the command starts, so sort f > f leaves f empty: the shell clears the file, then sort reads nothing. Write to a temporary file and move it into place instead. >> never truncates, which makes it the safe choice for logs.
With the option on, > refuses an existing regular file with cannot overwrite existing file, yet >> still appends and writing to /dev/null still works, because only regular files are protected. >| overrides the guard for one redirection, and set -C is the same switch.
Redirections apply left to right as a series of copies. In >file 2>&1, stdout is pointed at the file, then stderr copies that destination. In 2>&1 >file, stderr copies the terminal first, and moving stdout later does not drag it along. That order is useful: cmd 2>&1 >/dev/null | grep x pipes stderr alone.
2>&1 makes descriptor 2 another handle on wherever descriptor 1 points at that instant, and later changes to 1 leave 2 alone. The same works for any numbers, so exec 3>&1 saves the original stdout before you redirect it, and >&3 writes there later. Duplicating a descriptor that is not open fails with Bad file descriptor.
2>&- or <&- closes a descriptor for one command; with exec it stays closed for the rest of the script. A program that writes there gets an error rather than silent loss: echo hi >&- fails with Bad file descriptor and status 1. To throw output away, send it to /dev/null instead.
&> already works in macOS's bash 3.2, but &>> only appeared in bash 4.0 and is a syntax error there. A POSIX shell reads cmd &>file as cmd & followed by >file, so dash runs the command in the background and truncates the file. In #!/bin/sh scripts write >file 2>&1.
A redirection on { ...; }, a loop, an if or a function definition is set up once and inherited by every command inside, so { echo a; echo b; } > out writes both lines. A command inside can still override it with its own redirection. On a function definition, the redirection is performed each time the function runs.
A > file inside the body reopens and truncates the file on every pass, so only the last iteration's output survives. Placing the redirection after done opens the file once and keeps every line. Input works the same way: while read -r l; do ...; done < file reads the file through, line by line.
With no command, exec applies its redirections to the shell itself, so exec >log 2>&1 sends everything after it to log. Save the old destination with exec 3>&1 first if you want to restore it later with exec 1>&3 3>&-. Given a command, exec replaces the shell instead, and nothing after that line runs.
Descriptors 3 to 9 are yours to use; higher numbers may clash with ones bash keeps internally. Reading through read -u 3 leaves stdin free for the loop body, so commands inside cannot swallow the input. bash 4.1 added exec {fd}<file, which picks a free number of 10 or more and stores it in fd; close it with exec {fd}<&-.
Redirections are performed before the command runs, so nosuchcmd > out creates an empty out, or truncates an existing one, and only then fails with status 127. A redirection error, such as a missing directory, stops the command from running at all. Check that a command exists before pointing it at a file you care about.
K. Here-documents and here-strings
The delimiter is any word you choose, and the rest of the command can follow it on the first line, as in cat <<EOF | sort or cmd <<EOF > out; the body starts on the next line. Put a number in front, as in 3<<EOF, to feed a different descriptor. Here-documents are standard POSIX syntax, so dash and other sh shells accept them.
Quoting any part of the word, as 'EOF', "EOF" or \EOF, turns off expansion for the whole body, and the terminator is the word without its quotes. Unquoted, the body acts like a double-quoted string except that " is not special, and a backslash escapes $, backticks and \. Quote it when the body is code.
Tabs are stripped from every body line and from the terminator line, which lets you indent the block inside a function. An editor that converts tabs to spaces quietly breaks it: a space-indented EOF no longer ends the body, and bash keeps reading. Tabs or spaces after the first non-tab character are kept.
The line must match the delimiter exactly, so EOF or EOF is just another body line; the <<- form forgives leading tabs only. Bash then reads to the end of the file, swallowing the rest of the script as input, and warns that the here-document was delimited by end-of-file.
cmd <<<"$v" feeds one value without a pipe, so read runs in the current shell and read -r a b <<<"$line" keeps its variables, unlike echo "$line" | read a b. The added newline matters for byte counts: wc -c <<<abc reports 4. It is a bash extension that dash rejects.
-d '' makes read stop at a NUL byte, and a here-document contains none, so read hits end of file, fills the variable anyway and returns 1, which set -e treats as failure (checked in bash 5.3). Add || true, and write IFS= read -r -d '' to keep leading whitespace that plain read trims.
L. Conditional expressions: test, [ and [[
Because [ is run like any other command, the shell has already split the line into words before [ sees them. [1 = 1 ] looks for a command named [1 and fails with 127, and [ "$a"="$b" ] passes one non-empty word, so it is always true. Each operand and operator has to arrive as a separate argument.
The parser recognizes [[ before any expansion happens, so $x stays one operand even when it is empty or holds spaces, and a * inside a value is never matched against filenames. Parameter expansion, command substitution and quote removal still run. The cost is portability: bash, ksh and zsh have [[, but dash reports [[: not found.
With x= the shell hands [ the words = y ], and bash complains 'unary operator expected'; with x='a b' it gets four operands and reports 'too many arguments'. Both return status 2, which an if treats as false, so the else branch runs with only a stderr line to show for it. Quote it as [ "$x" = y ], or use [[ ]].
POSIX test defines = and != but not ==, and dash answers [ a == b ] with 'unexpected operator', so a #!/bin/sh script must use =. Bash's [ accepts both. Inside [[ ]] the two are identical, and both read an unquoted right side as a glob pattern, so [[ $a == $b ]] is a pattern match unless $b is quoted.
[[ $f == *.txt ]] is true for a.txt, while [[ $f == "*.txt" ]] matches only the literal text *.txt. Quoting can be partial: [[ $f == "$dir"/* ]] takes the variable's characters literally and still lets * match. Only the right side is a pattern, so [[ *.txt == $f ]] compares the left side as plain text and is false for a.txt.
=~ matches a POSIX extended regex anywhere in the string unless you anchor it with ^ and $. On success BASH_REMATCH[0] holds the whole match and [1] onward the capture groups. Since bash 3.2 any quoted part of the pattern matches literally, so store the regex in a variable and write [[ $s =~ $re ]] unquoted. A malformed regex returns 2.
[[ 10 < 9 ]] is true because the strings are compared character by character and '1' sorts before '9'; [[ 10 -lt 9 ]] or (( 10 < 9 )) gives the numeric answer. Single brackets add a worse trap: in [ 10 < 9 ] the < is an input redirection from a file named 9, so a string compare there must be written \<.
Most of these follow symlinks: -f is true for a link to a regular file and -e is false for a link whose target is gone. Only -L (or -h) examines the link itself, so [ -L p ] || [ -e p ] asks whether anything sits at that path. -s is false for an empty file, and -r, -w, -x ask whether you have that access, not who owns the file.
Quoting matters most here: with x empty, [ -n $x ] becomes the one-argument test [ -n ], which is true because '-n' itself is a non-empty string, while [ -z $x ] happens to give the right answer. [ "$x" ] is that same one-argument test used deliberately. In [[ ]] nothing is split, so [[ -n $x ]] is safe unquoted.
With four or more arguments [ falls back to precedence parsing, so a value such as ! or ( can be taken for an operator. POSIX.1-2024 removed -a, -o and the parentheses from test, so write [ "$a" ] && [ "$b" ]. Inside [[ ]], combine with &&, || and ( ); a binary -a there is a syntax error.
(( expr )) returns status 0 when the expression is non-zero, so (( 0 )) is false and (( count )) tests for non-zero. Names are read as variables without $, an empty or unset one counts as 0, and > means numeric greater-than. POSIX sh has the $(( )) expansion but no (( )) command; dash parses it as nested subshells with > as a redirection.
M. Compound commands and control flow
Bash runs the list between if and then and takes the then branch when the last command in it exits 0, so if grep -q err log needs no brackets; [ is one command among many. A common slip is if [ $(cmd) ], which tests whether the command printed something, not whether it succeeded. When no branch matches and there is no else, if returns 0.
Only the last command of the condition list decides: in while cmd1; cmd2; do, cmd1 runs on every pass but its status is ignored. while repeats as long as that status is 0 and until as long as it is non-zero, so until ping -c1 host; do sleep 1; done waits for success. Either loop returns 0 when the body never ran.
The list is expanded first and the body runs once per resulting word. An unmatched glob is the usual surprise: for f in *.log with no matches runs once with f set to the literal *.log, unless shopt -s nullglob makes the pattern vanish. With the in clause left out, the loop walks the positional parameters, as if in "$@" were written.
All three clauses are arithmetic, so names need no $ and < is numeric. The first clause runs once, the second is checked before each pass, and the third runs after each pass of the body. An omitted clause counts as 1, so for ((;;)) loops until break. The form is a bash extension outside POSIX; an sh script counts with while [ "$i" -lt "$n" ].
Leave out IFS= and read trims leading and trailing whitespace, so indentation disappears. Leave out -r and each backslash escapes the next character, so C:\new arrives as C:new, and a trailing backslash joins the next line. With both, each line arrives as written minus its newline. The IFS= prefix applies to that one read, not to the rest of the script.
When input ends without a newline, read still puts that partial last line in the variable but returns 1, so while read -r line stops before processing it. while IFS= read -r line || [[ -n $line ]] keeps it. mapfile stores such a line normally, which is one reason the loop is the place it goes missing.
Patterns are tried top to bottom and the first match wins, so a catch-all *) goes last. ;& runs the next clause's commands without testing its pattern, while ;;& goes on to test the next patterns. Both arrived in bash 4.0; POSIX.1-2024 adopted ;& but not ;;&. The case word is not split or globbed, so case $x in needs no quotes.
break 2 leaves the current loop and the one enclosing it, and continue 2 resumes the next pass of the outer loop. A count larger than the nesting depth just leaves every loop. Since bash 4.4, break inside a function cannot reach the caller's loop, though the bash 3.2 on macOS still lets it; inside cmd | while read it ends only that pipeline's subshell loop.
Braces are reserved words, so they need a space after { and a ; or newline before }; parentheses are operators and need neither. Reach for { } to redirect or guard several commands while keeping their effects, as in { echo a; echo b; } > log. Reach for ( ) when a change such as cd or set -e should stay contained.
A subshell works on a copy of the shell, so ( cd /tmp; x=1; trap '...' EXIT ) changes only that copy, and the parent keeps its directory, variables and traps. A trap set inside does run when the subshell itself ends. The same copying applies to $(...), background jobs and pipeline stages, so results have to come back as output, a status or a file.
Every stage of a pipeline is a subshell, so counters and arrays set inside cmd | while read vanish with it. Write while read ...; done < <(cmd) so the loop runs in the current shell, or turn on shopt -s lastpipe (bash 4.2), which works only while job control is off, as in scripts. Process substitution is bash-only; POSIX sh can use a here-document.
N. Shell functions and scoping
POSIX specifies only name() compound-command. The ksh-style function name { } form is a bash extension that dash rejects as a syntax error. In both forms the body may be any compound command: f() ( cd /tmp; ls ) runs in a fresh subshell on every call, so its cd never leaks out.
{ and } count only as separate words, so f() {echo hi; } is a syntax error, and in f() { echo hi } the } becomes an argument to echo and bash keeps reading until end of file. The closing brace must start a command, so it needs a ;, & or newline before it, which gives the one-line form f() { echo hi; }.
During a call $1, $@ and $# describe the function's own arguments and are restored afterwards, so a function cannot see the script's arguments unless they are passed in, as with main "$@". $0 still holds the script name, or under bash -c whatever word you passed after the command string, if any. For the function's own name, read FUNCNAME[0].
return stops the current function, or a file being sourced, and hands its caller a status; execution continues after the call. exit stops the whole shell, so used in a sourced file or in a function you loaded interactively it ends your session. A function that finishes without return reports the status of the last command it ran.
A status is a single byte: return 300 arrives as 44 and return -1 as 255, since only the low 8 bits survive. To hand back text or a large number, print it and capture it with v=$(f), sending other output to stderr. Capturing runs f in a subshell, so its variable changes are lost; a nameref (bash 4.3) or printf -v avoids the subshell.
Every assignment in a function writes to the global scope unless the name was declared local, or with declare, which is also local inside a function. That includes a for loop's variable, so a helper looping over i overwrites its caller's i. Declare locals at the top of the function, before anything assigns them.
A local is visible to every function its owner calls, so a callee that assigns x changes the caller's local rather than a global, and the change disappears when the caller returns. Which x a function sees depends on who called it, not on where it was written. Pass values as arguments rather than relying on a name some caller happens to define.
In local x=$(cmd) the status left in $? belongs to local, which succeeded, so it is 0 and set -e never fires even when cmd failed. Split it into local x and then x=$(cmd); a plain assignment keeps the substitution's status. declare, export and readonly mask failures the same way, and ShellCheck reports the pattern as SC2155.
FUNCNAME[0] is the running function, [1] its caller, and the last element is main in a script; with BASH_SOURCE and BASH_LINENO it yields a stack trace. Bash sets no recursion limit by default, so runaway recursion grows until the process runs out of stack and crashes. Setting FUNCNEST to a number caps the depth and stops with an error instead.
export -f f stores the function in the environment as a variable named BASH_FUNC_f%%, which a child bash turns back into a function at startup, so it reaches bash -c and scripts run by bash. dash and zsh strip that oddly named variable, so the function is lost if either sits in between, and other programs just carry it as data. Sourcing a file of definitions is the portable way to share functions.
O. Arrays and associative arrays
Each word inside the parentheses goes through the normal expansions, so unquoted words split on whitespace and a glob such as *.sh adds one element per matching file. Quote anything that must stay whole: a=(one "two three") has two elements. For the same reason a=($(cmd)) splits output on IFS and globs it, which is why mapfile is safer for lines.
unset 'a[1]' removes an element without renumbering the others, so the indices become 0, 2, 3 while ${#a[@]} drops to 3. A counting loop from 0 to ${#a[@]}-1 then visits the empty slot 1 and never reaches index 3. Quote the argument to unset, since an unquoted a[1] is also a glob pattern that could match a file.
Quoted, "${a[@]}" turns each element into exactly one word, spaces included, which is what loops and command arguments need. Unquoted, each element is split on IFS and globbed, so ("x y" z) comes out as three words. An empty array expands to no words at all, not to one empty string.
"${a[*]}" glues the elements together using the first character of IFS: with IFS=, the array ("x y" z) becomes x y,z, an empty IFS joins with nothing, and an unset IFS uses a space. Change IFS inside a subshell, (IFS=,; echo "${a[*]}"), so the rest of the script keeps its splitting. To pass elements on separately, use "${a[@]}".
Reading $a gives only element 0, so echo "$a" on (p q r) prints p with no warning, and writing a=x replaces element 0 while keeping the rest. The rule runs both ways: bash treats any plain variable as a one-element array, so ${s[0]} is its value. ShellCheck flags the read side as SC2128.
a+=(x y) adds elements after the highest existing index, so a sparse array ending at index 6 continues at 7. Without the parentheses, a+=3 appends text to element 0, turning (1 2) into (13 2). Applied to a plain string, s+=(more) makes it an array with the old value at index 0. += and arrays are bash extensions, absent from POSIX.
In ${a[@]:1:2} the offset is an index and the length counts elements, so on a sparse array the slice starts at the first element whose index is at least 1. A negative offset counts back from the end but needs a space, ${a[@]: -2}, or bash reads :- as a default value. For positional parameters offset 0 is $0, so "${@:2}" begins at $2.
Without declare -A, m[apple]=1 reads the subscript as arithmetic: apple is an unset variable worth 0, so every key lands on index 0 and the last write wins, silently. Declare first; inside a function declare -A creates a local array unless you add -g. Associative arrays arrived in bash 4.0, and macOS's /bin/bash 3.2 rejects -A as an invalid option.
Keys come back in the hash table's internal order, which matches neither insertion nor sorting: in bash 5.3, keys added as apple through elder came back as cherry, apple, elder, date, banana. Bash documents no ordering at all. When order matters, sort the keys, printf '%s\n' "${!m[@]}" | sort, or keep an indexed array of keys alongside.
"${!a[@]}" expands to the indices that actually hold values, so for i in "${!a[@]}" skips the holes in a sparse array and works unchanged for associative keys. A counting loop from 0 to ${#a[@]} can do neither. Keep the expansion quoted, since associative keys may contain spaces, and read each value as "${a[$i]}".
The environment holds only name=value strings, so export a on an array returns 0 yet passes nothing a child can use; a child bash sees $a as empty. To hand an array to another program, pass its elements as arguments, cmd "${a[@]}", or write them out one per line. Between bash processes, declare -p a produces text a child can eval.
P. Exit status and error handling
Bash returns 127 when the command cannot be found and 126 when the file exists but cannot run, such as a script without its execute bit. A process killed by signal n reports 128+n, so SIGTERM (15) shows as 143, and builtins return 2 for usage errors. These are conventions, not proof: a program can exit 130 on its own.
-e exits on an unchecked failure, -u makes expanding an unset name an error, and -o pipefail lets any failed stage fail the whole pipeline. None is complete: -e skips tested commands, -u exempts $@ (bash 4.4 extended that to an empty "${a[@]}"), and pipefail turns a reader that quits early into status 141. Treat them as a net, not as error handling.
set -e exits when any pipeline, list or compound command returns non-zero outside a tested context, so it covers more than simple commands. A failing $(...) aborts a plain assignment such as x=$(false), but not when it sits inside another command's arguments: echo "$(false)" succeeds because echo does. An ERR trap, if set, runs just before the exit.
set -e stays quiet for the condition of if, elif, while and until, for every member of an && or || list except the last, and for anything after !. The last member still counts, so true && false exits, and so do commands in a then branch. Meanwhile false && true returns 1 without exiting, so a final [ -f x ] && echo can end a script with status 1.
When a function runs as an if condition, in an && or || list, or after !, -e is ignored for every command in its body, so a failure halfway through is passed over and the function returns its last command's status. Even a set -e inside the function stays inert until that call finishes. Check failures explicitly in any function you also use as a test.
A pipeline's status is its last stage's, so grep pat missing.log | sort returns 0 even though grep failed with 2, and set -e carries on. ! in front of a pipeline inverts its status and also exempts it from -e. PIPESTATUS records every stage, but the next command overwrites it, so copy the array right away.
With set -o pipefail, the pipeline takes the status of the last stage, counting from the left, that exited nonzero, and returns 0 only when every stage succeeded. The side effect is SIGPIPE: in yes | head -1, head exits early, the writer is killed for writing to a closed pipe, and the pipeline reports 141 (128 + 13). Bash has had it since 3.0, and POSIX.1-2024 added it to set -o.
set -u turns expansion of a never-assigned name into an error that stops a non-interactive shell, catching typos such as $conifg. A variable set to the empty string is fine. When unset is legitimate, write ${v:-} or ${v-} for an empty default, or test with [[ -v v ]] (bash 4.2). $@ and $* are exempt even when there are no arguments.
In bash 4.3 and earlier, set -u treated an array with no elements as unset, so "${a[@]}" aborted with 'unbound variable'; bash 4.4 changed this. macOS's /bin/bash 3.2 still fails, which breaks strict-mode scripts there. The guard ${a[@]+"${a[@]}"} expands to nothing for an empty array and to the quoted elements otherwise.
set -x writes each command to stderr after expansion, so you see what really ran, including the filenames an unquoted glob produced. The prefix comes from PS4, default + . Assign it in single quotes, PS4='+ ${BASH_SOURCE}:${LINENO}: ', so it expands on every traced line; double quotes freeze the line number at the moment of assignment.
Under set -e the script stops at the first unchecked failure and adds no message of its own, so you learn only that it ended. cd "$dir" || { echo "cannot enter $dir" >&2; exit 1; } names the problem and still stops. Use braces, not parentheses: an exit inside ( ) ends only the subshell. A small die() function keeps such checks to one line.
( exit 1 ) ends only the subshell; the parent finds 1 in $? and keeps going, unless set -e is on, in which case that status stops the parent too. The same applies to exit inside $(...) or a pipeline stage, such as cmd | while read; do exit; done. To stop the whole script from inside, have the parent test the subshell's status.
: and true both return 0, but : still expands its arguments, which powers : "${PORT:=8080}" to set a default and : "${API_KEY:?must be set}" to abort when a variable is missing. : > file truncates a file. cmd || true marks a failure as expected so that set -e lets it through.
Q. Signals and traps
Bash keeps the handler as text and parses it again each time the trap fires, so the quotes decide when variables expand. trap 'rm -f "$tmp"' EXIT reads $tmp at exit, while double quotes fix the value when trap runs, and a value with spaces then breaks the command. Signal names ignore case and may drop SIG; trap -p shows what is set.
An EXIT trap runs on exit, at the end of the script, after a set -e abort, and when an untrapped SIGTERM or SIGHUP kills the shell (checked in bash 3.2 and 5.3). Nothing runs on SIGKILL, and exec cmd replaces the shell without running it. A subshell keeps its own EXIT trap, so one set inside ( ) fires when that subshell ends.
The ERR trap runs when a command fails outside the contexts set -e ignores, such as conditions, non-final && and || members, and !, and it works whether or not -e is on. With -e on it runs just before the exit, so trap 'echo "failed at line $LINENO" >&2' ERR is a cheap diagnostic. Functions and subshells do not inherit it unless set -E is on.
By default commands inside a function do not run the caller's ERR, DEBUG or RETURN handlers, and subshells and command substitutions drop them as well. set -E (errtrace) passes ERR down, and set -T (functrace) passes DEBUG and RETURN. Without -E, ERR still fires when the function as a whole returns non-zero, just not at the failing line inside it.
If a trapped signal arrives while bash is waiting on a foreground command, the handler runs only after that command exits, so a TERM during sleep 60 waits out the minute. Start the command in the background and wait for it: wait returns at once with a status above 128 and the trap runs. The child is still alive then, so the handler should stop it.
An empty handler makes the shell ignore the signal, and the commands it starts inherit that ignore. trap - SIG goes back to the disposition the shell started with, which is normally the default action but not always. A non-interactive shell that inherited a signal as ignored, as under nohup, can neither trap it nor reset it.
No process can catch or ignore SIGKILL (9) or SIGSTOP, so cleanup code never runs for them. Bash accepts trap 'cleanup' KILL without an error and even lists it in trap -p, yet kill -9 still ends the shell with status 137. Send SIGTERM first and keep SIGKILL as the last resort, so EXIT and TERM handlers get their chance.
A command killed by a signal reports 128 plus the signal number: Ctrl-C sends SIGINT (2), giving 130, while SIGTERM gives 143, SIGKILL 137 and SIGPIPE 141. kill -l 130 turns a status back into a name, here INT. A wait interrupted by a trapped signal also returns more than 128, even though the child may still be running.
If the script creates a temp file and sets the trap afterwards, a signal or failure in between leaks the file; set the trap after the work and set -e may exit before it exists. Install it first and let it read the name late: tmp=, then trap 'rm -f -- "$tmp"' EXIT, then tmp=$(mktemp). While tmp is empty, the rm has nothing to remove.
R. Job control and processes
%1 is job number 1, %+ or %% the current job (marked + in jobs output) and %- the previous one. %vim picks a job whose command starts with vim and %?log one whose command line contains log; if two jobs match, bash reports an error. jobs -l adds process IDs, and typing %1 alone means fg %1.
bg %1 lets a stopped job continue as if it had been started with &, and fg %1 brings it forward so it owns the terminal and receives your keystrokes. A background job that tries to read the terminal gets SIGTTIN and stops again, so interactive programs belong in the foreground. Both builtins fail with 'no job control' in a script.
Ctrl-Z makes the terminal send SIGTSTP to the foreground job, which stops but keeps its memory, open files and locks, and jobs lists it as Stopped. Unlike SIGSTOP, SIGTSTP can be caught by the program. Exiting the shell with stopped jobs first prints a warning, and exiting again at once terminates them, so resume or end them with fg, bg or kill.
wait "$pid" returns that child's exit status, so cmd & pid=$! followed by wait "$pid" is how a background failure gets noticed. A bare wait blocks until all background children finish but returns 0 whatever they returned. To check several jobs, save each $! and wait on them one by one; a PID that is not this shell's child gives 127.
wait -n returns when the next background job finishes, with that job's status, which suits keeping a fixed number of workers busy. It arrived in bash 4.3, so macOS's /bin/bash 3.2 rejects it as an invalid option. wait -n -p var (bash 5.1) also records which job finished, and with no children left wait -n returns 127.
disown acts after launch and changes only the shell's bookkeeping: the job leaves the job table, so bash will not forward its own SIGHUP when it exits; disown -h keeps the job listed but exempt. nohup cmd acts at launch: the command starts with SIGHUP ignored, whoever sends it, and output headed for a terminal goes to nohup.out.
In a script monitor mode is off, so background jobs stay in the script's own process group and fg and bg fail with 'no job control'. The job table still exists: in bash 3.2 and 5.3, jobs, wait %1 and kill %1 all work inside a script. set -m switches job control on, which gives each job its own process group.
A negative ID names a process group, and -- keeps kill from reading -1234 as a signal, so kill -- -1234 signals every process in group 1234. Signaling only the leader can leave its children running. Under set -m each job is its own group whose ID is the leader's PID; without it jobs share the script's group, and signaling that group hits the script too.
coproc NAME { cmd; } runs cmd in the background with two pipes: read its output from ${NAME[0]}, write its input to ${NAME[1]}, and find its PID in NAME_PID. Close the write end when finished so the command sees end-of-file, then wait for it. Coprocesses arrived in bash 4.0, and ordinary subshells do not get the descriptors.
S. Shell options: set and shopt
set handles the classic single-letter flags and their long set -o names (errexit, pipefail), and $- lists the active letters. shopt -s and shopt -u handle bash's own settings such as nullglob, lastpipe and nocasematch, which set rejects as invalid option names. shopt -o can also reach the set -o names, but not the reverse.
set -- a b c makes $1 a, $2 b and $# 3, and set -- alone clears them all. The -- protects values that start with a dash: set -- -x stores -x as $1, where set -x would switch on tracing. A related idiom, set -- $line, loads a line's words into the parameters, subject to IFS splitting and globbing.
After set -f, a * or ? in an unquoted word stays literal, which lets you split a string on IFS without stray filename matches. Word splitting itself still happens, and case and [[ == ]] still match patterns, because that is pattern matching, not filename expansion. set +f turns globbing back on, and until then it also applies to anything you source.
While set -a is on, every variable created or changed is marked for export, so set -a; . ./app.env; set +a loads KEY=value lines and hands them to child processes. The file runs as shell code, so values with spaces need quotes and a $(cmd) in it executes. Turn it off afterwards, or every later assignment leaks into the environment too.
A subshell inherits the parent's options, though $(...) drops -e unless inherit_errexit (bash 4.4) is set. A separately run script starts fresh, so set -e in your terminal does not reach bash script.sh. Exporting SHELLOPTS or BASHOPTS does pass set -o or shopt options to child bash processes. Put the options a script needs at its top.
shopt -s nocasematch makes case, [[ == ]], [[ != ]] and [[ =~ ]] ignore letter case, so [[ $ans == y* ]] accepts YES. It leaves [ ] and test unchanged, and filename globbing has its own option, nocaseglob. The setting stays on for the rest of the shell, so switch it off with shopt -u once the comparison is done. It was added in bash 3.1.
Normally each pipeline stage is a subshell, so cmd | read -r v sets v in a copy that is thrown away. With shopt -s lastpipe (bash 4.2) the final stage runs in the current shell and the assignment survives. It does nothing while job control is on, which is why it works in scripts but usually not at an interactive prompt.
POSIX mode changes bash only where its default disagrees with the standard: . stops searching the current directory, interactive startup reads $ENV instead of ~/.bashrc, and function names may not match special builtins. Extensions such as [[, arrays and local keep working, so it is no portability test; running under dash is. Bash started as sh enters it after reading startup files.
T. Invocation, startup files and the environment
A login shell reads /etc/profile, then only the first of ~/.bash_profile, ~/.bash_login and ~/.profile that exists, and runs ~/.bash_logout on exit; so creating ~/.bash_profile silently stops ~/.profile from loading. An interactive non-login shell reads ~/.bashrc instead, and a login shell never reads it unless ~/.bash_profile sources it.
Running bash script loads no startup files, so functions and settings from your ~/.bashrc are absent and the script sees only what it inherited. If BASH_ENV holds a filename, bash sources it first; the name is expanded but not searched for in PATH. Bash started under the name sh ignores BASH_ENV entirely.
With ssh host cmd the remote shell is neither login nor interactive, so /etc/profile and ~/.bash_profile do not run and PATH changes made there are missing. Bash tries to notice it was started by sshd and then reads ~/.bashrc, but a ~/.bashrc that returns early for non-interactive shells skips everything after that check. Put such PATH lines before the guard.
Each child gets its own copy of the exported variables when it starts, so a script that runs export X=1 or cd /tmp changes only itself and nothing flows back to the parent. To change the current shell, source the file instead of running it. X=1 cmd puts X in that one command's environment and leaves the shell's own variables untouched.
export X flags X so that child processes started afterwards receive it; processes already running are not affected. export -n X drops the flag but keeps the value in the current shell, while unset X deletes the variable outright. export -p lists flagged variables, and declare -x sets the same flag.
Lookup goes left to right, so PATH="$HOME/bin:$PATH" lets your copy win and PATH="$PATH:$HOME/bin" is used only when nothing earlier matches. An empty element, from a leading or trailing colon or ::, means the current directory, and PATH="$PATH:$x" creates one whenever $x is empty. Assigning PATH also clears bash's table of remembered command paths.
BASH_VERSINFO[0] gives the major version as a number, safer to compare than the BASH_VERSION string. RANDOM returns 0 to 32767 and repeats after the same seed, so keep it away from secrets. SECONDS counts from shell start, LINENO gives the current line, OLDPWD powers cd -, and BASH_SOURCE[0] names the file even when it is sourced, while $0 keeps the caller's value.
pushd dir changes to dir and puts it on top of the stack, above the directory you left; popd drops the top entry and changes to the next. dirs -v lists the stack with numbers, current directory first. Both commands print the stack, so scripts usually add >/dev/null. The stack belongs to the current shell, and a pushd inside ( ) is undone when it ends.
U. The builtins that carry scripts
read a b c splits the line on IFS and gives the first two fields to a and b; everything after, including its original separators, lands in c, and any name with no field left is set empty. With the default IFS, leading and trailing blanks are trimmed first. IFS=: read -r user rest splits on colons for that one command only.
Without -r, a backslash is an escape: read drops it and keeps the next character, so C:\new\tmp arrives as C:newtmp, and a line ending in a backslash is joined to the next one. With -r backslashes are ordinary data. ShellCheck flags a read without -r as SC2162, and there is rarely a reason to leave it out.
read -ra arr stores the fields in an array from index 0, clearing it first. -d ';' ends the read at a semicolon rather than a newline, and -d '' ends it at a NUL byte, which pairs with find -print0 for filenames of any shape. -n 3 returns after three characters or at the delimiter, whichever comes first, while -N 3 reads exactly three.
Bash's echo takes -e as an option and leaves \t alone without it, dash's echo prints -e as text and expands \t anyway, and bash's xpg_echo option shifts the rules again. printf '%s\n' "$var" prints the value exactly in every shell. Keep data out of the format string itself, since a % inside it would be read as a directive.
When arguments outnumber the directives, printf runs the format again, so printf '%s\n' "${files[@]}" prints one per line and printf '%s=%s\n' a 1 b 2 prints two pairs. A final round that runs short fills the gaps with empty strings or zeros. With no arguments at all the format still prints once, so printf '[%s]\n' outputs [].
%q escapes a value so that bash reading it back gets the original string, which is what you need when building a command for eval, ssh or a generated script. An empty value comes out as '', so it survives as an argument. Strings with control characters use the $'...' form, which shells written before POSIX.1-2024 need not accept. ${v@Q} (bash 4.4) quotes in place.
eval joins its arguments into one string and parses and expands it again, so data becomes code: with name='x; echo INJECTED', eval "echo $name" runs the second command too. Build commands in arrays and assign indirectly with printf -v or a nameref instead. If eval is unavoidable, quote every value with printf %q first.
Given a command, exec swaps the shell for that program in the same process, so nothing after it runs and an EXIT trap does not fire; if the program cannot be run, a non-interactive shell exits. With only redirections, as in exec >log 2>&1, it rewires the current shell for every later command, and exec 3>&- closes a descriptor.
command ls skips any function named ls and, since alias expansion only touches a command's first word, any alias as well; that is how a wrapper function reaches the real program without recursing. builtin cd runs bash's own cd even when a function shadows it. command -v name is the portable test for whether a command exists.
getopts 'vf:' opt handles clusters like -vf file and -vffile, leaving each option argument in OPTARG; after the loop, shift $((OPTIND-1)) leaves only the operands. A leading colon in the optstring selects silent mode, which sets the variable to ? for an unknown option and : for a missing argument. Long options are unsupported: --verbose is read as a run of single letters.
mapfile -t arr < file makes each line one element, keeping leading spaces, blank lines and even a last line with no newline; without -t each element keeps its trailing newline. Feed it with < file or < <(cmd), never cmd | mapfile, which fills an array in a subshell. It arrived in bash 4.0, and readarray is the same builtin.
V. Portability: bash versus POSIX sh
Under dash, the usual /bin/sh on Debian, [[ is 'not found', a=(1 2), <<< and <(cmd) are syntax errors, and x+=b runs as a command name. local is the sneaky one: POSIX leaves it unspecified, yet dash supports it, so a dash test will not catch it. $'...' joined the standard in POSIX.1-2024, but some dash builds still lack it.
Debian has pointed /bin/sh at dash since 6.0 (squeeze) and Ubuntu since 6.10, so a #!/bin/sh script that uses bash features breaks there even if it ran fine where sh is bash. macOS's /bin/sh is bash 3.2 in POSIX mode, which still accepts [[, hiding the problem. If a script needs bash, say so with #!/usr/bin/env bash.
POSIX gives echo no options and makes the output implementation-defined when the first argument looks like -n or -e, or any argument holds a backslash. In practice bash's echo -e 'a\tb' prints a tab, while dash and macOS's /bin/sh print -e followed by a tab. printf has one specified behavior, so use it for anything but fixed text.
POSIX specifies fname() compound-command and lists function among words a shell may reserve with unspecified results, so function f { ...; } is a syntax error in dash. Bash and ksh accept the keyword, but ksh93 makes typeset variables local only in that form, so the two spellings behave differently there. Use the parentheses form for anything run by sh.
. is a POSIX special builtin; source is bash's alias for it, and dash answers 'source: not found'. A name without a slash is looked up in PATH: bash then falls back to the current directory, but dash and bash's POSIX mode do not, so write . ./lib.sh. POSIX defines no arguments after the file name, though bash passes them as $1 onward.
[ "$a" == "$b" ] works in bash, but dash stops with 'unexpected operator' and a non-zero status, so on Debian an if built on it takes the else branch every time. POSIX test defines = and != for strings. Inside bash's [[ ]] both spellings are fine, since that construct is bash-only anyway.
Associative arrays and mapfile need bash 4.0, wait -n 4.3 and the ${v@Q} transform 4.4, yet macOS still installs bash 3.2 as /bin/bash. A script can test BASH_VERSINFO[0] near the top and exit with a clear message. #!/usr/bin/env bash finds a newer bash, such as one from Homebrew, only when it comes earlier in PATH than /bin.
ShellCheck reads a script without running it and reports known bug patterns by code: SC2086 for an unquoted expansion, SC2155 for local x=$(cmd) hiding a status, SC3010 for [[ in a #!/bin/sh file. It picks the dialect from the shebang, so one line can pass for bash and fail for sh. A # shellcheck disable=SC2086 comment silences a check for the next command.
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.