Prevent Overlapping Cron Jobs with flock: A Practical Linux Guide

Prevent overlapping cron jobs with flock: a glass clock, task capsules, and an exclusive lock Linux & Administration
Prevent overlapping cron jobs on Linux with flock. Compare skip and wait policies, test a self-locking Bash script, and diagnose common locking mistakes.
Linux Automation · Lesson 03

One schedule. Two running copies.
One avoidable problem.

Your script runs every five minutes. Today it takes twelve. Cron keeps launching it — even while the previous copy is still working. Learn how to put one clear rule around the job: only one instance at a time.

Beginner → IntermediateHands-on Linux labLocal exclusive locking
Quick answer

Wrap a foreground command in flock -n /path/to/job.lock command to attempt an exclusive lock and skip execution when the lock is busy. For a script launched both manually and by cron, put the lock inside the script so both entry points follow the same rule.

01Why cron jobs overlap

Scheduling answers “when should I start?” Your program still needs an answer to “may another copy start now?” Consider a report generator scheduled at 09:00, 09:05, and 09:10. If the first run takes twelve minutes, the second and third scheduled starts arrive before it finishes.

That can mean duplicate imports, competing writes, extra memory pressure, or two backup processes using the same destination. Whether overlap is harmful depends on the work. Prevent it when concurrent copies would violate your application’s rules.

Animated explainer · 15-minute window

Without a lock: three runs overlap

09:0009:0509:1009:15
A
B
C

A finishes at 09:12. B and C continue beyond the visible window.

With a nonblocking lock: one run, two skips

09:0009:0509:1009:15
A
B
SKIP
C
SKIP

Each full job lasts 12 minutes. With the skip policy, the starts at 09:05 and 09:10 do no work; the next scheduled opportunity is 09:15. The animation repeats for illustration.

02The lock is an agreement, not a file-exists test

Think of the lock as permission to enter a shared workspace. Every cooperating copy asks for exclusive access to the same lock file before doing the work.

On a local Linux filesystem, this is advisory locking: another program can ignore the agreement. A manual command that bypasses your wrapper can therefore still conflict with the scheduled job. All competing entry points must cooperate.

The existence of job.lock does not mean a job is running. The operating system tracks the lock associated with the open file. It is released when all file descriptors holding it are closed. The file can remain afterward.

Lock behavior: Linux flock(2) manual.

03Choose what a busy lock should mean

Before writing the command, decide what to do when a previous run is still active. A fresh status report might safely skip a run. A job that must process every submitted item needs a durable queue or another explicit delivery mechanism.

PolicyOptionBehaviorUseful when
Skip-nReturn immediately if busy.A later run can do the necessary work.
Bounded wait-w 30Wait up to 30 seconds for the lock.A short delay is acceptable.
WaitNo wait optionWait until the lock becomes available.Waiting processes are deliberately managed.

For this tutorial we choose skip: it avoids accumulating waiting copies when a five-minute schedule meets a twelve-minute task.

Two common choices:

Skip immediately
flock -n /path/to/job.lock /path/to/job.sh
Wait up to 30 seconds
flock -w 30 /path/to/job.lock /path/to/job.sh

The first command declines to run if the lock is busy. The second allows a short wait. Neither option queues a missed cron run for later.

04Read the command before copying it

Terminal
flock -n -E 75 /home/alex/.local/state/report-job/job.lock \
  /home/alex/bin/report.sh
flockRequest a lock around the command.
-nDo not wait when another owner holds it.
-E 75Use status 75 for a busy lock.
job.lockA stable file in an existing writable directory.

The directory must already exist and the execution user must be able to open the lock file. These are example paths; replace alex and the script location with your real account and files.

-E 75 makes a lock conflict recognizable, but it does not prove that every status 75 came from the lock. In command-wrapper form, the executed program’s exit status is also returned. If the program uses 75 itself, those outcomes collide. Keep separate log messages; use your application’s exit-code contract.

Running report.sh directly would bypass this outer lock. In the next example we place the lock inside the script instead, so manual and scheduled runs share one entry point.

Options and wrapper exit statuses: util-linux upstream manual.

05Mini lab: prove that the second run skips

You will create a temporary practice script that sleeps for 45 seconds. It changes no production data and installs no cron jobs. Use Bash on a Linux machine with the util-linux flock command and a local filesystem.

1. Check the tool and create a workspace

Terminal
command -v flock
flock --version

