Django doesn't have a native cron scheduler. Most teams use one of three approaches: Celery Beat for distributed task scheduling, APScheduler for lightweight in-process scheduling, or Django management commands triggered by system cron. All three share the same blind spot: when a task silently stops running, nothing tells you.
This guide covers the specific failure modes for each approach and how to add a single line of heartbeat monitoring so you get alerted before your users notice.
Celery Beat's most common silent failure isn't a task exception — it's a broken broker connection. Beat schedules tasks by pushing messages onto a Redis or RabbitMQ queue. If the broker goes down and comes back, Beat may reconnect but the task workers never drain the backlog. Or Beat itself crashes, the systemd unit restarts it, but the Redis celerybeat-schedule lock file is stale — Beat starts but skips all pending tasks until the next tick.
Task exceptions are even more invisible. A Celery worker catches all task exceptions, marks the task as FAILED in the result backend (if configured), and moves on. If you don't have a result backend, or nobody queries it, failures vanish.
# tasks.py — this failure is completely silent without error monitoring
@app.task
def send_weekly_report():
users = User.objects.filter(weekly_report=True)
for user in users:
data = fetch_analytics(user) # throws ConnectionError on third-party API outage
send_email(user.email, data) # never reached for this user
# Celery marks the task FAILED; no alert fires unless you explicitly check
APScheduler's BackgroundScheduler stores job state in memory by default. A Django process restart — gunicorn worker recycle, Heroku dyno restart, Kubernetes pod eviction — silently resets all job next-run times to "now" or "at startup + interval". This means jobs that should run at 3am might run at deploy time, or skip the 3am window entirely if the process was down during it.
# settings.py / apps.py — the scheduler reinitializes on every process start
scheduler = BackgroundScheduler()
scheduler.add_job(
cleanup_old_sessions,
'cron',
hour=3, minute=0,
id='nightly_cleanup'
)
scheduler.start()
# If gunicorn spawns 4 workers, this job runs 4x per cycle
# If the dyno was restarted at 3:05am, the 3am job was silently skipped
When you run Django management commands from a system crontab or Kubernetes CronJob, you get an entirely different failure category: the scheduler runs the command, the command exits 0, but the underlying logic did nothing because of a Django misconfiguration (wrong DJANGO_SETTINGS_MODULE, missing DATABASE_URL, silent ORM error). The system cron sees a zero exit code and marks it successful.
# crontab — runs every night at 2am
0 2 * * * /app/venv/bin/python manage.py send_digests --settings=myapp.settings.production
# manage.py send_digests exits 0 even if:
# - DATABASE_URL points to a read replica that went down
# - SENDGRID_API_KEY is unset (emails silently skipped)
# - ORM query returns 0 rows due to a bad filter (no-op, no error)
The most reliable way to monitor any scheduled task is the dead man's switch pattern: the task pings a URL on every successful completion. If the URL doesn't receive a ping within the expected window, you get an alert.
This works for all three Django scheduler types with minimal changes:
import requests
@app.task
def send_weekly_report():
# ... your existing task logic ...
users = User.objects.filter(weekly_report=True)
for user in users:
data = fetch_analytics(user)
send_email(user.email, data)
# Heartbeat — only fires on successful completion
requests.get(
"https://cronwatch.lior-seats.io/ping/YOUR_HEARTBEAT_TOKEN",
timeout=5
)
import urllib.request
def cleanup_old_sessions():
# ... your existing logic ...
Session.objects.filter(expire_date__lt=timezone.now()).delete()
# Heartbeat
urllib.request.urlopen(
"https://cronwatch.lior-seats.io/ping/YOUR_HEARTBEAT_TOKEN",
timeout=5
)
scheduler.add_job(cleanup_old_sessions, 'cron', hour=3, minute=0)
# management/commands/send_digests.py
import urllib.request
from django.core.management.base import BaseCommand
class Command(BaseCommand):
def handle(self, *args, **kwargs):
# ... your existing logic ...
sent = digest_service.send_all()
self.stdout.write(f"Sent {sent} digests")
# Heartbeat — only fires after successful handle() completion
urllib.request.urlopen(
"https://cronwatch.lior-seats.io/ping/YOUR_HEARTBEAT_TOKEN",
timeout=5
)
Your monitoring ping should never cause a task failure. Wrap it to be completely silent if the monitoring service is unreachable:
def heartbeat(token: str) -> None:
"""Fire-and-forget heartbeat ping. Never raises."""
try:
import urllib.request
urllib.request.urlopen(
f"https://cronwatch.lior-seats.io/ping/{token}",
timeout=5
)
except Exception:
pass # monitoring outage never breaks the task
# In your task:
heartbeat("YOUR_HEARTBEAT_TOKEN")
Or use requests with a similar pattern:
import requests
def heartbeat(token: str) -> None:
try:
requests.get(
f"https://cronwatch.lior-seats.io/ping/{token}",
timeout=5
)
except requests.RequestException:
pass
The heartbeat pattern tells you when a task doesn't run at all. But Celery Beat tasks can also fail halfway through. Use Celery's failure signals to send an alert:
from celery import signals
@signals.task_failure.connect
def on_task_failure(sender, task_id, exception, traceback, **kwargs):
# Send to Sentry, Slack, email — whatever your team uses
logger.error(
f"Celery task {sender.name} failed: {exception}",
exc_info=(type(exception), exception, traceback)
)
# Optionally: skip the heartbeat ping so CronWatch also alerts
You can also use @app.task(bind=True) with a custom on_failure method on specific tasks that need individual alerting.
If you use django-celery-beat to manage schedules in the database, add a health check query to your monitoring stack — the Beat process and the database both need to be healthy for tasks to fire:
# In your health check endpoint or a separate heartbeat task:
from django_celery_beat.models import PeriodicTask
def check_beat_health():
"""Verify celery-beat's last run times are recent."""
stale = PeriodicTask.objects.filter(
enabled=True,
last_run_at__lt=timezone.now() - timedelta(hours=2)
).values_list('name', flat=True)
if stale:
alert_team(f"Celery Beat tasks not running: {list(stale)}")
For management commands running as Kubernetes CronJobs, you can add the heartbeat ping as a post-exec step without modifying the Django code at all:
apiVersion: batch/v1
kind: CronJob
metadata:
name: send-digests
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: job
image: myapp:latest
command: ["/bin/sh", "-c"]
args:
- |
python manage.py send_digests && \
wget -qO- https://cronwatch.lior-seats.io/ping/YOUR_TOKEN
restartPolicy: Never
The && means the heartbeat ping only fires if manage.py send_digests exits 0. A non-zero exit skips the ping, CronWatch sees a missed heartbeat, and you get an alert.
| Approach | Good for | Silent failure risk | Heartbeat pattern |
|---|---|---|---|
| Celery Beat | Distributed tasks, retries, rate limits | Broker outage, worker crash | End of task function |
| APScheduler | Simple in-process scheduling | Process restart resets schedule | End of job function |
| Management command + crontab | Simple, no dependencies | Exit 0 despite logic failure | End of handle() |
| K8s CronJob | Containerised, isolated runs | Pod scheduling failures | Postfix && wget |
CronWatch monitors your scheduled tasks with a dead man's switch. Add one line to your Celery task or management command — get an alert if it doesn't fire on schedule.
Works with Celery Beat, APScheduler, management commands, and Kubernetes CronJobs. No infrastructure to run.
Join the CronWatch waitlist Free cron expression parser