Everyone "knows" bash. Almost nobody knows what it actually does between you pressing Enter and something happening. That gap is where every confusing shell bug lives, so we start by closing it — then we set up the two habits that will keep you out of trouble for the rest of the course.
What bash actually is
Bash is three things wearing one coat, and conflating them is the source of a lot of confusion:
- An interactive interface — the prompt, history, tab completion, job control.
- A command interpreter — it finds programs and runs them.
- A programming language — variables, functions, conditionals, loops.
The third one is the one people skip, and it's why so much shell code in the wild is written by superstition. This course treats bash as a language you learn properly, not a place you paste commands.
What happens when you press Enter
For every line you type, bash runs a fixed pipeline. Knowing the order explains almost every "why did it do that?" moment you will ever have:
- Split into words on whitespace.
- Expand — this is a whole sequence in itself: brace expansion, tilde, then parameter/variable expansion, command substitution and arithmetic, then word splitting, then filename globbing.
- Redirect — set up any
>,<, or pipes. - Locate the command — a function? a builtin? something on
PATH? - fork + exec — for an external program, bash clones itself and replaces the clone with the new program.
Expansion happens before the command runs, and word splitting happens after your variable is substituted. That single ordering fact is why an unquoted variable holding my file.txt arrives at the command as two arguments, not one. Module 01 is entirely about this.
The shebang
The first line of a script tells the kernel which interpreter to run it with. It only works if it is genuinely the first two bytes of the file — no blank line, no comment, no byte-order mark above it.
#!/usr/bin/env bash # find bash on PATH — portable, preferred
#!/bin/bash # a fixed path — fine on Linux, wrong on some BSDs
#!/bin/sh # POSIX shell — NOT bash. No arrays, no [[ ]].#!/usr/bin/env bash is the default you should reach for: it finds whichever bash is first on PATH, which on a Mac with Homebrew bash is the modern one rather than Apple's ancient 3.2. The tradeoff is that it obeys PATH, so in a security-sensitive setuid-adjacent context a fixed path is safer — we return to that in Module 17.
Writing #!/bin/sh at the top of a script full of bash features is the single most common portability bug in this whole subject. On Debian and Ubuntu /bin/sh is dash, which has no arrays and no [[ ]]. The script will work on your machine and fail on the target. Module 15 is dedicated to this.
Three ways to run a script — and why they differ
./script.sh # needs +x and a shebang. Runs in a NEW process.
bash script.sh # no +x needed, shebang ignored. Also a NEW process.
source script.sh # runs in YOUR CURRENT shell. No new process.
. script.sh # identical to source — the POSIX spellingThe distinction is not cosmetic. A new process gets a copy of your environment, so anything it changes — variables, the working directory — vanishes when it exits. That is why a script that ends in cd /somewhere appears to "not work": it did work, in a process that no longer exists.
If a script's job is to change your current shell (set variables, activate an environment, change directory), it must be sourced, and you should say so in its header comment. Everything else should be executed. When you find yourself reaching for source to make a normal script's side effects stick, that's a design smell — have it print the values instead and let the caller decide.
How a command is actually found
When you type a name, bash resolves it in a strict order — and it is not "search PATH":
- Alias (interactive shells only)
- Function you have defined
- Builtin —
cd,echo,testlive inside bash itself - PATH lookup — first match wins, left to right
Use type -a name to see every match in order — it is strictly more informative than which, which only knows about step 4 and is an external program on some systems. For scripts, use command -v name: it is a builtin, it is POSIX, and it is the correct way to ask "does this exist?".
$ type -a echo
echo is a shell builtin
echo is /bin/echo # two different echoes, subtly different flags!
# The right existence check in a script:
if command -v jq >/dev/null 2>&1; then ...The safety header
Start every script you write from here on with this. We will unpack each flag properly in Module 04 — including the important caveat that set -e is subtler than it looks — but adopt it now so the habit is automatic:
#!/usr/bin/env bash
set -euo pipefail
# -e exit on an unhandled command failure
# -u treat an unset variable as an error (catches typos in variable names)
# -o pipefail a pipeline fails if ANY stage fails, not just the last oneWithout pipefail, grep pattern file | sort reports success even when grep found nothing, because sort succeeded. That default has silently broken a lot of production scripts.
Install shellcheck now, not later
shellcheck is a static analyser for shell. It catches the entire class of quoting bugs Module 01 is about, plus a few hundred others, and it explains each one with a wiki link. Treat a shellcheck warning like a compiler error.
brew install shellcheck # macOS
sudo apt install shellcheck # Debian/Ubuntu
shellcheck myscript.shApple still ships bash 3.2 from 2007, because bash 4 changed to GPLv3. Associative arrays (Module 07), mapfile, and &>> all need bash 4+. Run bash --version. If it says 3.2, run brew install bash — the course assumes 4.0 or newer, and several modules will simply fail on 3.2 in ways that look like your mistake rather than the shell's age.
Takeaways
- Bash expands before it runs, and splits words after substituting variables — that ordering explains most surprising behaviour.
#!/usr/bin/env bashby default;#!/bin/shmeans POSIX shell, not bash, and will break arrays and[[ ]].- Executing runs in a new process (changes are lost); sourcing runs in your current shell (changes persist).
- Command lookup is alias → function → builtin → PATH. Use
command -vin scripts,type -awhen debugging. - Every script starts with
set -euo pipefail, and every script gets run through shellcheck.
Try it: the two checks every real script needs
Almost every serious script begins by confirming its environment: is the interpreter new enough, and are the tools it depends on actually present? Implement both checks the correct way — no which, no parsing of version strings by hand.
Loading starter code…
Checkpoint · Shell & Scripts
5 questions · pass at 70%
cd /var/log. You run it with ./go.sh, and afterwards your terminal is still in your home directory. Why?command -v jq the right way to test whether a program exists in a script, rather than which jq?#!/bin/sh at the top of a script that uses arrays and [[ ]]?set -o pipefail matter for a pipeline like grep ERROR app.log | sort?echo?Finished Module 00?
Passing the quiz marks it complete automatically. Or mark it yourself.