sa_lab_dir=$(mktemp -d)
printf 'Practice directory: %s\n' "$sa_lab_dir"

If flock is unavailable, stop here and obtain it through your Linux distribution’s package manager. Keep the printed directory path: Terminal B will use the same one.

2. Write a script that protects itself

Terminal
cat > "$sa_lab_dir/job.sh" <<'EOF'
#!/bin/bash
set -u
PATH=/usr/bin:/bin
export PATH
umask 077

job_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1
exec 9>>"$job_dir/job.lock" || exit 1

if flock -n -E 75 9; then
    : # Lock acquired; descriptor 9 stays open.
else
    lock_status=$?
    if [ "$lock_status" -eq 75 ]; then
        printf '%s SKIP: another instance is running\n' "$(date -Is)" >&2
    else
        printf '%s ERROR: lock acquisition failed (%s)\n' "$(date -Is)" "$lock_status" >&2
    fi
    exit "$lock_status"
fi

printf '%s START pid=%s\n' "$(date -Is)" "$$"
sleep 45
work_status=$?
printf '%s END pid=%s status=%s\n' "$(date -Is)" "$$" "$work_status"
exit "$work_status"
EOF
chmod 700 "$sa_lab_dir/job.sh"

What is descriptor 9? A file descriptor is simply a number Bash uses to refer to an open file. Here, exec 9>>... opens the lock file and calls it 9. Then flock ... 9 locks that open file. Bash keeps descriptor 9 open while the script runs, so the lock protects the work.

We distinguish a busy lock from another lock-acquisition error. The example reserves 75 for the busy branch and records the result of the work separately. Replacing sleep with real work also requires handling that application’s own failures.

3. Start the first copy in Terminal A

Terminal
"$sa_lab_dir/job.sh"
Example output · timestamps and PID vary
2026-10-16T09:00:00+00:00 START pid=12345
# After about 45 seconds:
2026-10-16T09:00:45+00:00 END pid=12345 status=0

As soon as you see START, move to Terminal B. Run the next step before Terminal A prints END.

4. Attempt a second copy in Terminal B

Terminal
# Replace this with the directory printed in Terminal A.
sa_lab_dir=/tmp/YOUR-PRINTED-DIRECTORY
"$sa_lab_dir/job.sh"
sa_status=$?
printf 'Second run status: %s\n' "$sa_status"
Example output · while Terminal A is active
2026-10-16T09:00:06+00:00 SKIP: another instance is running
Second run status: 75

The second process never prints START. That is the evidence: it declined the protected work. If both copies run, first confirm you used the same exact directory and that the first copy was still active.

5. Prove that the lock becomes available again

Terminal
# Wait until Terminal A has printed END.
"$sa_lab_dir/job.sh"
sa_status=$?
printf 'Later run status: %s\n' "$sa_status"

This time you should see a new START, then END, and status 0. You have verified that the lock becomes available again when the previous run finishes.

6. Remove the practice files when both runs finish

After both terminals have finished, remove the two files created in the private practice directory and then remove the empty directory. Never use deletion to unlock an active job.

Terminal · clean up the lab
rm -- "$sa_lab_dir/job.sh" "$sa_lab_dir/job.lock"
rmdir -- "$sa_lab_dir"

Run these commands in the terminal where sa_lab_dir points to the practice directory. This cleanup is for the temporary lab only—not for the production lock file.

06Deploy a locked job to cron

The lab lock lived next to job.sh. For a real job, keep the executable in ~/bin, the stable lock in a private state directory, and output in a log directory. The following example is runnable by a regular Linux account.

1. Create the directories

Run these commands as the user who owns the crontab, without sudo. mkdir -p creates missing directories, and chmod 700 limits access to the owner.

Terminal · create writable directories
mkdir -p "$HOME/bin" "$HOME/.local/state/report-job" "$HOME/logs"
chmod 700 "$HOME/.local/state/report-job" "$HOME/logs"

The lock will live at $HOME/.local/state/report-job/job.lock, and the output log at $HOME/logs/report-job.log. The script automatically creates the lock file on its first run.

2. Create a self-locking report script

This working example logs a start time, collects uptime and root filesystem usage, and logs the exit status. Later, replace those two commands with your real workload while keeping the locking code before the work.

Terminal · create the script
cat > "$HOME/bin/report-job.sh" <<'EOF'
#!/bin/bash
set -u
PATH=/usr/bin:/bin
export PATH
umask 077

