How groups and chords really work

Celery · Expert · 12 min

Chords are the most fragile canvas primitive, and their behavior under failure surprises even experienced Celery users.

A group is simple: each signature is sent as its own message carrying a shared group id, and a GroupResult holds their AsyncResults. Nothing coordinates them on the worker side.

A chord must run its callback exactly when all header tasks are done, and workers don't talk to each other, so coordination happens in the result backend:

  • With Redis (and other key-value backends supporting it), each header task, on finishing, stores its result and atomically increments a counter for the chord. The task that brings the counter to the header size triggers the callback, which receives all results read back from the backend.
  • Backends without that support fall back to a celery.chord_unlock task that polls periodically until all header results are ready, which is slower and adds load.

Consequences:

  • Header tasks must store results: ignore_result=True on them breaks the chord.
  • If any header task fails, the callback is not called; the chord fails with a ChordError, and you need link_error on the chord to react.
  • The callback receives every result at once, so huge headers with large results mean big backend reads and memory use. Prefer returning ids or small summaries.
  • Chords inside chains and nested groups are supported but harder to debug; keep workflows shallow, and test failure paths explicitly.

Example

from celery import Celery, chord, group

app = Celery("proj", broker="memory://", backend="cache+memory://")
app.conf.task_always_eager = True        # demo inline

@app.task
def count_words(doc): return len(doc.split())

@app.task
def summarize(counts): return {"docs": len(counts), "words": sum(counts)}

docs = ["a b c", "d e", "f"]
g = group(count_words.s(d) for d in docs)
print(g.apply_async().get())                         # [3, 2, 1]

res = chord(g)(summarize.s())
print(res.get())                                     # {'docs': 3, 'words': 6}

# Real backends: the last finishing header task increments the chord
# counter to 3 and triggers summarize([...all results...]).

Remember

  • Groups are independent messages sharing a group id
  • Chords coordinate via the result backend (counter on Redis, polling elsewhere)
  • Header tasks must store results; failures skip the callback
  • Keep header results small: the callback reads them all

Watch out for

  • Setting task_ignore_result=True globally and then using chords.

Check yourself

  1. Why do chords require a result backend?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.celeryq.dev/en/stable/userguide/canvas.html#chords