Fix Celery Beat Not Sending Scheduled Tasks

Fix Celery Beat that runs but never sends periodic tasks in Django: check that Beat is actually running, the scheduler and timezone settings, django-celery-beat database schedules, stale schedule files, and queue routing.

Problem statement

Workers process tasks you call from views, but nightly cleanups, hourly syncs, and reminder emails never happen. Either Beat is not running at all, or it runs and its log shows no Scheduler: Sending due task lines, or it sends them and nothing consumes them.

Beat is a separate process whose only job is to put messages on the broker at the right times. A worker never runs periodic tasks by itself. So a periodic task can fail at three points: Beat not running, Beat not considering the task due, or the message going to a queue no worker listens on.

Where a periodic task can stopBeat processmust be runningScheduletask due by its clockBroker queuerouted somewhereWorkerconsuming that queueCheck each stage in order; the Beat log shows whether the message was sent at all.
  1. Confirm a Beat process is running: systemctl status celery-beat or docker compose ps beat.
  2. Read its log for Sending due task lines at -l info.
  3. If you use django-celery-beat, start Beat with --scheduler django_celery_beat.schedulers:DatabaseScheduler and check the periodic task is enabled.
  4. Check CELERY_TIMEZONE and USE_TZ; crontab times are interpreted in Celery's timezone.
  5. Delete a stale celerybeat-schedule file after changing schedules or timezones.
  6. Make sure the task's queue has a worker: celery -A config inspect active_queues.

Step-by-step solution

1. Make sure Beat runs, exactly once

Beat must be its own supervised process:

celery -A config beat -l info

In systemd use a dedicated celery-beat.service; in Compose a separate beat service; in Kubernetes a Deployment with replicas: 1. Running the worker with -B embeds Beat, which is fine for development but means every worker replica runs its own Beat, so duplicates appear when you scale. Run one Beat only; see Fix Celery Beat Duplicate Tasks.

2. Read the Beat log

A healthy Beat logs every send:

[INFO/MainProcess] beat: Starting...
[INFO/MainProcess] Scheduler: Sending due task cleanup-sessions (accounts.tasks.cleanup)

If you see beat: Starting... and nothing else, Beat does not consider any task due. If you see Sending due task but the task never runs, the problem is downstream at the queue or worker.

3. Use the scheduler that holds your schedule

There are two schedulers and they do not share data:

  • Default PersistentScheduler: reads CELERY_BEAT_SCHEDULE from settings and keeps state in a local celerybeat-schedule file.
  • django_celery_beat DatabaseScheduler: reads periodic tasks from the database tables you edit in the admin.

If you created tasks in the Django admin but Beat runs the default scheduler, it never sees them. Start it with:

celery -A config beat -l info \
  --scheduler django_celery_beat.schedulers:DatabaseScheduler

or set CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler". In the admin, check the task is Enabled, has a schedule attached, and that Task (registered) points at a name that still exists.

two schedulers, two separate sourcesPersistentScheduler (default)✓reads CELERY_BEAT_SCHEDULE in settingsstate in celerybeat-schedule file✕ignores tasks added in the adminDatabaseScheduler✓reads django_celery_beat tables✓edit schedules in the adminneeds migrations and --scheduler flagTasks defined in one place are invisible to the other schedulerdjango-deployment.com
Pick one scheduler and define every periodic task where that scheduler looks.

4. Check timezones

Crontab schedules are evaluated in CELERY_TIMEZONE (default UTC). A task set for hour=2 runs at 02:00 UTC, which may look like "never" if you are watching at 02:00 local time:

TIME_ZONE = "Europe/Berlin"
USE_TZ = True
CELERY_TIMEZONE = TIME_ZONE
CELERY_ENABLE_UTC = True

With django_celery_beat, each crontab schedule also has its own timezone field. After changing timezone settings, run python manage.py shell -c "from django_celery_beat.models import PeriodicTask; PeriodicTask.objects.update(last_run_at=None)" so Beat recalculates due times.

5. Remove a stale schedule file

The default scheduler stores last_run_at per task in celerybeat-schedule. After renaming tasks, changing intervals, or switching timezone, that file can hold values that keep tasks "not due". Stop Beat, delete the file, start Beat:

sudo systemctl stop celery-beat
rm -f /srv/app/celerybeat-schedule*
sudo systemctl start celery-beat

Point the file at a writable path with --schedule /var/lib/celery/beat-schedule. In a read-only container, Beat fails to write it and may exit; give it a volume or use the database scheduler.

6. Check routing and workers

If Beat sends but nothing runs, find the queue:

celery -A config inspect active_queues

A task routed to reports via CELERY_TASK_ROUTES or options={"queue": "reports"} in the schedule needs a worker started with -Q reports. Otherwise the messages pile up in Redis unconsumed.

read the Beat log firstBeat log showsCause and fixnothing, process not runningno supervised beat service;add one, single replicaStarting... then silencewrong scheduler, task disabled,or stale schedule filesends at unexpected hoursCELERY_TIMEZONE differsfrom what you assumedSending due task, never runsqueue has no worker; start one with -QThe log line tells you which half of the pipeline is brokendjango-deployment.com
Work from the Beat log outward, not from the task code inward.

Verification check: temporarily add a test schedule every minute, for example {"task": "core.tasks.ping", "schedule": 60.0}, and watch for the Sending due task line in Beat and the succeeded line in a worker within two minutes. Remove it afterwards.

Explanation

Beat keeps an in-memory heap of tasks ordered by the next due time, wakes up when the earliest is due, publishes a message, and computes the next time. It never executes code itself. Because it is a single quiet process with no traffic, it is easy to forget in deploy configs, and because it only logs on sends, a misconfigured Beat looks idle rather than broken. Monitoring the result of scheduled jobs, not just the process, is what catches this.

Edge cases / notes

  • Beat crashes on startup with a database error if django_celery_beat migrations have not run. Run migrate before starting Beat in deploys.
  • Interval tasks after downtime. If Beat was down for a day, it runs a missed interval task once at startup, not once per missed interval.
  • Solar schedules and DST. Crontab schedules around a DST change can skip or repeat an hour in local timezones. Use UTC for critical jobs.
  • Monitor the outcome. Add a heartbeat check for important jobs so a silent Beat is noticed within one interval.

Next steps

FAQ

Do Celery workers run periodic tasks on their own?

No. A separate Beat process sends them as messages; workers then run them like any other task.

Can I run Beat inside the worker with -B?

For development. In production, run one dedicated Beat so scaling workers does not multiply schedules.

Why do admin changes not take effect?

Beat is probably using the default scheduler. Start it with the DatabaseScheduler to read schedules from the admin.

Where is celerybeat-schedule stored?

In Beat's working directory unless you pass --schedule. It must be writable.

Keep going