# Writing Shell Scripts

> Shell script development best practices. Use when writing, modifying, or reviewing bash/sh/zsh scripts, .sh files, .bash files, .bashrc, .bash_profile, or shell script templates (.sh.tftpl).

- Skill: `chogos/writing-shell-scripts` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add chogos/writing-shell-scripts`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chogos/writing-shell-scripts/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Chogos (https://skillmd.com/u/chogos)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/chogos/writing-shell-scripts

---


# Shell Script Development Best Practices

## Shebang

- `#!/usr/bin/env bash` for portability. `#!/bin/sh` for POSIX-only scripts.
- Executables: no extension or `.sh`. Libraries: always `.sh`, not executable.

## Error Handling

**Always** use `set -Eeuo pipefail` after the shebang:
- `-E`: ERR trap inherited in functions
- `-e`: exit on failure
- `-u`: exit on undefined variables
- `-o pipefail`: exit if any pipeline command fails

Use `|| true` only when explicitly ignoring failures.

## Formatting

- 2-space indent, never tabs. Max 80 characters per line.
- Place `; then` and `; do` on the same line as `if`/`for`/`while`.
- Split long pipelines across lines with `|` at the start of continuation lines.

## Variables

- **Always** quote: `"$variable"`, `"${prefix}_${suffix}"`.
- **Always** `$(command)` over backticks — nestable and unambiguous.
- `readonly` or `declare -r` for constants. `UPPER_CASE` for constants/environment variables.
- `local` for function variables. `lower_case` for local/function-scoped variables.
- Avoid globals; prefer function parameters and return values.

## Functions

- Define before use. `local` for all variables. Return meaningful exit codes.
- `lower_case` with underscores. Use `::` for library namespacing (`mylib::helper`).
- Document purpose, globals, arguments, outputs, and return values:
  ```bash
  # Downloads and verifies a file
  # Globals: DOWNLOAD_TIMEOUT
  # Args: $1=URL, $2=destination
  # Outputs: writes to stdout on success
  # Returns: 0 on success, 1 on failure
  download_file() {
      local url="${1:?URL required}"
      local dest="${2:?destination required}"
  }
  ```
- Use a `main` function in scripts with multiple functions; call it last:
  ```bash
  main() {
      parse_args "$@"
      run
  }
  main "$@"
  ```

## Safe file iteration

```bash
while IFS= read -r -d '' file; do
    process "$file"
done < <(find . -name "*.txt" -print0)
```

## Cleanup with trap

```bash
cleanup() {
    local exit_code=$?
    # Kill background jobs before cleaning up
    jobs -p | xargs -r kill 2>/dev/null || true
    wait 2>/dev/null || true
    rm -rf "$TEMP_DIR"
    exit "$exit_code"
}
trap cleanup EXIT
trap 'trap - EXIT; cleanup; kill -INT $$' INT
trap 'trap - EXIT; cleanup; kill -TERM $$' TERM
```

- `trap cleanup EXIT` runs on normal exit, errors (`set -e`), and signals.
- Separate `INT`/`TERM` traps re-raise the signal so the parent process sees the correct exit status.
- Kill child processes explicitly — by default, backgrounded jobs survive the parent's exit.

## Logging

```bash
log_info()  { printf '[INFO]  %s\n' "$*"; }
log_warn()  { printf '[WARN]  %s\n' "$*" >&2; }
log_error() { printf '[ERROR] %s\n' "$*" >&2; }
die()       { log_error "$@"; exit 1; }
```

## Input/Output

- `printf '%s\n' "$msg"` over `echo` for formatted output.
- Errors to stderr: `>&2`.
- Use heredocs for multi-line strings. **Quote the delimiter** (`<<'EOF'`) to prevent variable and command expansion — unquoted heredocs expand `$VAR` and `` `cmd` ``:
  ```bash
  # Unquoted — $VAR and $() expand (use for generating output with runtime values)
  cat <<EOF
  Host: $HOSTNAME  Port: $PORT
  EOF

  # Quoted — no expansion (use for writing scripts, SQL, config with literal $ chars)
  cat <<'EOF'
  echo "This $VARIABLE will not be expanded"
  EOF
  ```

## Arithmetic

- Use `(( ))` or `$(( ))` for math. Never `let`, `$[ ]`, or `expr`.

## String Manipulation

Prefer parameter expansion over `sed`/`awk` for simple transformations (`${var#pattern}`, `${var%pattern}`, `${var/old/new}`).

Defaults and guards:
```bash
"${1:-default}"    # use default if $1 is unset or empty
"${1:?error msg}"  # exit with error if $1 is unset or empty
"${var:+alt}"      # use alt if var is set and non-empty, else empty
```

