# Prevent Overlapping Cron Jobs: flock, Locks, and Concurrency Policies

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/prevent-overlapping-cron-jobs

> Stop a cron job from overlapping itself: choose skip, wait, or replace, then use flock, systemd, Kubernetes concurrencyPolicy, or a database or Redis lock.

**To prevent overlapping cron jobs, run each job under a lock that only one run can hold at a time.** On one Linux host, prefix the crontab command with `flock`: `flock -n /var/lib/inventory/sync.lock /opt/inventory/sync.sh` runs the command only if it can take the lock immediately, and otherwise exits without running it. Cron won't do this for you: cronie and Debian's cron start a new copy at every matching minute, finished or not ([cronie job.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/job.c)). Jobs on several hosts need a shared lock, such as a database advisory lock or a Redis key; Kubernetes CronJobs and systemd timers have overlap policies of their own.

## Decide What the Next Run Should Do

Every overlap fix implements one of four policies:

| Policy  | The new run                                     | Fits                                                               | One host                    | Kubernetes                                 |
| ------- | ----------------------------------------------- | ------------------------------------------------------------------ | --------------------------- | ------------------------------------------ |
| Skip    | Exits without doing anything                    | Syncs, cleanups, and polls, where the next run catches up          | `flock -n`                  | `Forbid` with a short deadline             |
| Wait    | Starts when the previous run ends, or gives up  | Jobs that occasionally run long, where a late run beats none       | `flock -w 300`              | `Forbid` with no `startingDeadlineSeconds` |
| Replace | Stops the running copy and starts over          | Only the newest result matters, and an interrupted run is harmless | `run-this-one` (Ubuntu)     | `Replace`                                  |
| Queue   | Every occurrence eventually runs, one at a time | Work tied to a specific period, such as one invoice batch per hour | Blocking `flock`, unordered | None                                       |

