Make Celery Tasks Idempotent and Safe to Retry

Design Django Celery tasks that can run twice without harm: enqueue on commit, idempotency keys and unique constraints, row locks, external API idempotency, and bounded retries with backoff.

Problem statement

In production, Celery tasks run more than once. Workers are killed mid-task during deploys and the task is redelivered; the Redis broker redelivers messages whose visibility timeout expires; network errors trigger retries after the side effect already happened; and users double-click buttons that enqueue work. Each duplicate can send a second email, charge a card twice, or create duplicate rows.

You cannot make delivery exactly-once, but you can make tasks idempotent: running them again produces the same result as running them once. This guide covers the patterns that achieve that in a Django app, and how to configure retries so transient failures recover without hammering dependencies.

Duplicate-safe task flowEnqueue on commitdata exists firstClaim the worklock or unique keySide effectwith idempotency keyRecord completionskip on re-runAssume every task can run twice; record progress so a second run finds nothing left to do.
  1. Enqueue tasks with transaction.on_commit() so they never run before their data is committed.
  2. Pass IDs, not objects, and reload state inside the task.
  3. Record completion in the database (a status field or a unique "done" row) and check it at the start of the task.
  4. Use database constraints (UniqueConstraint, get_or_create) or select_for_update() so concurrent duplicates cannot both proceed.
  5. Send idempotency keys to external APIs that support them (payments, messaging).
  6. Retry only transient errors, with autoretry_for, exponential backoff, jitter, and a maximum number of retries.

Step-by-step solution

1. Enqueue after commit

from functools import partial

from django.db import transaction

from billing.tasks import send_receipt


def complete_order(order):
    with transaction.atomic():
        order.status = "paid"
        order.save(update_fields=["status"])
        transaction.on_commit(partial(send_receipt.delay, order.pk))

Without on_commit, a fast worker can pick up the task before the transaction commits, find the old state, and do the wrong thing (or fail with DoesNotExist). If the transaction rolls back, the task is never sent.

2. Make re-runs detect finished work

@app.task(bind=True)
def send_receipt(self, order_id):
    order = Order.objects.get(pk=order_id)
    if order.receipt_sent_at:
        return "already sent"

    send_receipt_email(order)

    Order.objects.filter(pk=order_id, receipt_sent_at__isnull=True).update(receipt_sent_at=timezone.now())

This covers the common case: a task redelivered after it finished runs, sees the marker, and returns. A small window remains (crash after sending, before recording), which is acceptable for emails but not for payments; the next patterns close it.

3. Claim work atomically

When two copies of a task can run at the same time, a check followed by an action is a race. Let the database decide who wins:

from django.db import IntegrityError, transaction


class TaskRun(models.Model):
    key = models.CharField(max_length=200, unique=True)
    created = models.DateTimeField(auto_now_add=True)


@app.task
def generate_invoice(order_id):
    try:
        with transaction.atomic():
            TaskRun.objects.create(key=f"invoice:{order_id}")
            invoice = Invoice.objects.create(order_id=order_id, ...)
    except IntegrityError:
        return "duplicate, skipped"
    return invoice.pk
only one run may claim the workclaimed = Order.objects.filter(pk=order_id,status="pending").update(status="processing")ifnot claimed:return  # another run already has it1231The condition is part of the UPDATE.2One SQL statement: atomic, no race.30 rows updated means someone else won.Read-then-write in Python leaves a window for a duplicate rundjango-deployment.com
select_for_update inside a transaction is the alternative for complex claims.

The unique key and the invoice are created in one transaction: either both exist or neither does, and a concurrent duplicate fails on the unique constraint. For state machines, select_for_update() serialises updates:

with transaction.atomic():
    order = Order.objects.select_for_update().get(pk=order_id)
    if order.status != "pending_fulfilment":
        return "not pending"
    order.status = "fulfilling"
    order.save(update_fields=["status"])

Keep these transactions short and never wrap slow external calls in them.

4. Use idempotency keys with external APIs

A payment request that times out may still have succeeded. Retrying without protection charges twice. Many APIs accept an idempotency key; Stripe, for example, takes an Idempotency-Key header and returns the original result for repeated keys:

@app.task(bind=True, autoretry_for=(stripe.APIConnectionError,), retry_backoff=True, max_retries=5)
def capture_payment(self, payment_id):
    payment = Payment.objects.get(pk=payment_id)
    if payment.captured:
        return "already captured"
    result = stripe.PaymentIntent.capture(
        payment.intent_id,
        idempotency_key=f"capture-{payment.pk}",
    )
    Payment.objects.filter(pk=payment.pk).update(captured=True, provider_status=result.status)
