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.

Why a periodic task runs twiceBeat instancesone, or moreBrokerone message eachWorkersexecute all of themSide effectsduplicatedDifferent task IDs mean duplicate scheduling; the same ID twice means redelivery.

Quick answer

  1. In worker logs, compare task IDs for the duplicated runs.
  2. Different IDs: find every Beat process: pgrep -af "celery.*beat" on every host, plus -B flags on workers, extra replicas, and old services.
  3. Run exactly one Beat process for the whole deployment; remove -B from workers.
  4. Check that a schedule entry is not defined twice (settings plus database scheduler, or two names for the same task).
  5. Same ID: check acks_late, visibility timeout, and retries; make the task idempotent.
  6. Add a lock or uniqueness guard inside tasks whose side effects must not repeat.
  7. 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"
where the second scheduler hidesHow to spot itcelery worker -B-B or --beat in a worker unitSecond serversame beat unit enabled on bothContainer replicas!beat service scaled above 1Staging on prod broker✕same broker URL in two environmentsps and systemctl on every host, not just the one you expectdjango-deployment.com
Every running scheduler sends every scheduled task once.

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 a RollingUpdate strategy 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_for or self.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.
one message, two executionsWorker A takes taskacks_late, not acked yetTask runs longpast visibility_timeoutRedis redeliversthinks A diedWorker B runs it toosame task IDFixtimeout above longest taskSame task ID in two logs means redelivery, not two schedulersdjango-deployment.com
Different task IDs at the same time point to two schedulers instead.

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.

make running twice harmlessNot idempotent✕sends the invoice email again✕charges the card again✕adds a second report rowIdempotent✓checks a sent_at field first✓uses a payment idempotency key✓unique constraint per periodDuplicates will happen eventually; make them boringdjango-deployment.com
A database unique constraint is the most reliable duplicate guard.

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
  • DigestRun or 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 -B in 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

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.

Keep going