Prevent a Bash Script from Running Twice with flock

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
util-linux 2.40.2 (flock)
Applies to Linux distributions with Bash installed
Privilege Normal user
Scope Single-instance Bash scripts with flock: command wrapper, in-script file-descriptor locks, -n, -w, -E, lock lifetime, cron/systemd wrappers, NFS/CIFS caveats, and a short comparison with pgrep and PID files. Does not cover parallel workers — see run shell scripts in parallel.
Related guides Bash best practices
Shell scripting course hub
Short on time?Beta Get the gist of this 14-minute read in seconds, then ask follow-up questions

Use flock when only one instance of a Bash script should run at a time. It works for cron jobs, manual starts, and scripts that may be triggered from more than one place.

flock attaches a lock to an open file descriptor. The kernel releases that lock when every process holding the descriptor exits. This differs from a simple lock file whose existence alone is used to indicate that a job is running.


Best Way to Prevent Duplicate Bash Runs

When a job takes longer than its scheduling interval, another instance may start before the first finishes. Both processes can then modify the same files or logs. The fix is to serialize starts so only one instance runs the critical section at a time.

For Bash scripts on Linux, flock is usually the simplest solution:

  • One lock covers the whole script without parsing process lists.
  • The lock drops when descriptors close, so a crash does not leave a stale token the way a forgotten PID file can.
  • Cron can wrap the script in one line without editing its body.

Use flock for normal single-instance scripts. PID files are still useful when another tool needs the running process ID. pgrep and ps | grep appear in older snippets too, but they race easily and need extra validation.


How flock Actually Works

flock manages flock(2) locks from the shell. On normal local Linux filesystems, the lock is advisory: processes that do not cooperate with flock are not prevented from accessing the file. Network filesystems can behave differently, as discussed below.

The lock is tied to an open file description, not to the pathname string on disk:

  • Opening "${XDG_RUNTIME_DIR:-/tmp}/backup.lock" gives you a file descriptor.
  • flock sets or waits for a lock on that descriptor.
  • As long as a process still holds an inherited or duplicated descriptor for the locked open file description, the lock remains active.
  • When the last holder closes the descriptor, the kernel releases the lock even if the pathname still exists.

An empty lock file left behind after a run is harmless. The open descriptor holds the lock; the pathname's existence does not.

util-linux ships two common forms:

Form Typical use
flock [options] file command … Wrap an external command or script
flock [options] fd-number Lock a descriptor you opened with exec inside the script

Exclusive mode (-x, the default) allows one writer. Shared mode (-s) allows multiple readers; most single-instance scripts stay exclusive.


Wrap a Script with flock

The wrapper form is useful for cron because it does not require modifying the script. flock opens the lock path (creating a zero-byte file if permissions allow), acquires the lock, runs the command, then exits and releases the lock when the command finishes.

Create a temporary test directory and a script that runs for eight seconds:

bash
mkdir -p /tmp/flock-lab

Write the slow job script into that directory:

bash
cat > /tmp/flock-lab/slow-job.sh << 'EOF'
#!/usr/bin/env bash
echo "PID $$ started at $(date +%T)"
sleep 8
echo "PID $$ finished at $(date +%T)"
EOF

Make it executable before you run it:

bash
chmod +x /tmp/flock-lab/slow-job.sh

Start one copy under a lock in the background:

bash
flock -n /tmp/flock-lab/backup.lock /tmp/flock-lab/slow-job.sh &

While that run is still sleeping, try a second start with the same lock file:

bash
flock -n /tmp/flock-lab/backup.lock /tmp/flock-lab/slow-job.sh

The first terminal prints a start line and sleeps; the second command returns immediately with no new output because -n refused to wait. Check the exit status on the second shell:

bash
echo $?
output
1

When the wrapper successfully launches the child, flock normally exits with the child's status. Here the second run never started the script, so you only see the default conflict code.


Lock from Inside the Script

When you control the script, open the lock file once near the top and keep the descriptor for the whole run. Fixed descriptor 9 is a common convention because 0–2 are stdin, stdout, and stderr.

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