retry without charging twiceCharge sentIdempotency-Key: order-812Timeoutdid it succeed?Celery retriessame keyProvider recogniseskeyreturns the first resultOne chargetask completesDerive the key from your data, never from a random value per attemptdjango-deployment.com
A new random key on each retry defeats the whole mechanism.

Derive the key from your own stable identifiers, never from random values generated per attempt. For APIs without idempotency support, record the request before sending it and reconcile unknown outcomes by querying the provider.

5. Configure retries

@app.task(
    bind=True,
    autoretry_for=(requests.ConnectionError, requests.Timeout),
    retry_backoff=True,        # 1s, 2s, 4s, 8s ...
    retry_backoff_max=600,     # cap the delay at 10 minutes
    retry_jitter=True,         # randomise delays to avoid thundering herds
    max_retries=6,
)
def sync_customer(self, customer_id):
    customer = Customer.objects.get(pk=customer_id)
    response = crm_session().put(f"{CRM_URL}/customers/{customer.external_id}", json=customer.as_crm(), timeout=10)
    response.raise_for_status()
  • Retry only transient errors (connection failures, timeouts, 429, 503). Validation errors and 404s will not succeed on retry; let them fail and alert.
  • Always set a timeout on network calls, or a hung connection holds the worker indefinitely.
  • After max_retries, the task fails; make sure failures reach your error tracker.

For HTTP errors, raise a custom exception for retryable status codes and list it in autoretry_for.

6. Understand redelivery settings

CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_BROKER_TRANSPORT_OPTIONS = {"visibility_timeout": 3600}
retry and redelivery settingsEffectautoretry_for + retry_backoffretry named exceptions, with growing delaysacks_late!ack after running: rerun if the worker diesreject_on_worker_lostrequeue when the worker process is killedvisibility_timeout!Redis/SQS: redeliver unacked after N secondsEvery one of these can run a task twice: idempotency is not optionaldjango-deployment.com
Set visibility_timeout above your longest task plus any ETA.
  • acks_late acknowledges messages after the task finishes, so a worker crash causes redelivery instead of loss. That is only safe for idempotent tasks.
  • With Redis as the broker, unacknowledged messages are redelivered after visibility_timeout (one hour by default). Tasks that run longer, or ETA/countdown tasks scheduled further ahead than the timeout, can be delivered twice. Keep tasks shorter than the timeout, or raise it above your longest task and ETA.
  • prefetch_multiplier = 1 with acks_late avoids a worker holding many unacknowledged messages that are redelivered together if it dies.

Verification check:

Test idempotency directly: call each important task twice in a test (and concurrently if possible) and assert one side effect.

def test_generate_invoice_is_idempotent(db, order):
    generate_invoice(order.pk)
    generate_invoice(order.pk)
    assert Invoice.objects.filter(order=order).count() == 1

Explanation

Celery guarantees at-least-once delivery when configured with late acknowledgement, and at-most-once otherwise; neither is exactly-once. Networks add another source of ambiguity: a request that timed out may have succeeded. Idempotency moves the guarantee into your data: the database records what has been done, constraints stop concurrent duplicates, and external systems deduplicate with keys. With that in place, retries and redeliveries become safe, and you can configure Celery for reliability rather than hoping duplicates do not happen.

Edge cases / notes

Chords, chains, and groups

Workflows multiply the effect of duplicates. Make each step idempotent individually rather than relying on the workflow to run once.

Cleaning up task records

Tables of claimed keys grow. Delete rows older than your maximum retry and redelivery window with a periodic task.

When to automate this

Add a test helper that runs any task twice and compares side effects, and require it for tasks that touch money, email, or external systems. Monitor retry counts per task; a steady increase points to a failing dependency before tasks start exhausting their retries.

Next steps

FAQ

Why do Celery tasks run twice?

Redelivery after worker crashes or visibility timeouts, retries after partial success, and duplicate enqueues from users all cause repeats. Delivery is at-least-once at best.

How do I make a Celery task idempotent?

Record completion in the database, claim work with unique constraints or row locks, and use idempotency keys for external APIs.

Should I use acks_late in Celery?

Yes for idempotent tasks, so crashed tasks are redelivered rather than lost. Avoid it for tasks that are unsafe to repeat.

Which errors should Celery retry automatically?

Transient ones: connection errors, timeouts, rate limits, and temporary server errors. Do not retry validation errors or missing resources.

Learning path · step 6 of 12

Background jobs with Celery

Keep going