Linux & Bash · Part I · Module 10 · The Environment, PATH and Shell Startup
PATH: How the Shell Finds a Program
You type a name and something runs. Which something is decided by one environment variable holding an ordered list of directories — and because the search stops at the first match, the order of that list quietly decides which version of a tool your whole team is using.
Intermediate18 min readLinuxPart IFundamentalsEnvironmentPATH
Part I · Module 10 · Chapter 10.2 · PATH: How the Shell Finds a Program
Chapter 1.7 said the shell finds ls by looking through PATH, and deferred the detail. This is the detail, and it matters more than it sounds: on a machine with three versions of a tool installed, PATH is the only thing deciding which one you get.
1. An Ordered List of Directories
$ echo "$PATH" | tr ':' '\n' | head -8
/opt/homebrew/bin
/opt/homebrew/sbin
/Users/engineer/.nvm/versions/node/v24.8.0/bin
/Library/Frameworks/Python.framework/Versions/3.13/bin
/usr/local/bin
/System/Cryptexes/App/usr/bin
/usr/bin
/binOne string, colons between entries, and tr turning them into lines so a human can read it. The colon convention is exactly the one Chapter 10.1 described: the environment holds only strings, so a list is a string with a separator that everybody agrees on.
The order is the whole point.
Two conditions a file must meet to be chosen: it must have the name you typed, and it must be executable by you — the x bit from Chapter 5.3. A readable file with no execute bit is skipped and the search continues.
And one thing PATH does not do: it is not searched recursively. Only the directories listed, never their subdirectories.
2. Proving Which Copy You Get
Three commands, and the difference between them matters.
$ which python3
/Library/Frameworks/Python.framework/Versions/3.13/bin/python3which reports the first match — the one that will run.
$ which -a python3
/Library/Frameworks/Python.framework/Versions/3.13/bin/python3
/usr/local/bin/python3
/usr/bin/python3which -a reports all of them, in search order. Three python3 installations on an ordinary machine, which is entirely typical — and the one being used is the one that happens to be listed first.
$ type -a python3
python3 is /Library/Frameworks/Python.framework/Versions/3.13/bin/python3
python3 is /usr/local/bin/python3
python3 is /usr/bin/python33. First Match Wins — Demonstrated
Worth doing once, because it converts an abstract rule into something you have seen.
$ mkdir -p /tmp/fake/bin
$ printf '#!/bin/bash\necho "I am the FAKE ls at $0"\n' > /tmp/fake/bin/ls
$ chmod +x /tmp/fake/bin/lsPut that directory first:
$ export PATH="/tmp/fake/bin:$PATH"
$ type -a ls
ls is /tmp/fake/bin/ls
ls is /bin/ls
$ ls
I am the FAKE ls at /tmp/fake/bin/lsls now runs a script. The real one is still installed, still listed by type -a, and never reached.
Put the same directory last instead and nothing changes:
$ export PATH="$PATH:/tmp/fake/bin"
$ type ls
ls is /bin/ls4. Extending It Without Breaking It
export PATH="/opt/tool/bin:$PATH"The $PATH on the right is what makes this safe — you are building a new value out of the old one. Leave it out and you have replaced the list:
$ export PATH="/opt/tool/bin"
$ ls
bash: ls: command not foundNothing is broken permanently; the next shell gets a fresh PATH from the startup files in Chapter 10.3. But this shell can now run almost nothing, and the error names the wrong thing — ls is exactly where it always was.
Two habits worth having:
Quote it. PATH can contain a space, and Chapter 9.2 applies here like anywhere else.
Avoid stacking duplicates. A setup script sourced twice prepends twice:
$ echo "$PATH" | tr ':' '\n' | sort | uniq -d
/opt/tool/binDuplicates are harmless to correctness and make the list unreadable. The usual guard is to add only if it is not already there:
case ":$PATH:" in
*":/opt/tool/bin:"*) ;;
*) export PATH="/opt/tool/bin:$PATH" ;;
esacThe colons around both sides are what make the test exact — without them, /opt/tool would match /opt/tool-old. (case is Chapter 16.6.)
5. The Empty Entry
This one is a genuine security problem and it is easy to create by accident.
An empty field in PATH means the current directory. All three of these contain one:
:/usr/bin:/bin leading colon
/usr/bin::/bin doubled colon
/usr/bin:/bin: trailing colonSo does an explicit .. Here is what that allows:
$ cd /tmp/untrusted
$ printf '#!/bin/bash\necho DANGER\n' > ./date
$ chmod +x ./date
$ PATH=":/usr/bin:/bin" date
DANGERA file dropped in a directory, and date runs it. On a shared machine — a scratch directory, a downloads folder, anywhere another person can write — this is how a command gets hijacked.
6. The Shell Remembers Where It Found Things
Searching the list on every command would be wasteful, so Bash caches each result:
$ hash
hits command
1 /usr/bin/grepThe cache is normally invisible. It becomes visible when it is wrong:
$ which -a mytool
/usr/local/bin/mytool
$ # ... you install a newer one earlier on PATH ...
$ mytool --version
1.0 # still the old oneBash is using the remembered location. Clear it:
hash -r # forget everything
hash -d mytool # forget one commandhash -r is the thing to try when a command insists on being a version you have just replaced, and it looks like the shell is ignoring you. Starting a new shell does the same thing.
7. Reading Somebody Else's PATH
A practical skill, because a broken environment is usually a PATH someone built up over years.
echo "$PATH" | tr ':' '\n' | nlNumbered, one per line, in priority order. Then:
# which entries do not exist?
echo "$PATH" | tr ':' '\n' | while IFS= read -r d; do
[ -d "$d" ] || echo "missing: $d"
done
# which entries are duplicated?
echo "$PATH" | tr ':' '\n' | sort | uniq -d
# which entries are writable by anyone but you?
echo "$PATH" | tr ':' '\n' | while IFS= read -r d; do
[ -d "$d" ] && [ -w "$d" ] && [ ! -O "$d" ] && echo "others can write: $d"
doneThose use the while IFS= read -r loop from Chapter 8.6 and the test syntax from Chapter 16.2. The third one is the security audit: a directory on your PATH that somebody else can write to is the same hazard as §5, with extra steps.
The engineering case
export VCS_HOME=/opt/synopsys/vcs/2024.09
export PATH="$VCS_HOME/bin:$PATH"Prepended, because the point is to select a tool version. On a machine with four simulator releases installed, the one your regression uses is decided entirely by which bin directory is earliest — and "the regression used the wrong compiler" is almost always this, found with one which -a.
$ which -a vcs
/opt/synopsys/vcs/2024.09/bin/vcs
/opt/synopsys/vcs/2023.03/bin/vcsPractice
1 — Read yours.
echo "$PATH" | tr ':' '\n' | nl2 — Find your duplicate installations.
for c in python3 grep ls make; do echo "== $c"; type -a "$c"; done3 — Shadow a command, then undo it. In a throwaway shell:
mkdir -p /tmp/fake/bin
printf '#!/bin/bash\necho "FAKE ls"\n' > /tmp/fake/bin/ls
chmod +x /tmp/fake/bin/ls
PATH="/tmp/fake/bin:$PATH" bash -c 'type ls; ls'
PATH="$PATH:/tmp/fake/bin" bash -c 'type ls'Using PATH=... bash -c scopes the change to that one command, so your shell is never affected.
4 — Break it on purpose, in a subshell.
( export PATH="/opt/nothing"; ls )
echo "your shell is fine: $(ls /tmp >/dev/null && echo yes)"5 — Create and detect an empty entry.
unset TOOLBIN
( export PATH="$PATH:$TOOLBIN"; case "$PATH" in (*:) echo "trailing empty entry created";; esac )6 — See the hash cache.
hash -r
grep --version >/dev/null; sort --version >/dev/null 2>&1
hash7 — Audit for missing and duplicated entries. Run the two loops from §7.
8 — Add a directory the safe way.
mkdir -p ~/bin
case ":$PATH:" in *":$HOME/bin:"*) echo "already there" ;; *) export PATH="$HOME/bin:$PATH" ;; esac
echo "$PATH" | tr ':' '\n' | head -2Check Your Understanding
1. which -a python3 lists three. Which one runs, and why?
2. You add export PATH="$PATH:/opt/tool-2.0/bin" to get a newer tool and still get the old one. What went wrong?
3. export PATH="/opt/tool/bin" and now almost nothing works. What happened, and how do you recover?
4. Why is a leading colon in PATH a security problem?
5. You install a new version of a command earlier on PATH and the shell keeps running the old one. What is happening?
6. Why is type -a a better first question than which?
8. Remember
PATHis an ordered, colon-separated list of directories, searched left to right.- The first executable match wins and later directories are never consulted.
- It is not recursive — only the listed directories, never their subdirectories.
- A file needs the right name and the execute bit to be chosen.
which -aandtype -alist every match in search order. Most machines have several copies of the common tools.- Prefer
type -a: it is a builtin and sees aliases, functions and builtins too. - Prepend to override, append to supplement. Appending when you meant to override fails silently.
- Always build on
$PATH:export PATH="/new:$PATH". Omitting it replaces the list. - An empty field means the current directory — a leading, doubled or trailing colon, or a bare
.. Never allow one. - An unset variable in a
PATHline creates an empty entry.export PATH="$PATH:$MAYBE_UNSET"is a real hazard. - Bash caches lookups.
hash -rwhen a command insists on being the version you just replaced. No such file or directorymeans a stale cache;command not foundmeans the search failed.- Guard against duplicates with a
casetest using colons on both sides. - Which simulator your regression ran is a
PATHquestion, answerable with onewhich -a. - Two installations of one tool are normal in EDA, and
PATHorder picks the version — Chapter 25.2 measures the same command reporting 2024.09 and 2025.03, library included.
You can now say which program a name resolves to. The next question is where PATH was set in the first place — which depends entirely on what kind of shell you are in, and is the single most common reason a setting works in one place and not another.
Where this fits
Part of the Linux & Bash curriculum.
