Python has no shortage of scheduling libraries. APScheduler is feature-rich and integrates with Flask and FastAPI. Celery Beat handles distributed task scheduling for high-volume apps. The schedule library is so simple you can be running jobs in five minutes. And for simple scripts, crontab plus a Python executable remains perfectly reasonable.
The problem they all share: when a job stops running, nothing tells you. No alert, no dashboard notification, no email. The job just quietly stops producing output, and you find out from a user reporting stale data or a missed deadline.
This article covers the specific failure modes for each scheduler and how to add a dead man's switch — the simplest reliable approach to monitoring any background job.
The root cause differs by scheduler, but the symptom is the same: silence.
APScheduler catches exceptions inside jobs and logs them — it doesn't re-raise them or send an alert. If your job throws an exception, the scheduler logs a warning and moves on to the next scheduled run. If nobody is watching the logs, nobody knows.
# This fails silently in APScheduler by default
from apscheduler.schedulers.blocking import BlockingScheduler
scheduler = BlockingScheduler()
@scheduler.scheduled_job('cron', hour=3)
def nightly_sync():
result = fetch_from_api() # raises ConnectionError at 3am
process(result) # never runs
# exception is logged; next run is at 3am tomorrow
# you find out from a user two days later
scheduler.start()
APScheduler has an event system you can use to catch failures, but it's not configured by default. The job either needs to handle its own exceptions or you need to add a job error listener at the scheduler level.
Celery Beat is the scheduler component of Celery. If the Beat process dies — OOMed, killed by the OS, crashed on startup — no tasks get enqueued. Your workers are running, waiting for tasks that will never arrive. There's no default alert.
This is distinct from task failures (which Celery logs and can retry). Beat dying means the entire scheduling loop stops. It's the higher-level failure mode and the harder one to detect, because all the other moving parts (workers, broker) appear healthy.
For a detailed breakdown of Celery Beat monitoring, see Celery Beat monitoring: keeping your scheduler healthy.
schedule library: no persistence, no recoveryThe schedule library (pip: schedule) is the simplest Python scheduler. You define jobs with a readable API and run them in a loop. The problem is that it has no persistence, no error handling, and no retry logic.
import schedule
import time
def my_job():
fetch_data() # if this raises, the loop continues but the job didn't complete
schedule.every().day.at("03:00").do(my_job)
while True:
schedule.run_pending()
time.sleep(60)
If my_job() raises an unhandled exception, the schedule library will catch it, log it (if you have logging configured), and keep running. The job appears to have run — it was called — but it didn't actually do anything. Nothing surfaces this to you unless you're watching logs.
If the process itself dies — which happens to long-running Python scripts more often than you'd expect — the scheduler stops entirely. No backfill, no recovery, no alert.
Running python /opt/jobs/sync.py from crontab is reliable for simple cases. But cron doesn't care about the exit code in a meaningful way — it emails output to the local mail spool, which nobody reads on production servers. If the Python script crashes, exits non-zero, or throws a traceback, you get silence.
The most reliable pattern for monitoring any scheduled job — regardless of the scheduler — is a dead man's switch. Your job sends an HTTP ping at the end of a successful run. A monitoring service waits for that ping on the expected schedule. If the ping doesn't arrive within the grace period, you get an alert.
This catches three failure modes at once:
One function call at the end of each job:
import requests
CRONWATCH_URL = "https://cronwatch.io/ping/YOUR-TOKEN"
def ping_success():
"""Call at the end of every job that completes successfully."""
try:
requests.get(CRONWATCH_URL, timeout=5)
except Exception:
pass # Don't let monitoring failure break the job
The cleanest APScheduler approach is a job event listener that pings on success and skips on failure (failures get handled by your existing alerting):
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
import requests
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
PING_URLS = {
'nightly_sync': 'https://cronwatch.io/ping/TOKEN-1',
'weekly_report': 'https://cronwatch.io/ping/TOKEN-2',
}
def job_listener(event):
job_id = event.job_id
if event.exception:
logger.error(f"Job {job_id} failed: {event.exception}", exc_info=event.traceback)
# optionally: send Slack alert, fire PagerDuty, etc.
else:
# Job completed — ping the monitor
url = PING_URLS.get(job_id)
if url:
try:
requests.get(url, timeout=5)
except Exception:
pass
scheduler = BlockingScheduler()
scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
@scheduler.scheduled_job('cron', id='nightly_sync', hour=3)
def nightly_sync():
fetch_and_sync()
@scheduler.scheduled_job('cron', id='weekly_report', day_of_week='mon', hour=9)
def weekly_report():
generate_report()
scheduler.start()
The listener fires after every job execution, success or failure. On success, it pings the monitor. On failure, it logs the exception and you can add alerting there too.
For Celery, add a dedicated heartbeat task that Beat schedules every few minutes. If Beat dies, the heartbeat task stops being enqueued, and your monitor alerts you:
# tasks.py
from celery import Celery
import requests
app = Celery('myapp', broker='redis://localhost:6379/0')
HEARTBEAT_URL = 'https://cronwatch.io/ping/YOUR-BEAT-TOKEN'
@app.task
def beat_heartbeat():
"""Ping to prove Beat is alive and enqueuing tasks."""
try:
requests.get(HEARTBEAT_URL, timeout=5)
except Exception:
pass
# celery_beat_schedule in your Django settings or celery config:
app.conf.beat_schedule = {
'beat-heartbeat': {
'task': 'myapp.tasks.beat_heartbeat',
'schedule': 60.0, # every 60 seconds
},
# ... your other tasks
}
# Your existing tasks: wrap with ping at the end
@app.task
def nightly_sync():
do_sync()
# Only ping after successful completion
try:
requests.get('https://cronwatch.io/ping/YOUR-SYNC-TOKEN', timeout=5)
except Exception:
pass
Set your monitor's expected interval to 2 minutes. If Beat dies and the heartbeat task stops appearing, you'll get an alert within 2 minutes.
schedule library: wrap each jobimport schedule
import time
import requests
import logging
logger = logging.getLogger(__name__)
def monitored(job_func, ping_url):
"""Wrapper that pings the monitor only on successful completion."""
def wrapper():
try:
job_func()
# Only ping if the job succeeded
requests.get(ping_url, timeout=5)
except Exception as e:
logger.error(f"{job_func.__name__} failed: {e}", exc_info=True)
# Don't ping — the monitor will alert you when ping is late
return wrapper
def fetch_prices():
# your actual job logic
data = api.get_prices()
db.update(data)
schedule.every().hour.do(
monitored(fetch_prices, 'https://cronwatch.io/ping/YOUR-TOKEN')
)
while True:
schedule.run_pending()
time.sleep(30)
# crontab entry
0 3 * * * python /opt/jobs/sync.py && curl -fsS https://cronwatch.io/ping/YOUR-TOKEN
The && ensures the curl only fires if the Python script exits 0. If it crashes or exits with an error code, no ping — and your monitor alerts you.
In the Python script itself, make sure it exits non-zero on failure:
import sys
def main():
try:
run_sync()
except Exception as e:
print(f"Sync failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
# implicit sys.exit(0) on normal completion
Different schedulers need different monitoring priorities:
| Scheduler | Primary risk | Monitoring pattern |
|---|---|---|
APScheduler | Exceptions swallowed | Job event listener → ping on EVENT_JOB_EXECUTED |
Celery Beat | Beat process dies | Heartbeat task every 60s + per-task ping |
schedule lib | Exception stops job silently | Wrap each job function → ping on success |
crontab + Python | Exit code ignored | && curl https://cronwatch.io/ping/TOKEN after script |
CronWatch is a dead man's switch monitor for scheduled jobs. Add one requests.get() call to your job; if the ping stops arriving, you get an email alert. Works with APScheduler, Celery Beat, the schedule library, crontab, and any other scheduler.