The performance traps in generated shell scripts
Shell performance is almost entirely about how many processes you start. Generated scripts start a great many, and the fix is usually to delete code rather than add it.
There is one performance model in shell and everything follows from it: starting a process costs roughly a millisecond, and shell builtins cost roughly nothing. A loop that spawns three processes per iteration over ten thousand items spends thirty seconds doing nothing but fork and exec.
Generated shell spawns freely, because the pipeline-of-tools style is idiomatic and reads well. It is idiomatic between stages of a pipeline and expensive inside a loop.
Process spawning#
1. Command substitution inside a loop#
for f in *.txt; do
name=$(basename "$f" .txt) # one process
dir=$(dirname "$f") # two
size=$(stat -c%s "$f") # three
echo "$dir/$name: $size"
doneThree processes per file. Two of them are unnecessary:
for f in *.txt; do
name=${f##*/}; name=${name%.txt} # parameter expansion: free
dir=${f%/*}
size=$(stat -c%s "$f") # this one genuinely needs a process
echo "$dir/$name: $size"
doneLook for: $( ) inside a for or while body. Ask whether parameter expansion can do it.
| Spawns a process | Builtin equivalent | |
|---|---|---|
basename "$p" | ${p##*/} | |
dirname "$p" | ${p%/*} | |
| `echo "$s" \ | cut -d: -f1` | ${s%%:*} |
| `echo "$s" \ | cut -d: -f2-` | ${s#*:} |
| `echo "$s" \ | tr a-z A-Z` | ${s^^} |
| `echo "$s" \ | sed 's/a/b/'` | ${s/a/b} |
expr "$a" + "$b" | $(( a + b )) | |
| `echo "$s" \ | wc -c` | ${#s} |
seq 1 100 | {1..100} |
2. Useless use of cat#
cat file | grep pattern | wc -l # three processes
grep -c pattern file # oneNot a large absolute saving, but it is the tell for a script written by concatenating pipeline idioms rather than thinking about what each stage does. Almost every tool takes a filename.
3. A loop where one tool call would do#
The big one, and the difference is often 100x.
# 10,000 processes
for line in $(cat access.log); do
echo "$line" | grep -q ERROR && echo "$line" >> errors.log
done
# one process
grep ERROR access.log > errors.log# a loop calling sed per file
for f in *.conf; do sed -i 's/old/new/' "$f"; done
# one invocation
sed -i 's/old/new/' ./*.confThe question to ask of every loop in a shell script: can the tool inside it take all the input at once? Usually it can, and grep, sed, awk and sort are all far faster processing a stream than being started repeatedly.
4. awk instead of a loop with several tools#
# spawns per line
while read -r line; do
user=$(echo "$line" | cut -d' ' -f1)
bytes=$(echo "$line" | cut -d' ' -f5)
echo "$user $bytes"
done < access.log
# one process, whole file
awk '{ print $1, $5 }' access.logIf your loop body is mostly text manipulation, the loop is the problem. awk exists for exactly this and is orders of magnitude faster.
Subshells#
5. A pipeline where the right-hand side needs to keep state#
count=0
find . -name '*.log' | while read -r f; do
count=$((count + 1)) # runs in a subshell
done
echo "$count" # 0A correctness bug (failure mode 7) that is also a performance one — the subshell is a fork. Process substitution avoids both:
while read -r f; do
count=$((count + 1))
done < <(find . -name '*.log')6. $( ) where a variable would do#
if [[ "$(cat /etc/hostname)" == "prod" ]]; then # process per checkRead it once into a variable if you use it more than once. Obvious, and generated scripts re-read constantly because each line is written independently.
Doing work you do not need#
7. Reading a file more than once#
errors=$(grep -c ERROR app.log)
warns=$(grep -c WARN app.log)
total=$(wc -l < app.log) # three full passes over a large fileread -r errors warns total < <(awk '
/ERROR/ {e++} /WARN/ {w++} END {print e+0, w+0, NR}' app.log)One pass. On a multi-gigabyte log this is the difference between minutes and seconds.
8. sort without bounds on a huge file#
sort on a very large file spills to disk. LC_ALL=C sort is meaningfully faster than a locale-aware sort (and is also what you want for deterministic output). sort -S 50% gives it more memory. sort -u beats sort | uniq — one process instead of two.
9. find -exec one at a time#
find . -name '*.tmp' -exec rm {} \; # one rm per file
find . -name '*.tmp' -exec rm {} + # batches: far fewer processes
find . -name '*.tmp' -print0 | xargs -0 rm # same ideaThe \; versus + difference is one character and often a 50x improvement. Generated find commands use \; because it appears more often in examples.
Parallelism#
10. Serial work that could be parallel#
for f in *.jpg; do convert "$f" -resize 800x "out/$f"; done # one at a time
find . -name '*.jpg' -print0 \
| xargs -0 -P "$(nproc)" -I{} convert {} -resize 800x out/{} # all coresxargs -P is the simplest parallelism available in shell and it is a one-flag change. parallel gives you more control if you have it.
Two caveats: bound it to the actual constrained resource (-P matched to cores for CPU work, lower for anything hitting a network or a disk), and beware interleaved output — have each job write to its own file, or use xargs -P with --line-buffered tools.
Measuring#
time ./script.sh # wall, user, sys
# high sys time = process spawning
# count how many processes a script starts
strace -f -e trace=execve ./script.sh 2>&1 | grep -c execve # Linux
dtruss -f ./script.sh 2>&1 | grep -c exec # macOS
# find the slow line
PS4='+ $(date "+%s.%N") ${BASH_SOURCE}:${LINENO}: '
set -xThat PS4 trick is worth knowing: it timestamps every traced line, so you can see exactly where the seconds went without any instrumentation.
The honest limit#
Shell is a process orchestrator. If your script is spending its time on data manipulation rather than on coordinating other programs, no amount of optimisation fixes the mismatch.
Rewrite it when: you are processing more than a few thousand items in a shell loop, you have written a nested loop, you need a data structure more complex than a flat array, or the profiling above says the script itself is the bottleneck rather than the tools it calls.
Python starts in about 20 ms and then processes a million rows faster than shell processes a thousand. That trade flips quickly.
The reviewer's shortcut
Look at every loop and count the processes per iteration. Zero is ideal, one is fine, three means look harder. Then ask whether the loop is needed at all — most shell loops in generated code exist to feed one line at a time to a tool that would happily take the whole file.
Common questions#
Is process spawning really that expensive?#
Around a millisecond each, which is irrelevant once and thirty seconds across thirty thousand. That is why the loop is where it matters and a five-stage pipeline is not — the pipeline starts five processes total, the loop starts five per item.
Should I always prefer parameter expansion over cut and basename?#
Inside loops and hot paths, yes — it is free and there is no readability cost once you know the syntax. In a one-off line where clarity matters more, basename reads better to more people and the cost is one millisecond. Optimise the loop, not the script.
Is xargs -P safe?#
For independent tasks, yes. Be careful with output interleaving — have each job write to its own file rather than appending to a shared one — and match the parallelism to the constrained resource, which for network or disk work is often far lower than your core count.
When exactly should I stop and use Python?#
Roughly: a few thousand loop iterations, a nested loop, any data structure beyond a flat array, or JSON that needs more than a single jq call. Under those thresholds shell is the right tool and is usually shorter; over them it is slower, harder to test, and harder to review.
Get the Bash agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.