lock_file="$HOME/.local/state/report-job/job.lock"
exec 9>>"$lock_file" || {
    printf '%s ERROR: cannot open lock file\n' "$(date -Is)" >&2
    exit 1
}

if flock -n -E 75 9; then
    : # This run owns the lock.
else
    lock_status=$?
    if [ "$lock_status" -eq 75 ]; then
        printf '%s SKIP: previous run is still active\n' "$(date -Is)" >&2
    else
        printf '%s ERROR: flock failed (%s)\n' "$(date -Is)" "$lock_status" >&2
    fi
    exit "$lock_status"
fi

printf '%s START pid=%s\n' "$(date -Is)" "$$"
# Safe sample work: collect basic system status.
/usr/bin/uptime
work_status=$?
if [ "$work_status" -eq 0 ]; then
    /usr/bin/df -h /
    work_status=$?
fi
printf '%s END pid=%s status=%s\n' "$(date -Is)" "$$" "$work_status"
exit "$work_status"
EOF
chmod 700 "$HOME/bin/report-job.sh"

Here descriptor 9 holds the exclusive lock on the same path every time. Manual and cron launches of this script therefore follow the same rule.

3. Test it manually and inspect the log

Terminal · test before scheduling
"$HOME/bin/report-job.sh" >> "$HOME/logs/report-job.log" 2>&1
tail -n 15 "$HOME/logs/report-job.log"

Check for timestamped START and END messages and the system-status output. Successful work ends with status=0; investigate any ERROR before scheduling.

4. Add the job to the user crontab

Open the crontab with crontab -e. For an example user named alex, add the lines below, changing /home/alex to your own home directory. The cron schedule belongs in the editor, not in a normal terminal.

Terminal · edit the user crontab
crontab -e
Example user crontab · change /home/alex
SHELL=/bin/sh
PATH=/usr/bin:/bin
*/5 * * * * /home/alex/bin/report-job.sh >> /home/alex/logs/report-job.log 2>&1

Save and exit. This entry calls the self-locking script directly; do not wrap it in another flock on the same file. A user crontab normally provides that user’s HOME, which the script uses for its lock path. Verify the installed schedule without replacing existing entries:

Terminal · confirm the installed schedule
crontab -l

5. Observe scheduled runs and manage log growth

Terminal · read recent log entries
tail -n 30 "$HOME/logs/report-job.log"

Use timestamped START, END, SKIP, and ERROR messages to distinguish completed runs from refused or failed ones. A skipped start is not automatically retried. For long-running use, configure log rotation or retention so the file does not grow forever.

This setup assumes one local Linux machine and one user account. Shared storage and multi-server jobs need extra care; see troubleshooting below.

07Troubleshoot the rule, the path, and the lifetime

What you observeWhat to check next
Two copies still runSame lock file? Same locking entry point? One launch bypassing the wrapper?
Cannot open the lock fileParent directory, file permissions, execution user, and a writable filesystem.
Every launch skipsIs the previous workload hung? Is a child process keeping the descriptor open?
Works manually, fails from cronPaths, permissions, environment, interpreter, and captured error output.
Lock file exists after exitThis alone is normal; inspect the active processes and lock state.

For troubleshooting, read the timestamped log first, then inspect active processes. Where available, lslocks can help show local locks, though its visibility depends on system settings and permissions.

For payments, imports, or other work that must survive retries and crashes, also design idempotency, transaction boundaries, and durable job state. Mutual exclusion does not make a partially completed operation automatically recoverable.

08Check your understanding

A lock file still exists. Is the job still running?

Not necessarily. The file can remain after the operating system has released the lock. Test lock acquisition or inspect the running processes; do not infer ownership from the pathname.

Does -n save the skipped run for later?

No. It returns immediately when the lock is busy. Whether the next scheduled run can safely cover the missed work is an application-level decision.

Why put locking inside the script?

So manual and scheduled launches share the same protected entry point. A separate command that modifies the same data still needs to follow the agreement.

Does status 75 always mean “busy”?

No. Here it is our chosen conflict status. A wrapped command can independently return 75, so log the acquisition outcome and understand the application’s exit codes.

Servers Academy · Key takeaway
Schedule the start.
Protect the work.
Observe the result.

Cron schedules launches, your script controls concurrent execution, and its timestamped log shows the result. Test the rule manually before relying on it unattended.

Technical references

Rate article
Servers Academy
Add a comment