Fix Celery Beat Running Tasks Twice
Fix Celery periodic tasks running twice in Django: duplicate Beat processes, -B on workers, overlapping deploys, duplicate schedules, and redelivery.
Problem statement
A periodic task scheduled with Celery Beat runs more than once per interval: customers receive the daily digest twice, invoices are generated in duplicate, or a nightly cleanup runs several times in parallel and deadlocks.
Duplicates come from two different places, and the fix differs:
- Duplicate scheduling: more than one scheduler sends the task. Beat has no leader election, so every Beat process sends every due task.
- Duplicate execution: one message is executed more than once, because of retries, redelivery after a worker crash or visibility timeout, or a task that enqueues itself again.
The worker logs tell you which one you have: two different task IDs at the same time means duplicate scheduling; the same task ID executed twice means duplicate execution.
Quick answer
- In worker logs, compare task IDs for the duplicated runs.
- Different IDs: find every Beat process:
pgrep -af "celery.*beat"on every host, plus-Bflags on workers, extra replicas, and old services. - Run exactly one Beat process for the whole deployment; remove
-Bfrom workers. - Check that a schedule entry is not defined twice (settings plus database scheduler, or two names for the same task).
- Same ID: check
acks_late, visibility timeout, and retries; make the task idempotent. - Add a lock or uniqueness guard inside tasks whose side effects must not repeat.
- Verify with logs over several intervals.
Step-by-step solution
1. Compare task IDs in worker logs
sudo journalctl -u celery-worker --since "today" | grep "send_daily_digest" | grep -E "Received|succeeded"
Example of duplicate scheduling:
Received task: notifications.tasks.send_daily_digest[6f1c...]
Received task: notifications.tasks.send_daily_digest[a92e...]
Example of duplicate execution of the same message:
Received task: notifications.tasks.send_daily_digest[6f1c...]
Received task: notifications.tasks.send_daily_digest[6f1c...]
With several worker hosts, search all of them, or use your central log system.
2. Find every scheduler
On each server:
pgrep -af "celery.*beat"
pgrep -af "celery.*worker.*(-B|--beat)"
systemctl list-units --type=service | grep -i -E "celery|beat"
In containers:
docker ps --format '{{.Names}} {{.Command}}' | grep -i beat
kubectl get pods -A | grep -i beat
Common sources of a second scheduler:
- Workers started with
-B(celery worker -B), which embed a scheduler in each worker process - A Beat service enabled on every server, because the same provisioning role or image is used everywhere
- Kubernetes Beat Deployment with
replicas: 2, or aRollingUpdatestrategy that briefly runs old and new pods together - Autoscaling groups or platform process types that scale the scheduler process with web or worker processes
- An old cron job that still calls the task directly, alongside the new Beat schedule
- Staging pointed at the production broker, running its own Beat against production queues
3. Run exactly one scheduler
Remove -B from worker commands and run Beat as its own process on one host:
celery -A config worker --loglevel=INFO
celery -A config beat --loglevel=INFO --schedule=/var/lib/myapp-beat/celerybeat-schedule
In Kubernetes, use a single replica and stop the old pod before starting a new one:
apiVersion: apps/v1
kind: Deployment
metadata:
name: celery-beat
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: celery-beat
template:
metadata:
labels:
app: celery-beat
spec:
containers:
- name: beat
image: registry.example.com/django-app:abc1234
command: ["celery", "-A", "config", "beat", "--loglevel=INFO"]
Recreate accepts a brief gap in scheduling during deploys, which is safer than a brief overlap. Tasks due during the gap are sent when Beat starts, depending on the scheduler and the schedule type.
4. Check for duplicate schedule definitions
With django-celery-beat's database scheduler, entries can exist both in CELERY_BEAT_SCHEDULE and as manually created PeriodicTask rows with different names:
python manage.py shell -c "
from django_celery_beat.models import PeriodicTask
for t in PeriodicTask.objects.filter(task__icontains='send_daily_digest'):
print(t.id, t.name, t.enabled, t.crontab or t.interval)
"
Two enabled rows for the same task name produce two sends. Disable or delete the extra one. Also check for a renamed schedule key in settings: after a rename, the database may still contain the entry under the old name.
With the file scheduler, the same task listed twice under different keys in CELERY_BEAT_SCHEDULE produces the same result.
5. Duplicate execution of one message
If the same task ID runs twice, the message was delivered again. Causes:
- Late acknowledgement with worker loss: with
task_acks_late = True, a task is acknowledged after it finishes. If the worker is killed mid-task (deploy, OOM, restart), the message is redelivered and runs again from the start. - Redis visibility timeout: with the Redis broker, a message that is not acknowledged within the visibility timeout (one hour by default) is redelivered to another worker, even if the first worker is still running it. Long tasks with
acks_late, or tasks with ETAs or countdowns longer than the timeout, are affected. - Autoretry:
autoretry_fororself.retry()intentionally runs the task again after a failure; if the failure happened after the side effect (email sent, then an exception), the retry repeats the side effect.
Adjust the Redis visibility timeout to exceed your longest task and longest ETA:
CELERY_BROKER_TRANSPORT_OPTIONS = {"visibility_timeout": 60 * 60 * 6}
6. Make tasks idempotent
Even with one scheduler, exactly-once execution is not guaranteed. Design periodic tasks so running them twice is harmless.
Record completion with a unique constraint:
# notifications/models.py
from django.db import models
class DigestRun(models.Model):
run_date = models.DateField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)
# notifications/tasks.py
from celery import shared_task
from django.db import IntegrityError, transaction
from django.utils import timezone
from .models import DigestRun
@shared_task
def send_daily_digest():
today = timezone.localdate()
try:
with transaction.atomic():
DigestRun.objects.create(run_date=today)
except IntegrityError:
return "already sent today"
deliver_digests(today)
The unique constraint guarantees that only one execution per day proceeds, regardless of how many schedulers or deliveries occurred. If deliver_digests can fail partway, record per-recipient progress so a retry continues rather than restarts.
Use a short-lived lock for overlapping runs:
from django.core.cache import cache
@shared_task
def rebuild_search_index():
if not cache.add("lock:rebuild_search_index", "1", timeout=60 * 30):
return "already running"
try:
do_rebuild()
finally:
cache.delete("lock:rebuild_search_index")
cache.add only sets the key if it does not exist, which works as a lock with a shared cache such as Redis (not with per-process local memory caches). The timeout releases the lock if the worker dies.
7. Separate environments
Staging and production must not share a broker or broker database. A staging Beat connected to production Redis schedules tasks that production workers execute. Use different Redis instances or at least different database numbers and credentials.
8. Verify
Watch several intervals of a frequent task, such as a one-minute heartbeat:
sudo journalctl -u celery-worker --since "15 min ago" | grep "beat_heartbeat" | grep Received | awk '{print $1, $2, $3}'
Verification check:
- exactly one Beat process exists across all hosts and containers
- each periodic task is received once per interval
DigestRunor equivalent guards show one row per period- deploys do not create overlapping Beat processes
Explanation
Celery Beat is a clock that publishes messages; it has no awareness of other Beat processes. Any deployment topology that runs two clocks doubles every schedule. Separately, Celery's delivery guarantees are at-least-once when acknowledgements are late or messages time out, so even a single clock can lead to repeated execution. Running one scheduler removes duplicate sends; idempotent task design makes the remaining redelivery cases safe.
Edge cases / notes
Beat restarts and missed runs
A Beat restart around a scheduled time may send a task just before shutdown and again on startup if the schedule state was not saved. Idempotency guards handle this.
Database scheduler with multiple Beat processes
Storing the schedule in the database does not coordinate multiple Beat processes; each reads the same schedule and sends tasks.
Third-party singleton helpers
Packages exist that add locking or single-Beat election. They can help in environments where you cannot guarantee one scheduler, but they add dependencies on Redis locks and failure modes of their own.
Monitoring for duplicates
Duplicates are easy to miss until customers notice. Two lightweight signals catch them early:
Count task receipts per interval. For a task that should run once per hour, alert if logs contain more than one Received task line for it in any hour. With structured logs in a central system, this is a simple aggregation query; on a single server, a quick check works:
sudo journalctl -u celery-worker --since "24 hours ago" -o short-iso \
| grep "Received task: reports.tasks.hourly_rollup" \
| cut -c1-13 | sort | uniq -c | awk '$1 > 1'
The cut -c1-13 keeps the date and hour from ISO timestamps; any output line means an hour with more than one run.
Count schedulers directly. Export the number of running Beat processes to monitoring, or run a periodic check across hosts:
for host in app-1 app-2 worker-1; do
printf "%s: " "$host"
ssh "$host" 'pgrep -fc "celery.*beat" || true'
done
The total across hosts should be exactly 1.
Audit trail in the database
For business-critical periodic work, record each run with the task ID and host:
import socket
from celery import shared_task
from django.db import models
class PeriodicRun(models.Model):
task_name = models.CharField(max_length=200)
period = models.CharField(max_length=40)
task_id = models.CharField(max_length=255)
hostname = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["task_name", "period"], name="unique_periodic_run"),
]
@shared_task(bind=True)
def hourly_rollup(self):
from django.db import IntegrityError
from django.utils import timezone
period = timezone.now().strftime("%Y-%m-%dT%H")
try:
PeriodicRun.objects.create(
task_name="hourly_rollup",
period=period,
task_id=self.request.id,
hostname=socket.gethostname(),
)
except IntegrityError:
return "duplicate run skipped"
run_rollup(period)
Skipped duplicates leave evidence in worker logs through the return value, and the table shows which host executed each period, which helps find a rogue scheduler.
Common mistakes
- Using
celery worker -Bin production. - Scaling a Beat process type or Deployment above one.
- Rolling updates that briefly run two Beat pods.
- Assuming a unique scheduler guarantees tasks run exactly once.
Next steps
- Set up Beat correctly with Run Celery Beat for Scheduled Tasks in Django Production.
- If tasks never run instead, see Fix Celery Tasks Stuck in PENDING.
- Review the setup with the Celery Production Checklist for Django.
- For simple schedules without a broker, see Schedule Django Management Commands with Cron or systemd Timers.
FAQ
Why do my Celery periodic tasks run twice?
Usually two schedulers are running: a separate Beat process plus a worker started with -B, or Beat running on several servers or replicas. Each scheduler sends every due task.
How do I make sure only one Celery Beat runs?
Run Beat as a dedicated process on a single host or as a single-replica deployment with a recreate strategy, and never use -B on workers.
Can the same Celery task message run twice?
Yes. With late acknowledgement, a worker crash causes redelivery, and with the Redis broker, messages not acknowledged within the visibility timeout are redelivered. Design tasks to be idempotent.
How do I prevent duplicate emails from a Celery task?
Record completion with a unique database constraint per period or recipient before sending, and skip the work when the record already exists.