Review & Verify Updated 2026-09 8 min read View as Markdown

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#

shell
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"
done

Three processes per file. Two of them are unnecessary:

shell
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"
done

Look for: $( ) inside a for or while body. Ask whether parameter expansion can do it.

Spawns a processBuiltin 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#

shell
cat file | grep pattern | wc -l      # three processes
grep -c pattern file                 # one

Not 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.

shell
# 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
shell
# 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/' ./*.conf

The 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#

shell
# 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.log

If 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#

shell
count=0
find . -name '*.log' | while read -r f; do
  count=$((count + 1))                # runs in a subshell
done
echo "$count"                          # 0

A correctness bug (failure mode 7) that is also a performance one — the subshell is a fork. Process substitution avoids both:

shell
while read -r f; do
  count=$((count + 1))
done < <(find . -name '*.log')

6. $( ) where a variable would do#

shell
if [[ "$(cat /etc/hostname)" == "prod" ]]; then     # process per check

Read 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#

shell
errors=$(grep -c ERROR app.log)
warns=$(grep -c WARN app.log)
total=$(wc -l < app.log)               # three full passes over a large file
shell
read -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#

shell
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 idea

The \; 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#

shell
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 cores

xargs -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#

shell
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 -x

That 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.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.