Cron matches calendar fields and launches commands at matching times. It does not inherently wait for an earlier invocation of the same command to finish. If a minute-level task takes seventy seconds, the next minute can launch another copy. Treat trigger policy, concurrency policy, and successful completion as separate responsibilities when operating recurring work.
Read fields as calendar selections
A conventional user crontab entry contains minute, hour, day of month, month, day of week, then the command. Asterisks select all values in their fields. A step such as */15 in minutes selects minute values within each hour; it is not a timer measuring fifteen minutes since the previous completion. A step that does not divide the field range evenly can produce unequal gaps across boundaries.
Day matching has a special rule in the Debian/Vixie format: when day of month and day of week are both restricted, either matching day can permit execution, while the minute, hour, and month constraints still apply. This is not a disjunction between month and weekday. Write representative dates down and verify the intended calendar before installing a terse expression.
Timezone, daylight-saving changes, and downtime affect calendar scheduling. Implementations differ in how they handle skipped or repeated times. Debian cron documents special handling for certain small clock changes and fixed-time jobs, so rules copied from another scheduler may be wrong here. This experiment does not change the clock or test those transition cases. A calendar trigger is also not a durable queue that automatically guarantees every missed run will be replayed.
When it breaks
Overlapping jobs can duplicate side effects, compete for resources, or overwrite shared files. If one invocation must run at a time, implement that policy explicitly. A nonblocking advisory lock can let a later invocation record a skip; a blocking lock can instead leave waiting processes. Neither choice provides exactly-once business effects or repairs a partially completed operation.
A local file lock only coordinates participants using the same relevant locking facility. It does not automatically serialize jobs on unrelated hosts, and network filesystem semantics require separate examination. Keep the lock held for the work’s lifetime, and make skipped work visible. Silently dropping every later trigger while one worker hangs can look like a healthy schedule unless completion age is monitored.
Cron is not universally log-free. Debian logs job starts to syslog by default and offers additional logging controls. Command output may be mailed when a working mail path exists, but a minimal container often lacks that path. Record useful start, completion, failure, and skip outcomes through an intentional destination. A start message proves dispatch, not completion of the application’s task.
Check it quickly
Use a fresh disposable Debian 12 Docker container as root, with no existing root crontab or cron daemon. Install python3, cron, and procps using apt, save the following as cron-overlap.py, and run python3 cron-overlap.py. Allow roughly two minutes: the script waits for actual calendar ticks rather than replacing cron with a simulated scheduler.
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import subprocess
import time
assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container'
assert subprocess.run(['pgrep', '-x', 'cron'], capture_output=True).returncode == 1
assert subprocess.run(['crontab', '-l'], capture_output=True).returncode != 0
with TemporaryDirectory(prefix='cron-overlap-') as directory:
root = Path(directory)
events_path = root / 'events.jsonl'
stop = root / 'stop'
worker = root / 'worker.py'
worker.write_text('''from pathlib import Path
import fcntl,json,os,sys,time
root=Path(__file__).parent
kind=sys.argv[1]
def record(event):
data=json.dumps({'event':event,'kind':kind,'pid':os.getpid(),'monotonic':time.monotonic()})+'\\n'
fd=os.open(root/'events.jsonl',os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600)
try: os.write(fd,data.encode())
finally: os.close(fd)
lock=None
if kind=='locked':
lock=open(root/'job.lock','a')
try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:
record('skip')
raise SystemExit(0)
record('start')
try:
until=time.monotonic()+70
while time.monotonic()<until and not (root/'stop').exists(): time.sleep(0.1)
finally:
record('end')
if lock: lock.close()
''')
table = ''.join(f'* * * * * /usr/bin/python3 {worker} {kind} >>{root}/worker.log 2>&1\n'
for kind in ('plain', 'locked'))
subprocess.run(['crontab', '-'], input=table, text=True, check=True)
daemon = subprocess.Popen(['cron', '-f'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def read_events():
if not events_path.exists():
return []
return [json.loads(line) for line in events_path.read_text().splitlines()]
try:
deadline = time.monotonic() + 140
while time.monotonic() < deadline:
assert daemon.poll() is None, 'cron exited'
events = read_events()
plain = [e for e in events if (e['kind'], e['event']) == ('plain', 'start')]
locked = [e for e in events if (e['kind'], e['event']) == ('locked', 'start')]
skipped = [e for e in events if (e['kind'], e['event']) == ('locked', 'skip')]
if len(plain) >= 2 and skipped:
break
time.sleep(0.2)
else:
raise AssertionError('two real cron ticks were not observed')
assert len(plain) == 2 and len(locked) == 1 and len(skipped) == 1
assert plain[1]['monotonic'] - plain[0]['monotonic'] < 70
assert not any(e['event'] == 'end' and e['pid'] == plain[0]['pid'] for e in events)
print(json.dumps({'actual_cron_starts': len(plain), 'overlap_observed': True,
'seconds_between_starts': round(plain[1]['monotonic']-plain[0]['monotonic'], 3),
'locked_starts': len(locked), 'locked_skips': len(skipped)}), flush=True)
finally:
daemon.terminate()
stop.touch()
daemon.communicate(timeout=5)
subprocess.run(['crontab', '-r'], check=True)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
events = read_events()
started = {e['pid'] for e in events if e['event'] == 'start'}
ended = {e['pid'] for e in events if e['event'] == 'end'}
if started <= ended:
break
time.sleep(0.1)
else:
raise AssertionError('test workers did not finish')
Two every-minute entries run workers that would last seventy seconds. The plain worker has no lock. The locked worker uses a shared file and a nonblocking exclusive flock, recording a skip when acquisition fails. Each event includes the worker PID and a monotonic timestamp, so distinct invocations and their overlap can be checked directly.
On Debian cron 3.0pl1-162, the plain worker started twice, 60.003 seconds apart, before its first invocation ended. The guarded worker started once and recorded one skip. All assertions passed. Cleanup stopped cron, signaled the test workers through their stop file, waited for their end records, and removed the temporary crontab. The experiment verifies local overlap and explicit exclusion; it does not implement durable retries, distributed locking, recovery after reboot, or exactly-once effects. Select those policies from the job’s actual business contract instead of assuming the calendar expression supplies them.
Sources
- Debian 12: crontab(5), calendar fields, steps and the two day fields, checked 2026-09-13.
- Debian 12: cron(8), daemon execution, logging and clock-change behavior, checked 2026-09-13.
- Linux: flock(2), advisory locks and nonblocking acquisition, checked 2026-09-13.