LOCK="${XDG_RUNTIME_DIR:-/tmp}/my-backup-job.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
  echo "Another instance is already running; exiting."
  exit 1
fi

echo "PID $$ holds the lock"
# ... rest of script ...

Bash 4.1+ also supports a dynamic file descriptor so you do not hard-code a number:

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

LOCK="${XDG_RUNTIME_DIR:-/tmp}/my-backup-job.lock"
exec {lock_fd}>"$LOCK"
if ! flock -n "$lock_fd"; then
  echo "Another instance is already running; exiting."
  exit 1
fi

echo "PID $$ holds the lock on fd $lock_fd"
# ... rest of script ...

Run the in-script example twice with a short sleep in the body. The second start prints the busy message and exits before the work section.


Fail Immediately with -n

-n (non-blocking) is what you want for cron overlap and interactive double-starts. If the lock is held, flock returns at once instead of queuing behind the first run.

Without -n, the default is to wait until the lock is free. Waiting may be appropriate for manual runs, but scheduled jobs often should skip instead of accumulating multiple waiting instances.

Pair -n with logging so operators see a skipped run:

bash
if ! flock -n 9; then
  echo "$(date -Is) backup already running; skipping" >&2
  exit 0
fi

Use exit 0 when a skipped run is considered normal; use a non-zero status when monitoring should treat lock contention as a failure.


Wait for the Lock with -w

-w SECONDS waits up to a bounded time. Fractional seconds are allowed. If the lock becomes available before the timeout, the command runs normally and flock returns the command's exit status. If the lock is still held when the timer expires, flock fails with the conflict exit code (default 1).

Start a holder that sleeps three seconds:

bash
flock -n /tmp/flock-lab/wait.lock bash -c 'echo holder $$; sleep 3' &

After one second, ask for the same lock with a ten-second wait budget:

bash
flock --verbose -w 10 /tmp/flock-lab/wait.lock bash -c 'echo acquired $$ at $(date +%T)'

Sample output:

output
holder 39549
acquired 39593 at 19:40:47
flock: getting lock took 2.004343 seconds
flock: executing bash

The verbose lines confirm flock blocked until the holder released the lock, then ran the inner command.

For a timeout instead of success, start a holder on the same lock file and set the wait budget shorter than the holder's sleep:

bash
flock -n /tmp/flock-lab/timeout.lock sleep 5 &

Wait one second so the background process acquires the lock:

bash
sleep 1

Now try a two-second wait while the holder still runs:

bash
flock --verbose -w 2 /tmp/flock-lab/timeout.lock bash -c 'echo acquired'

Sample output:

output
flock: timeout while waiting to get lock

Check the exit status:

bash
echo $?
output
1

A zero wait (-w 0) is treated like -n.


Use a Separate Conflict Exit Code

Lock contention is not the same as your script failing mid-run. Use -E so callers can tell them apart. The value must be 0–255; util-linux defaults to 1 when -n or -w cannot acquire the lock.

bash
flock -n -E 75 /tmp/flock-lab/backup.lock /tmp/flock-lab/slow-job.sh

When another instance holds the lock, the command prints nothing and returns the conflict code. Check the status:

bash
echo $?
output
75

Cron can treat 75 as "already running" while still alerting on 1 from a real error inside the job. See Bash exit codes for how shells surface statuses above 125.

When the command runs, flock returns that command's exit status:

bash
flock -n /tmp/flock-lab/status.lock bash -c 'exit 42'

The shell records the child's exit status:

bash
echo $?
output
42

Lock Lifetime and File Descriptor Inheritance

The lock lasts until every process that inherited the locking descriptor closes it. Background children and subshells keep the parent's open file table entries, so a child can extend the lock after the parent shell exits.

This lab script acquires a lock, starts a background subshell, then exits:

