What Is Celery Beat? Setup, Beat vs Worker and Pitfalls
Sagar Tayde · · 7 min read
Celery beat is the scheduler that sends periodic tasks to your Celery workers: a report every morning, a cleanup every hour, a sync every few minutes. It does not run tasks itself. It only puts them on the queue at the right time.
This guide answers what beat is and how it differs from a worker, shows the setup, then what we saw when we tested the two mistakes that cause most beat problems: running more than one beat, and restarting it.
On this page
- What is Celery beat?
- Celery beat vs Celery worker
- How beat fits with workers
- Set up a periodic task
- Test 1: two beats, every task twice
- Test 2: what a restart does
- Timezones
- Storing the schedule in Django’s database
- Running beat in production
- FAQ
What is Celery beat?
Celery beat is Celery’s built-in scheduler for periodic tasks. It runs as its own process, reads a
schedule of intervals or crontab() times, and when a task is due it sends a message to the broker. A Celery worker then picks the message up and runs
the task. You run exactly one beat per schedule.
Celery beat vs Celery worker
Periodic tasks need both. Beat decides when; the worker does the work.
| Celery beat | Celery worker | |
|---|---|---|
| Job | Sends due tasks to the broker on a schedule | Takes tasks from the broker and runs them |
| Runs your task code | No | Yes |
| How many to run | Exactly one per schedule | As many as the load needs |
| Command | celery -A proj beat |
celery -A proj worker |
| Keeps state | Last run times, in celerybeat-schedule by default |
None between tasks |
| If it is missing | Nothing gets queued on time | Queued tasks wait and never run |
The Celery docs say it directly: “You have to ensure only a single scheduler is running for a schedule at a time, otherwise you’d end up with duplicate tasks.” Workers have no such limit.
How beat fits with workers
A Celery setup with periodic tasks has three parts:
- Beat decides when a task is due and sends a message to the broker.
- The broker, usually Redis or RabbitMQ, holds the message.
- Workers take messages off the broker and run the tasks.

