---
title: "Shell Scripting: Best Practices"
slug: "shell"
category: "coding"
tags: ["coding", "shell", "bash"]
status: "stable"
last_updated: 2026-05-14
summary: "Bash defaults: set -euo pipefail, quoted variables, shellcheck in CI, traps for cleanup, and absolute paths in scheduled jobs."
related: ["[[coding/general-principles]]", "[[ops/github-pages]]", "[[cheatsheets/bash-one-liners]]"]
---

## Overview

Bash is forgiving in ways that bite in production. A script that works on a developer laptop fails silently in cron because a variable was empty, a path was relative, or a pipe swallowed the exit code. This page lists the defaults that make Bash scripts predictable. Read [[coding/general-principles]] first.

## `set -euo pipefail` at the top of every script

Three flags. They turn Bash from "keep going on errors" into "exit on the first failure."

```bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
```

- `-e`: exit on any command that returns non-zero.
- `-u`: exit when reading an unset variable. Catches typos like `$pth` for `$path`.
- `-o pipefail`: the exit code of a pipeline is the rightmost non-zero exit code, not just the last one. Without this, `cat missing.txt | grep foo` reports success.

Set `IFS` to newline and tab so word splitting on unquoted expansions stops splitting on every space. Better still, quote your expansions.

## Quote every variable

`"$var"` is the default. `$var` is the exception. Unquoted variables word-split on `IFS` and glob-expand on `*`, which corrupts filenames with spaces and matches files that look like flags.

```bash
# Bad
rm $tmpfile

# Better
rm -- "$tmpfile"
```

Use `"${var}"` when an adjacent character would be parsed as part of the name (`"${prefix}_log.txt"`). Use `--` before path arguments to commands so a filename starting with `-` does not become a flag.

## Shellcheck in CI

`shellcheck` finds the bugs `set -euo pipefail` cannot. Run it on every script in CI.

```yaml
- name: shellcheck
  run: shellcheck --severity=warning scripts/*.sh
```

Treat `shellcheck` warnings as errors. The handful of false positives get a per-line disable with a comment explaining why:

```bash
# shellcheck disable=SC2086  # word splitting intended for flag list
ssh "$host" $flags 'uptime'
```

## Prefer `[[ ]]` over `[ ]`

Bash's `[[ ]]` is the conditional you want. POSIX `[ ]` (the `test` builtin) is the one you tolerate when targeting `/bin/sh`.

- `[[ -n "$var" ]]` does not word-split. `[ -n $var ]` does.
- `[[ "$a" == abc* ]]` supports glob matching. `[ ]` does not.
- `[[ "$a" =~ ^[0-9]+$ ]]` supports regex.
- `&&` and `||` work inside `[[ ]]`.

Reach for `[ ]` only when the script's shebang is `#!/bin/sh` and POSIX compatibility is a real requirement (Alpine BusyBox, system rc scripts).

## Trap on `EXIT` for cleanup

Cleanup runs whether the script succeeds, fails, or is interrupted. Use a single `EXIT` trap.

```bash
tmpdir="$(mktemp -d)"
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXIT
```

For long-running scripts, also trap `INT` and `TERM` to log the signal before exiting:

```bash
trap 'echo "interrupted" >&2; exit 130' INT TERM
```

Avoid `trap '' EXIT`-style empty traps; they hide cleanup bugs. Avoid setting multiple `EXIT` traps; only the last one wins.

## POSIX only when portability matters

Write Bash 5 by default. Use arrays, `[[ ]]`, `${var/foo/bar}`, and `mapfile` freely. Drop to POSIX `sh` only when the script must run somewhere Bash is not installed (a Debian slim container, an Alpine base, a BSD jail, a Dockerfile's `RUN` against `/bin/sh`).

For multi-OS portability, write the script in a real language. A Python or Go binary travels better than a portable shell script.

## Absolute paths in cron and systemd

A script that runs from `cron`, `systemd`, or `launchd` does not inherit the interactive shell's `PATH`, working directory, or environment. Reference everything by absolute path. For a quick reminder of cron expression syntax, see [[cheatsheets/cron-syntax]].

```bash
#!/usr/bin/env bash
set -euo pipefail

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG="/var/log/myapp/job.log"

cd "$SCRIPT_DIR"
/usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin /usr/local/bin/myapp run >> "$LOG" 2>&1
```

Set `PATH` explicitly inside the script. Use `/usr/bin/env -i` to start from an empty environment when the job must be reproducible. Log to a known file with `>>` and `2>&1`; cron emails for unhandled output and you do not want surprise emails at 3 a.m.

## One script does one thing

Every script has a single purpose stated in a comment block on line two. If a script has grown to dispatch on a `case` over five subcommands, split it into five scripts with a thin wrapper, or rewrite it in a language with a real CLI library. See [[coding/general-principles]] on deletion over abstraction, [[file-organization/dotfiles]] for the chezmoi or stow patterns that distribute shared shell scripts across machines, and [[cheatsheets/bash-one-liners]] for common idioms.

## Related

- [[coding/general-principles]]
- [[ops/github-pages]]
