Task messages and the protocol

Celery · Expert · 14 min

Knowing the wire format lets you debug stuck messages, evolve task signatures safely and interoperate with other producers.

Celery sends tasks through Kombu, its messaging library, which abstracts transports (AMQP, Redis, SQS). A task message (protocol version 2, the default since Celery 4) has:

  • Headers: task (the name), id, root_id and parent_id (workflow lineage), group, retries, eta, expires, timelimit, argsrepr/kwargsrepr (safe string forms for logs and monitoring), origin.
  • Body: a serialized tuple (args, kwargs, embed), where embed holds callbacks, errbacks, chain and chord for canvas workflows.
  • Properties: correlation_id (the task id), reply_to, content type and encoding.

The worker's consumer receives the message, looks up the task name in its registry, deserializes the body (only accepting accept_content types), and hands the request to the pool. An unknown name produces a "Received unregistered task" error and the message is rejected.

Practical consequences:

  • Renaming a task or module breaks queued messages; deploy new code alongside old names or use explicit name= strings.
  • Changing a task's signature must stay compatible with messages already queued: add parameters with defaults and keep accepting old ones during a rollout.
  • Non-Python producers can enqueue Celery tasks by writing this format, and app.send_task("name", args=...) lets Python clients send by name without importing the task code.

Example

from celery import Celery

app = Celery("proj", broker="memory://")

@app.task(name="billing.charge")
def charge(order_id, amount, currency="EUR"):
    return f"{order_id}:{amount}{currency}"

# Build the message the client would publish, without sending it
sig = charge.s(42, 9.99)
msg = app.amqp.create_task_message(
    "a1b2", "billing.charge", sig.args, sig.kwargs)
print(msg.headers["task"], msg.headers["id"], msg.headers["argsrepr"])
# billing.charge a1b2 (42, 9.99)
args, kwargs, embed = msg.body
print(args, kwargs, sorted(embed))
# (42, 9.99) {} ['callbacks', 'chain', 'chord', 'errbacks']

# Send by name, without importing the task's code:
# app.send_task("billing.charge", args=(42, 9.99))

Remember

  • Protocol v2: routing info in headers, (args, kwargs, embed) in the body
  • Workers dispatch by task name from their registry
  • Renames and signature changes must stay compatible with queued messages
  • send_task() enqueues by name without importing the task

Watch out for

  • Renaming a task module in a deploy while thousands of messages with the old name are still queued.

Check yourself

  1. You add a required parameter to a task and deploy. What happens to messages already in the queue?

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

Official docs: docs.celeryq.dev/en/stable/internals/protocol.html