← all posts

How to stop a cron job from running twice at the same time

Cron starts the next run whether or not the last one finished. flock fixes that in one word, with three gotchas: the exit code, the lock file location, and child processes that keep the lock.

11 min read

Cron has exactly one rule about time: when the clock matches, start the command. It doesn’t check whether the last run of the same command is still going. It has no idea.

So a job like this:

*/5 * * * * /usr/local/bin/sync.sh

is fine as long as sync.sh always finishes in under five minutes. The day an upstream API slows down and one run takes seven, the next run starts on top of it. Now two copies are reading the same input and writing the same output. If the slowness is caused by load, the second copy adds more load, the third copy starts before either finishes, and by the time you notice there are fourteen of them.

The fix is a lock, and on Linux the lock you want is flock.

Short answer

Put flock -n -E 0 /run/lock/<job>.lock in front of the command, and timeout after it so a hung run can’t hold the lock forever:

*/5 * * * * flock -n -E 0 /run/lock/sync.lock timeout 20m /usr/local/bin/sync.sh

systemd timers and Kubernetes CronJobs with concurrencyPolicy: Forbid don’t need this, because they never stack runs. If you’d rather set it per job in config and see every skipped run, RunWisp has it built in as on_overlap. The rest of this post covers the gotchas.

Here’s what each option does to the same bad afternoon. One run of a */5 job takes 12 minutes instead of 3. We ran each crontab line with real flock and timeout, sped up 60 times:

*/5 * * * * sync.sh
one slow run, four outcomes
Timeline from 14:00 to 14:40 of a job scheduled every 5 minutes whose 14:10 run takes 12 minutes. Plain cron starts the 14:15 and 14:20 runs on top of it, so two copies run at once twice. flock -n -E 0 skips the 14:15 and 14:20 runs and exits 0. flock -w 240 makes the 14:15 run wait, give up at 14:19 with exit 1, and starts the 14:20 run late at 14:22. flock -n -E 0 with timeout 8m kills the slow run at 14:18 with exit 124, skips 14:15, and runs 14:20 on time.

Two things in that chart surprise people. The -w row turned a slow run into a failure: the 14:15 run waited its four minutes, gave up, and exited 1, which your alerting will report. And the -n -E 0 row looks perfect while losing two runs without a trace.

flock, the four flags you need

FlagWhat it doesUse it when
-nlock busy? give up nowa skipped run is fine (syncs, polls, caches)
-w Nlock busy? wait up to N seconds, then give upevery run matters (imports, batches)
-E 0exit code for “gave up” (default 1)always, with -n, so skips don’t page you
-odon’t pass the lock down to the command’s childrenthe job starts anything in the background

The one-word fix

flock ships with util-linux, so it’s already on basically every Linux box. Put it in front of the command:

*/5 * * * * flock -n /run/lock/sync.lock /usr/local/bin/sync.sh

flock opens the lock file (creating it if needed), takes an exclusive lock on it, runs your command, and releases the lock when the command exits. With -n, a run that finds the lock already held gives up immediately instead of waiting.

The important property: the lock belongs to the open file, not to the file’s existence. When the process exits, for any reason, including kill -9 and a crash, the kernel releases the lock. The file stays on disk, and that’s fine. Nothing to clean up.

That’s why this beats the PID file approach (if [ -f /tmp/sync.pid ]; then exit; fi). A PID file survives a crash or a reboot. Then every later run sees it and refuses to start, or you write logic to check whether that PID is still alive, and then whether that PID is still your process, and you are now maintaining a lock implementation.

Gotcha 1: a skipped run looks like a failure

When the lock is busy, flock -n exits with code 1. From the outside, “skipped because the last run is still going” and “the job crashed” now look identical. If you alert on non-zero exits (and you should), every overlap becomes a false alarm.

-E sets the exit code for the “lock was busy” case:

# skipped runs exit 0 and stay quiet
*/5 * * * * flock -n -E 0 /run/lock/sync.lock /usr/local/bin/sync.sh

Quiet isn’t always what you want, though. If the job overlaps once a month, nobody cares. If it overlaps every single time, the job has quietly become a “every 10 minutes” job and you should know. A middle ground is a distinctive code, like -E 75, that your alerting treats as “note it, don’t page”.

The exit codes you’ll see from a locked, time-limited cron line:

