Developer Tools

Scripts should be idempotent by default

5 min read

The best project scripts can run twice without making the second run weird.

That is the simplest test I know for whether a script belongs in a repo.

Development scripts get interrupted. Dependencies fail. Ports stay open. A generated file already exists. A Docker container is half-created. A migration ran before the seed step failed. Someone hits control-C at exactly the wrong time. The next person runs the same command and inherits the mess.

An idempotent script treats that as normal.

It can start from a partially completed state, do the right thing, and stop with a useful message.

check before creating

The first habit is to check for existing state before creating more state.

Bad scripts assume the world is empty. Good scripts ask what is already there.

if [ ! -d "node_modules" ]; then
  pnpm install
fi

if ! docker volume inspect app_pgdata >/dev/null 2>&1; then
  docker volume create app_pgdata
fi

That is not sophisticated. It prevents stupid failures.

The same pattern applies to generated files, local databases, cache directories, build output, SSL certificates, fixture data, and service containers. If the script can detect the desired state, it should converge on it instead of blindly adding another copy.

names should be stable

Idempotency gets harder when scripts create unnamed things.

A container named postgres is easier to reason about than a random container created every run. A temp directory with a job ID is easier to clean than one named by timestamp only. A generated file with a stable path is easier to replace than a pile of numbered outputs.

Stable names make cleanup possible.

For local dev scripts, I want predictable names:

  • container names
  • volume names
  • network names
  • PID files
  • log files
  • lock files
  • cache directories

Predictability is not glamorous. It is how stop.sh, reset.sh, and status.sh can exist.

partial failure needs a path

A script should assume it can fail halfway.

The question is what happens next.

If a database starts but migrations fail, can the next run retry migrations? If a file downloads halfway, does the script verify checksum before trusting it? If a service starts and the health check fails, does the script print the log path? If a generated file is invalid, does the script overwrite it cleanly or keep appending?

The script should leave enough evidence for recovery.

I like scripts that make their phases visible:

checking dependencies
starting database
waiting for health check
running migrations
seeding fixtures
writing env file
done

When a phase fails, the user knows where to look.

destructive actions should be explicit

Idempotent does not mean harmless.

A reset script may delete data. A cleanup script may remove containers. A migration script may change schema. Those actions need clear boundaries.

I prefer separate commands:

  • init.sh creates or updates the local environment
  • serve.sh starts it
  • stop.sh stops running processes
  • reset.sh removes local state intentionally
  • test.sh verifies the expected path

That split avoids the script that sometimes initializes and sometimes destroys depending on flags nobody remembers.

If a command deletes data, make the name say so. If it targets production, stop and require explicit confirmation or use a different tool entirely. Local convenience scripts should not develop production-grade footguns.

locks prevent duplicate work

Some scripts should refuse to run twice at the same time.

Starting two dev servers on the same port is annoying. Running two seed jobs against the same database can be worse. Running two code generators into the same output directory can create garbage.

A simple lock file can be enough:

LOCKFILE=".tmp/init.lock"

if [ -e "$LOCKFILE" ]; then
  echo "init is already running or exited uncleanly: $LOCKFILE"
  exit 1
fi

mkdir -p .tmp
trap 'rm -f "$LOCKFILE"' EXIT
touch "$LOCKFILE"

Locking should have a recovery path. If the process died, the user should know how to clear the stale lock safely.

environment checks should fail early

An idempotent script should check prerequisites before it mutates state.

If the script needs pnpm, Docker, psql, ffmpeg, a specific Node version, or a local .env file, say that up front. Do not create half the environment and then fail because the last command is missing.

Early checks also make error messages better:

missing: docker
expected: Docker running locally before init
fix: start Docker Desktop and rerun ./init.sh

That is much better than a stack of command failures from deep inside the script.

Version checks belong here too. A script that assumes Node 26 should not quietly run under Node 20 and leave behind strange build output.

output should be boring

Good script output is a kind of interface.

It should say what changed, what was skipped because it already existed, and what failed. It should avoid dumping pages of noise unless the user asks for verbose mode.

Useful output:

database: already running
migrations: up to date
fixtures: seeded 14 records
server: http://localhost:4321

That tells the user the script converged. It also makes reruns feel safe.

the second run is the test

The best test for a project script is simple:

Run it. Run it again.

The second run should not create duplicate data, fail because a file exists, start another copy of a service, corrupt output, change unrelated files, or require manual cleanup.

Then interrupt it halfway and run it again.

That is where the real bugs appear.

Scripts should be idempotent by default because local tooling is part of the product experience for developers. A repo that can recover from a messy half-run is calmer to work in. A repo that cannot makes every setup failure feel personal.

Good scripts make recovery feel ordinary. That is worth designing before setup breaks again.

Jeremy London

About Jeremy London

Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.