To stop a slow cron job from overlapping itself, wrap it in a mutual-exclusion lock so any second start fails immediately while the first run is still active. The most portable way on Linux is to put flock right in the crontab line — * * * * * /usr/bin/flock -n /tmp/backup.lock /path/to/backup.sh — where the -n flag makes the second invocation exit at once instead of piling up, and the kernel releases the lock automatically when the job ends (even if it crashes). Other options are a PID-checked lock file, a systemd service and timer (which refuses to run a second instance by design), or a shared database or Redis lock when the job runs across multiple servers.
That is the summary an AI overview gives you. What it can't give you is why each method fails in the ways it does — why a touch-based lock file goes stale after a crash, why PID checks misfire when PIDs get recycled, and exactly which line of flock to trust. This article walks through every method with working code, then tells you which one to reach for.
Overlap in one picture
When run #1 runs long, the scheduler fires run #2 on top of it. A lock is the gate that lets run #1 keep the resource and forces run #2 to exit cleanly instead of colliding.
The Problem of Concurrent Cron Jobs
When a scheduled cron job takes longer than expected, the scheduler might start another instance of the same job before the first one completes. This can cause problems like database conflicts, file corruption, duplicate data, and resource exhaustion.
For example, if a daily backup job runs at 2 AM and takes 2 hours to complete, but another instance starts at 3 AM, you'll have two backup processes running simultaneously, competing for resources.
The methods at a glance
Every solution below is a variation on one idea — acquire a lock, and if you can't, exit. They differ in where the lock lives and who cleans it up:
| Method | How it works | Command / mechanism | Cleans up after a crash? |
|---|---|---|---|
| flock | Kernel advisory lock on a file descriptor | flock -n /tmp/job.lock command | Yes — kernel releases on exit |
| Lock file + PID | Create a file, store $$, verify with kill -0 | echo $$ > lock; kill -0 $PID | No — needs stale-lock handling |
| systemd timer | Service won't start while its instance is Active | .service + .timer units | Yes — service manager owns it |
| App-level / DB advisory lock | Shared lock every host can see | pg_try_advisory_lock(), Redis SET NX PX | With a TTL / lease |
If you're on Linux and unsure, use flock (single host) or a systemd timer (systemd host). Reach for a database or Redis lock only when the job runs on more than one machine.
Lock File Method
The most common and reliable approach is using lock files. A lock file serves as a flag indicating that a job is already running.
Basic Lock File Implementation
#!/bin/bash
LOCK_FILE="/tmp/backup.lock"
# Check if lock file exists
if [ -f "$LOCK_FILE" ]; then
echo "Backup job is already running (lock file exists)"
exit 1
fi
# Create lock file
touch "$LOCK_FILE"
# Trap to remove lock file on exit (success or failure)
trap "rm -f $LOCK_FILE" EXIT
# Your actual job code
echo "Starting backup at $(date)"
mysqldump --all-databases > /backups/backup.sql
gzip /backups/backup.sql
echo "Backup completed at $(date)"
# Lock file is removed by trap on exit
Improved Lock File with PID
Store the process ID (PID) in the lock file to verify the job is still running:
#!/bin/bash
LOCK_FILE="/tmp/backup.lock"
# Check if lock file exists
if [ -f "$LOCK_FILE" ]; then
LOCK_PID=$(cat "$LOCK_FILE")
# Check if the process with that PID still exists
if kill -0 "$LOCK_PID" 2>/dev/null; then
echo "Backup job is already running (PID: $LOCK_PID)"
exit 1
else
# Process doesn't exist, remove stale lock file
rm -f "$LOCK_FILE"
fi
fi
# Create lock file with our PID
echo $$ > "$LOCK_FILE"
# Trap to remove lock file on exit
trap "rm -f $LOCK_FILE" EXIT
# Your actual job code
echo "Starting backup at $(date)"
mysqldump --all-databases > /backups/backup.sql
echo "Backup completed at $(date)"
Lock File with Timeout
Remove lock files that are too old to prevent permanent blocking:
#!/bin/bash
LOCK_FILE="/tmp/backup.lock"
LOCK_TIMEOUT=7200 # 2 hours in seconds
# Check if lock file exists
if [ -f "$LOCK_FILE" ]; then
LOCK_TIME=$(stat -f%m "$LOCK_FILE" 2>/dev/null || stat -c%Y "$LOCK_FILE")
CURRENT_TIME=$(date +%s)
LOCK_AGE=$((CURRENT_TIME - LOCK_TIME))
if [ $LOCK_AGE -lt $LOCK_TIMEOUT ]; then
echo "Backup job is already running (lock age: $LOCK_AGE seconds)"
exit 1
else
echo "Removing stale lock file (age: $LOCK_AGE seconds)"
rm -f "$LOCK_FILE"
fi
fi
# Create lock file
echo $$ > "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
# Your actual job code
echo "Starting backup at $(date)"
mysqldump --all-databases > /backups/backup.sql
echo "Backup completed at $(date)"
Process Check Method
Instead of lock files, check if a process with the same name is already running:
#!/bin/bash
JOB_NAME="backup"
# Count running instances of this job
RUNNING=$(pgrep -f "$JOB_NAME" | wc -l)
if [ $RUNNING -gt 1 ]; then
echo "Another instance of $JOB_NAME is already running"
exit 1
fi
# Your actual job code
echo "Starting backup at $(date)"
mysqldump --all-databases > /backups/backup.sql
echo "Backup completed at $(date)"
This method is simpler but less reliable if multiple different jobs might have similar names.
Mutex with flock (the recommended default)
flock is the answer most experienced admins reach for. It asks the Linux kernel for an advisory lock on a file, and — critically — the kernel releases that lock the instant the holding process exits, no matter how it exits. There is no file to clean up and no stale-lock problem, because the lock is tied to the open file descriptor, not to the file's mere existence.
The cleanest pattern is to put flock directly in the crontab line, so the lock wraps the entire job with nothing to add to your script:
# In crontab -e — one line, no wrapper script needed.
# -n = non-blocking: if the lock is held, exit immediately (skip this run).
* * * * * /usr/bin/flock -n /tmp/backup.lock /path/to/backup.sh
The general form is flock -n <lockfile> <command> — flock acquires the lock, runs your command while holding it, and drops it on exit. A few useful variants:
# Skip this run if another is active (non-blocking):
flock -n /tmp/job.lock /path/to/job.sh
# Wait up to 30s for the lock, then give up (bounded queue):
flock -w 30 /tmp/job.lock /path/to/job.sh
# Run an inline command string instead of a script:
flock -n /tmp/job.lock -c 'mysqldump --all-databases | gzip > /backups/db.sql.gz'
If you prefer to keep the lock logic inside the script, use the file-descriptor form. This is handy when only part of the script is the critical section:
#!/bin/bash
# Open fd 200 on the lock file, then take a non-blocking exclusive lock.
exec 200>/tmp/backup.lock
flock -n 200 || { echo "Another instance is running"; exit 1; }
# Your actual job code goes here — the lock is held until the script exits.
echo "Starting backup at $(date)"
mysqldump --all-databases > /backups/backup.sql
echo "Backup completed at $(date)"
flock is more robust than a hand-rolled lock file because:
- The lock is released automatically when the process exits — including on a crash or
kill -9, which a bashtrapcannot catch. - No stale locks: the file existing on disk means nothing; only a live holder counts.
- It works across shells and is a single, well-tested
util-linuxbinary present on virtually every Linux system.
systemd Timers: Overlap Prevention for Free
On any modern systemd-based distribution (most current Linux systems), you can skip cron and its lock files entirely. A systemd service will not start a second time while an instance of it is already active — that guarantee is built into the service manager, so you get overlap prevention with no lock code at all. Pair the service with a timer unit to handle scheduling.
Define the job as a service (/etc/systemd/system/backup.service):
[Unit]
Description=Nightly database backup
[Service]
Type=oneshot
ExecStart=/path/to/backup.sh
Then a timer to schedule it (/etc/systemd/system/backup.timer):
[Unit]
Description=Run backup every 2 hours
[Timer]
OnCalendar=*-*-* 0/2:00:00
Persistent=true
[Install]
WantedBy=timers.target
Enable it with systemctl enable --now backup.timer. If the timer fires while the previous backup.service run is still active, systemd simply does not start a second one — the trigger is skipped. You also get structured logs (journalctl -u backup.service), automatic dependency ordering, and Persistent=true to catch up on runs missed while the machine was off. For a single host, this is usually the best option; reach for cron + flock when you need portability to older or non-systemd systems.
Database-Based Locking
For systems where file-based locks aren't appropriate, use database locks:
#!/bin/bash
DB_HOST="localhost"
DB_USER="backup"
DB_PASS="password"
DB_NAME="system"
JOB_NAME="backup"
# Try to acquire lock in database
LOCK_QUERY="INSERT INTO job_locks (job_name, started_at) VALUES ('$JOB_NAME', NOW()) ON DUPLICATE KEY UPDATE started_at=NOW();"
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "$LOCK_QUERY"
if [ $? -ne 0 ]; then
echo "Failed to acquire database lock"
exit 1
fi
# Function to release lock on exit
release_lock() {
DELETE_QUERY="DELETE FROM job_locks WHERE job_name='$JOB_NAME';"
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "$DELETE_QUERY"
}
trap release_lock EXIT
# Your actual job code
echo "Starting backup at $(date)"
mysqldump --all-databases > /backups/backup.sql
echo "Backup completed at $(date)"
Language-Specific Solutions
Different programming languages offer specialized approaches:
Python
import os
import sys
import time
from pathlib import Path
LOCK_FILE = "/tmp/backup.lock"
def acquire_lock():
if os.path.exists(LOCK_FILE):
try:
with open(LOCK_FILE, 'r') as f:
pid = int(f.read())
# Check if process exists
os.kill(pid, 0)
print("Another instance is running")
return False
except (ProcessLookupError, ValueError):
# Process doesn't exist or invalid PID, remove lock
os.remove(LOCK_FILE)
# Create lock file with our PID
with open(LOCK_FILE, 'w') as f:
f.write(str(os.getpid()))
return True
def release_lock():
if os.path.exists(LOCK_FILE):
os.remove(LOCK_FILE)
try:
if not acquire_lock():
sys.exit(1)
# Your actual job code
print(f"Starting backup at {time.ctime()}")
# Run backup commands
print(f"Backup completed at {time.ctime()}")
finally:
release_lock()
Node.js
const fs = require('fs');
const path = require('path');
const LOCK_FILE = '/tmp/backup.lock';
async function acquireLock() {
try {
// Check if lock exists
if (fs.existsSync(LOCK_FILE)) {
const pid = fs.readFileSync(LOCK_FILE, 'utf-8').trim();
// Check if process is running
try {
process.kill(pid, 0);
console.log('Another instance is running');
return false;
} catch (e) {
// Process doesn't exist, remove lock
fs.unlinkSync(LOCK_FILE);
}
}
// Create lock file
fs.writeFileSync(LOCK_FILE, process.pid.toString());
return true;
} catch (err) {
console.error('Lock error:', err);
return false;
}
}
function releaseLock() {
try {
if (fs.existsSync(LOCK_FILE)) {
fs.unlinkSync(LOCK_FILE);
}
} catch (err) {
console.error('Error releasing lock:', err);
}
}
(async () => {
if (!await acquireLock()) {
process.exit(1);
}
try {
console.log(`Starting backup at ${new Date()}`);
// Run backup commands
console.log(`Backup completed at ${new Date()}`);
} finally {
releaseLock();
}
})();
Cron Configuration Best Practices
Set Appropriate Time Intervals
Locking stops corruption, but it can't rescue a schedule that's fundamentally too tight — you'll just skip runs. Give the job room: the interval should comfortably exceed its worst-case runtime. Use the builder below to generate a correct cron expression for the cadence you settle on.
Make sure cron intervals allow jobs to complete:
# If backup takes 1 hour, schedule every 2 hours minimum
0 */2 * * * /backup/scripts/backup.sh
# Don't schedule more frequently than execution time
# Every 5 minutes when job takes 30 minutes = problem
*/5 * * * * /backup/scripts/backup.sh # BAD
Add Logging and Monitoring
#!/bin/bash
LOCK_FILE="/tmp/backup.lock"
LOG_FILE="/var/log/backup.log"
if [ -f "$LOCK_FILE" ]; then
echo "[$(date)] Backup already running - skipping" >> "$LOG_FILE"
exit 1
fi
touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
echo "[$(date)] Backup started" >> "$LOG_FILE"
# Your backup code
echo "[$(date)] Backup completed" >> "$LOG_FILE"
Alert on Lock Conflicts
Monitor and alert when concurrent execution is prevented:
#!/bin/bash
LOCK_FILE="/tmp/backup.lock"
ALERT_EMAIL="admin@example.com"
if [ -f "$LOCK_FILE" ]; then
# Send alert
echo "Backup conflict detected at $(date)" | \
mail -s "Cron Job Overlap Alert" "$ALERT_EMAIL"
exit 1
fi
touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
# Your backup code
Testing Concurrency Prevention
Test your implementation:
# Simulate long-running job
#!/bin/bash
LOCK_FILE="/tmp/test.lock"
if [ -f "$LOCK_FILE" ]; then
echo "Already running"
exit 1
fi
touch "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
echo "Job started at $(date)"
sleep 60 # Simulate 60-second job
echo "Job completed at $(date)"
Then run it multiple times quickly:
# Start first instance
./job.sh &
# Try to start second instance immediately
./job.sh # Should be blocked
# Wait for first to complete
wait
Which Method to Use
| Method | Pros | Cons | Best For |
|---|---|---|---|
| Lock file | Simple, reliable | File system dependent | Most cases |
| Process check | No cleanup needed | Less reliable with similar names | Simple jobs |
| flock | Automatic cleanup | Requires flock utility | Robust implementation |
| Database lock | Distributed systems | More complex | Multi-server setups |
Recommendation: On a single Linux host, use flock — ideally right in the crontab line (flock -n /tmp/job.lock /path/to/job.sh) — because the kernel releases the lock automatically and there is no stale-lock problem. On a systemd system, a service + timer is even cleaner and needs no lock code at all. Only hand-roll a PID + timeout lock file if flock genuinely isn't available, and only move to a database or Redis lock when the job runs across multiple servers.
Preventing concurrent execution of the same cron job is essential for data integrity and system stability. Implementing proper locking mechanisms ensures that your scheduled jobs complete their work without conflicts, regardless of how long they take to run.