ExitFromMeans
0your job, or -E 0ran fine, or skipped. You can’t tell which.
1flock -n / flock -wlock was busy (unless you set -E), or your job failed
75flock -E 75skipped, and now you can tell
124timeoutthe run hit its time limit and was killed

Gotcha 2: where the lock file lives

Two bad places and one good one:

WhereVerdictWhy
project directory, NFS✗flock over network filesystems is unreliable; a release can swap the directory out
/tmp✗systemd-tmpfiles deletes files there after 10 days (Ubuntu 26.04’s default), lock or not
/run/lock (aka /var/lock)✓a tmpfs meant for exactly this, writable by every user, emptied on reboot like the locks

The /tmp failure is quiet: if the file is deleted while a run holds the lock and the next run creates a fresh one, both get “a lock” and both run.

Name the file after the job, not something generic like cron.lock, or two unrelated jobs will block each other.

Gotcha 3: children inherit the lock

flock holds the lock through an open file descriptor, and child processes inherit open file descriptors. If your job starts something that outlives it (a background worker, a nohup command, a server it “just kicks”), that child keeps the descriptor open, and the lock stays held after your script has exited. Every later run gets skipped, and nothing looks wrong.

Here it is happening, then fixed:

$ flock -n sync.lock sh -c 'nohup sleep 600 >/dev/null 2>&1 &'
$ flock -n sync.lock true; echo $?
1                                   # skipped: the lock outlived the job
$ fuser -v sync.lock
                     USER        PID ACCESS COMMAND
/run/lock/sync.lock: deploy   550731 f.... sleep

$ flock -n -o sync.lock sh -c 'nohup sleep 600 >/dev/null 2>&1 &'
$ flock -n sync.lock true; echo $?
0                                   # -o: the child never got the lock

-o closes the lock’s descriptor before running your command. flock itself still holds the lock until the command exits, so the protection is the same, but nothing the command starts can take it along. If you lock inside the script instead (below), the equivalent is 9>&- on the command that launches the child.

Locking from inside the script

If you’d rather not depend on the crontab line being right, take the lock inside the script:

#!/bin/bash
exec 9>/run/lock/sync.lock
flock -n 9 || { echo "previous run still going, skipping"; exit 0; }

# ... the actual work ...

exec 9> opens the file on descriptor 9 for the rest of the script, and flock -n 9 locks it. The lock lasts until the script exits. This also works when someone runs the script by hand while cron is running it, which the crontab-line version only covers if they remember to type flock too.

Wait instead of skip

Sometimes every run has to happen: each one processes its own batch of uploads, or its own hour of logs. Skipping would lose work. Use -w to wait for the lock instead of giving up:

*/5 * * * * flock -w 240 /run/lock/import.lock /usr/local/bin/import.sh

That waits up to four minutes for the previous run, then gives up with exit 1. Without a limit (plain flock, no -n or -w), runs pile up behind each other indefinitely, each one a sleeping process waiting its turn. Keep the wait shorter than the interval.

A lock doesn’t fix a hung job

If sync.sh hangs forever on a network call, flock does exactly what you asked: it skips every run after it, forever. You’ve traded fourteen stacked copies for zero working ones. Add a timeout inside the lock:

*/5 * * * * flock -n -E 0 /run/lock/sync.lock timeout 20m /usr/local/bin/sync.sh

Now a stuck run is killed after 20 minutes, exits 124, and the next run gets the lock. Pick a timeout well above the job’s normal runtime and below “this is clearly broken”.

When flock isn’t the answer

A lock is per machine

flock locks a file on one box. The same crontab on three app servers takes three locks and runs three times.

Multiple servers. flock is local to one machine. If the same crontab runs on three app servers, each takes its own lock and all three run. Either run the job on one server only, or use a lock they share: a PostgreSQL advisory lock (SELECT pg_try_advisory_lock(42)), a Redis SET key value NX EX 600, or your framework’s equivalent (Laravel’s withoutOverlapping(), for example, uses the cache).

You’re on systemd timers. You already have this. A timer starts its service unit, and if that unit is still running from last time, there’s nothing to start. Runs never stack.

You’re on Kubernetes. CronJob has concurrencyPolicy: Forbid skips, Replace kills the old run, Allow (the default) lets them overlap.

