Bash Fundamentals
Bash is the GNU Bourne-Again Shell: a command interpreter that reads lines you type or a script you hand it, expands them, and runs the resulting commands. It is the default interactive shell for the root and human accounts on most Debian and Ubuntu installs, and it is what you land in when you SSH into a box. Its job is narrow and exact — parse a line into words, perform a fixed sequence of expansions on those words, find the command, and execute it — and almost every surprising thing a shell does is explained by getting that sequence right or wrong.
The operational consequence is that the shell is not a programming language you reason about by intuition; it is a text processor with sharp edges. An unquoted variable that happens to contain a space becomes two arguments. A glob that matches nothing is passed through literally by default. On Debian and Ubuntu, /bin/sh is dash, not Bash — so a script that works at your prompt can fail under sh script.sh or in a #!/bin/sh cron job because the Bash-only features it relied on do not exist there.
Command Parsing and Word Splitting
When Bash reads a command line it splits it into words on whitespace, then performs expansions, then splits the results of most expansions again on the characters in IFS (space, tab, newline by default). That second split is the one that bites people: a variable holding two words expands into two separate arguments unless you quote it. The fix is mechanical and absolute — wrap every variable and command substitution in double quotes unless you have a specific reason to want splitting.
file="my report.txt" # Wrong: rm sees two arguments, "my" and "report.txt" rm $file # Right: rm sees one argument rm "$file"
Globbing is the other half of word handling. *, ?, and [...] are expanded by the shell against the filesystem before the command runs — the command never sees the pattern, only the filenames it matched. If a pattern matches nothing, Bash passes the literal pattern through unchanged, so a loop over *.log in an empty directory runs once with the string *.log. Setting shopt -s nullglob makes a non-matching pattern expand to nothing instead, which is almost always what a script wants.
Quoting Rules
Bash has three quoting forms and they are not interchangeable. Single quotes are literal — nothing inside them is special, not even $. Double quotes suppress word splitting and globbing but still perform parameter, command, and arithmetic expansion, so "$var" is interpolated while spaces in its value are preserved. A backslash escapes the single following character. The default rule for production scripts is double quotes around every expansion and single quotes for any string you want kept verbatim.
| Form | Variables expand? | Splitting / globbing? | Use for |
|---|---|---|---|
'single' | No | No | Literal strings, regex, sed scripts |
"double" | Yes | No | Values that may contain spaces — the default |
| unquoted | Yes | Yes | Only when you deliberately want a list to split |
Variables, Environment, and Quoting Scope
A shell variable lives only in the current shell. Marking it with export places it in the environment, which is the copy a process passes to every child it spawns — that is how PATH, HOME, and your custom settings reach the programs you launch. Children cannot write back to the parent's environment, so a variable set inside a subshell or a piped command vanishes when that subshell exits. This is why cmd | while read x; do count=$((count+1)); done leaves count at zero in Bash: the while runs in a subshell on the right of the pipe.
GREETING="hello" # shell variable, this shell only export EDITOR=/usr/bin/vim # now inherited by child processes env | grep EDITOR # visible; GREETING is not
Names are case-sensitive and conventionally uppercase for environment variables, lowercase for script-local ones, which keeps your variables from colliding with the dozens the system already exports. Assignment takes no spaces around the = — x = 1 tries to run a command called x. Reference a value with $name or, when the name butts against other characters, the unambiguous ${name}.
Startup Files and Login vs Interactive Shells
Bash reads different files depending on how it starts, and getting this wrong is why "my PATH works in the terminal but not over SSH" happens. A login shell (an SSH session, or bash -l) reads /etc/profile then the first it finds of ~/.bash_profile, ~/.bash_login, ~/.profile. A non-login interactive shell (a new terminal tab inside an existing session) reads /etc/bash.bashrc and ~/.bashrc. A non-interactive shell running a script reads neither, unless BASH_ENV points at a file.
On Debian and Ubuntu the convention is to put everything in ~/.bashrc and have ~/.profile source it, so both kinds of shell pick up the same configuration. Put PATH edits and exported variables in ~/.profile or ~/.bash_profile (they need to apply once at login and be inherited); put aliases, the prompt, and shell options in ~/.bashrc (they are per-interactive-shell and are not inherited). On Red Hat systems the equivalent system file is /etc/bashrc rather than /etc/bash.bashrc, and /etc/profile.d/*.sh is the standard drop-in directory on both families.
Exit Status and Command Sequencing
Every command returns an integer exit status: 0 is success, anything from 1 to 255 is a failure, and the most recent value is in $?. The shell is built on this convention. && runs the next command only if the previous one succeeded; || runs it only on failure; a bare ; runs the next command regardless. In a pipeline, $? is the status of the last command only, so grep pattern file | sort reports success even if grep found nothing — unless you set pipefail.
set -o pipefail mkdir -p /opt/app && cd /opt/app || { echo "setup failed" >&2; exit 1; } # && chains the steps; || handles the first failure
- Leaving variables unquoted —
cp $src $dstbreaks the moment a path contains a space or a glob character, silently copying the wrong files or none. Quote every expansion:cp "$src" "$dst". - Writing
#!/bin/bashfeatures (arrays,[[ ]],local) into a script run asshor with a#!/bin/shshebang — on Debian and Ubuntushisdash, and these fail with cryptic syntax errors at runtime, often only in cron. - Putting spaces around
=in an assignment —name = valueis parsed as running the commandnamewith arguments=andvalue, not as setting a variable. - Expecting a variable set inside
cmd | while read ...to survive the loop — the right side of a pipe runs in a subshell in Bash, so the assignment is discarded when the loop ends. - Putting
PATHexports only in~/.bashrcand then wondering why a non-interactive SSH command or a script does not see them — non-interactive shells skip~/.bashrcentirely. - Trusting a pipeline's exit status without
set -o pipefail—$?reflects only the last stage, so a failedgreporcurlupstream is masked by a successfulteeorsortdownstream. - Looping over an unguarded glob like
for f in *.confin a directory with no matches — withoutshopt -s nullglobthe loop body runs once with the literal string*.conf.
- Start every non-trivial script with
set -euo pipefail— exit on error, treat unset variables as errors, and propagate failures through pipelines. It turns silent breakage into a loud, early stop. - Quote every parameter and command substitution by default — write
"$var"and"$(cmd)"unless you have a deliberate reason to want word splitting. - Run
shellcheckon every script before shipping it — install it withapt install shellcheck, and it catches the quoting, splitting, andsh-incompatibility bugs above before they reach production. - Match the shebang to the features you use —
#!/bin/bashif you use arrays or[[ ]],#!/bin/shonly for strictly POSIX scripts, and never assumeshis Bash on Debian or Ubuntu. - Put exported variables and
PATHedits in~/.profileand per-shell settings in~/.bashrc, with~/.profilesourcing~/.bashrcso login and interactive shells stay consistent. - Prefer
[[ ... ]]over[ ... ]in Bash scripts — it does not word-split or glob its operands, so[[ -n $x ]]is safe even when$xis empty or unquoted. - Use
${var:?message}for required inputs — it aborts with your message if the variable is unset, instead of letting an empty value flow into a destructive command likerm -rf "$dir/".
/bin/sh on Debian and Ubuntu, with no Bash extensionsPowerShell — Windows' object-passing shell, where commands exchange typed objects instead of the byte streams Bash pipesKnowledge Check
A script written and tested at your Bash prompt fails when run from a #!/bin/sh cron job on Ubuntu. What is the most likely cause?
- On Debian and Ubuntu
/bin/shisdash, so Bash-only features like arrays and[[ ]]are not available and error out - Cron always strips quoting from the script before running it, so every variable word-splits differently than it did at your interactive prompt
- Cron forcibly runs every scheduled script as
root, and Bash refuses to execute a script owned by a non-root user - The
/bin/shinterpreter is started with a differentPATHthat excludes/usr/bin, so none of the script's commands are found
Why does count stay at zero after find . -type f | while read f; do count=$((count+1)); done in Bash?
- The right side of the pipe runs in a subshell, so the increment happens in a child whose variables are discarded when the loop exits
readconsumes the current value ofcounton each iteration and silently resets the counter back to zero before the arithmetic in the loop body runs- Arithmetic with
$(( ))evaluates the expression correctly but is structurally unable to persist its result into a named shell variable likecount findemits its matching paths separated by null bytes rather than newlines, soreadnever sees a complete line and the loop body never actually runs even once
What is the difference between single and double quotes in Bash?
- Single quotes are fully literal; double quotes still perform parameter, command, and arithmetic expansion while suppressing word splitting and globbing
- Single quotes still expand simple variables like
$varbut never command substitutions such as$(cmd); double quotes expand both forms equally and identically - They are fully interchangeable — Bash treats both forms identically during parsing and the choice between them is purely a matter of personal style
- Double quotes make the entire contents fully literal, while single quotes are the form that interpolates
$varand substitutes command output
A pipeline curl -fsS "$url" | tee out.txt exits with status 0 even though the download failed. What fixes the masked failure?
- Set
set -o pipefailso the pipeline's exit status becomes that of the first failing stage, not just the last - Set
set -eon its own, which already makes every stage of a pipeline abort the script the moment it fails - Move
teeahead ofcurlso the command that can fail is the last stage in the pipeline - Quote
$urlproperly, since an unquoted URL argument is the reason the pipeline's exit status is being lost
You got correct