Cron Job Running Twice? Find the Duplicate and Fix It
Contents
A cron job that runs twice almost always has two launchers: the same command scheduled in two places, a second scheduler or daemon, a second machine, or a retry or catch-up that starts it again. The timing of the two runs usually tells you which. Two runs in the same second point to a duplicate entry, a second daemon, or a second host. Runs minutes apart point to a second scheduler, a retry, or a slow run overlapping the next. Runs only on some nights point to daylight saving or Kubernetes catch-up, and runs only after deploys to @reboot, an appended crontab line, or a deploy hook.
Each cause below comes with the command that proves it. Behavior is checked against the source of cronie 1.7.2, Debian's cron 3.0pl1-197, and BusyBox 1.37.0, and against the Kubernetes 1.37 and systemd 262 documentation, on October 24, 2026.
Record Every Run Before You Change Anything#
If you delete the entry that looks extra before you know which one it is, you can leave the job running zero times. First make each run record where it came from. Paste this near the top of the job's own script, so it records the run whichever launcher started it:
# Near the top of /opt/app/send-digest.sh: one line per run, whoever starts it.
via= p=$$
while [ "$p" -gt 1 ] 2>/dev/null; do
p=$(awk '/^PPid:/ { print $2 }' "/proc/$p/status" 2>/dev/null)
via="$via<$(awk '/^Name:/ { print $2 }' "/proc/$p/status" 2>/dev/null)"
done
echo "$(date -u +%FT%TZ) host=$(uname -n) user=$(id -un) pid=$$ via=$via" \
>> /var/log/app/send-digest-runs.logvia lists the run's ancestors, nearest first, from the PPid and Name fields of /proc/PID/status (proc_pid_status(5)). A cron run passes through cron or crond, a systemd timer's service sits directly under systemd, and a deploy tool or the script itself appears by name. Make the log writable by every account that might run the job. For a job that's a function inside an application, log the host name and process ID from the application.
Compare two lines from the same scheduled time:
| The two lines differ in | Likely cause |
|---|---|
host |
Every server or replica schedules it |
user |
Two crontabs, such as root's and a service account's |
via |
Two launchers: cron and a systemd timer, a deploy hook, or the script re-running |
Only pid, in the same second |
Two entries for the same user, or two cron daemons |
| Time, by one schedule interval, with the first run unfinished | A slow run overlapping the next |
| Time, by seconds or minutes, after a failure | A retry |
| Time, by exactly one hour in UTC, on the night clocks go back | Daylight saving |
Read Cron's Own Log#
Cron logs each job it starts (how to know if a cron job ran covers where the log lives), and the format differs by implementation:
# Debian and Ubuntu: job lines are tagged CRON
journalctl -t CRON --since yesterday | grep CMD
# Fedora, RHEL, and other cronie systems: tagged CROND
journalctl -t CROND --since yesterday | grep CMDOct 20 03:00:01 app-1 CRON[48211]: (root) CMD (/opt/app/send-digest.sh)
Oct 20 03:00:01 app-1 CRON[48212]: (deploy) CMD (/opt/app/send-digest.sh)Debian's cron and cronie write (user) CMD (command) just before starting the job's shell, with that shell's PID in brackets. The per-job process uppercases its program name, so the tag is CRON or CROND, not the daemon's lowercase name (Debian do_command.c, cronie do_command.c). BusyBox crond logs USER deploy pid 48211 cmd /opt/app/send-digest.sh at its default level, to syslog unless started with -d or -L (crond.c).
Two CMD lines for one time on one host mean cron started the job twice. One line on each of several hosts means several machines run it. A run with no CMD line wasn't started by cron.
Same Second: Two Entries, Two Daemons, or Two Hosts#
The Same Command in Two Places#
Search every place a schedule can live for the job's name. Run this as root:
grep -rn send-digest /var/spool/cron /etc/crontabs /etc/crontab /etc/cron.d \
/etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly \
/etc/periodic /etc/anacrontab /etc/systemd /usr/lib/systemd/system 2>/dev/nullThat covers user crontabs on cronie, Debian, and Alpine, the system crontabs, the periodic directories, anacron, and systemd units. If the job runs under another name, the crontab guide has a script that lists every schedule on a host. The usual finds:
- Root's crontab and a service account's. The log lines show two users.
- A user crontab and an
/etc/cron.dfile naming the same user. The same user appears twice in the same second. - A line appended on every deploy. A script that runs
(crontab -l; echo "0 3 * * * /opt/app/send-digest.sh") | crontab -adds another copy each time.sudo crontab -u deploy -l | grep -v -e '^#' -e '^$' | sort | uniq -dprints every line that appears more than once. - A renamed Ansible task. Ansible's cron module finds its entry by an
#Ansible: <name>comment, so "changing the name value will result in a new cron task being created" (cron module).
Give the job one owner. Deploy it as a whole /etc/cron.d file that each deploy replaces, or add the line only if it's absent. The crontab guide shows both.
Two Cron Daemons#
Debian's cron and cronie lock a crond.pid file at startup, and a second copy exits with an error such as can't lock /var/run/crond.pid, otherpid may be 612 (Debian misc.c, cronie misc.c). BusyBox crond 1.37.0 and 1.38.0 write their PID file without a lock (pidfile.c), so a second crond starts and runs every crontab again. In containers, that happens when an entrypoint starts crond and then the CMD or a supervisor starts another. Two different cron implementations in one image don't share a lock either.
This works with both procps and BusyBox's ps:
ps -eo pid,ppid,user,args | grep -i '[c]ron'More than one lowercase cron or crond process means two daemons. The uppercase CRON and CROND processes on Debian and cronie are per-job children of the daemon.
Every Server or Replica Runs It#
If host differs and each host's log has one CMD line, every machine has the schedule, usually from a crontab baked into the image or pushed to the whole fleet. Cron doesn't coordinate across servers (what cron won't do).
In-app schedulers repeat the problem. node-cron's docs describe a nightly backup that "runs four times instead of once" on four instances (distributed coordination). APScheduler says a job store shared between processes leads to "duplicate execution" (FAQ). Celery's worker -B embeds beat in each worker, which its docs call convenient only "if you'll never run more than one worker node" (periodic tasks). Each web-server worker process that starts the scheduler on import is another instance. For APScheduler under Gunicorn, Uvicorn, or a Flask or Django development reloader, see APScheduler running twice.
Schedule the job in exactly one place: one host, one beat process, or one Kubernetes CronJob. Otherwise, turn on coordination where the scheduler has it, such as node-cron's distributed option. In Go, gocron v2 has WithDistributedElector and WithDistributedLocker; Golang cron jobs covers both.
Minutes Apart: A Second Scheduler, a Retry, or an Overlap#
Cron and a systemd Timer#
A job moved to a systemd timer while its crontab line stays behind runs on both schedules. If one run's via shows systemd directly:
systemctl list-timers --all
systemctl cat send-digest.servicelist-timers shows each timer's last and next run and the service it activates (systemctl), and systemctl cat prints the service's ExecStart=. Keep one. Either run systemctl disable --now send-digest.timer or delete the crontab line.
A Periodic Directory, a Crontab Line, or a Copy of the Script#
A script in /etc/cron.daily plus a crontab line for the same job runs at two different times. Inside a periodic directory, run-parts runs each executable whose name passes its filter, one after another. Fedora's and BusyBox's run-parts accept dots in names, so an executable send-digest.bak left next to send-digest runs too, after it (Fedora run-parts, BusyBox run_parts.c). run-parts --test /etc/cron.daily lists what will run.
On Debian and Ubuntu, /etc/crontab runs the daily, weekly, and monthly directories only when test -x /usr/sbin/anacron fails (crontab.main). Anacron's /etc/cron.daily/0anacron runs anacron -u cron.daily, which updates anacron's timestamp whenever the directory runs (0anacron). If the test -x guard is removed, daily scripts can run twice in one day, because nothing stops cron from repeating a run that anacron already did. On cronie systems, cronie-noanacron's dailyjobs file skips the directories while /etc/cron.hourly/0anacron exists (dailyjobs).
A Retry Reruns the Whole Job#
A wrapper or scheduler that retries a failed run repeats everything the failed attempt had already done, such as the emails it sent or the rows it inserted. The run log shows the first attempt, then a second start seconds or minutes later. A Kubernetes Job retries up to backoffLimit times, 6 by default. With restartPolicy: OnFailure, the container reruns in the same pod; with Never, a new pod starts. The docs say the program must handle "temporary files, locks, incomplete output and the like caused by previous runs" (Job). Set backoffLimit: 0 for a job that isn't safe to repeat, or make it safe as described in the last section.
A Slow Run Overlaps the Next#
If the second start comes exactly one interval after the first and the first hasn't finished, nothing was duplicated: cron started the next run on time, and both copies are now working on the same data. Cronie and Debian's cron start a new copy at every matching minute. BusyBox crond skips a line whose previous run is still alive. flock -n skips a run while the previous one holds the lock. In Kubernetes, set concurrencyPolicy: Forbid instead of the default Allow. Preventing overlapping cron jobs covers the options in depth.
Only Some Nights: Daylight Saving and Kubernetes Catch-Up#
The Fall-Back Night#
When clocks go back, one local hour happens twice: 01:00 to 01:59 in London on October 25, 2026, and in New York on November 1. BusyBox crond, cronie entries under CRON_TZ, and Kubernetes CronJobs with .spec.timeZone run a fixed-time job in that hour in both passes, an hour apart in UTC. Cronie and Debian's cron run it once for entries in the system zone. Cron daylight saving time has the full table and the fixes. A scheduler running in UTC avoids the problem entirely.
Kubernetes CronJobs Run "Approximately Once"#
Kubernetes says a CronJob creates a Job "approximately once per execution time of its schedule," that "two Jobs might be created, or no Job might be created," and that Jobs should therefore be idempotent (job creation). It also documents two easier-to-spot causes:
- Catch-up. A run missed while the controller was down starts when it recovers, unless
startingDeadlineSecondshas passed. Unsuspending a CronJob without that field also starts the missed run immediately. The controller creates one Job, for the most recent missed time (utils.go), and the next run follows on schedule. - A replacement pod. By default, when a pod is deleted, for example during a node drain, the Job controller creates its replacement as soon as the old pod is marked for deletion, so both can run at once.
podReplacementPolicy: Failed, stable since Kubernetes 1.34, waits until the old pod has fully failed (pod replacement policy).
Each Job's name ends in its scheduled time in minutes since the epoch, and since Kubernetes 1.32 each Job also carries that time as an annotation:
kubectl get jobs -n reports -o custom-columns='JOB:.metadata.name,SCHEDULED:.metadata.annotations.batch\.kubernetes\.io/cronjob-scheduled-timestamp,CREATED:.metadata.creationTimestamp,FAILED:.status.failed'How to read the output:
- Two Jobs scheduled at the same local time with different UTC offsets are the fall-back night.
- A Job created well after its scheduled time is a catch-up.
- A Job with failed pods is a retry, and so is a pod with restarts in
kubectl get pods.
By default, a CronJob keeps only its last 3 successful Jobs and its last failed one, so check soon after the duplicate. Kubernetes CronJob monitoring covers the rest of the spec.
Only After Deploys or Restarts#
@reboot in containers. Debian's cron, cronie, and BusyBox crond run @reboot entries when the daemon starts and finds no marker file: /run/crond.reboot on Debian, cron.reboot beside cronie's PID file, crond.reboot in BusyBox's PID directory (Debian cron.c, cronie cron.c, BusyBox crond.c). On a server, /run "is flushed on boot" (UAPI file hierarchy), so that's once per boot. A new container starts without the marker, since a container's writable layer is deleted with it (Docker storage), and Kubernetes restarts a crashed container "with a clean state" (volumes). Each deploy that recreates the container, each replica, and each Kubernetes container restart runs the @reboot jobs again.
A deploy hook that runs the job to check it works, on top of cron's run. That run's via shows the deploy tool.
A rolling update of an in-process scheduler. A Deployment's default maxSurge of 25%, rounded up, starts new pods before old ones stop, so for a short window both schedule the job. strategy: Recreate stops every old pod first (Deployment strategy).
A script that re-runs itself. The common case switches user and forgets to stop:
#!/bin/sh
# Before: starts a copy of itself as app, then keeps running as root too
if [ "$(id -un)" != app ]; then
sudo -u app "$0" "$@"
fi#!/bin/sh
# After: exec replaces the root process, so only the app copy runs
if [ "$(id -un)" != app ]; then
exec sudo -u app "$0" "$@"
fiThe run log shows two lines a moment apart, user=app and user=root, and the script's own name appears in the app run's via.
Make the Job Safe to Run Twice#
Removing the second launcher fixes today's cause, but a fall-back night, a Kubernetes retry, or the next deploy can bring a duplicate back. The lasting fix is a job whose second run does nothing. For a once-a-day job, claim the date in a database the job already uses, before doing any work:
CREATE TABLE job_runs (
job text NOT NULL,
run_date date NOT NULL,
claimed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (job, run_date)
);
-- First statement of every run. Use the zone the schedule runs in.
INSERT INTO job_runs (job, run_date)
VALUES ('send-digest', (now() AT TIME ZONE 'UTC')::date)
ON CONFLICT DO NOTHING
RETURNING job;In PostgreSQL, RETURNING returns only rows that were actually inserted (INSERT). The job continues when it gets a row back and exits 0 when it gets none. The database enforces the primary key, so the claim holds across hosts, replicas, and daemons. If the run fails, delete its row so a retry can claim the date, or keep it if a failed run must never repeat automatically. On a single host, a date file under a lock does the same; the daylight saving guide shows one. Make the effects idempotent too: write with upserts keyed on what each row represents, and record each email sent under its recipient and date.
CronRadar doesn't flag a duplicate: a run that happens twice isn't one of its failure modes, which are missed or late, failed, stuck, disappeared, and bad outcome. It covers the opposite risk this fix creates. If you remove the entry that was actually working, or a crashed run keeps its claim, the job runs zero times and nothing reports an error. A check-in that carries the job's schedule turns that silence into an alert once the grace period passes. There is no CronRadar SDK for crontab, systemd timers, or Kubernetes CronJobs, so each job reports with its own HTTP check-in.
Frequently Asked Questions#
Why is my cron job running twice?#
Something starts it twice. The usual causes are the same command in two crontabs, cron plus a systemd timer, a second cron daemon, the same crontab on several servers, a retry, or a slow run overlapping the next. Compare the two runs' times, and log each run's host, user, and parent processes. The field that differs points to the cause.
Why does my cron job run twice at the same time?#
Two schedules fired together: two entries, such as root's crontab and a user's, two cron daemons on one host, or every server or replica running its own copy. Cron's log tells them apart. Two CMD lines on one host mean two entries or two daemons, and one line on each host means several hosts.
Can daylight saving time make cron run twice?#
Yes, on the night clocks go back, for a job scheduled in the repeated hour. BusyBox crond, cronie entries under CRON_TZ, and Kubernetes CronJobs with a time zone run it in both passes. Cronie and Debian's cron run a fixed-time job in the system zone once. Running the scheduler in UTC avoids it.
Why does my Kubernetes CronJob run twice?#
Kubernetes only promises a Job approximately once per scheduled time. A second run can come from a retry (backoffLimit defaults to 6), a catch-up after controller downtime or unsuspending, a replacement pod started while the old one terminates, or the fall-back night when .spec.timeZone is set. List the Jobs with their scheduled-time annotation to tell these apart, and make the job idempotent.
How do I stop a cron job from running twice?#
Find and remove the second launcher first, using cron's log and a per-run record of host, user, and parent process. Then make the job safe to repeat: claim the run's date in a table with a unique key, and exit when the claim already exists. If runs can overlap, add flock -n or concurrencyPolicy: Forbid.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.