Here’s what the common schedulers do when a run is due and the last one is still going:

SchedulerBy defaultSkipWaitReplaceRecord of skips
cronruns both✗✗✗✗
cron + flock(you choose)✓✓ -w✗✗
systemd timerskips✓✗✗✗
Kubernetes CronJobruns both✓✗✓as events, briefly
Laravel schedulerruns both✓✗✗✗
RunWispwaits (queue)✓✓✓✓

Your scheduler has it built in. RunWisp, our project, is an open-source cron replacement that ships as a single binary. It runs one copy of each task by default and lets you choose what happens to the run that arrives late:

[tasks.sync]
cron       = "*/5 * * * *"
run        = "/usr/local/bin/sync.sh"
timeout    = "20m"
on_overlap = "skip"   # or "queue" (the default), or "kill" to replace the old run

The part flock -n can’t give you is the record: a skipped run shows up in the run history as skipped, next to the ones that ran, so a job that has started overlapping every time is visible at a glance instead of silently halving its own frequency.

localhost:9477/tasks/sync-orders
the runs flock would have dropped silently
RunWisp run history for sync-orders, scheduled every minute: skipped runs at 17:35, 17:38, 17:41 and 17:43 each follow a run that took longer than a minute (1m 37s, 1m 49s, 1m 37s, 1m 24s). The selected 17:42 run succeeded in 1m 24s with the output 'sync-orders: upserted 27 orders in 84s'.
If you only have one or two jobs that need this, `flock` is less to install. The details are in the [overlapping runs docs](https://docs.runwisp.com/concepts/concurrency/).

The real fix is upstream

A lock stops the damage. It doesn’t explain why a five-minute job is taking seven. If you’re skipping runs regularly, the job has outgrown its schedule: the input grew, a query lost an index, the API it calls got slower. Log how long each run takes (date before and after is enough), look at a few weeks of it, and either make the job faster or run it less often. Locking is for the bad day, not for every day.

Common questions

How do I prevent a cron job from running if it's already running?
Prefix the command with flock -n and a lock file: */5 * * * * flock -n /run/lock/sync.lock /usr/local/bin/sync.sh. The first run takes the lock; a run that starts while it's held exits immediately instead of running. The lock is released when the process exits, even if it crashes, so there is no stale lock to clean up.
Why does flock -n make my cron job report a failure?
Because when the lock is busy, flock -n exits with code 1, which looks the same as your job failing. If you alert on non-zero exits, every skipped run becomes an alert. Pass -E 0 (flock -n -E 0 ...) to make a skipped run exit 0, or pick a distinctive code like -E 75 and treat that one as a skip.
Does cron run jobs in parallel?
Yes. Cron starts each job at its scheduled time without checking whether the previous run of the same job is still going. Two runs of the same job can overlap, and so can different jobs. Only the jobs you protect with a lock are kept from overlapping.
Is a PID file good enough to prevent overlapping cron jobs?
Not on its own. If the job is killed or the server reboots, the PID file stays behind, and later runs either refuse to start forever or have to guess whether that PID is still the same process. flock avoids this because the kernel drops the lock when the process holding it exits.
Does flock work across multiple servers?
No. flock locks a file on one machine. If the same job runs on several servers, use a lock they all share, such as a PostgreSQL advisory lock (SELECT pg_try_advisory_lock(42)) or a Redis key set with NX and an expiry, or make sure only one server runs the job.
Is there a cron replacement that prevents overlapping runs by default?
Yes. systemd timers never start a second copy of a service that's still running, and Kubernetes CronJobs do the same with concurrencyPolicy: Forbid. RunWisp, an open-source single-binary cron replacement, runs one copy of each task by default and lets you pick per task whether a late run waits (queue), is dropped (skip) or replaces the old one (kill). Skipped runs are kept in its run history, so you can see a job that has started overlapping. It can read your existing crontab, so you don't have to rewrite the jobs.
  1. How to get alerted when a cron job fails (and when it never runs)

    MAILTO, a curl on failure, a heartbeat service, or a scheduler that alerts on its own. What each one catches, what it misses, and the two-layer setup that covers both.

  2. Your 02:30 cron job might run twice on October 25

    Or not at all next March. We mapped every clock change in 418 time zones and tested six schedulers: which cron lines break, where, and a 30-second check for your own box.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh