why every fork costs time, and source avoids it
A subshell is convenient, but not free: every set of parentheses, every pipe and every command substitution spawns a new child process with its own copy of the environment. Sourcing runs the same commands in the current process, saves the fork and shares variables directly, but comes with very different side effects on script state.
Table of Contents
- 1. What fundamentally sets subshells and sourcing apart
- 2. How Bash creates a subshell
- 3. Sourcing: same process, shared scope
- 4. Where subshells appear implicitly
- 5. Measuring performance: concrete benchmark numbers
- 6. Variable loss: why sourcing can be riskier
- 7. Practical decision: when subshell, when sourcing
- 8. Common mistakes when choosing between the two
- 9. Subshell and sourcing compared directly
- 10. Summary
- 11. FAQ
1. What fundamentally sets subshells and sourcing apart
A subshell is a copy of the current shell that runs as its own child process, with its own memory space, its own process ID, and a complete copy of every exported variable and function at the time it is created. Sourcing, on the other hand, run with source file.sh or the shorthand . file.sh, reads the contents of a file and executes it in the current process, without spawning a new process. This fundamental difference between subshell and sourcing has direct consequences for performance and for state shared between parts of a script.
Anyone starting a subshell pays, for every single instance, the cost of a fork syscall, meaning the operating system duplicating the process address space, usually optimized with copy-on-write, but never free. Sourcing does not incur this cost, since no new process is created, but the main script and the included file share the same namespace for variables, functions and even set shell options like set -e. This sharing is exactly a blessing and a curse at once, as the following sections show.
In short, rarely executed scripts, the difference between subshell and sourcing is usually irrelevant. But once a loop creates a subshell hundreds or thousands of times, for example through command substitution in every iteration, the fork overhead adds up to a measurable slowdown that can be avoided through deliberate sourcing or by avoiding unnecessary subshells.
2. How Bash creates a subshell
Technically, a subshell is created through the operating system's fork syscall, which Bash calls internally for every subshell construct. The new process gets its own process ID, visible through the BASHPID variable, while the $$ variable keeps returning the PID of the original shell, even inside the subshell. This difference between $$ and BASHPID is a reliable way to detect whether code is currently running inside a subshell.
Every subshell gets a copy of all exported environment variables, all shell functions and most shell options at the time it is created. Changes to variables inside the subshell do not affect the parent shell, because they are separate memory areas. The operating system uses copy-on-write, so memory pages are only duplicated once actually modified, making fork considerably cheaper than a full memory copy, but the syscall overhead itself remains.
#!/usr/bin/env bash
set -euo pipefail
echo "Parent PID (\$\$): $$"
echo "Parent BASHPID: $BASHPID"
(
# Everything in here runs in a genuine subshell (new process)
echo "Subshell PID (\$\$): $$"
echo "Subshell BASHPID: $BASHPID"
x=99
echo "x inside subshell: $x"
)
# The parent's variable x was never touched by the subshell
echo "x in parent (unset): ${x:-not set}"
The example shows the crucial effect: $$ stays identical across the process boundary of the subshell, while BASHPID reveals the actual, different process ID. The variable x, set inside the subshell, simply does not exist in the parent shell after leaving the parentheses, because the entire subshell environment is discarded on exit.
3. Sourcing: same process, shared scope
Sourcing with source or the dot operator reads a file line by line and executes every line as if it stood directly in the calling script. There is no fork, no new process, no copy of the environment, just direct execution in the existing process address space. Variables set inside the included file keep existing afterward in the calling script, unlike a subshell, where they are lost.
This behavior makes sourcing the standard method for library files defining shared functions and configuration variables, for example source ./lib/logging.sh at the top of a larger script. Without the fork overhead of a subshell, sourcing is also marginally faster, though the real performance advantage lies less in sourcing itself once and more in avoiding repeated subshells when the same logic is called often inside a loop.
# lib/logging.sh — sourced, not executed as a subprocess
log_info() {
printf '[%(%Y-%m-%dT%H:%M:%S)T] [INFO] %s\n' -1 "$1"
}
LOG_PREFIX="myapp"
# main.sh
#!/usr/bin/env bash
set -euo pipefail
source ./lib/logging.sh
# Variables and functions from the sourced file are directly usable —
# no subshell, no separate process, same variable scope
log_info "Using prefix: $LOG_PREFIX"
# Verify we're still in the same process as before sourcing
echo "Still same process: BASHPID=$BASHPID"
4. Where subshells appear implicitly
Many Bash constructs create a subshell without any explicit parentheses appearing in the code, and that is exactly what leads to surprising performance loss and variable loss in practice. Command substitution with $(command) runs in a subshell. Every stage of a pipe except optionally the last runs in its own subshell. A group in parentheses (command1; command2) is explicitly a subshell, while curly braces { command1; command2; } stay in the current process.
The last stage of a pipe is particularly tricky: while read -r line; do ((count++)); done < <(command) runs without a subshell thanks to process substitution, while command | while read -r line; do ((count++)); done creates a subshell for the while block, causing count to be back at its old value after the loop. This side effect is one of the most common sources of bugs for counters and accumulators in pipe constructs.
#!/usr/bin/env bash
set -euo pipefail
count=0
# WRONG: the pipe puts the while loop in a subshell —
# "count" is lost as soon as the loop ends
find /var/log -name "*.log" | while read -r _; do
((count++))
done
echo "Count after pipe-while: $count" # 0 — subshell discarded the change
count=0
# RIGHT: process substitution avoids the subshell for the loop body
while read -r _; do
((count++))
done < <(find /var/log -name "*.log")
echo "Count after process substitution: $count" # correct value
# Alternative in Bash 4.2+: shopt -s lastpipe runs the last pipe
# segment in the current shell, but only outside of job control
shopt -s lastpipe
count=0
find /var/log -name "*.log" | while read -r _; do
((count++))
done
echo "Count with lastpipe: $count" # correct value, no subshell
5. Measuring performance: concrete benchmark numbers
To make the actual cost difference between a subshell and sourcing visible, a simple benchmark with the time builtin is worthwhile. In a loop with several thousand iterations, the fork overhead of a subshell shows up clearly against a function call in the same process, which causes practically no additional process startup.
On typical server hardware, the difference at 10,000 iterations often falls somewhere between several hundred milliseconds and a few seconds, depending on system load and kernel version. For a script that only runs once, that is irrelevant. For a script that wraps thousands of small tasks per second in subshells, for example in a log processing pipeline, the difference becomes a direct factor in overall runtime.
#!/usr/bin/env bash
set -euo pipefail
iterations=10000
echo "=== Subshell overhead (explicit parentheses) ==="
time {
for ((i = 0; i < iterations; i++)); do
(: "no-op in subshell")
done
}
echo "=== Function call, no subshell ==="
noop() { :; }
time {
for ((i = 0; i < iterations; i++)); do
noop
done
}
echo "=== Command substitution overhead ==="
time {
for ((i = 0; i < iterations; i++)); do
result="$(echo "$i")"
done
}
6. Variable loss: why sourcing can be riskier
As much as sourcing avoids the fork overhead of a subshell, the shared namespace carries its own risks. A sourced file can accidentally overwrite variables of the calling shell, since no isolation protection exists. A lib.sh using a variable named result or count can silently overwrite same-named variables in the main script, a bug that could not happen inside a subshell by construction.
set options are also shared during sourcing. If a sourced file sets set +e to tolerate errors for a single command and forgets to set -e again afterward, that error tolerant setting suddenly applies to the entire rest of the main script. Inside a subshell, this effect would have stayed confined to the parentheses. Anyone writing library files should therefore consistently use local for all internal variables in functions and reset any set changes at the end of the function.
7. Practical decision: when subshell, when sourcing
A subshell is the right choice when deliberate isolation is wanted, for example to temporarily change the working directory with cd without affecting the parent shell: (cd /tmp/build && make) leaves the parent shell unchanged in the original directory after the command. A subshell is likewise suited for setting environment variables for just a single command, without exporting them permanently.
Sourcing is the right choice for shared function libraries, configuration files, and anywhere performance matters in frequently executed loops. The rule of thumb: isolation and safety from accidental state leaks favor the subshell, while shared state and performance in hot paths favor sourcing. The two techniques are not mutually exclusive, a well-structured script often uses both deliberately in different places.
8. Common mistakes when choosing between the two
The most common mistake is an unintended subshell caused by a pipe before a while loop, where a counter or array appears unchanged after the loop. The second common mistake is sourcing a file with naming collisions in internal variables that silently overwrite values in the main script, without any visible error.
A third mistake concerns performance assumptions made without measurement: many developers avoid subshells on principle, even where they only occur once per script run and the fork overhead in the millisecond range is completely irrelevant. The choice between subshell and sourcing should always be made in the context of the actual call frequency, not as a blanket rule.
9. Subshell and sourcing compared directly
The table below summarizes the key differences between subshell and sourcing.
| Criterion | Subshell ( ), $( ), pipe | Sourcing (source, .) |
|---|---|---|
| New process | Yes (fork syscall) | No |
| Variable changes visible | No, local only | Yes, shared scope |
| Isolation of set options | Yes | No, shared |
| Performance in loops | fork overhead per iteration | No additional process startup |
| Typical use | Directory changes, targeted isolation | Libraries, configuration, hot paths |
This comparison makes clear: there is no universally better tool, just two complementary mechanisms with opposite strengths. A subshell protects against state leaks, sourcing saves process starts. Deliberately choosing between the two, rather than reflexively using one of them, is the real gain in both performance and robustness.
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Bash scripts slowed down by unnecessary subshells?
We analyze loops and pipes for implicit subshells, measure the actual fork overhead, and choose deliberately between subshell and sourcing, so your automation runs faster and more predictably.
Performance audit
Spot implicit subshells in loops and pipes
Library structure
Build sourcing patterns with clean variable scope
Benchmarking & tuning
Concrete runtime measurements instead of gut feeling for optimizations
10. Summary
A subshell is created through a fork syscall and spawns its own process with isolated state, which offers protection against state leaks but costs performance on every call. Sourcing executes code in the current process, shares variables and set options directly, and avoids the fork overhead, which is noticeably faster in loops with many iterations, but requires stricter discipline around variable names.
Implicit subshells caused by pipes, command substitution and parentheses are the most common source of unexpected variable loss and unnecessary performance loss in Bash scripts. Understanding the mechanics behind subshell and sourcing lets you use both techniques deliberately, instead of relying on chance or blanket avoidance strategies.
Subshells vs. Sourcing — Key Takeaways
Mechanics
Subshell = fork with its own process and BASHPID. Sourcing = execution in the current process, no fork.
Performance
fork overhead accumulates in loops. Sourcing causes no additional process startup.
Implicit subshells
Command substitution, pipes and parentheses create subshells, often unnoticed and with variable loss.
Decision
Subshell for isolation, sourcing for shared state and performance in frequently executed paths.