bash
cat > /tmp/flock-lab/inherit-demo.sh << 'EOF'
#!/usr/bin/env bash
LOCK=/tmp/flock-lab/inh.lock
exec 9>"$LOCK"
flock -n 9 || { echo "busy"; exit 1; }
echo "parent $$ holds lock"
(sleep 6; echo "child subshell done") &
echo "parent exiting now (subshell keeps FD)"
exit 0
EOF

Run it, then immediately try another locked command:

bash
chmod +x /tmp/flock-lab/inherit-demo.sh
/tmp/flock-lab/inherit-demo.sh

While the background subshell still sleeps, a second locked command should fail immediately:

bash
flock -n /tmp/flock-lab/inh.lock bash -c 'echo second run'

The second start fails while the subshell still holds descriptor 9, even though the parent PID is gone. After the subshell finishes, the lock frees without deleting the pathname.

With the wrapper form, flock --close lockfile command closes the locking descriptor in command while the separate flock process continues to hold the lock. This prevents descendants of command from inheriting the descriptor. --close cannot be combined with --no-fork.

For an in-script descriptor lock, explicitly close the descriptor before starting a background process that should not inherit it.

flock does not detect deadlocks. Two scripts waiting on each other's locks will sit until an operator intervenes.


Choose a Safe Lock Path

Pick a path the service user owns and can open exclusively:

  • /run/myapp/job.lock for system jobs, where /run/myapp is created with the right ownership (preferred over a predictable file in world-writable /var/tmp)
  • "$XDG_RUNTIME_DIR/myjob.lock" or a private directory under $HOME for user cron
  • /tmp/flock-lab/*.lock is fine for the disposable examples in this article

/var/tmp is world-writable. A predictable path such as /var/tmp/my-backup-job.lock can be created first by another local account. Prefer an owned runtime directory instead.

Avoid predictable lock paths in world-writable directories. Another user may pre-create the path or manipulate it before your script opens it. Prefer a directory owned by the account running the job.

Use one lock per independent job. Sharing one file between unrelated scripts serializes them together, which is rarely what you want.


Use flock with Cron and systemd

Cron starts commands independently and has no built-in overlap guard. Wrap the command:

text
*/15 * * * * flock -n /run/myapp/nightly-backup.lock /usr/local/bin/nightly-backup.sh

Add -E 75 when the scheduler should record a distinct skip code. Redirect wrapper stderr to your log if skips should appear in the same file as script output.

A systemd timer activating the same service unit does not normally start a second copy while that service is still active, so flock is usually unnecessary solely for timer overlap. An in-script lock can still help when the script may also be launched directly, from cron, or by another service.


NFS and CIFS Caveats

Local ext4/xfs locks behave predictably. Network filesystems are not always the same.

flock behavior on NFS and CIFS depends on the client, server, kernel, protocol version, and mount options. util-linux warns that locking may be limited or may fail on some network mounts. Before relying on flock across hosts sharing one lock path, test acquire and release on the actual mount. For multi-host locking, use a distributed coordination mechanism such as a database lock, Redis, Consul, or another lock service rather than assuming a shared filesystem provides the required semantics.

Even on local disk, advisory locks only work when every participant uses flock.


Why pgrep, ps | grep, and PID Files Are Weaker Defaults

Process-name checks have several problems:

  • pgrep -f backup matches any command line containing backup, not only your script.
  • Two starts in the same second can both pass a PID-file test before either writes the file.
  • A stale PID file after a crash or kill -9 needs validation and cleanup; persistent PID-file locations can also leave stale state across reboot.
  • Pattern races with ps | grep are worse: the grep process itself can appear in the match set.

kill -0 is still the right way to test whether a saved PID is alive when you already maintain a PID file. The kill and pkill guide covers signal 0 in more detail.

Demonstrate the broad pgrep match with a script named my-backup.sh:

bash
pgrep -f my-backup.sh

A shorter pattern is broader and may include unrelated jobs:

bash
pgrep -f backup

Both patterns can return multiple PIDs when unrelated jobs share a substring. This can be useful for manual inspection, but it is unreliable for deciding whether another instance may start.


