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.
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.
- 01 Why cron jobs overlap
- Without a lock: three runs overlap
- With a nonblocking lock: one run, two skips
- 02 The lock is an agreement, not a file-exists test
- 03 Choose what a busy lock should mean
- 04 Read the command before copying it
- 05 Mini lab: prove that the second run skips
- 1. Check the tool and create a workspace
- 2. Write a script that protects itself
- 3. Start the first copy in Terminal A
- 4. Attempt a second copy in Terminal B
- 5. Prove that the lock becomes available again
- 6. Remove the practice files when both runs finish
- 06 Deploy a locked job to cron
- 1. Create the directories
- 2. Create a self-locking report script
- 3. Test it manually and inspect the log
- 4. Add the job to the user crontab
- 5. Observe scheduled runs and manage log growth
- 07 Troubleshoot the rule, the path, and the lifetime
- 08 Check your understanding
- Technical references
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.
Without a lock: three runs overlap
A finishes at 09:12. B and C continue beyond the visible window.
With a nonblocking lock: one run, two skips
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.
| Policy | Option | Behavior | Useful when |
|---|---|---|---|
| Skip | -n | Return immediately if busy. | A later run can do the necessary work. |
| Bounded wait | -w 30 | Wait up to 30 seconds for the lock. | A short delay is acceptable. |
| Wait | No wait option | Wait 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:
flock -n /path/to/job.lock /path/to/job.shflock -w 30 /path/to/job.lock /path/to/job.shThe 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
flock -n -E 75 /home/alex/.local/state/report-job/job.lock \
/home/alex/bin/report.shThe 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
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
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
"$sa_lab_dir/job.sh"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=0As 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
# 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"2026-10-16T09:00:06+00:00 SKIP: another instance is running
Second run status: 75The 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
# 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.
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.
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.
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
"$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.
crontab -eSHELL=/bin/sh
PATH=/usr/bin:/bin
*/5 * * * * /home/alex/bin/report-job.sh >> /home/alex/logs/report-job.log 2>&1Save 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:
crontab -l5. Observe scheduled runs and manage log growth
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 observe | What to check next |
|---|---|
| Two copies still run | Same lock file? Same locking entry point? One launch bypassing the wrapper? |
| Cannot open the lock file | Parent directory, file permissions, execution user, and a writable filesystem. |
| Every launch skips | Is the previous workload hung? Is a child process keeping the descriptor open? |
| Works manually, fails from cron | Paths, permissions, environment, interpreter, and captured error output. |
| Lock file exists after exit | This 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.
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.








