Celery chain error handling (tested with a real worker)

When a task in a Celery chain fails, the remaining tasks are skipped and marked FAILURE, result.get() raises the original exception, and an errback attached to the chain, chain(a.s(), b.s(), c.s()).on_error(handler.s()), runs once with the failing task's request. An errback on only the last signature is never called.

A chain runs tasks one after another, passing each result to the next. When one task in the middle fails, the rest of the chain never runs, the caller's get() raises the original exception, and an errback added with .on_error() on the whole chain is called once with the failing task's request. 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. Chain 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 add(x, y):
    print(f"add({x}, {y})")
    return x + y

@app.task
def div(x, y):
    print(f"div({x}, {y})")
    return x / y

@app.task
def on_error(request, exc, tb):
    print(f"errback: task {request.task} {request.id[:8]} failed with {exc!r}")
$ celery -A tasks worker -c 1 --loglevel=INFO > worker.log 2>&1 &
$ cat run_chain.py
from celery import chain
from tasks import add, div, on_error

ok = chain(add.s(2, 2), div.s(2), add.s(10))()
print("ok:", ok.get(timeout=10))

bad = chain(add.s(2, 2), div.s(0), add.s(10)).on_error(on_error.s())()
try:
    bad.get(timeout=10)
except Exception as e:
    print("caller:", type(e).__name__, e)
print("last task state:", bad.state)
print("middle task state:", bad.parent.state)
print("first task:", bad.parent.parent.state, bad.parent.parent.result)
del ok, bad
$ timeout 40 python run_chain.py
ok: 12.0
caller: ZeroDivisionError division by zero
last task state: FAILURE
middle task state: FAILURE
first task: SUCCESS 4

The caller gets ZeroDivisionError, not a wrapper exception. The last task add.s(10) never ran, yet its state is FAILURE: Celery marks the remaining tasks of the chain as failed so that get() on the final result does not hang. The first task keeps its SUCCESS result (4), so you can still read partial progress through .parent. The worker log:

$ grep -aE "add\(|div\(|errback|raised unexpected" worker.log | cut -c1-200
[2026-10-23 11:24:34,310: WARNING/ForkPoolWorker-1] add(2, 2)
[2026-10-23 11:24:34,324: WARNING/ForkPoolWorker-1] div(4, 2)
[2026-10-23 11:24:34,327: WARNING/ForkPoolWorker-1] add(2.0, 10)
[2026-10-23 11:24:34,331: WARNING/ForkPoolWorker-1] add(2, 2)
[2026-10-23 11:24:34,332: WARNING/ForkPoolWorker-1] div(4, 0)
[2026-10-23 11:24:34,336: WARNING/ForkPoolWorker-1] errback: task tasks.div ce639419 failed with ZeroDivisionError('division by zero')
[2026-10-23 11:24:34,336: ERROR/ForkPoolWorker-1] Task tasks.div[ce639419-ea9f-490f-969e-93d81f592a0d] raised unexpected: ZeroDivisionError('division by zero')

The errback ran once, for tasks.div, and received the real exception (unlike a chord errback, which gets a ChordError). request.task and request.id tell you which step failed.

2. Errback on the last signature only: not called

$ cat run2.py
from celery import chain
from tasks import add, div, on_error

# errback attached only to the LAST signature
r = chain(add.s(2, 2), div.s(0), add.s(10).on_error(on_error.s()))()
try:
    r.get(timeout=10)
except Exception as e:
    print("caller:", type(e).__name__, e)
del r
$ timeout 40 python run2.py
caller: ZeroDivisionError division by zero
$ grep -aE "add\(|div\(|errback|raised unexpected" <(tail -n +53 worker.log) | cut -c1-200
[2026-10-23 11:24:36,601: WARNING/ForkPoolWorker-1] add(2, 2)
[2026-10-23 11:24:36,604: WARNING/ForkPoolWorker-1] div(4, 0)
[2026-10-23 11:24:36,607: ERROR/ForkPoolWorker-1] Task tasks.div[39691d6f-2031-4cd1-b9c3-2b6a5954da17] raised unexpected: ZeroDivisionError('division by zero')

(The tail -n +53 only skips the log lines from the first run.) With on_error attached to add.s(10) instead of the chain, nothing handles the failure in div: the last task never starts, so its errback never fires. Call .on_error() on the chain(...) object, which applies the errback to every task in it.

3. Eager mode (tests): raised from the chain() call

$ cat eager.py
from celery import chain
from tasks import app, add, div, on_error
app.conf.task_always_eager = True

try:
    r = chain(add.s(2, 2), div.s(0), add.s(10)).on_error(on_error.s())()
    print("got a result:", r.state)
except Exception as e:
    print("raised by the chain() call:", type(e).__name__, e)
$ timeout 20 python eager.py 2>&1
add(2, 2)
div(4, 0)
errback: task tasks.div fca79556 failed with ZeroDivisionError('division by zero')
raised by the chain() call: ZeroDivisionError division by zero

With task_always_eager = True the errback does run, but the exception is raised straight out of the chain(...)() call instead of later from get(). Wrap the call itself in try (or pytest.raises) in eager-mode tests.

Related: Celery chord error handling, Celery link_error errback example, Celery MaxRetriesExceededError.

Frequently asked questions

How do I handle errors in a Celery chain?

Call .on_error(handler.s()) on the chain object. The handler runs with (request, exc, traceback) for the task that failed, and get() on the chain result raises the original exception.

Do the remaining chain tasks run after a failure?

No. In the test the task after the failing one never ran, and its state was FAILURE, so get() does not hang.

Why is my chain errback not called?

If on_error is attached only to the last signature, it is never called when an earlier task fails, because the last task never starts. Attach it to the chain.

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 worker (-c 1), 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.