Celery Beat Not Running Tasks: A Troubleshooting Guide
Contents
A Celery periodic task runs only when three things hold: a separate celery beat process is running, a worker consumes the queue the task is routed to, and that worker has the task registered under the name beat sends. When one is missing, nothing fails and nothing runs. Start beat with --loglevel=info and look for Scheduler: Sending due task: if the line is missing, the problem is beat or the schedule it loaded; if it's there, the problem is between the broker and a worker. A task that runs twice almost always has two beats sending it, or a broker delivering it again.
The causes below are in the order worth checking, each with a way to confirm it and a fix, checked against the source of Celery 5.6.3, django-celery-beat 2.9.0, and celery-redbeat 2.4.2, the current stable releases on October 25, 2026. For Flower, Prometheus, and worker health, see Celery monitoring.
Find Where the Run Stops#
Run the first command only where beat isn't already running, or stop the beat service first: a second beat sends every due task again.
# 1. What did beat load, and does it send? Look for "Sending due task".
celery -A proj beat --loglevel=debug
# 2. Which workers are online, and which queues does each consume?
celery -A proj status
celery -A proj inspect active_queues
# 3. Does each worker know the task by the name beat sends?
celery -A proj inspect registered
# 4. Are messages piling up? (Redis broker, database 0, default queue "celery")
redis-cli -n 0 llen celeryA healthy beat logs this, abridged; the messages come from beat.py:
[2026-10-25 05:59:58,409: DEBUG/MainProcess] Current schedule:
<ScheduleEntry: send-invoices billing.tasks.send_invoices() <crontab: 0 6 * * * (m/h/dM/MY/d)>
[2026-10-25 06:00:00,004: INFO/MainProcess] Scheduler: Sending due task send-invoices (billing.tasks.send_invoices)
[2026-10-25 06:00:00,011: DEBUG/MainProcess] billing.tasks.send_invoices sent. id->1f0e8c62-5c1e-4c47-9a3b-2d1f6e0b7a19
[2026-10-25 06:00:00,012: DEBUG/MainProcess] beat: Waking up in 5.00 minutes.| What you see | Most likely cause |
|---|---|
| No beat process, or a banner and nothing else | Beat isn't running, or logs at WARNING |
Sending due task, but the task never starts |
No worker reads its queue, or none knows the task |
Received unregistered task in a worker log |
The task module isn't imported, or the name differs |
Current schedule: lacks the task |
Wrong setting name, or the task is disabled |
| Runs at the wrong hour or day | timezone, or crontab() argument order |
| Runs twice | Two beats, a killed beat, or broker redelivery |
Beat Isn't Running, or Is Running Quietly#
Beat is its own process, started with celery -A proj beat (starting the scheduler). A deployment that defines only celery -A proj worker has workers and no schedule, and so does one where beat crashed at startup and nothing restarted it.
celery -A proj worker -B embeds beat in that worker. Celery's docs call it convenient "if you'll never run more than one worker node" and say it "isn't recommended for production use." Every worker started with -B runs its own beat, so a second worker sends every task twice, and -B refuses to start on Windows (worker.py).
A running beat can also look idle. Its --loglevel defaults to WARNING (bin/beat.py) and Sending due task is an INFO message, so a healthy beat prints its banner and nothing more; run it with --loglevel=info. At debug level, beat: Waking up in 5.00 minutes. is normal: the default scheduler sleeps at most 300 seconds and wakes sooner when an entry is due (beat_max_loop_interval, beat.py).
Fix: run exactly one beat as its own service under something that restarts it, such as a systemd unit, a Compose service with a restart policy, or a one-replica Kubernetes Deployment with the rollout setting shown under running twice.
Beat Sends the Task, but No Worker Reads Its Queue#
Beat publishes and moves on. It routes each task from task_routes or the entry's "options": {"queue": ...}, and with task_create_missing_queues (on by default) a routed queue is created on first use (automatic routing). A worker, though, consumes only the queues in task_queues, or the default queue celery when that's unset, unless -Q names others (worker queues). Route a task to reports, start workers without -Q reports,celery, and it waits in a queue nobody reads.
Confirm by comparing the task's queue with what inspect active_queues lists, then check the queue's length with redis-cli llen reports or rabbitmqctl list_queues name messages consumers (inspecting queues). On SQS, inspect doesn't work, because "SQS doesn't yet support worker remote control commands" (SQS caveats); check the message count in AWS. Fix: add the queue to a worker's -Q, or remove the route. The waiting messages all run once a consumer appears, unless the entry sets expires.
The Worker Doesn't Know the Task#
Beat sends a task by name and doesn't need to import it; the worker does. When a worker receives a name it hasn't registered, it logs this (consumer.py), rejects the message, and marks the task failed with NotRegistered (consumer.py), so nothing piles up:
Received unregistered task of type 'billing.tasks.send_invoices'.
The message has been ignored and discarded.
Did you remember to import the module containing this task?
Or maybe you're using relative imports?Two things usually cause it:
- The module is never imported in the worker. In Django,
app.autodiscover_tasks()imports each installed app'stasks.py, andproj/__init__.pymust import the Celery app so@shared_taskbinds to it (Django first steps). Elsewhere, list task modules inincludeorimports. - The name differs. A task's default name is its module plus the function name (task names), so moving the function, or importing the module under a different path, changes it. The entry's
taskstring must match exactly.
Confirm with inspect registered. Fix the import, or set name= on the task decorator and use the same string in the schedule.
The Schedule Beat Loaded Isn't the One You Wrote#
The Current schedule: block shows what beat loaded. Without starting beat, python -c "from proj.celery import app; print(sorted(app.conf.beat_schedule))" prints the entry names.
The Setting Has the Wrong Name#
With app.config_from_object("django.conf:settings", namespace="CELERY"), Celery reads uppercase names with a CELERY_ prefix: CELERY_BEAT_SCHEDULE, CELERY_TIMEZONE (Django first steps). Without a namespace, it's lowercase beat_schedule, or the pre-4.0 CELERYBEAT_SCHEDULE (defaults.py). Any other spelling, such as BEAT_SCHEDULE in a namespaced Django project, is ignored without an error, and beat starts with an empty schedule.
Crontab Arguments Aren't in Cron Order#
Celery's signature is crontab(minute, hour, day_of_week, day_of_month, month_of_year) (schedules.py), so crontab(0, 6, 1) is 06:00 every Monday, not 06:00 on the 1st. Pass keywords, or use crontab.from_string("0 6 1 * *"), which reads standard cron order (Celery 5.5 and later). The debug listing prints crontabs in standard order, so crontab(0, 6, 1) shows as <crontab: 0 6 * * 1 (m/h/dM/MY/d)>. Two more differences from Linux cron:
- Sunday is 0 only. Day of week takes 0 to 6, and 7 raises
ValueErrorwhen the schedule is built (schedules.py); cronie's crontab(5) accepts 0 or 7. - Both day fields must match. When
day_of_weekandday_of_monthare both restricted, a date must satisfy both (schedules.py), where Linux cron runs on either.crontab(minute=0, hour=9, day_of_week=1, day_of_month="1-7")is the first Monday of the month.
The Task Runs at the Wrong Hour: Time Zones#
A crontab follows the wall clock of the timezone setting (timezone). When it's unset, Celery uses UTC, or the host's local zone if enable_utc is False (base.py); in Django, an unset CELERY_TIMEZONE falls back to Django's TIME_ZONE (utils.py). So crontab(minute=0, hour=6) in a project with TIME_ZONE = "Europe/Berlin" runs at 04:00 or 05:00 UTC, depending on daylight saving time.
When timezone or enable_utc changes from the value recorded in the schedule file, the default scheduler clears its stored run times, logging Reset: Timezone changed from 'UTC' to 'Europe/Berlin' (beat.py). Under a zone with daylight saving, a task's UTC time moves twice a year, and Celery's docs don't say what a crontab does with a local time that's skipped or repeated. Keep timezone = "UTC" unless the rule really is local; cron timezone covers the trade-off.
The Schedule File Is Unwritable, Not Kept, or Reset#
The default PersistentScheduler stores each entry's last run time in celerybeat-schedule, in beat's working directory, possibly with a suffix such as .db from the dbm backend (beat_schedule_filename). Move it with celery -A proj beat -s /var/lib/celery/celerybeat-schedule.
- Beat can't write it. On a read-only container filesystem, beat logs
Removing corrupted schedule filewith the underlying error, though nothing is corrupt, tries once more, and exits withbeat raised exception(beat.py, apps/beat.py). Point-sat a writable volume. - It isn't kept. A new entry's last run is the moment beat starts (beat.py), and an interval schedule first fires one full interval later (entries). So a
timedelta(days=1)entry in a container without a volume, redeployed more than once a day, never runs. A crontab entry runs at its next matching time; use crontab, or keep the file on a volume. - It's reset. Corruption or a
timezonechange gives every entry a fresh start; missed runs after downtime covers what that does to catch-up.
django-celery-beat: Wrong Scheduler, Disabled Tasks, Unseen Edits#
Tasks created in the Django admin run only when beat uses the database scheduler: celery -A proj beat -S django, or CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler". If beat's banner shows . scheduler -> celery.beat.PersistentScheduler, the admin's tasks are ignored.
With the right scheduler, django-celery-beat 2.9.0 still skips a task (schedulers.py) when:
enabledis off,start_timeis in the future, orone_offis set and it has run once (beat then disables it).expireshas passed. Beat disables the task, so movingexpireslater doesn't revive it; re-enable it too.- Its crontab uses 7 for Sunday. The admin's help text says "Sunday is 0 or 7" (models.py) and its python-crontab validator maps a lone 7 to Sunday (crontab.py), but Celery rejects it with
Invalid end range: 7 > 6.(schedules.py), and the database scheduler drops any task whose schedule raisesValueError, without a log line (schedulers.py). Use 0.
Each crontab row has its own timezone, defaulting to CELERY_TIMEZONE or else UTC, not Django's TIME_ZONE (models.py). So in a project that sets only TIME_ZONE, an admin-made crontab runs on UTC unless someone picked its zone.
Beat reloads after model save() or delete(), logging DatabaseScheduler: Schedule changed. Bulk writes such as queryset update() don't trigger that, so the README says to call PeriodicTasks.update_changed(); 2.9.0 also reloads everything every five minutes (schedulers.py). The database scheduler doesn't reset when the time zone changes; the README's fix makes every task start as if it had never run:
from django_celery_beat.models import PeriodicTask, PeriodicTasks
PeriodicTask.objects.all().update(last_run_at=None)
PeriodicTasks.update_changed()Celery Beat Running Twice#
"You have to ensure only a single scheduler is running for a schedule at a time, otherwise you'd end up with duplicate tasks" (periodic tasks); beat has no leader election. Log self.request.id in the task: a task sent twice runs under two task IDs, while a redelivery runs one task ID twice.
Two Beats Are Sending#
The usual sources are worker -B on more than one worker, -B alongside a standalone beat, a beat Deployment with more than one replica, and rollouts. A Kubernetes Deployment's default RollingUpdate sets maxSurge to 25%, rounded up, so with one replica the new beat starts before the old one stops (Deployments). Recreate terminates the old pod first:
spec:
replicas: 1
strategy:
type: RecreateThat covers upgrades only; a pod you delete by hand is replaced "immediately (even if the old Pod is still in a Terminating state)." For standby beats, use a scheduler with a lock. RedBeat 2.4.2 keeps the schedule in Redis, and a second beat blocks at startup until the active one's lock is free. A clean shutdown releases it; after a crash, the standby takes over only when the lock expires, after redbeat_lock_timeout (1,500 seconds by default) (config, schedulers.py). Distributed cron compares other ways to run a scheduler once.
Beat Was Killed Before It Saved#
The default scheduler writes run times every three minutes and on SIGTERM or SIGINT (beat.py, apps/beat.py). A SIGKILL, from the out-of-memory killer or an orchestrator's stop timeout, loses up to three minutes of them, so a task sent in that window is due again after the restart. beat_sync_every = 1 saves after every send (beat_sync_every).
The Broker Delivered It Again#
Redis and SQS redeliver a message not acknowledged within the visibility timeout: one hour by default on Redis, 30 minutes on SQS (Redis, SQS). A task is normally acknowledged right before it runs, but with task_acks_late, after (task_acks_late), so a late-acked task that outlasts the timeout starts again on another worker. Raise the timeout above your longest task, changing the three settings the Redis caveats list.
Workers Were Down and the Queue Filled#
Beat keeps sending while workers are down, and they run every copy when they return. With "options": {"expires": 3600} on the entry, a worker marks a task older than that REVOKED instead of running it (expiration). Whatever the cause, a task that's safe to run twice makes a duplicate harmless; cron job running twice shows how.
Report Each Run From Inside the Task#
Every cause above looks the same from outside: no run and no error. A check-in from the task body turns that into an alert whichever part broke, because a stopped beat, a dead worker, an unregistered name, and an unread queue all mean no start arrives.
There's no installable CronRadar SDK for Python or Celery (checked September 20, 2026) and no auto-discovery of beat schedules, so each periodic task reports with its own HTTP check-in to its own monitor. This standard-library helper has a five-second timeout and logs monitoring errors instead of raising them. Keep the key in CRONRADAR_API_KEY, with a separate CronRadar application and key per environment:
# proj/cronradar.py
import base64
import logging
import os
import urllib.parse
import urllib.request
log = logging.getLogger(__name__)
def check_in(monitor_key, event, *, schedule, run_id=None, grace_period=None, message=None):
"""Report one lifecycle event to CronRadar. Never raises into the task."""
params = {"schedule": schedule, "run_id": run_id, "gracePeriod": grace_period, "message": message}
query = urllib.parse.urlencode(
{name: value for name, value in params.items() if value is not None},
quote_via=urllib.parse.quote,
)
url = f"https://cron.life/api/v1/ping/{urllib.parse.quote(monitor_key, safe='')}/{event}?{query}"
try:
token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
request = urllib.request.Request(url, data=b"", method="POST")
# Unlike add_header(), this header is not copied to a redirect.
request.add_unredirected_header("Authorization", f"Basic {token}")
with urllib.request.urlopen(request, timeout=5):
pass
except Exception as error:
# A monitoring problem must never fail the task or change its result.
log.warning("CronRadar %s for %s not recorded (%s)", event, monitor_key, type(error).__name__)The task reports its start, then its completion or failure, and re-raises its own exception so Celery records the failure as usual:
# billing/tasks.py
from celery import shared_task
from billing.invoices import send_due_invoices
from proj.cronradar import check_in
MONITOR_KEY = "send-invoices"
SCHEDULE = "0 6 * * *" # The beat entry's crontab, in UTC
GRACE_PERIOD = 600 # Seconds
@shared_task(bind=True, soft_time_limit=900)
def send_invoices(self):
run = {"schedule": SCHEDULE, "run_id": self.request.id, "grace_period": GRACE_PERIOD}
check_in(MONITOR_KEY, "start", **run)
try:
sent = send_due_invoices()
except Exception as error:
# The exception type only: never arguments, payloads, or log output.
check_in(MONITOR_KEY, "fail", message=type(error).__name__, **run)
raise
check_in(MONITOR_KEY, "complete", **run)
return sentThe first check-in creates the monitor, and a deleted monitor comes back on the next run. schedule and gracePeriod apply only when a check-in creates the monitor; change them later in its settings. With timezone = "UTC", building the beat entry with crontab.from_string(SCHEDULE) keeps the two in step.
- No run arrives: the overdue check runs once a minute. Once 06:00 UTC passes without a check-in, the monitor shows
warning; once it's past the 600-second grace period and at least two minutes late, it goescriticaland opens an incident, alerting by email, Slack, webhook, Discord, or PagerDuty. - The task raises:
failalerts immediately. Withself.retry()orautoretry_for, every failed attempt sends one; the signal handlers in Celery task retries report only the final failure, including a dead pool process and a retry that expired. - It hangs or its worker dies: a
startwith no finish is flagged as stuck when the next expected time plus grace passes with no check-in, and the next run'sstartresets that window; CronRadar has no per-run time limit.soft_time_limitturns a hang intoSoftTimeLimitExceeded, reported asfail, except on the gevent pool (time limits). - It runs twice: CronRadar records both runs and doesn't alert on extra runs.
CronRadar reads schedule as five-field cron in UTC (schedule syntax), so translate each entry:
| Beat entry | timezone |
schedule to send |
|---|---|---|
crontab(minute=0, hour=6) |
UTC |
0 6 * * * |
crontab(minute=30, hour=1, day_of_week=1) |
Asia/Tokyo |
30 16 * * 0 (Sunday in UTC) |
crontab(minute=0, hour=9, day_of_week=1, day_of_month="1-7") |
UTC |
0 9 1-7 * 1 (both day fields must match) |
crontab(minute=0, hour=6) |
America/New_York |
0 10 * * *, grace of an hour plus run time |
timedelta(hours=1) |
Any | None; switch to a crontab |
The New York row follows CronRadar's daylight-saving rule: send the earlier of the two UTC times, 10:00 in summer, with a grace period of at least an hour plus the run's usual duration. In winter the monitor shows warning for an hour each day before the run arrives.
Frequently Asked Questions#
Why is Celery beat running tasks twice?#
Usually two beats are running: worker -B on several workers, more than one beat replica, or old and new beats overlapping in a rolling deploy. A beat killed before saving its run times, and a broker redelivering a late-acknowledged task, also cause it. Log the task ID: two IDs mean the task was sent twice, one ID twice means redelivery.
Do I need a separate Celery beat process?#
Yes, in production. Beat only sends tasks and workers only run them, so run one celery -A proj beat process alongside your workers. celery worker -B combines them, but Celery recommends it only for a single worker node, since every -B worker runs its own beat.
Where is the celerybeat-schedule file?#
In the directory beat started in, as celerybeat-schedule, possibly with a suffix such as .db. Move it to a writable volume with celery beat -s or beat_schedule_filename. Deleting it resets every entry's last run time; django-celery-beat and RedBeat don't use it.
Why does Celery beat send tasks but nothing runs?#
The message reached the broker and no worker ran it. Either no worker consumes the queue the task routes to, which celery -A proj inspect active_queues shows, or the workers don't know the task and log Received unregistered task.
Does Celery beat catch up missed runs?#
Partly. The default scheduler sends each overdue entry once when beat restarts, if its schedule file survived, and beat_cron_starting_deadline limits how far back a crontab looks. Tasks queued while workers were down all run unless they set expires. Missed runs after downtime compares other schedulers.
Put this guide to work
Connect your Celery job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
Celery SDK connections are temporarily unavailable. Use manual HTTP reporting below. This setup does not discover scheduled tasks automatically.