Web Development

How do I prevent multiple instances of the same cron job

Learn techniques to prevent concurrent execution of the same cron job, using lock files, process checks, and other mechanisms to ensure only one instance runs.

By Inventive HQ Team

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.

A lock preventing overlapping cron runs A timeline shows run one holding a lock across its whole execution. Run two starts while run one is still active, hits the lock, and exits immediately with status 1. One lock, two scheduled starts Run #1 holds the lock for its whole runtime; Run #2 is turned away 2:00 time → 4:00 Run #1 running — holds /tmp/backup.lock Run #2 blocked → exit 1

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:

MethodHow it worksCommand / mechanismCleans up after a crash?
flockKernel advisory lock on a file descriptorflock -n /tmp/job.lock commandYes — kernel releases on exit
Lock file + PIDCreate a file, store $$, verify with kill -0echo $$ > lock; kill -0 $PIDNo — needs stale-lock handling
systemd timerService won't start while its instance is Active.service + .timer unitsYes — service manager owns it
App-level / DB advisory lockShared lock every host can seepg_try_advisory_lock(), Redis SET NX PXWith 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.

Advertisement

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 bash trap cannot 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-linux binary 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.

Loading interactive tool...

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

MethodProsConsBest For
Lock fileSimple, reliableFile system dependentMost cases
Process checkNo cleanup neededLess reliable with similar namesSimple jobs
flockAutomatic cleanupRequires flock utilityRobust implementation
Database lockDistributed systemsMore complexMulti-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.

Frequently Asked Questions

How do I stop cron from running the same job twice?

Wrap the job in a mutual-exclusion lock so a second start fails fast while the first is still running. The simplest, most portable way on Linux is flock directly in the crontab: change the line to * * * * * /usr/bin/flock -n /tmp/backup.lock /path/to/backup.sh. The -n (non-blocking) flag makes the second invocation exit immediately instead of queuing up, and the kernel releases the lock automatically when the first run ends, even if it crashes.

What does flock -n do?

flock -n acquires an exclusive advisory lock on a file without blocking. If another process already holds the lock, flock exits right away with status 1 instead of waiting, so the second cron run simply does nothing. Drop the -n and flock will instead wait (optionally with -w SECONDS for a timeout) until the lock is free. The lock lives in the kernel and is tied to the open file descriptor, so it is released the instant the process exits.

Why are lock files with PID checks unreliable?

A plain lock file created with touch is not atomic against a crash: if the job is killed with SIGKILL or the machine reboots, the file is left behind and every future run thinks the job is still active (a stale lock). Storing the PID and testing it with kill -0 helps, but PIDs are recycled, so a stale PID can accidentally match an unrelated new process. flock and systemd avoid this entirely because the kernel or the service manager owns the lock lifecycle, not a file you have to clean up yourself.

Should I use a systemd timer instead of cron for this?

If you are on a systemd-based Linux distribution, yes — it is the cleanest option. A systemd service unit will not start a second time while an instance is already active (RefuseManualStart is not even needed; the service is simply Active), and pairing it with a timer unit gives you the schedule. You get automatic overlap prevention, structured logging in the journal, and dependency handling with no lock files to manage. Cron plus flock is the right choice when you need portability across older or non-systemd systems.

How do I prevent overlap for a distributed job running on multiple servers?

File locks and systemd only coordinate a single host, so for a job scheduled on several servers you need a shared lock they all see. Use a database row lock (an INSERT into a job_locks table with a unique key, or PostgreSQL advisory locks via pg_try_advisory_lock), or a distributed lock in Redis (SET key value NX PX ttl) or a coordination service like ZooKeeper or etcd. Always attach a TTL or lease so a crashed holder cannot block the job forever.

What is a stale lock and how do I recover from one?

A stale lock is a lock file left behind by a job that died without cleaning up, permanently blocking future runs. Recover by validating the lock before honoring it: check whether the recorded PID is still alive with kill -0, or compare the lock file's age against a timeout (for example, delete it if older than the job's maximum expected runtime). Better, avoid the problem: flock and systemd never leave stale locks because the lock is released automatically when the process ends.

Does a bash trap reliably remove the lock file?

A trap 'rm -f $LOCK_FILE' EXIT runs on normal exit and on most signals (INT, TERM), which covers Ctrl+C and ordinary termination. It does not run on SIGKILL (kill -9) or a power loss, so a trap-based lock file can still go stale. That is the core reason to prefer flock, whose lock the kernel releases no matter how the process dies.

How frequently can I safely schedule a job that uses locking?

Locking prevents corruption from overlap, but it does not fix a schedule that is fundamentally too tight — you will just skip runs. As a rule, the interval should comfortably exceed the job's worst-case runtime; if a backup takes about an hour, schedule it every two hours, not hourly. If you genuinely need frequent execution of a slow job, split the work into smaller idempotent chunks or move to a queue/worker model rather than fighting the scheduler with locks.

cronconcurrencylockingsystem administration