Celery Task Idempotency
At-least-once delivery from any broker means tasks can run more than once. If your task is “send a $100 charge” and it runs twice, that’s a real customer with a real complaint. Idempotency is the only sane defense.
Why tasks run twice
Even without bugs:
- Worker crashes between executing the task and ack-ing the broker → broker redelivers.
task.delay()retried by the caller after timeout, but the original succeeded.acks_late=Truemeans ack only after success — also means crashes between work and ack = redelivery.- Visibility timeout expired on SQS while the task was running → message redelivered to another worker.
- Manual retries via
self.retry()that succeed after the original also did.
Treat at-least-once as a fact of life. Make tasks idempotent.
Three idempotency strategies
1. Use a natural unique identifier
If the operation already has a unique ID, persist by that ID.
@app.task(bind=True, autoretry_for=(IntegrityError,), retry_backoff=True)
def record_payment(self, payment_id, amount, user_id):
try:
with transaction.atomic():
Payment.objects.create(id=payment_id, amount=amount, user_id=user_id)
charge_card(amount, user_id, idempotency_key=payment_id)
except IntegrityError:
# Already inserted — task ran before. Done.
return
The DB’s unique constraint on payment_id is the idempotency check. Second run gets IntegrityError; we treat it as “already done.”
This is the cleanest pattern when you have an externally-supplied ID.
2. Dedupe by message ID in a cache
When there’s no natural ID, dedupe by message-level identity.
import redis
r = redis.Redis()
@app.task(bind=True, acks_late=True)
def send_email(self, email_id, to, body):
# Atomic: set if not exists; 24h TTL
if not r.set(f"task:done:{email_id}", "1", nx=True, ex=86400):
# Another run already started/finished
return
try:
smtp.send(to, body)
except Exception:
r.delete(f"task:done:{email_id}") # let retry try again
raise
Caveats:
- The TTL must outlive the worst-case retry window.
- “Set the flag before doing work” means a crash mid-work leaves the flag set and the work undone. Use sparingly; only when the work is itself idempotent or where loss is OK.
A safer variant: set the flag after success, but check before. Re-runs are caught only after the first success; less protection against concurrent retries.
3. Application-level idempotency keys
Same pattern as HTTP idempotency keys but at the queue layer. Generate a key client-side; store (key → result) in the DB.
class IdempotencyKey(models.Model):
key = models.CharField(primary_key=True, max_length=64)
result = models.JSONField(null=True)
status = models.CharField(max_length=20) # in_progress, completed, failed
created_at = models.DateTimeField(auto_now_add=True)
@app.task
def process_with_idempotency_key(key, payload):
obj, created = IdempotencyKey.objects.get_or_create(key=key, defaults={"status": "in_progress"})
if not created and obj.status == "completed":
return obj.result
if not created and obj.status == "in_progress":
# Another worker is on it (or crashed mid-work).
# Either wait/retry, or accept brief duplicate.
raise Retry(countdown=5)
try:
result = do_work(payload)
obj.result = result
obj.status = "completed"
obj.save()
return result
except Exception:
obj.status = "failed"
obj.save()
raise
Production patterns like Stripe’s idempotency keys work this way.
Idempotency in distributed transactions
External side effects must support their own idempotency keys, or you can’t make the task idempotent end-to-end. Stripe, Twilio, modern payment APIs all accept an Idempotency-Key header.
@app.task
def charge_payment(payment_id, amount, user_id):
stripe.Charge.create(
amount=amount,
customer=user_id,
idempotency_key=payment_id, # Stripe ignores duplicate requests with same key
)
For an API that doesn’t, you must check “has this already happened?” before doing it. Often impossible without a side channel — design payment flows around APIs that support idempotency keys.
Idempotency + outbox + at-least-once = exactly-once-effect
The proper pattern for “do this once and only once across services”:
# Producer side (web layer)
@app.post("/orders")
async def create_order(order):
with transaction.atomic():
order_row = Order.objects.create(...)
Outbox.objects.create(
event_type="OrderPlaced",
payload=json.dumps(order_row.to_dict()),
idempotency_key=order_row.id,
)
# Outbox relay (Celery beat / cron / separate worker)
@app.task
def publish_outbox():
for row in Outbox.objects.filter(published_at__isnull=True)[:100]:
kafka_publish(row.event_type, row.payload, key=row.idempotency_key)
row.published_at = timezone.now()
row.save()
# Consumer side (subscriber Celery worker)
@app.task
def handle_order_placed(payload, idempotency_key):
if ProcessedEvent.objects.filter(event_id=idempotency_key).exists():
return
with transaction.atomic():
ProcessedEvent.objects.create(event_id=idempotency_key)
do_downstream_work(json.loads(payload))
Three layers of safety: producer writes outbox atomically with business data; relay publishes at-least-once with stable key; consumer deduplicates by that key. Net effect: every order is processed once and only once downstream.
Common mistakes
- “My task is idempotent because the function returns the same value.” Idempotency is about side effects, not return values.
set_user_emailis idempotent if calling it twice with the same email results in one email value. Sending an email twice is NOT idempotent — the second email is a side effect. - Relying on
acks_late = False. Default Celery acks as soon as the worker receives the task. A crash during execution loses the task. Settingacks_late = Trueis correct for important work but means redelivery on crash — handle accordingly. - Setting the dedup flag before the work. A crash mid-work leaves the flag set, work undone. Set the flag after commit, or use a “claim then complete” two-phase state.
- Long-running tasks + short visibility timeout. SQS / RabbitMQ redelivers. Either lengthen the timeout or break the work into smaller idempotent units.
- Using PID + timestamp as idempotency key. Pid recycles, timestamps repeat under load. Use UUIDs or domain-meaningful IDs.
Interview angle
- “Why aren’t Celery tasks exactly-once?” — at-least-once delivery is the broker’s guarantee; tasks can run twice on worker crash, retries, visibility timeout expiry. The fix is idempotency at the consumer.
- “How do you make a Celery task idempotent?” — three options: (a) unique constraint in the DB on a natural ID, treat IntegrityError as “already done”; (b) dedup via Redis/cache by message ID; (c) full idempotency-key table with status tracking. Best is (a) when a natural ID exists.
- “How do you make a third-party API call idempotent?” — most modern APIs accept an
Idempotency-Keyheader (Stripe, Twilio, etc.). The provider rejects duplicates. For APIs without one, check “has this already happened?” before calling — often impossible without a side channel. - “
acks_late = True— when do you use it?” — when the cost of losing a task on crash exceeds the cost of running it twice. Combined with idempotency, gives reliable processing. Default (acks_late = False) is fine for fire-and-forget jobs. - “How do you stop a stuck task from being redelivered forever?” — combination of (a)
max_retriescap, (b) move to DLQ after exhaustion, (c) alert on DLQ depth. See 05_dead_letter_queue.md. - “How do you build exactly-once processing across services?” — at-least-once delivery + idempotent consumers + (often) transactional outbox at the producer. The producer writes the event in the same DB transaction as the business change; the consumer deduplicates by message ID. Net effect: each event handled once and only once downstream.