Command Execution and the Shell Environment
Type ls and Bash runs a program. Type cd and Bash does something fundamentally different — no program runs at all. Both look identical from the keyboard, but Bash resolves them through completely different mechanisms, and knowing which is which explains a surprising amount of otherwise-confusing behavior: why cd can change your shell’s directory but a script calling cd in a subprocess can’t change yours, why installing a new version of a tool sometimes doesn’t take effect until you open a new terminal, and why PATH order is a real security concern and not just trivia. This chapter covers how Bash actually resolves and executes a command line, from builtins through PATH lookup to exec.
Builtins vs. External Commands
Every command you type falls into one of two broad categories:
- Builtins are implemented inside Bash itself — no separate program file exists on disk for them.
cd,echo,export,readonly,printf, andexitare all builtins you’ve already used in this course. - External commands are separate executable files that live somewhere on disk —
/bin/ls,/usr/bin/grep, and so on. Running one of these means Bash locates the file and starts it as a new process.
This distinction isn’t cosmetic. A builtin runs inside your current shell process — it can directly affect that shell’s state, which is exactly why cd (a builtin) can change your shell’s working directory, while a script that runs cd internally cannot change the directory of the shell that invoked it: that script ran as a separate child process, as covered in Chapter 1, and whatever it does to its own state disappears when it exits.
type — Identifying What A Command Actually Is
The type builtin tells you exactly how Bash would resolve a given name, without running it:
type cdcd is a shell builtintype lsls is aliased to `ls --color=auto'type ifif is a shell keywordtype can report several categories: shell builtin, a file path (external command), shell keyword (reserved words like if, for, while that are part of Bash’s own grammar, not commands at all), and — once you reach later chapters covering functions and aliases — function or alias as well.
Tip
You may also see which used for this purpose elsewhere. Prefer type in your own scripts: which is a separate external program whose behavior and availability vary across systems, while type is a Bash builtin that’s always present and correctly reports builtins and keywords that which can’t see at all.
PATH And Command Lookup
When you run an external command by name — ls, not /usr/bin/ls — Bash has to find the actual file before it can run it. It does this by searching the directories listed in the PATH environment variable, in order, left to right, stopping at the first match:
echo "$PATH"/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbinPATH is a colon-separated list of directories. When you type ls, Bash checks /usr/local/bin/ls, then /usr/bin/ls, then /bin/ls, and so on, running the first one it finds. If none of the directories contain a matching, executable file, you get the familiar:
bash: some-command: command not foundOrder matters directly: if two directories in PATH both contain a program with the same name, whichever directory comes first wins, every time. This is how you can intentionally override a system tool — put a directory containing your own version earlier in PATH — and it’s also the basis of a real security risk, covered under Shell-Safety Considerations below.
You extend PATH the same way you’d modify any variable, combined with export since child processes need to see it too:
export PATH="$HOME/bin:$PATH"This prepends $HOME/bin to the existing PATH, so commands there are found before anything else — a common pattern for giving your own scripts priority, or making them runnable by name from anywhere without a full path.
Command Hashing
Searching every directory in PATH on every single invocation would be wasteful, so Bash caches the resolved location of each external command the first time it’s run, in a per-shell hash table. Check it with:
hashhits command
3 /usr/bin/ls
1 /usr/bin/grepThis cache is invisible almost all the time — but it’s the answer to a specific piece of confusing behavior: if you install a new version of a tool earlier in PATH after your shell has already run the old one, your shell keeps using the cached (old) location until the cache is cleared, even though a brand-new shell would find the new one immediately. Clear it manually with:
hash -rcommand — Forcing A Specific Lookup
The command builtin runs a command while skipping some of the usual resolution steps — specifically, it bypasses shell functions of the same name, going straight to a builtin or an external command instead:
command lsYou haven’t defined any functions yet in this course — that’s coming in Chapter 10 — but keep command in mind for then. It becomes genuinely useful once a function you’ve written happens to share a name with an existing command, and you specifically want the real one, not your own shadowing version.
A related, portable check for “does this command exist at all” is:
command -v ls/usr/bin/lscommand -v prints the resolved path (or nothing, with a nonzero exit status, if it doesn’t exist) — a common pattern for testing whether a required tool is available before a script relies on it.
Environment Inheritance & Child Processes
Every time Bash runs an external command, it does so through two underlying operating-system operations: fork, which creates a new child process as a near-identical copy of the current one, and exec, which then replaces that child’s program with the one you asked to run. The child process inherits a copy of its parent’s environment — every exported variable, as covered in the previous chapter — at the moment it’s created.
This is exactly why exporting matters: a variable that was never exported is a shell variable living only in the parent’s memory, and forking doesn’t reach into “the shell’s internal variables in general” — it copies the environment, which only exported variables are part of.
Roughly, you can think the whole picture as:
flowchart TD
A["You run: some_command"] --> B{"Keyword?"}
B -- yes --> C["Handled by Bash's own grammar"]
B -- no --> D{"Builtin?"}
D -- yes --> E["Runs inside current shell process"]
D -- no --> F{"In hash table cache?"}
F -- yes --> G["Fork + exec cached path"]
F -- no --> H["Search PATH directories in order"]
H --> I{"Found?"}
I -- yes --> J["Cache it, then fork + exec"]
I -- no --> K["command not found"]
exec — Replacing The Shell Process
exec does something different from ordinary command execution: instead of forking a new child process, it replaces the current process’s program entirely, in place, keeping the same process. Nothing after a successful exec in a script ever runs — there is no “after,” because the script’s own process no longer exists as the script; it’s now running whatever exec was given instead.
#!/usr/bin/env bash
echo "Before exec"
exec echo "Replaced the process"
echo "This line never runs"Before exec
Replaced the processThat third echo never executes — once exec echo "Replaced the process" runs, the script’s process has already become that echo command, run its single line, and exited. There’s no script left to return to.
This has a genuinely useful application: a short wrapper script that does some setup and then hands off to a long-running program can exec that program as its final step, rather than running it normally. Run normally, the wrapper script would stick around as an idle parent process for as long as the program runs, adding an unnecessary layer between whatever started the script and the actual program — a layer that has to correctly forward signals, exit codes, and so on. exec-ing it instead means the program simply becomes the process the wrapper used to be, with nothing left in between.
Best Practices
- Use
type, notwhich, inside scripts — it’s a builtin, always available, and correctly identifies builtins and keywords thatwhichcan’t see. - Be deliberate about
PATHorder when you modify it. Prepending trusted directories is fine; anything else deserves a second look (more on this below). - Reach for
command -vwhen a script needs to check whether a required external tool is installed before relying on it, rather than assuming it’s present. - Put
execonly where you actually mean “hand off control permanently.” Anything written after it in the same script is dead code — treat that as a deliberate signal, not an oversight to debug later.
Shell-Safety Considerations
PATH order isn’t just a convenience question — the order you list directories in determines which program actually runs when there’s a name collision, and that has direct security consequences if it’s set carelessly.
Here’s the naive, dangerous version: adding the current directory (.) to PATH, especially near the front, so that commands “just work” no matter where you happen to be:
export PATH=".:$PATH"This looks harmless until you cd into a directory that contains a maliciously named file. Demonstrate the risk safely with a throwaway file, not a real system command:
mkdir -p /tmp/shell-course-demo
cd /tmp/shell-course-demo
cat > innocuous-tool << 'EOF'
#!/usr/bin/env bash
echo "This is NOT the real innocuous-tool — it ran from the current directory instead."
EOF
chmod +x innocuous-tool
export PATH=".:$PATH"
innocuous-toolThis is NOT the real innocuous-tool — it ran from the current directory instead.Because . was placed first in PATH, Bash found and ran the file sitting in the current directory before it ever looked in any of the trusted system directories — with zero indication to you that anything unusual happened. In a real attack, this is exactly how a malicious file dropped into a shared or downloaded directory — deliberately named after a common command — can get silently executed the moment someone cds in and runs what they think is the real thing.
The fix: never add . (or any directory a script doesn’t fully control, such as a temp folder shared with other users) to PATH — especially not ahead of trusted system directories. If you genuinely want to run something in the current directory, say so explicitly with ./:
unset PATH
export PATH="/usr/local/bin:/usr/bin:/bin"
./innocuous-tool./innocuous-tool makes the intent unambiguous — you’re asking to run the file right here, on purpose, rather than letting ordinary command lookup silently prefer it over the real system tool of the same name.
Hands-On: Resolving, Hashing, And Handing Off Commands
1. Set up a working directory.
mkdir -p ~/shell-course/ch3
cd ~/shell-course/ch32. Explore how a few familiar names resolve.
type cd
type ls
type if
type exportcd is a shell builtin
ls is /usr/bin/ls
if is a shell keyword
export is a shell builtin3. Add a personal script directory to PATH and run a script by name.
mkdir -p ~/shell-course/ch3/mybin
cat > ~/shell-course/ch3/mybin/greet-me << 'EOF'
#!/usr/bin/env bash
echo "Greetings from mybin."
EOF
chmod +x ~/shell-course/ch3/mybin/greet-me
export PATH="$HOME/shell-course/ch3/mybin:$PATH"
greet-meGreetings from mybin.4. Check the hash table, then clear it.
hash
hash -r
hashThe first hash shows greet-me cached with its resolved path; after hash -r, the table is empty until you run something again.
5. Reproduce the PATH safety issue in an isolated demo folder, then fix it.
mkdir -p /tmp/shell-course-demo
cd /tmp/shell-course-demo
cat > innocuous-tool << 'EOF'
#!/usr/bin/env bash
echo "This is NOT the real innocuous-tool — it ran from the current directory instead."
EOF
chmod +x innocuous-tool
export PATH=".:$PATH"
innocuous-toolThis is NOT the real innocuous-tool — it ran from the current directory instead.Fix it by removing . from PATH and calling the local file explicitly:
export PATH="/usr/local/bin:/usr/bin:/bin"
./innocuous-tool6. See exec replace a script’s process.
cd ~/shell-course/ch3
cat > handoff.sh << 'EOF'
#!/usr/bin/env bash
echo "Before exec"
exec echo "Replaced the process"
echo "This line never runs"
EOF
chmod +x handoff.sh
./handoff.shBefore exec
Replaced the process7. Clean up.
cd ~
rm -rf ~/shell-course/ch3
rm -rf /tmp/shell-course-demoReopen a fresh terminal afterward, since this chapter’s demos modified PATH in your current shell session.
You’ve now seen how Bash actually resolves a command name — keyword, builtin, cached path, or a fresh PATH search — watched exec hand off a process entirely, and reproduced a real PATH-ordering vulnerability safely in an isolated folder. The next chapter looks at where all of this setup — PATH, exported variables, and more — actually gets configured in the first place: Bash’s startup files, and the difference between login and interactive shells.