# The performance traps in generated shell scripts

> Source: https://learn-bash.net/review/performance/
> Part of Learn Bash, free to read.

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

```bash
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:

```bash
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 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

```bash
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.

```bash
# 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
```

```bash
# 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

```bash
# 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

```bash
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](/review/failure-modes/)) that is also a performance one — the subshell is a `fork`. Process substitution avoids both:

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

### 6. `$( )` where a variable would do

```bash
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

```bash
errors=$(grep -c ERROR app.log)
warns=$(grep -c WARN app.log)
total=$(wc -l < app.log)               # three full passes over a large file
```

```bash
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](/review/failure-modes/)). `sort -S 50%` gives it more memory. `sort -u` beats `sort | uniq` — one process instead of two.

### 9. `find -exec` one at a time

```bash
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

```bash
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

```bash
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.

:::verdict 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.