## Working with JSON (jq)

Use `jq` for JSON processing — never parse JSON with `grep`/`sed`/`awk`.

```bash
# Extract a field
name=$(jq -r '.user.name' response.json)

# Iterate over array elements
jq -r '.items[] | .id' data.json | while IFS= read -r id; do
    process "$id"
done

# Build JSON safely with --arg (handles escaping)
jq -n --arg name "$USER" --arg host "$HOSTNAME" \
    '{"name": $name, "host": $host}'

# Modify JSON in-place
jq '.config.timeout = 30 | .config.retries = 3' config.json > tmp.json \
    && mv tmp.json config.json

# Select and filter
jq '[.[] | select(.status == "active")]' users.json
```

- Always use `-r` (raw output) when piping `jq` output to other commands — without it, strings include quotes.
- Use `--arg` and `--argjson` to pass shell variables into jq expressions safely.
- Use `--exit-status` (or `-e`) to make jq return non-zero when the filter produces `false` or `null`.

## Arrays

```bash
# Indexed arrays
files=("one.txt" "two.txt" "three.txt")
files+=("four.txt")             # append
echo "${files[0]}"              # first element
echo "${files[@]}"              # all elements (preserves quoting)
echo "${#files[@]}"             # length

# Iterate safely (handles spaces in elements)
for f in "${files[@]}"; do
    process "$f"
done

# Associative arrays (bash 4+)
declare -A config
config[host]="localhost"
config[port]="5432"
for key in "${!config[@]}"; do
    echo "$key=${config[$key]}"
done
```

## Security

- Validate all user inputs. Use `--` to separate options from arguments.
- Avoid `eval`. Secure permissions (`chmod 600`) for sensitive files.
- SUID/SGID forbidden on shell scripts — use `sudo` instead.
- Use `mktemp` for temp files — never hardcode `/tmp/myfile`:
  ```bash
  TEMP_DIR="$(mktemp -d)" || die "failed to create temp dir"
  ```

## ShellCheck

Run `shellcheck` on every script. Fix all warnings — don't suppress without justification.

```bash
# Suppress a specific rule with an explanation
# shellcheck disable=SC2086  # word splitting intended for $flags
docker run $flags "$image"

# Suppress for an entire file (top of file, after shebang)
# shellcheck disable=SC2034  # variables sourced by another script

# Source directive for files shellcheck can't find
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
```

Rule ranges: SC1xxx = syntax/parsing, SC2xxx = best practices, SC3xxx = POSIX compatibility.

## Portability

- Check required tools: `command -v git >/dev/null || die "git required"`.
- Avoid GNU-specific options when possible (see [portability-matrix.md](portability-matrix.md)).
- **POSIX sh vs bash**: POSIX sh has no arrays, no `[[ ]]`, no `<()` process substitution, no `{a,b}` brace expansion, no `+=` append. Use `#!/bin/sh` only when you avoid all bash-isms.
- **macOS ships bash 3.2** (2007, GPLv2). Associative arrays (`declare -A`) require bash 4+. If targeting macOS, either use `#!/bin/sh` (POSIX) or require users to install modern bash via Homebrew.

## New script workflow

```
- [ ] Start with shebang and set -Eeuo pipefail
- [ ] Add argument parsing with usage/help
- [ ] Add prerequisite checks (required tools)
- [ ] Implement main logic with cleanup trap
- [ ] Add logging and error handling
- [ ] Run validation loop (below)
```

## Validation loop

1. `shellcheck script.sh` — fix all warnings (SC1xxx=syntax, SC2xxx=best practices, SC3xxx=POSIX)
2. `bash -n script.sh` — fix syntax errors
3. Test with valid input — verify expected behavior
4. Test with invalid input — verify error messages and exit codes
5. Test on macOS if targeting both platforms
6. Repeat until shellcheck is clean and all cases pass

## Deep-dive references

**CLI tool template**: See [patterns/cli-tool-template.md](patterns/cli-tool-template.md) for a production-ready script template
**Deployment scripts**: See [patterns/deployment-scripts.md](patterns/deployment-scripts.md) for deploy, rollback, health check patterns
**Testing**: See [patterns/testing-patterns.md](patterns/testing-patterns.md) for bats framework, mocking, CI integration
**GNU vs BSD**: See [portability-matrix.md](portability-matrix.md) for cross-platform command differences

## Official references

- [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html) — naming, formatting, features, best practices
- [ShellCheck Wiki](https://www.shellcheck.net/wiki/) — rule-by-rule documentation (SC1xxx syntax, SC2xxx quality, SC3xxx POSIX)

