← Blog  ·  October 2026

Django Cron Job Monitoring: Alerts When Celery Beat or Management Commands Stop Running

Your weekly report email stopped going out three weeks ago. Celery Beat is running — the process is healthy, no exceptions in Sentry. You discovered it when a client asked why the data was stale.

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.


Failure modes by scheduler

Celery Beat: the broker disappears quietly

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: process restarts reset the clock

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

Management commands via system cron: the crontab entry disappears

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)

Adding heartbeat monitoring in one line

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:

Celery Beat task

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
    )

APScheduler job

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 command

# 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
        )
Placement matters Put the heartbeat call at the end of the task, after all meaningful work is done. If you put it at the top, you'll get a ping even when the task silently fails halfway through.

Handling the ping failure gracefully

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

Celery Beat — catching task failures too

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.


Celery Beat with database scheduler

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)}")

Kubernetes CronJob — add a post-execution ping

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.


Comparison of Django scheduling options

ApproachGood forSilent failure riskHeartbeat pattern
Celery BeatDistributed tasks, retries, rate limitsBroker outage, worker crashEnd of task function
APSchedulerSimple in-process schedulingProcess restart resets scheduleEnd of job function
Management command + crontabSimple, no dependenciesExit 0 despite logic failureEnd of handle()
K8s CronJobContainerised, isolated runsPod scheduling failuresPostfix && wget

🕐 Know the moment a Django job stops running

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

Summary