# The shell mistakes language models actually make

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

Shell has the widest gap of any language on this network between "works when I ran it" and "correct". Generated shell is written in the style of the tutorials it learned from, and tutorial shell omits every guard, because guards make examples longer.

The good news: `shellcheck` catches the large majority of what follows. If you take one thing from this page, install it.

## Quoting and expansion

### 1. Unquoted variables

```bash
cp $src $dst              # breaks on any path with a space
rm -rf $BUILD_DIR         # breaks catastrophically on an unset variable
```

Word splitting and glob expansion happen on unquoted expansions. A filename with a space becomes two arguments; a filename with `*` becomes a glob.

**Correct:** `cp -- "$src" "$dst"`. Always quote. There are almost no exceptions.

**Catch it with:** shellcheck SC2086 — its most-triggered rule.

### 2. Parsing `ls`

```bash
for f in $(ls *.txt); do ...
```

Breaks on spaces, newlines and unusual characters, and produces a literal `*.txt` when nothing matches.

**Correct:**

```bash
for f in ./*.txt; do
  [[ -e "$f" ]] || continue     # the glob may not have matched
  ...
done
```

### 3. Unquoted array expansion

```bash
"${arr[@]}"        # correct: one word per element
"${arr[*]}"        # one word, joined by the first IFS character
${arr[@]}          # subject to splitting. wrong.
```

Generated array code gets this wrong regularly, and it is silent until an element contains a space.

## Error handling

### 4. No `set -e`, or `set -e` alone

```bash
#!/bin/bash
cd /some/path          # if this fails...
rm -rf ./*             # ...this runs in the wrong directory
```

**Correct:** the four-line header.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
```

`set -u` in particular prevents the `rm -rf "$UNSET_VAR/"` class of accident, which is the worst outcome available in this language.

:::warn `set -e` has gaps you should know about
It does not fire inside a condition (`if cmd; then`), inside `&&`/`||` chains except the last command, or in a function whose result is being tested. `pipefail` covers pipelines; the `ERR` trap covers reporting. Do not treat `set -e` alone as a safety net.
:::

### 5. Exit codes lost through a pipe

```bash
generate | grep -q pattern    # $? is grep's, not generate's
```

`set -o pipefail` fixes it. `${PIPESTATUS[@]}` if you need the individual codes.

### 6. `cd` without a check

```bash
cd "$dir"                     # if it fails, the rest runs where you started
rm -rf ./build
```

**Correct:** `cd "$dir" || exit 1`, and verify a marker file exists before anything destructive.

## Subshells and scope

### 7. Variables set in a pipeline are lost

```bash
count=0
find . -name "*.log" | while read -r f; do
  count=$((count + 1))        # a subshell. the outer count stays 0.
done
echo "$count"                 # 0
```

The right-hand side of a pipe runs in a subshell. **Correct:** process substitution.

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

### 8. `read` without `-r`

```bash
while read line; do          # backslashes get mangled
```

`read -r` always. And `IFS= read -r line` to preserve leading and trailing whitespace.

## Tests and comparisons

### 9. `[` versus `[[`

```bash
[ $x = "y" ]                  # breaks if x is empty or contains spaces
[[ $x == "y" ]]               # safe: no word splitting inside [[ ]]
```

In bash, use `[[ ]]`. Use `[ ]` only when POSIX portability is a real requirement.

### 10. String versus numeric comparison

```bash
[[ "$a" > "$b" ]]             # string comparison. "9" > "10" is true.
(( a > b ))                   # numeric
```

### 11. Testing command success by parsing output

```bash
if [[ $(command) == *"success"* ]]; then     # fragile
if command; then                             # use the exit code
```

## Destructive operations

### 12. `rm -rf` on anything computed

The single highest-risk pattern in generated shell.

```bash
rm -rf "$dir/$subdir"         # what if either is empty?
```

Defences, in order of value: `set -u`; validate the variable is non-empty and not `/`; use `--` before paths; prefer `trash` for interactive use; add a `--dry-run` flag that prints instead.

```bash
[[ -n "${dir:-}" && "$dir" != "/" ]] || { echo "refusing" >&2; exit 1; }
```

### 13. `eval`

Almost never necessary and almost always an injection. If generated code contains `eval` on anything interpolated, rewrite it — usually with an array.

## Portability

### 14. GNU flags on macOS

`sed -i` takes an argument on BSD sed and not on GNU sed. `date -d` is GNU-only. Generated scripts assume GNU because most Linux examples do. If your team is mixed, either mandate `gsed`/`gdate` or avoid the flags.

### 15. `#!/bin/bash` versus `#!/usr/bin/env bash`

macOS ships bash 3.2 at `/bin/bash`, which lacks associative arrays and `${var,,}`. Use `#!/usr/bin/env bash` so a modern bash from Homebrew is found.

## The one tool

```bash
shellcheck -S warning scripts/*.sh
shfmt -w -i 2 -ci -bn scripts/
```

`shellcheck` catches items 1, 2, 3, 5, 7, 8, 9, 10 and flags several others. It has almost no false positives and every warning links to an explanation.

Put it in your agent's post-edit hook and generated shell arrives already fixed — see [shell scripts as agent guardrails](/ai/hooks-and-guardrails/).

:::verdict The short version
Install shellcheck. Use the four-line header. Quote everything. Then the only thing left to read for is whether the script does the right thing — and if it is over 100 lines, the answer is probably to rewrite it in Python.
:::

:::promo warp
:::

## Common questions

### Is shellcheck really enough?

For the syntax-level bugs, close to it — quoting, splitting, subshells, comparison operators. What it cannot tell you is whether a destructive command is pointed at the right path, which is the failure that actually hurts. That one needs `set -u`, validation, and a dry-run flag.

### Why does generated shell omit error handling?

Because the shell in the training data omits it. Tutorials, Stack Overflow answers and README snippets are all written for brevity, and none of them are expected to run unattended. The model reproduces the distribution.

### When should I stop writing shell?

At about 100 lines, or the first time you need an array of anything other than strings, real error handling, or a function that returns a value. Beyond that, Python or Go is shorter, testable and far easier to review.
