Celery chord error handling (tested with a real worker)
When a header task in a Celery chord fails, the callback is skipped, the callback's on_error errback receives ChordError("Dependency <id> raised ..."), and result.get() in the caller raises the original exception. Attach the errback to the callback: chord(header)(callback.s().on_error(handler.s())).
A chord runs a group of header tasks in parallel, then one callback with the list of their results. If any header task fails, the callback does not run: the worker marks the callback as failed with ChordError and calls the callback's on_error errback. Measured with Celery 5.6.3 (kombu 5.6.2, redis-py 8.1.0), a real worker and Redis 7 as broker and result backend.
1. Chord error handling with a real worker
$ cat tasks.py
from celery import Celery
app = Celery("tasks", broker="redis://127.0.0.1:8964/0", backend="redis://127.0.0.1:8964/1")
@app.task
def div(x, y):
return x / y
@app.task
def total(xs):
print(f"callback got {xs}")
return sum(xs)
@app.task
def on_error(request, exc, tb):
print(f"errback got {exc!r}")
@app.task(ignore_result=True)
def div_noresult(x, y):
return x / y
$ celery -A tasks worker -P solo --loglevel=INFO > worker.log 2>&1 &
$ cat run_chord.py
from celery import chord
from tasks import div, div_noresult, total, on_error
ok = chord([div.s(1, 1), div.s(2, 1)])(total.s())
print("ok:", ok.get(timeout=10))
bad = chord([div.s(1, 1), div.s(1, 0)])(total.s().on_error(on_error.s()))
try:
bad.get(timeout=10)
except Exception as e:
print("caller:", type(e).__name__, e)
print("callback state:", bad.state)
try:
r = chord([div_noresult.s(1, 1), div_noresult.s(2, 1)])(total.s())
print("ignore_result:", r.get(timeout=5))
except Exception as e:
print("ignore_result:", type(e).__name__, e)
$ timeout 40 python run_chord.py
ok: 3.0
caller: ZeroDivisionError division by zero
callback state: PENDING
ignore_result: 3.0
The caller sees the original exception (ZeroDivisionError), because get() on a chord result checks the header group first. Note that callback state is still PENDING at that moment. The worker log shows what happened on the other side:
$ grep -E "callback got|errback|Chord .* raised|raised unexpected" worker.log | cut -c1-240
[2026-10-23 10:55:11,145: WARNING/MainProcess] callback got [1.0, 2.0]
[2026-10-23 10:55:11,153: ERROR/MainProcess] Chord '03412c5a-4c48-4d8a-8641-4813dd8ff4aa' raised: ChordError("Dependency f6e6e945-7b18-4162-8fb9-3c1154b203be raised ZeroDivisionError('division by zero')")
[2026-10-23 10:55:11,154: WARNING/MainProcess] errback got ChordError("Dependency f6e6e945-7b18-4162-8fb9-3c1154b203be raised ZeroDivisionError('division by zero')")
[2026-10-23 10:55:11,155: ERROR/MainProcess] Task tasks.div[f6e6e945-7b18-4162-8fb9-3c1154b203be] raised unexpected: ZeroDivisionError('division by zero')
[2026-10-23 10:55:11,160: WARNING/MainProcess] callback got [1.0, 2.0]
The callback total was not called for the failing chord (both "callback got" lines are from the successful chords). The errback received ChordError("Dependency <task id> raised ..."), not the ZeroDivisionError itself, so parse or log the message, or look up the failed header task by its id. Header tasks with ignore_result=True still completed the chord here (last line of the script output).
2. The callback's own state flips to FAILURE a moment later
$ cat run2.py
import time
from celery import chord
from tasks import div, total, on_error
bad = chord([div.s(1, 1), div.s(1, 0)])(total.s().on_error(on_error.s()))
try:
bad.get(timeout=10)
except Exception as e:
print("caller:", type(e).__name__, e)
print("callback state right away:", bad.state)
time.sleep(2)
print("callback state 2s later:", bad.state, repr(bad.result))
print("parent:", type(bad.parent).__name__, bad.parent.failed())
$ timeout 40 python run2.py
caller: ZeroDivisionError division by zero
callback state right away: PENDING
callback state 2s later: FAILURE ChordError("Dependency 9a317545-cfd4-43a9-b973-62cafebcd622 raised ZeroDivisionError('division by zero')")
parent: GroupResult True
If you poll the callback's state instead of calling get(), expect PENDING briefly, then FAILURE with the ChordError as its result. bad.parent is the header GroupResult; failed() tells you whether any header task failed.
3. Eager mode (tests) behaves differently
$ cat eager.py
from celery import chord
from tasks import app, div, total, on_error
app.conf.task_always_eager = True
try:
r = chord([div.s(1, 1), div.s(1, 0)])(total.s().on_error(on_error.s()))
print("result:", r.get())
except Exception as e:
print("raised while applying the chord:", type(e).__name__, e)
$ timeout 20 python eager.py 2>&1
raised while applying the chord: ZeroDivisionError division by zero
With task_always_eager = True the header exception is raised straight out of the chord(...)(...) call, and neither the callback nor the errback runs (no "errback got" line). Unit tests in eager mode therefore cannot verify your chord on_error handler; test it with a real worker (for example the celery_worker pytest fixture) or call the errback function directly.
Related: Celery link_error errback example, Celery MaxRetriesExceededError, Celery Error 111 connection refused.
Frequently asked questions
How do I handle errors in a Celery chord?
Attach an errback to the callback signature: chord(header)(callback.s().on_error(on_error.s())). It runs with (request, exc, traceback) when any header task fails.
What is ChordError in Celery?
The exception the worker stores on the chord callback when a header task fails; its message names the failed dependency's task id and original exception.
Why is my chord errback not called in tests?
With task_always_eager the header exception is raised directly from the chord call and neither the callback nor the errback runs. Test with a real worker.
Written with AI assistance. Measured on 2026-10-23 with Celery 5.6.3, kombu 5.6.2 and redis-py 8.1.0 on Python 3.13.5, one solo worker, Redis 7 (Docker) as broker and backend. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; Celery and Redis are free.