Sheet 05 · Code snippetsSurveyed 2026

Blog post image for Bash Script Locking: Prevent Concurrent Runs with a PID File - A Bash snippet that uses a PID file so only one instance of a script runs at a time. Essential for cron jobs and automation scripts that must not overlap. Includes stale lock detection and cleanup on exit.

Bash Script Locking: Prevent Concurrent Runs with a PID File

Published: Updated: 04 Mins read04 Mins listen
Markdown for AI(opens in a new tab)

Quick Tip

Wrap any script that must not run twice at once in a PID-file lock, and a second copy simply exits instead of corrupting your data.

The Problem

The Problem

Cron does not care whether your last run finished. If a job is scheduled every five minutes but one run takes seven, cron cheerfully starts a second copy while the first is still going. Now two processes are reading the same input, writing the same output file, and racing each other to a half-written mess.

The same thing happens with a webhook that fires twice, a retry that overlaps the original, or an impatient human running the script by hand while the scheduled run is live.

Real-world impact

Overlapping runs are the kind of bug that only shows up under load, which is exactly when you can least afford it. You get truncated files, doubled database rows, and log output interleaved from two processes so you cannot even tell what happened. The flow below shows what a single guarded run should do instead.

On startup the script checks for a PID file. If one exists and its process is alive, it exits. If the process is gone, the lock is stale and gets cleared. Either way it then writes its own PID and registers a trap to remove the file on exit.

The Solution

The Fix

Before doing any work, the script writes its own process ID into a lock file. If that file already exists, it checks whether the process it names is still alive. If it is, this run backs off and exits. If the process is gone, the lock is stale (a previous run crashed) so it clears it and carries on. A trap removes the lock file on the way out, whether the script finished cleanly or died.

TL;DR

  • One PID file per script decides who gets to run.
  • kill -0 $pid tells you if the previous owner is still alive without actually signalling it.
  • A trap ... EXIT guarantees the lock is released even on error.

When two runs overlap, the second one loses cleanly:

Cron fires the script twice. Run A creates the PID file and works. Run B sees the file, confirms A's process is still alive, and exits cleanly. When A finishes, its trap removes the lock so the next run can proceed.

Script Implementation

Setup

Start strict, and decide where the lock lives. Naming it after the script keeps different jobs from stepping on each other’s locks.

with_lock.sh
#!/usr/bin/env bash
set -euo pipefail
# One lock per script name, in a writable runtime dir.
LOCK_FILE="${TMPDIR:-/tmp}/$(basename "$0").pid"

Acquiring the lock

This is the core. If a lock file exists and names a live process, exit. If it exists but the process is gone, treat it as stale and reclaim it.

Terminal window
acquire_lock() {
if [[ -e "$LOCK_FILE" ]]; then
local old_pid
old_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
# kill -0 checks the process exists without sending a real signal.
if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then
echo "Already running as PID $old_pid, exiting." >&2
exit 1
fi
# The owner is gone: stale lock from a crashed run. Reclaim it.
echo "Clearing stale lock from PID ${old_pid:-unknown}." >&2
fi
echo $$ > "$LOCK_FILE"
}

Releasing on exit

Register the cleanup once, right after acquiring, so it runs on normal exit, on error (thanks to set -e), and on Ctrl-C.

Terminal window
release_lock() {
rm -f "$LOCK_FILE"
}
acquire_lock
trap release_lock EXIT

Entry point

Everything after the trap is your real work. It runs knowing no other copy is active.

Terminal window
main() {
echo "Running with lock held (PID $$)..."
# ... the actual job goes here ...
sleep 5
echo "Done."
}
main "$@"

Usage and Benefits

Why This Helps

The whole guard is about fifteen lines and needs nothing outside of Bash itself. Drop it at the top of any script and overlapping runs stop being a problem: the first run works, the rest exit immediately, and a crash never leaves a lock that blocks every future run.

Running it

Prove it to yourself by starting one in the background and immediately launching a second:

Terminal window
./with_lock.sh & # first run grabs the lock
./with_lock.sh # second run prints "Already running..." and exits 1

The first run holds the lock for its full duration; the second sees a live PID and steps aside. Kill the first mid-run and the next invocation clears the stale lock instead of getting stuck.

Community Discussion

Your Turn

How do you keep your cron jobs from overlapping? PID files, lock directories, or something your scheduler does for you? I would like to hear what has held up in production.

Alternative approaches

If you are on Linux, flock is worth a look. It locks a file descriptor at the kernel level, so there is no stale-PID logic to write yourself:

Terminal window
exec 9>"${TMPDIR:-/tmp}/$(basename "$0").lock"
flock -n 9 || { echo "Already running, exiting." >&2; exit 1; }
# lock is released automatically when fd 9 closes on exit

The PID-file version is more portable (it works the same on macOS and older shells) and it records who holds the lock, which is handy for debugging. flock is simpler and race-free where you have it. Pick whichever matches where your scripts run.

Was this useful?

You might also enjoy

More posts on similar topics

Bash Retry Function: Automatically Retry Failing Commands with Exponential Backoff

Bash Retry Function: Automatically Retry Failing Commands with Exponential Backoff

Quick Tip Wrap any flaky command in one reusable retry function and stop re-running red pipelines by hand. The Problem The Problem Some commands fail for reasons that have nothing to

AWS EC2 Instance Management with Boto3: Start, Stop, and Query Instances

AWS EC2 Instance Management with Boto3: Start, Stop, and Query Instances

If you've ever spent 20 minutes clicking through the AWS Console just to stop a handful of dev instances, you already know the pain. It's tedious, it doesn't scale, and one wrong click can ruin your a

Essential Bash Variables for Every Script

Essential Bash Variables for Every Script

Overview Quick Tip You know what's worse than writing scripts? Writing scripts that break every time you move them to a different machine. Built-in Bash variables fix that. The problem wi

Multi-Environment Secret Management with HashiCorp Vault

Multi-Environment Secret Management with HashiCorp Vault

Managing secrets safely across multiple environments with HashiCorp Vault Storing secrets in .env files, hardcoding them, or even using separate secret managers per environment creates security

Check S3 Bucket Existence

Check S3 Bucket Existence

Quick Tip Don't let your deployment blow up because of a missing S3 bucket. This Bash script lets you check if a bucket exists before anything fails. The Problem Missing bucket failure

AWS Secrets Manager

AWS Secrets Manager

Loading secrets in a Node.js app without exposing them If you're still storing API keys or database credentials in .env files or hardcoding them into your codebase, it's time for a better appro

6 related posts