Bash is less a programming language than a text-rewriting machine that ends each line by running a program: it splits your line into words, rewrites some of them, and hands the result to a command. Nearly every shell bug called weird is that rewriting doing exactly what it was told, on input the author never pictured — a filename with a space, an empty variable, a pattern that matched nothing. Learn the rewriting order and where quoting stops it, and the language stops surprising you. Every behavior below was executed rather than recalled, and where bash and a strict standard shell disagree, this guide says so.
Each chapter opens with the short version. Tap one to read the detail.
How a script starts, and why it behaves differently there
~2 min
A script runs in a fresh, non-interactive shell that reads none of your interactive startup files. That fact explains most "works in my terminal, fails from cron" reports. Running a script forks a child; source runs it inside the shell you are already in.
The first line matters only if it begins #!: the kernel reads it and runs the named interpreter on your file. #!/usr/bin/env bash is common because it takes whichever bash is first on your search path rather than insisting on one location. That line is consulted only when the file is executed directly, so ./script needs the executable bit while bash script does not.
The distinction people trip over is running versus sourcing. Running starts a separate shell and everything it changes dies with it; sourcing runs the same lines in your current shell, so its directory changes and variables persist.
# child.sh contains: cd /tmp ; MARK=set-by-script
bash child.sh # your directory is unchanged, MARK is unset
source child.sh # you are now in /tmp and MARK is set
The misconception that costs the most time is assuming a script inherits your interactive setup. It does not: a non-interactive shell reads no .bashrc and no profile, only the environment variables it was handed. A command that works when you type it can fail from a scheduler or a remote connection purely because your search path was never built — so diagnose by printing the environment from inside the script, not by editing its logic.
Rule: when a script behaves differently somewhere else, suspect the environment it was given before you suspect the code.
Quoting, and the one bug that outnumbers all the others
~2 min
An unquoted $var is not "the value". It is the value chopped into words at spaces, then each word matched against filenames. "$var" is always exactly one word. This habit alone removes more shell bugs than everything else in this guide put together.
Watch a filename containing a space. Unquoted, the command never receives that filename at all — it receives two arguments:
f="my report.txt"
ls $f # ls: my: No such file or directory
# ls: report.txt: No such file or directory
ls "$f" # one argument; works
The same step also expands patterns. A variable holding * becomes, unquoted, every name in the current directory — three arguments in a directory of three files, one if you quote it. That is how a script passes its own tests and then does something irreversible on a machine whose directory listing differs. Neither failure raises a shell error; both simply run a command you did not write.
Single quotes make everything inside literal, and cannot contain a single quote at all. Double quotes still let $, backtick and backslash act, but switch off both word splitting and filename matching, which is why they are the everyday choice. For a real tab or newline you need the dollar-single-quote form, $'a\tb'; ordinary quotes keep the backslash as a backslash.
One detail explains why quoting feels invisible: the quote characters are stripped in the final expansion step, so the command never sees them. Quoting instructs the shell about how to build the arguments; it is never part of the data. It follows that you cannot quote a variable's contents "in advance" — the protection has to be at the point of use, every time.
Rule: quote every expansion by default, and treat leaving one bare as a decision you could defend in a comment.
The expansion pipeline: parameters, splitting and filename patterns
~3 min
Expansions run in a fixed order: braces, tilde, parameters and substitutions, then word splitting, then filename matching, then quote removal. Only those middle steps change how many words you have, and the order explains failures that look like syntax errors.
Because braces expand before parameters, a range built from a variable never becomes a range — it quietly becomes one literal word:
n=3
printf '[%s]\n' {1..$n} # [{1..3}] — a single literal word
printf '[%s]\n' {1..3} # [1] [2] [3]
The output looks almost right, which is what makes it dangerous; use a counting loop or a sequence command when the bound is a variable.
Word splitting is the step that turns one result into several. It applies only to unquoted results, and splits on the characters in IFS — space, tab and newline unless changed. Setting IFS in front of one command changes it for that command alone, the clean way to split a line on commas. The two argument-list forms differ for the same reason: "$@" gives one word per argument, preserving any that contain spaces, while "$*" glues them into one word and unquoted forms re-split everything.
The trimming operators repay memorizing: ${p##*/} strips the longest leading match (a basename), ${p%/*} the shortest trailing one (a directory), and ${v:-fallback} supplies a default when a variable is unset or empty. Their right side is a pattern, not a regular expression — ${t/a+/X} hunts for a literal plus sign and changes nothing.
That same pattern language drives filename matching, where a real trap lives: every other pattern language returns nothing when nothing matches, but filename expansion hands your pattern back unchanged, so a loop runs exactly once with the pattern itself as the value:
for f in *.md; do echo "[$f]"; done # with no .md files: prints [*.md]
shopt -s nullglob # now the loop body runs zero times
That is why scripts complain that a file literally named *.md does not exist. nullglob removes an unmatched pattern and failglob makes it an error; either beats a loop body running on a filename that was never there.
The syntax is small and has no quantifiers: * is any string including an empty one, ? is exactly one character, and classes such as [[:digit:]] sit inside brackets. So a* means anything starting with a, never a repeated letter. Two limits catch people out — a wildcard never matches a leading dot, so * skips hidden files, and it never crosses a directory separator, so *.txt cannot reach a subdirectory. Recursing needs globstar, which older releases such as the one macOS ships lack.
Rule: name the step you are relying on — extra words come only from splitting or filename matching, and decide what an empty match means before you write the loop.
Variables, exports and arrays
~2 min
x=1 takes no spaces around the equals sign. A variable stays invisible to child processes until exported, and a child only ever receives a copy. An array is not a string: bare $a means element zero, and only "${a[@]}" yields one word per element.
Assignment is syntax, not an operator, so x = 1 is a request to run a command named x. Assignment also does not export: a child sees only variables marked for export, and VAR=1 somecommand sets one for that command's environment without touching yours. The child gets a copy, so nothing it does can change your variables.
The previous command's status lives in $? and is replaced by the very next command, including the echo you use to look at it. Save it first if you need it twice.
Arrays are where confusion concentrates, because the wrong syntax is shorter than the right one:
"${a[*]}" collapses everything into one word instead. Indices can also have holes once an element is removed, so counting from zero to the length skips real entries; "${!a[@]}" lists the indices that exist. Arrays also cannot travel in the environment — a child shell sees nothing, however you export them.
Rule: write "${a[@]}" every time, and reach for an array the moment a variable holds a list rather than a value.
Wiring: pipes, file descriptors and here-documents
~3 min
A pipeline reports only its last stage's status, so a command that dies mid-pipe looks like success. Redirections are pointer copies applied left to right, and they happen before the command runs — which is why their order matters so much.
This is the quiet data-loss bug of shell scripting:
cat /no/such/file | wc -l # prints 0, and $? is 0
echo "${PIPESTATUS[@]}" # 1 0 — the failure was there all along
set -o pipefail # from here, that pipeline reports 1
The count is wrong, the status says everything is fine, and the script writes the wrong number somewhere permanent. PIPESTATUS is overwritten by the next command, so read it immediately or not at all. Each stage also runs in its own subshell, which is why a loop on the right of a pipe cannot keep what it counts.
Because && and || share one precedence level and associate leftwards, a && b || c is not an if-then-else: if a succeeds and b then fails, c runs anyway. Write a real if whenever the success branch can itself fail. A trailing & backgrounds a command, returns success immediately, and leaves the process id in $!; the real status arrives later, from wait.
Redirection is the other half of the wiring, and its order is not decoration:
cmd >log 2>&1 # both streams into log
cmd 2>&1 >log # stderr copies the terminal, then stdout moves to log
2>&1 makes descriptor two a duplicate of wherever descriptor one points right then — not a permanent link. Reverse the order and you have aimed stderr at the terminal and then quietly moved stdout away. Because redirections are prepared first, sort data.txt > data.txt empties the file before sort opens it. A redirection on a whole loop, done > file, opens the file once instead of truncating it every pass; exec > log redirects the rest of the script.
Here-documents feed lines straight to a command's input, and the delimiter decides whether they are text or code. Quote it, as <<'EOF', and nothing inside expands; leave it bare and variables and substitutions are evaluated as inside double quotes. The <<- form strips leading tabs only — space indentation survives, and a space-indented terminator does not terminate anything.
Rule: read a pipeline for its status and a redirection as pointer copies, left to right — and never name one file as both input and output.
Three different things: [ ], [[ ]] and (( ))
~2 min
[ is an ordinary command, so every space separates arguments and an unquoted empty variable becomes a syntax error. [[ ]] is shell syntax and never splits its operands. (( )) is integer arithmetic whose exit status is the inverse of the value.
The bracket is a command whose last argument must be ]. Hand it an unquoted empty variable and it simply receives fewer arguments than it expected:
x=""
[ $x = y ] # [: =: unary operator expected (status 2)
[ "$x" = y ] # false, no error
[[ $x = y ]] # false, no error — operands are never split here
A value containing a space produces [: too many arguments from the same cause. Inside [[ ]] the shell parses the construct itself, so quoting operands is optional there and mandatory elsewhere.
[[ ]] adds a trap of its own: the right side of == is a pattern, so [[ $f == *.txt ]] matches by suffix while [[ $f == "*.txt" ]] demands a literal asterisk. Its < compares strings, not numbers — [[ 10 < 9 ]] is true, because "1" sorts before "9". Use -lt and friends, or the arithmetic form.
Arithmetic succeeds when the expression is nonzero and fails when it is zero, inverting the usual intuition. (( i++ )) yields the value before the increment, so the first increment of a zero counter reports failure and an && chained after it silently does not run; write (( ++i )) or i=$((i+1)) where a status is read. It is integer-only: 7/2 is 3, and the remainder takes the left operand's sign, so -7 % 3 is -1.
Rule: [[ ]] for strings and files, (( )) for numbers, plain [ ] only when the script must run under a strict standard shell.
Loops, functions, and where your state lives
~3 min
A subshell can run your code and then discard everything it changed. Pipelines, ( … ) and $( … ) all create one; { …; } and a shell function do not. Knowing which you are inside explains a counter that stays at zero and a local that hides a failure.
The counter below is not an arithmetic bug: the loop ran, counted correctly, then exited the process holding the count:
n=0; printf 'a\nb\nc\n' | while read -r l; do n=$((n+1)); done
echo "$n" # 0
n=0; while read -r l; do n=$((n+1)); done < <(printf 'a\nb\nc\n')
echo "$n" # 3
Process substitution, < <(cmd), presents a command's output as a file the loop reads directly, keeping the loop in your shell. The same rule explains why a directory change inside ( … ) does not move you, while { …; } groups without a subshell and does. Command substitution shares the nature: $(cmd) runs in a child, so assignments inside do not survive, and it strips every trailing newline. Unquoted, its result is word-split like any other expansion, so it belongs in quotes.
Read input safely while you are here. while IFS= read -r line is canonical for two reasons: clearing IFS stops leading and trailing whitespace being trimmed, and -r stops backslashes being eaten. One edge remains — read reports failure on a final line with no newline, so the last record disappears unprocessed. Adding || [ -n "$line" ] to the condition recovers it.
Functions sit on the other side of the line: they run in your current shell, so everything they touch is shared by default, and one forgotten local is how a helper clobbers a caller's loop variable. Even declared, scope follows the call chain, so a function you call can read and modify your locals. return carries only a small integer status, so to hand back data you print it and capture the output. And combining a declaration with a capture hides failure, because the status you get is the declaration's:
f() { local v=$(exit 3); echo "$?"; } # prints 0 — the failure vanished
g() { local v; v=$(exit 3); echo "$?"; } # prints 3 — the failure is visible
That difference also decides whether error-exit mode notices: the first form let a script carry on, the second stopped it.
Rule: ask which shell your state-changing code runs in — keep a counting loop off the right of a pipe, and split every local declaration from its assignment.
Exit status, set -euo pipefail, and what it does not catch
~2 min
A status of zero means success. Error-exit mode stops on a failing command, but never on one being tested — inside if, while, &&, || or after ! — and testing a function suspends it for that whole function body. Cleanup belongs in an exit trap.
Learn the conventional statuses: 127 for a command not found, 126 for one found but not executable, and 128 plus the signal number when something was killed — so an interrupt shows as 130, a termination as 143.
Now the part that misleads people who trust that line to protect them:
set -euo pipefail
check() { false; echo "still running"; }
if check; then echo "check succeeded"; fi # prints BOTH lines
Because check is being tested, error-exit is suspended for its entire body. The false does not stop it, the echo runs, and the function's status becomes the echo's — so a function that failed reports success. The same suspension covers anything left of && or ||, and anything after !.
Unset-variable mode is the most reliably useful of the three, turning a typo into an immediate error rather than an empty string; ${v:-} is the deliberate opt-out where empty is legitimate. None of them gives you a message, so a check you truly depend on deserves cmd || { echo "what failed" >&2; exit 1; }.
A trap on EXIT runs however the shell ends, failure paths included — install it immediately after creating the thing it removes, never after the work. The ERR trap is not inherited by functions or subshells unless trace inheritance is also enabled.
Rule: strict mode is a seatbelt, not a diagnosis — check anything you actually depend on, explicitly.
The builtins that carry real scripts
~2 min
read -r and printf are the two you cannot work without: without -r a backslash is eaten, and echo's handling of options and escapes differs between shells on one machine. eval re-parses its argument, which is exactly why untrusted text must never reach it.
The portable-output argument is not theoretical — three shells on one computer disagreed about a single command:
bash -c 'echo "a\tb"' # a\tb — backslash kept
dash -c 'echo "a\tb"' # a<tab>b — escape interpreted
sh -c 'echo -n x' # printed "-n x" — the option was not recognised
printf '%s\t%s\n' a b # identical in all three
Use printf wherever the exact bytes matter. It also recycles its format string over leftover arguments, so one call formats a whole list; %q quotes a value so the shell can safely read it back.
For input, read splits a line on the field separator and puts all remaining fields into the last variable named, making read key rest a tidy parser. Without -r it treats backslashes as escapes, so a path like C:\Users\me arrives as C:Usersme — silently wrong, never an error.
eval re-runs the whole expansion machinery on its argument, so interpolating anything a user controls hands them the shell. Prefer ${!name}, which reads a variable named by another variable without the risk.
Options come in two families: set -o toggles the standard ones, shopt the bash-only ones. Three earn their keep early — set -- replaces the positional arguments, set -f turns filename expansion off, and set -a exports everything assigned afterwards, the tidy way to load a configuration file.
Rule: printf over echo, read -r always, and treat eval as the tool you must justify.
bash versus a POSIX sh
~2 min
#!/bin/sh does not promise bash. Under a strict standard shell, [[, arrays, here-strings and process substitution are syntax errors, source is not found, and [ a == b ] fails. Testing with your own sh proves nothing when that sh is bash under another name.
Run the same lines under a strict shell and the extensions announce themselves:
dash -c '[[ x = x ]]' # [[: not found
dash -c 'a=(1 2 3)' # Syntax error: "(" unexpected
dash -c 'cat <<< hi' # Syntax error: redirection unexpected
dash -c '[ a == a ]' # [: a: unexpected operator — use = instead
The standard defines only the fname ( ) { …; } form of a function, and only . for reading a file into the current shell; the function keyword and source are both extensions. It also names local, source, declare, let and mapfile among command names whose behavior it leaves unspecified — so even where one works, that is a property of the shell in front of you, not a guarantee.
Standardized and available are also different dates. Dollar-single-quote escapes such as $'a\tb' are defined in the current issue of the standard, yet the strict shell tested here still printed them literally.
Then there is drift within bash itself. On the release macOS ships as /bin/bash, associative arrays, ${v^^}, mapfile, globstar, |& and wait -n all failed outright, though every one is fine on a modern release. And the tempting test is false comfort: bash invoked as sh accepted [[ quite happily, standard mode and all.
Rule: pick a shell in the #! line and mean it — if that line says sh, test with a shell that is not bash.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
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.