Linux & Bash · Part I · Module 10 · The Environment, PATH and Shell Startup
Sourcing vs Executing: Why ./setup.sh Does Nothing
A new engineer is handed a setup script, runs it the obvious way, and finds the tool still missing. Nothing failed, no error appeared, and the script did exactly what it was written to do. The difference between the two ways of running it is one word, and it is the most consequential word in this module.
Intermediate17 min readLinuxPart IFundamentalsEnvironmentBash
Part I · Module 10 · Chapter 10.4 · Sourcing vs Executing
Chapter 7.4 proved that nothing a child does reaches its parent. Chapter 10.1 proved that the environment is copied rather than shared. This chapter is the practical consequence, and it is the one you will use most.
1. The Experiment
A small setup script, doing the three things such scripts do:
cd /tmp
export SETUP_RAN=yes
MYVAR=local
echo " inside: PID=$$ pwd=$(pwd)"Run it the obvious way:
$ echo "shell PID=$$ pwd=$(pwd)"
shell PID=44741 pwd=/tmp/demo
$ ./setup.sh
inside: PID=44743 pwd=/tmp
$ echo "after: pwd=$(pwd) SETUP_RAN=[${SETUP_RAN-unset}]"
after: pwd=/tmp/demo SETUP_RAN=[unset]Now source it:
$ source ./setup.sh
inside: PID=44741 pwd=/tmp
$ echo "after: pwd=$(pwd) SETUP_RAN=[${SETUP_RAN-unset}] MYVAR=[${MYVAR-unset}]"
after: pwd=/tmp SETUP_RAN=[yes] MYVAR=[local]Look at the PIDs. That is the whole chapter.
- Executed: the script reported 44743, a different process from the shell's 44741. Its
cdand itsexportapplied to that process, which then exited. - Sourced: the script reported 44741 — your shell's own PID. There was no child. The lines ran in the shell you are sitting in, so the directory changed and the variables stayed.
Note too that MYVAR persisted even though it was never exported. Sourcing is not "exporting harder" — there is simply no process boundary for anything to cross.
2. Two Spellings
source setup.sh
. setup.shIdentical in Bash. . is the POSIX spelling and works in every shell; source is the Bash and C-shell name and reads better. Use . in anything with a #!/bin/sh line, and either elsewhere.
$ . ./setup.sh >/dev/null
$ echo "dot worked: SETUP_RAN=[$SETUP_RAN]"
dot worked: SETUP_RAN=[yes]A sourced file is read, not run
$ ls -l lib.sh
-rw-r--r-- 1 engineer engineer 48 Oct 8 11:02 lib.sh
$ source ./lib.shNo execute bit, and it works. Sourcing reads the file's text and feeds it to the shell, so only the read permission matters. A shebang line is likewise ignored — it is a comment as far as the sourcing shell is concerned, and the file runs in whatever shell sourced it regardless of what the first line claims.
That is worth remembering in both directions: a file written for bash and sourced into zsh will be interpreted by zsh, shebang or not.
Where it looks
source lib.sh with no slash searches PATH before the current directory — which is surprising and occasionally dangerous. Be explicit:
source ./lib.sh # this directory, definitely
source "$script_dir/lib.sh" # a path you computed$script_dir from Chapter 8.4's $(cd "$(dirname "$0")" && pwd) idiom is the robust way to source a file that sits next to a script.
3. Why Every Tool Says "source"
This is the practical payoff, and it is most of what you will meet in engineering work.
# /opt/synopsys/vcs/2024.09/setup.sh
export VCS_HOME=/opt/synopsys/vcs/2024.09
export PATH="$VCS_HOME/bin:$PATH"
export LM_LICENSE_FILE=27000@licsrv1
export UVM_HOME="$VCS_HOME/etc/uvm"Every line sets something the caller needs to keep. Run it as ./setup.sh and all four apply to a process that immediately exits:
$ ./setup.sh
$ which vcs
vcs not foundNo error. No warning. The script succeeded completely and achieved nothing.
$ source /opt/synopsys/vcs/2024.09/setup.sh
$ which vcs
/opt/synopsys/vcs/2024.09/bin/vcs4. Two Ways It Bites Back
Sourcing runs code in your shell with your authority. Two consequences follow, and both catch people once.
exit in a sourced script exits your shell
# bad.sh
echo " about to exit"
exit 3
echo "never reached"$ ( source ./bad.sh; echo "this line does NOT print" )
about to exit
$ echo "subshell exited with $?"
subshell exited with 3The parentheses are protecting the demonstration — they run it in a subshell. Without them, that exit 3 closes your terminal.
This is not a bug; exit means "end this shell", and when a script is sourced, this shell is yours. A script that might be sourced should use return instead:
# good.sh
if [ -z "${TOOL_HOME:-}" ]; then
echo "TOOL_HOME is not set" >&2
return 1 2>/dev/null || exit 1
fireturn 1 works when sourced; the || exit 1 covers being executed, where return outside a function is an error. It is ugly and it is the standard idiom. (return proper is Chapter 18.3, and Chapter 18.6 is this idiom used for a real library.)
A sourced script sees your arguments
$ set -- outer1 outer2
$ source ./args.sh
$1=[outer1] $#=[2]
$ source ./args.sh inner1
$1=[inner1] $#=[1]With no arguments of its own, a sourced script inherits the caller's positional parameters. Give it arguments and it gets those instead — and note that the caller's are restored afterwards.
The first case surprises people: a sourced file that reads $1 picks up whatever the enclosing script happened to have, which is rarely what its author meant. A sourced library should take nothing from $@ unless it was designed to.
5. Making Sourcing Idempotent
Setup scripts get sourced more than once — someone sources it, then sources a wrapper that sources it again. Chapter 10.2 showed the result: duplicate PATH entries.
The standard guard:
# setup.sh
if [ -n "${TOOL_SETUP_DONE:-}" ]; then
return 0 2>/dev/null || exit 0
fi
export TOOL_SETUP_DONE=1
export TOOL_HOME=/opt/tool
case ":$PATH:" in
*":$TOOL_HOME/bin:"*) ;;
*) export PATH="$TOOL_HOME/bin:$PATH" ;;
esacTwo defences doing different jobs: the flag makes the whole file a no-op on a second sourcing, and the case makes the PATH line safe even if the flag is cleared or the file is split. Both are worth having, because setup scripts get copied and edited by people who will not read the top.
6. Where Else This Rule Appears
Once you can see it, the same mechanism explains several things you have already met:
| Situation | Why it works the way it does |
|---|---|
.bash_profile reading .bashrc (10.3) | Sourcing — a child would change nothing |
cd being a builtin (1.7) | An external cd could not change your directory |
| A script cannot leave you elsewhere (7.1) | It is a child |
| A function changing your shell (7.4) | Functions also run without forking |
$(cd … && pwd) leaving you put (8.4) | The subshell is a child, deliberately |
A piped while loop losing its counter (8.6) | Each pipeline stage is a child |
Six apparently unrelated behaviours, one rule. That is the sign of a model worth having rather than a list of exceptions.
Practice
mkdir -p /tmp/srcdemo && cd /tmp/srcdemo1 — Run the experiment.
printf 'cd /tmp\nexport SETUP_RAN=yes\nMYVAR=local\necho " inside: PID=$$ pwd=$(pwd)"\n' > setup.sh
chmod +x setup.sh
echo "shell PID=$$ pwd=$(pwd)"
./setup.sh; echo " after exec: pwd=$(pwd) SETUP_RAN=[${SETUP_RAN-unset}]"
cd /tmp/srcdemo
source ./setup.sh; echo " after source: pwd=$(pwd) SETUP_RAN=[${SETUP_RAN-unset}] MYVAR=[${MYVAR-unset}]"
cd /tmp/srcdemoThe PID line is the one to read.
2 — Confirm . is the same.
unset SETUP_RAN; . ./setup.sh >/dev/null; echo "SETUP_RAN=[$SETUP_RAN]"; cd /tmp/srcdemo3 — Source a file with no execute bit.
printf 'export FROM_LIB=yes\n' > lib.sh
ls -l lib.sh
source ./lib.sh && echo "sourced without +x: FROM_LIB=[$FROM_LIB]"
./lib.shThe last line fails — executing does need the bit.
4 — Watch exit in a sourced script, safely.
printf 'echo " about to exit"\nexit 3\necho "never reached"\n' > bad.sh
( source ./bad.sh; echo "this does NOT print" )
echo "subshell status: $?"Keep the parentheses.
5 — See the argument inheritance.
printf 'echo " \$1=[$1] \$#=[$#]"\n' > args.sh
set -- outer1 outer2
source ./args.sh
source ./args.sh inner1
echo "caller still has: \$1=[$1] \$#=[$#]"6 — Add the must-be-sourced guard.
cat > guarded.sh <<'EOF'
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
echo "error: source this, do not execute it" >&2
exit 1
fi
export GUARDED=ok
EOF
chmod +x guarded.sh
./guarded.sh
source ./guarded.sh && echo "sourced fine: GUARDED=$GUARDED"7 — Make it idempotent. Add the TOOL_SETUP_DONE flag and the case guard from §5 to a script that prepends to PATH, source it three times, and confirm with echo "$PATH" | tr ':' '\n' | sort | uniq -d that nothing duplicated.
8 — Clean up. cd /tmp && rm -rf /tmp/srcdemo
Check Your Understanding
1. You run ./setup.sh and which vcs still reports nothing. What happened, and what is the fix?
2. How can you prove, from inside a script, whether it was sourced or executed?
3. Why does a sourced script not need the execute bit?
4. A colleague's setup script calls exit 1 on an error. What happens when you source it and the error occurs?
5. A sourced library reads $1 and gets a value nobody passed it. Where did it come from?
6. You source a tool setup three times and PATH has three copies of its bin. What two guards prevent that?
7. Remember
- Executing runs a script in a new process; sourcing runs its lines in the shell you already have.
- The PID is the proof. A sourced script reports your shell's
$$. - Anything meant to change your current shell must be sourced. No flag makes an executed script change its parent.
source fileand. fileare identical;.is the POSIX spelling.- A sourced file needs only read permission, and its shebang is ignored — it runs in whatever shell sourced it.
source lib.shsearchesPATHfirst. Writesource ./lib.shor a full path.- Tool setup scripts must be sourced because every line sets something the caller keeps. Executing one succeeds and achieves nothing.
- Detect the mistake: compare
${BASH_SOURCE[0]}with$0. exitin a sourced script exits your shell. Usereturn 1 2>/dev/null || exit 1.- A sourced script inherits the caller's positional parameters when given none of its own.
- Sourcing runs everything with your authority. Read the file first.
- Make setup scripts idempotent with a done-flag and a
caseguard onPATH. - One rule explains six earlier behaviours —
cdas a builtin, scripts not changing your directory, the subshell in$(cd … && pwd), and the pipedwhileloop that loses its counter. - When a compile differs between two engineers, the cause is usually a define or a flag in a build script — Chapter 24.5 finds them, and measures a
-Dthat is accepted and ignored. - A setup script sourced twice doubles
PATHunless it guards — and Chapter 25.6 measures the substring bug in the guard everyone writes first.
Sourcing a file can also define functions, and a function defined in your shell shadows a command of the same name. That is the last piece of "why did this name do something unexpected" — and the next chapter.
Where this fits
Part of the Linux & Bash curriculum.