Skip is the right default when the work isn't tied to one occurrence: a sync of everything changed since its last success loses nothing to a skip, while one that processes "the last 15 minutes" loses that window. If every period must be processed, record which are done and have each run handle all pending ones; the queue becomes a safe skip. [Preventing errors in scheduled automation](https://cronradar.com/blog/prevent-errors-in-scheduled-automation) covers making runs safe to repeat. Schedulers name these policies differently and choose different defaults; the [CronRadar Scheduled Job Methodology's overlap table](https://cronradar.com/methodology/evidence) maps the names across Kubernetes, Temporal, Cadence, HCL Workload Automation, Netflix Maestro, Pinterest Pinball, and systemd.

## One Host: flock

`flock` ships in util-linux on Debian, Ubuntu, Fedora, RHEL, and Arch; everything below matches the [util-linux 2.41 source](https://github.com/util-linux/util-linux/blob/v2.41/sys-utils/flock.c) and [flock(1)](https://man7.org/linux/man-pages/man1/flock.1.html) and works in 2.33 and later. Alpine's BusyBox `flock` has no `-w`, `-E`, or `-o` ([flock.c](https://github.com/mirror/busybox/blob/1_36_1/util-linux/flock.c)), so run `apk add flock` for util-linux's.

### The Crontab Line

```text
*/15 * * * * flock -n -E 75 /var/lib/inventory/sync.lock /opt/inventory/sync.sh
```

- **`-n`** fails instead of waiting when the lock is held. flock creates the lock file if it's missing, and once it has the lock, runs the command and holds the lock until the command exits.
- **`-E 75`** sets the exit status for "lock busy." The default, 1, looks exactly like the job's own failure; when the command runs, flock exits with the command's status. 75 is `EX_TEMPFAIL` in [sysexits.h](https://man7.org/linux/man-pages/man3/sysexits.h.3head.html); any value from 0 to 255 your job doesn't use works.
- **A skip is silent.** flock prints nothing and cron mails only output, so nothing records a skip unless something reads the exit status.

_Figure: A job scheduled every 15 minutes whose 02:00 run takes 22 minutes: without a lock, cron starts the 02:15 copy anyway and two copies run at once until 02:22. With flock -n -E 75, the 02:15 start finds the lock held and exits 75 without running the job._

For the wait policy, `flock -w 300` waits up to 300 seconds, then exits with the `-E` code. With neither option, flock waits indefinitely, every run that arrives meanwhile waits too, and [flock(2)](https://man7.org/linux/man-pages/man2/flock.2.html) promises no order among them. The lock is advisory, excluding only processes that ask for it, so make manual runs use the same `flock` command.

### Locking Inside the Script

To make a script protect itself however it's started, lock a file descriptor at the top:

```bash
#!/bin/sh
# /opt/inventory/sync.sh
exec 9>/var/lib/inventory/sync.lock
if ! flock -n 9; then
  logger -t inventory-sync "skipped: the previous run still holds the lock"
  exit 0
fi

/opt/inventory/bin/export-orders
/opt/inventory/bin/push-stock-levels
```

Everything after the `flock` line runs under the lock, which is released when the script exits and descriptor 9 closes. `9>` needs write permission on the file, and `logger` sends the skip to syslog instead of cron mail.

### Background Children Keep the Lock

A flock lock belongs to the open file, every child inherits the descriptor, and the lock is released only when all copies close ([flock(2)](https://man7.org/linux/man-pages/man2/flock.2.html)). If the job starts something that outlives it, such as `nohup /opt/inventory/bin/watcher &`, that process keeps the lock and every later run skips. `flock -o` closes the descriptor in the command's process, so only flock itself holds the lock until the command exits ([source](https://github.com/util-linux/util-linux/blob/v2.41/sys-utils/flock.c#L424-L432)). In the in-script form, close it for that child: `nohup /opt/inventory/bin/watcher 9>&- &`. When a job keeps skipping, [lslocks(8)](https://man7.org/linux/man-pages/man8/lslocks.8.html) shows which process holds the lock.

### Where to Put the Lock File

- **In a directory the job's user owns,** such as `/var/lib/inventory/`. The contents never matter, and a leftover file is harmless; the lock exists only while a process holds the file open.
- **`/run/lock` (also `/var/lock`) suits root jobs.** Upstream systemd creates it root-only, mode 0755 ([legacy.conf.in](https://github.com/systemd/systemd/blob/main/tmpfiles.d/legacy.conf.in)); check `ls -ld /run/lock` before using it for another user.
- **Not a fixed name in `/tmp`.** Any local user can create `/tmp/sync.lock` first and hold its lock, and your job skips forever. Cleanup isn't the danger: systemd-tmpfiles ages `/tmp` out after 10 days upstream ([tmp.conf](https://github.com/systemd/systemd/blob/main/tmpfiles.d/tmp.conf)) but skips any file holding a BSD lock ([tmpfiles.d(5)](https://www.freedesktop.org/software/systemd/man/latest/tmpfiles.d.html)).
- **Never delete it at the end of the job.** A run that opened the old file just before the delete can lock it while the next run locks a new file, and the two run side by side.
- **On a local file system.** flock(1) warns that it can fail outright on NFS and CIFS, depending on mount options.

## PID Files Go Stale; flock Locks Don't

The older idiom fails three ways:

```bash
# Don't use this: stale files, PID reuse, and a race.
pidfile=/var/lib/inventory/sync.pid
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
  exit 0
fi
echo $$ > "$pidfile"
/opt/inventory/sync.sh
rm -f "$pidfile"
```

- **Stale files.** A run ended by SIGKILL, the OOM killer, or a power cut never reaches `rm`.
- **PID reuse.** Linux reuses PIDs after they wrap at `pid_max` ([proc_sys_kernel(5)](https://man7.org/linux/man-pages/man5/proc_sys_kernel.5.html)). If a stale file names a PID that now belongs to another process, `kill -0` can succeed, and the job skips until someone deletes the file.
- **The race.** Two runs can both pass the test before either writes the file. Creating it atomically, with `mkdir` for example, fixes the race but not the stale file.

A flock lock has none of these problems: the kernel releases it when the last descriptor closes, which happens when the process exits for any reason, and no lock survives a reboot.

Ubuntu's `run-one` package (1.17 in Ubuntu 26.04; not in Debian) wraps the same mechanism: `run-one /opt/inventory/sync.sh` runs `flock -xn` on a file in `~/.cache/run-one/` named after an MD5 hash of the command line ([source](https://git.launchpad.net/ubuntu/+source/run-one/plain/run-one?h=applied/ubuntu/resolute)). The lock is per user and per exact command line, and a skip exits 1. `run-this-one` first kills that user's processes with a matching command line: the replace policy ([run-one(1)](https://manpages.ubuntu.com/manpages/noble/man1/run-one.1.html)).

## systemd Timers Never Overlap, but Wait by Default

A timer never starts a second copy of its service. From [systemd.timer(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html) (systemd 262): "in case the unit to activate is already active at the time the timer elapses it is not restarted, but simply left running." The slot isn't simply skipped, though. The timer waits for the service to stop ([timer.c](https://github.com/systemd/systemd/blob/v262.1/src/core/timer.c#L837-L842)) and then schedules from its previous trigger time, which is now in the past, so it starts again at once; several missed slots collapse into one run. That's the wait policy. For a skip, set `DeferReactivation=yes` (systemd 257 and later, `OnCalendar=` timers only), which schedules the next run from when the service finished. The protection covers the unit, not the script, so running the script directly bypasses it. [systemd timers](https://cronradar.com/blog/systemd-timers) covers timers in full.

## Kubernetes: Forbid Skips, Then May Run Late

`concurrencyPolicy` sets what the CronJob controller does when a Job is due while the previous one is still active ([concurrency policy](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#concurrency-policy)). `Allow`, the default, starts both. `Forbid` skips the new Job and records a `JobAlreadyActive` event. `Replace` deletes the running Job and creates the new one.

The docs add that under `Forbid`, "when the previous Job run finishes, .spec.startingDeadlineSeconds is still taken into account and may result in a new Job run." In the Kubernetes v1.37.1 controller, a finishing Job wakes the CronJob, which looks for the most recent scheduled time since the last Job it started, ignoring times more than `startingDeadlineSeconds` old ([utils.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/utils.go#L100-L115)). The skipped time counts, so:

- **With no `startingDeadlineSeconds`,** the most recent skipped run starts as soon as the previous Job finishes; several skips still produce one late run. That's the wait policy.
- **With `startingDeadlineSeconds: 120`,** the late run starts only if the previous Job finished within 120 seconds of the skipped time. A deadline well under the interval makes `Forbid` a true skip. The docs warn against values below 10 seconds, and the deadline applies to on-time starts too.

```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: inventory-sync
spec:
  schedule: '*/15 * * * *'
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 120 # drop a skipped run instead of starting it late
  jobTemplate:
    spec:
      activeDeadlineSeconds: 1800 # a hung run can't block later runs forever
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: sync
              image: registry.example.com/inventory-sync:1.4.2
              command: ['/opt/inventory/sync.sh']
```

Three gaps remain. A [source comment](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/cronjob_controllerv2.go#L595-L600) concedes it is "theoretically possible to have concurrency with Forbid" when the controller's view is stale. A Job made with `kubectl create job --from=cronjob/inventory-sync` is owned by the CronJob but never added to the active list the controller checks ([cronjob_controllerv2.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/cronjob_controllerv2.go#L459-L474)), so a scheduled run can start beside it. And `Replace` deletes the old Job in the background ([injection.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/injection.go#L107-L110)), so its pod gets the usual [30-second termination grace period](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination) and may still be shutting down as the new pod starts. [Kubernetes CronJob monitoring](https://cronradar.com/blog/kubernetes-cronjob-monitoring) covers the rest of the spec.

## Many Hosts: Database and Redis Locks

A flock lock lives in one machine's kernel, so a crontab copied to three hosts runs the job three times. The simplest fix is to schedule it on one host; [why a cron job runs twice](https://cronradar.com/blog/cron-job-running-twice) covers finding duplicate schedulers. When any host must be able to run it, take the lock in a service they all reach.

A lock stops two runs at once, not two runs of one occurrence: a host that finishes a 20-second job before another host's clock reaches that minute lets the second host run it again. When that matters, record each occurrence, for example as a row keyed on the scheduled time with a unique constraint.

### Database Advisory Locks

| Database                      | Take it without waiting                                                           | Released by                                                                    | Watch for                                                                                           |
| ----------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| PostgreSQL 18, session-level  | `SELECT pg_try_advisory_lock(724001)` returns true or false                       | `pg_advisory_unlock(724001)` or the session ending                             | Each successful lock call needs its own unlock; rollback doesn't release it                         |
| PostgreSQL, transaction-level | `SELECT pg_try_advisory_xact_lock(724001)`                                        | Commit or rollback                                                             | The work must fit in that one transaction                                                           |
| MySQL 8.4                     | `SELECT GET_LOCK('inventory.sync', 0)` returns 1, 0, or NULL on error             | `RELEASE_LOCK('inventory.sync')` or the session ending; not commit or rollback | Names are server-wide, at most 64 characters, and exist on one `mysqld` only                        |
| SQL Server                    | `sp_getapplock` with `@LockMode = 'Exclusive'`, `@LockTimeout = 0`; -1 means busy | `sp_releaseapplock`, or the end of the owning session or transaction           | The default owner, `Transaction`, needs an open transaction; use `@LockOwner = 'Session'` otherwise |

Sources: [PostgreSQL advisory locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS), [MySQL locking functions](https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html), [sp_getapplock](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql).

Each lock belongs to a database session, so the job must hold one dedicated connection open for the whole run, outside any connection pool. Three things break that:

- **A pooler in between.** In transaction pooling, PgBouncer returns the server connection to the pool after each transaction, and its feature table marks session-level advisory locks "Never" supported in that mode ([PgBouncer features](https://www.pgbouncer.org/features.html)). Connect the lock directly or through session pooling.
- **The connection dying first.** A network drop, failover, or server-side timeout ends the session and releases the lock without telling the running job. PostgreSQL's `idle_session_timeout` and `idle_in_transaction_session_timeout`, both off by default, end exactly this kind of idle connection ([client settings](https://www.postgresql.org/docs/current/runtime-config-client.html)).
- **An open transaction.** A driver's implicit transaction leaves the lock connection idle in a transaction all run, which those docs warn holds back vacuum; use autocommit.

### Redis: SET NX PX With a Token

Redis's [SET documentation](https://redis.io/docs/latest/commands/set/) gives the single-instance pattern: `NX` with an expiry, a random token as the value, and a release script that deletes the key only if it still holds that token. As a crontab wrapper:

```bash
#!/bin/sh
# /opt/inventory/redis-lock-run.sh COMMAND [ARGS...]
# Set REDISCLI_AUTH in the environment if the server needs a password.
key="lock:inventory-sync"
token="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"

# Hold the lock for at most 30 minutes; any reply but OK means it's taken.
[ "$(redis-cli -h "$REDIS_HOST" SET "$key" "$token" NX PX 1800000)" = "OK" ] || exit 75

"$@"
rc=$?

# Delete the key only if it still holds this run's token.
redis-cli -h "$REDIS_HOST" EVAL \
  "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" \
  1 "$key" "$token" > /dev/null
exit "$rc"
```

[redis-cli](https://redis.io/docs/latest/develop/tools/cli/) prints plain replies when its output isn't a terminal, and an unreachable server returns no `OK`, so the job skips. On Redis 8.4 and later, [`DELEX key IFEQ token`](https://redis.io/docs/latest/commands/delex/) replaces the script, and `SET key token IFEQ token PX 1800000` renews only your own lock.

A lease fails differently from a session lock:

- **The TTL must outlast the longest run.** Past it, the key expires, another host takes the lock, and the runs overlap; the token only stops the first run from deleting the second one's key. Cap the job's runtime below the TTL, or renew the lease while it runs.
- **A crash leaves the key** until it expires, and every host skips meanwhile.
- **Failover can grant it twice.** Replication is asynchronous; the [distributed locks page](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/) walks through a primary crashing before the lock reaches its replica, so a second client gets the same lock.

### No Lock Guarantees Mutual Exclusion

Any lock that expires or depends on a connection can be lost while its holder keeps working: a garbage-collection pause, a stalled VM, a partition, or a clock change (Redis TTLs don't use a monotonic clock). The same Redis page advises: "You should implement fencing tokens. This is especially important for processes that can take significant time and applies to any distributed locking system." A fencing token grows with each grant, and the resource being written rejects tokens older than the highest it has seen ([Martin Kleppmann](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)). Where you don't control the writes, make the job safe to run twice and treat the lock as what makes overlap rare.

## In-App Schedulers Have Their Own Settings

- **Hangfire:** `[DisableConcurrentExecution]` makes a second run wait, then fail on timeout; [Hangfire DisableConcurrentExecution](https://cronradar.com/blog/hangfire-disable-concurrent-execution) shows how to skip instead.
- **Quartz.NET:** `[DisallowConcurrentExecution]` prevents concurrent runs of one JobDetail; two JobDetails sharing a class can still overlap ([More about jobs](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-jobs.html)).
- **node-cron 4:** `noOverlap: true`, off by default, skips a run while the previous one executes and emits `execution:overlap` ([scheduling options](https://nodecron.com/scheduling-options.html)).
- **Celery 5.6:** nothing built in; the [task cookbook](https://docs.celeryq.dev/en/stable/tutorials/task-cookbook.html) builds a lock from an atomic cache `add` with an expiry, and warns that "your tasks run-time shouldn't exceed the timeout."

Each guards only its own scheduler's runs.

## Monitor the Job Without Alerting on Every Skip

Put the monitoring inside the lock: flock runs the wrapper, and the wrapper runs the job, so a skipped run sends nothing. There is no CronRadar SDK for crontab and no auto-discovery, so each cron job reports with its own HTTP check-in:

```text
*/15 * * * * flock -n -E 75 /var/lib/inventory/sync.lock /opt/inventory/sync-monitored.sh
```

```bash
#!/bin/sh
# /opt/inventory/sync-monitored.sh
# The key lives in a file only the job's account can read: cronie and Debian's cron
# copy crontab variables into the headers of the mail they send.
CRONRADAR_API_KEY=$(cat /etc/cronradar/api-key 2>/dev/null)
url="https://cron.life/api/v1/ping/inventory-sync"
run_id="inventory-sync-$(date +%s)-$$"

report() {
  event=$1
  shift
  curl --fail --silent --show-error --max-time 5 --output /dev/null \
    --request POST --get --user "$CRONRADAR_API_KEY:" \
    --data-urlencode "schedule=*/15 * * * *" \
    --data-urlencode "gracePeriod=600" \
    --data-urlencode "run_id=$run_id" \
    "$@" "$url/$event" || true
}

report start
timeout -k 1m 30m /opt/inventory/sync.sh
rc=$?
if [ "$rc" -eq 0 ]; then
  report complete
else
  report fail --data-urlencode "message=sync.sh exited with code $rc"
fi
exit "$rc"
```

When the 02:00 run takes 22 minutes, CronRadar sees this:

- **02:00:** `start` arrives, and the monitor's expected time moves to the next occurrence, 02:15.
- **02:15:** flock finds the lock held and exits 75 before the wrapper starts. Nothing is sent; the API has no event for a skipped run.
- **02:22:** `complete` arrives before 02:15 plus the 10-minute grace period, so no alert fires, and the expected time moves to 02:30.

If a run is still going at 02:25 with its `start` as the last event, the monitor goes `critical` as stuck. That's the right alert: the job has overrun its interval by more than the grace you allowed, so set `gracePeriod` to the overrun you'll tolerate. At 02:30, `timeout` stops the job, the wrapper reports `fail`, and the lock is free for the next run.

The order matters. With flock inside the wrapper instead, every skipped run would report a `start` and then a `fail`, and since every check-in moves the expected time, those reports would keep resetting the stuck window of the run they skipped. Without `timeout`, a hung run holds the lock forever and the stuck alert is the only signal; CronRadar has no per-job maximum runtime, so the limit belongs in the job.

The first run creates the monitor from its `schedule`, and the [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) explains how missed runs are detected.

## Frequently Asked Questions

### How do I stop a cron job from running if it's already running?

Put `flock -n` and a lock file in front of the command: `*/15 * * * * flock -n /var/lib/inventory/sync.lock /opt/inventory/sync.sh`. If the previous run still holds the lock, the new run exits without running the command.

### What does flock -n do?

It requests the lock without waiting. If another process holds it, flock exits with status 1, or the code set with `-E`, without running the command; otherwise it runs the command and releases the lock when the command exits.

### Is a PID file enough to prevent overlapping cron jobs?

No. A killed run leaves the file behind, its PID can later belong to an unrelated process, and two runs can both check before either writes. The kernel releases a flock lock when the process exits, however it exits.

### How do I prevent overlapping jobs across multiple servers?

Schedule the job on one server, or take a lock every server reaches: a PostgreSQL advisory lock, MySQL `GET_LOCK`, SQL Server `sp_getapplock`, or a Redis key set with `NX` and an expiry. None of them guarantees exclusion through network failures or long pauses, so keep the job safe to run twice.

### What is concurrencyPolicy Forbid in Kubernetes?

It stops the CronJob controller from starting a Job while the previous one is still active. When the previous Job finishes, the controller still starts the most recent skipped run unless `startingDeadlineSeconds` has passed, so set that well below the interval for a true skip.
