Monitoring Celery beat periodic tasks

Updated November 3, 2026

Celery beat is how most Django and Python projects run periodic tasks: nightly reports, cleanups, syncs with external APIs. It is also easy to lose without noticing. Beat is a single process that only sends tasks to the broker; workers run them. If beat stops, nothing fails and nothing is logged as an error. The tasks simply stop being sent.

What goes wrong

A small ping helper

Put this decorator next to your tasks. It reports the start, the success or the error of every run. A failing ping never breaks the task:

# monitoring.py
import functools
import urllib.request

from celery.exceptions import Retry


def _ping(url, body=None):
    try:
        data = body.encode()[:100_000] if body else None
        urllib.request.urlopen(url, data=data, timeout=10)
    except Exception:
        pass  # monitoring must never break the task


def heartcron(url):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            _ping(url + "/start")
            try:
                result = fn(*args, **kwargs)
            except Retry:
                raise  # a retry isn't a failure yet
            except Exception as e:
                _ping(url + "/fail", f"{type(e).__name__}: {e}")
                raise
            _ping(url)
            return result
        return wrapper
    return decorator

Wrap the tasks

Put @app.task on the outside, so Celery registers the wrapped function:

# tasks.py
from .celery import app
from .monitoring import heartcron


@app.task
@heartcron("https://ping.heartcron.com/<uuid>")
def nightly_report():
    build_and_send_report()

And the schedule, with the time zone set explicitly:

# celery.py
from celery.schedules import crontab

app.conf.timezone = "America/New_York"
app.conf.beat_schedule = {
    "nightly-report": {
        "task": "app.tasks.nightly_report",
        "schedule": crontab(hour=3, minute=0),
    },
}

In Heartcron, create a check with the cron expression 0 3 * * *, the same time zone, and a grace period a little longer than the task usually takes. Not sure how a Celery crontab() maps to cron? Its arguments go in the order minute, hour, day of week, day of month, month of year, while the cron fields are minute, hour, day of month, month, day of week. The cron expression explainer helps check the result.

What each outcome looks like

SituationWhat Heartcron sees
The task runs and succeeds/start, then success with the duration
The task raises an exception/fail with the error text, alert right away
The task hangs/start without success, alert after the grace period
Beat, the broker or all workers are downNothing arrives, alert after the grace period
Two beats send the task twiceTwo runs in the event log where you expect one

Watch beat itself

A task that runs once a day tells you beat is gone only a day later. Add a tiny task every five minutes whose only job is to ping. It proves that beat, the broker and at least one worker all work:

@app.task
@heartcron("https://ping.heartcron.com/<uuid>")
def heartbeat():
    pass

app.conf.beat_schedule["heartbeat"] = {
    "task": "app.tasks.heartbeat",
    "schedule": 300.0,
}

Give its check a 5-minute period and a grace period of 5–10 minutes.

Many tasks without many URLs

A readable URL from your project’s ping key and a short name saves you from copying a UUID per task. Give each check a short name, such as sync-stripe, in the dashboard or with heartcron checks create:

@heartcron("https://ping.heartcron.com/<ping-key>/sync-stripe")

Plain cron jobs in the same project, such as Django management commands, can be wrapped without code changes: heartcron run cleanup -- python manage.py clearsessions with the open-source heartcron CLI.

More guides