You can run many workers. You should run exactly one beat, for reasons the first test makes clear.
Set up a periodic task
# tasks.py
import os
from celery import Celery
from celery.schedules import crontab
app = Celery("tasks", broker=os.environ["REDIS_URL"])
app.conf.timezone = "Asia/Kolkata"
app.conf.beat_schedule = {
"daily-digest": {
"task": "tasks.send_digest",
"schedule": crontab(hour=9, minute=0), # 9:00 am IST
},
"cleanup-every-15-min": {
"task": "tasks.cleanup",
"schedule": crontab(minute="*/15"),
},
}
@app.task
def send_digest(): ...
@app.task
def cleanup(): ...
Run the worker and beat as two separate processes:
celery -A tasks worker --loglevel=info
celery -A tasks beat --loglevel=info
A schedule can be a crontab(...) for calendar times or a number of seconds for a fixed interval,
such as 300.0 for every five minutes.
Test 1: two beats, every task twice
Setup for both tests: Celery 5.7.0 on Python 3.14 with Redis 8.10.3 as the broker. One worker process, and a task scheduled every 2 seconds that records each run. Measured 7 November 2026 on a laptop.
| Setup (20 seconds) | Task runs | Expected |
|---|---|---|
| One beat | 9 | about 10 |
| Two beats | 18 | about 10 |
Two beats sent every task twice, and the worker ran both copies. Beat does not know another beat exists.
This happens more easily than it sounds. Deploy beat inside a web service that runs two instances, or
start a worker with -B (which embeds beat) on two machines, and you have two beats.
Test 2: what a restart does
We stopped the only beat for 10 seconds and started it again with the same schedule file.
| What we checked | Result |
|---|---|
| Runs during the 10-second gap | 0 |
| First run after the restart | 0.6 seconds after start-up |
| Total in 30 seconds (about 15 expected) | 9 |
Beat did not replay the runs it missed. A run came 0.6 seconds after start-up, then the normal schedule carried on. With a 2-second schedule, we cannot tell whether that first run was a catch-up or simply the next tick.
For a job every few minutes, that is usually fine.
For a daily job it depends on the schedule file, where beat keeps last-run times.
If the file is lost, for example on a host that wipes files on each deploy, a beat that is down at 9:00 am skips the whole day. If a daily task must not be missed, record when it last succeeded and check that on start-up.
Timezones
Celery uses UTC unless you set timezone to an IANA zone name, the same names Python’s
zoneinfo module uses. Set it once:
app.conf.timezone = "Asia/Kolkata"
After that, crontab(hour=9, minute=0) means 9:00 am in India. Without it, the same line runs at
2:30 pm Indian time. India has no daylight saving, so the time stays fixed all year.
In Django, the setting is CELERY_TIMEZONE when you load Celery’s settings from Django with the CELERY
namespace. If you do not set it, Celery’s
periodic tasks guide says Django’s
TIME_ZONE is used.
If you change the timezone later, the default file scheduler notices and resets itself. The Django database scheduler does not, so reset its last-run times by hand:
python manage.py shell -c "from django_celery_beat.models import PeriodicTask; PeriodicTask.objects.update(last_run_at=None)"
Storing the schedule in Django’s database
The django-celery-beat package keeps schedules in your database, so you can add or change them from
the Django admin without redeploying:
pip install django-celery-beat
celery -A proj beat --scheduler django_celery_beat.schedulers:DatabaseScheduler
It changes where the schedule lives, not how many beats you may run. Still run exactly one.
Running beat in production
- One beat process, always. Run it as its own service with a single instance, separate from your web app and your workers.
- Do not use
worker -Bin production unless you are certain only one such worker exists. Celery’s own docs say it “isn’t recommended for production use”. - Keep the schedule file on disk if you use the default scheduler, or use the database scheduler, so last-run times survive restarts.
- Make sure a worker listens on the task’s queue. If a task is routed to a queue no worker consumes, beat keeps sending it and nothing runs, with no error.
- Make tasks idempotent. A restart, a retry or a second beat can all cause a repeat run.
- Watch the effect. Check that the report was written or the email sent, not just that beat logged a message.
On Voroa, run beat as a background worker with one instance, and your Celery workers as another. Both reach Redis on its internal address in Mumbai.
A service’s files on Voroa are reset on every deploy, so beat’s schedule file does not survive one. Use
the django-celery-beat database scheduler, or record each task’s last success in your database. Our
Celery and Redis guide covers the worker side, including idle command costs.
If a job is a simple script, a Voroa Cron Job can run it on a schedule without beat at all. Schedules are in IST. See scheduling a task on Voroa and worker or cron job. For the same duplicate-run test in a pure-Python scheduler, see our APScheduler guide.
FAQ
Is Celery beat the same as a Celery worker? No. Beat only schedules: it sends due tasks to the broker. A worker runs them. You need one beat and at least one worker for periodic tasks.
Why are my Celery periodic tasks running twice? Usually because two beat processes are running. In our test, two beats doubled every task. Run exactly one.
Does Celery beat run missed tasks after a restart? Not the missed ones. In our test it skipped the runs it missed, and a run came 0.6 seconds after it started. Whether an overdue run fires depends on the schedule file surviving the restart.
Should I run beat with worker -B?
Only in development. In production, run beat as its own single process.
How do I set the timezone for Celery beat?
Set timezone in the Celery configuration, for example Asia/Kolkata. Otherwise schedules use UTC.
Can I change schedules without redeploying?
Yes, with django-celery-beat, which stores schedules in the database and shows them in the Django admin.
Tested 7 November 2026 with Celery 5.7.0 and Redis 8.10.3; definitions and settings re-checked against the Celery 5.7 docs on 14 November 2026. Found something out of date? Write to support@getvoroa.com.
Updated November 14, 2026