When PID Files Are Still Appropriate

PID files still make sense when a daemon or legacy supervisor explicitly defines a PID-file contract, or when another tool needs the PID to signal the running process:

  • You need to signal an already-running daemon (kill -HUP "$(cat /run/foo.pid)").
  • The program is not a short Bash cron job but a long-lived service with a defined contract.

A PID file can identify an existing process, but these checks alone do not provide race-free mutual exclusion. If another component already guarantees a single daemon instance, validate a PID file like this:

  1. If the file exists, read the PID and run kill -0 (or check /proc/$pid).
  2. If the PID is dead, remove the stale file and continue.
  3. Once some separate mechanism has guaranteed that this instance is the sole runner, write $$ (or the daemon child's PID).
  4. Use a Bash trap on EXIT to remove the file when the script ends cleanly.

Never delete a PID file at the end of a run that was supposed to represent a still-running daemon. For one-shot cron work, prefer flock and skip the PID file entirely.


Complete Single-Instance Template

This template combines strict mode, a dynamic lock descriptor, non-blocking acquire, and a dedicated conflict code. It assumes /run/myapp already exists and is writable by the account running the script; for a normal user job, use "${XDG_RUNTIME_DIR:-/tmp}/myapp-job.lock" instead.

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

readonly LOCK=/run/myapp/myapp-job.lock
readonly LOCK_BUSY=75

exec {lock_fd}>"$LOCK"
if ! flock -n "$lock_fd"; then
  echo "$(date -Is) myapp-job: already running" >&2
  exit "$LOCK_BUSY"
fi

main() {
  echo "PID $$ running job"
  # work here
}

main "$@"

For cron without editing the script:

bash
flock -n -E 75 /run/myapp/myapp-job.lock /usr/local/bin/myapp-job.sh

The lock file may remain a zero-byte file after each run. That is normal. Do not delete it unless you are retiring the job; absence of the path is not what grants the lock.


Summary

flock prevents overlapping script instances by placing a lock on an open file descriptor. The wrapper form is convenient for cron, while an in-script descriptor lock keeps the locking logic inside the script.

Use -n to skip when another instance is already running, -w when a short wait is acceptable, and -E when schedulers must tell lock skips apart from real failures. Remember that children inherit open descriptors, so a background subshell can hold the lock after the parent exits.

flock behavior on NFS and CIFS depends on the filesystem, server, kernel, and mount options, so test locking on the actual shared mount. pgrep and PID files can still help when a supervisor contract requires them, but for typical Bash maintenance jobs, flock is the cleaner default.


References


Frequently Asked Questions

Does the lockfile need to be deleted?

No. The lock is on the open file descriptor, not on the pathname's presence. A zero-byte file left at /run/myapp/job.lock after a successful run is normal. Delete the path only when you retire the job or want to pick a new location.

What if the script crashes?

The kernel releases the advisory lock when the crashing process closes its descriptors. Unlike an orphaned PID file pointing at a dead process, there is no stale lock token to clean up before the next cron slot. Work in progress may still be half-done; the lock only prevents overlap, not partial output.

Is flock safe over NFS?

Not always. flock behavior on NFS and CIFS depends on the client, server, kernel, protocol version, and mount options. util-linux warns that locking may be limited or may fail on some network mounts. Test on your mount or keep lock files on local disk. For multi-host locking, use a distributed coordination mechanism such as a database lock, Redis, Consul, or another lock service rather than assuming a shared filesystem provides the required semantics.

How do I distinguish lock conflict from script failure?

Use flock -E to set a dedicated conflict code (for example 75) for -n and -w failures. When the wrapper actually runs the child, flock returns the child's exit status. Teach your monitor or cron mail to treat the conflict code as a skip, not an application error.

Why can a child keep the lock alive?

File descriptors are inherited across fork. A background subshell or daemonized child keeps a copy of the locking descriptor open, so the advisory lock survives after the parent shell exits. Close unneeded descriptors or use flock --close on wrapper forms when children should not serialize later runs.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)