Blogs / Choosing a Python Task Queue: Celery, Dramatiq, RQ, arq

Choosing a Python Task Queue: Celery, Dramatiq, RQ, arq

Published
September 8, 2026
Author
Faizan Nadeem
Tags
Python Celery Task Queues Backend Development
An automated guided vehicle carrying stacked racks of components through a factory warehouse, with more loaded racks lined up in the background
Photo by Homa Appliances on Unsplash

Most teams don’t choose a Python task queue β€” they inherit one. Someone bootstrapped the project three years ago, Celery was the obvious name, and now every “should we background this?” decision defaults to @app.task without anyone actually weighing the alternatives. That’s not necessarily wrong, but it’s not a decision either β€” it’s momentum. And momentum is a bad reason to run a message broker, a result backend, and a supervisor process for a job that RQ could handle with a fraction of the moving parts.

The honest version of this comparison isn’t “Celery is bloated” or “RQ is a toy” β€” both of those takes are wrong often enough to be useless. Celery, Dramatiq, RQ, and arq solve the same basic problem β€” take work off the request path and run it somewhere else, reliably β€” but they disagree sharply on broker support, how they treat async def, how much operational surface they demand, and what happens when a worker dies mid-task. Those disagreements matter more than benchmark numbers, because they determine what breaks at 2 a.m. and how hard it is to debug.

You’ll learn:

  • What a task queue actually does under the hood β€” broker, backend, and worker, and why that split matters
  • A celery vs dramatiq comparison across brokers, serialization, and retry design
  • Where each of the four queues stands on native asyncio support
  • The real operational weight of running each one in production β€” not just pip install
  • How each system handles worker crashes, redelivery, and at-least-once vs at-most-once delivery
  • A decision matrix to help you pick without a week of research
  • What migrating between queues actually involves once you’ve outgrown your first choice

Table of Contents

  1. The Basics
  2. The Four Contenders at a Glance
  3. Broker Support
  4. Async Support: Where Celery Falls Short
  5. Operational Weight
  6. Failure Semantics
  7. Decision Matrix
  8. Migration Notes
  9. Where to Go From Here

The Basics

A task queue has three moving parts, no matter which library you pick, and confusing them is the source of most early mistakes.

The broker is the message transport β€” usually Redis or RabbitMQ (or SQS, if you’re on AWS). It holds serialized task messages until a worker picks them up. The result backend is a separate store β€” often also Redis, but not required β€” that holds return values and task state if you actually need to check on a task later. And the worker is the process that pulls messages off the broker and executes your function. Producers (your web app) and workers are separate processes, usually on separate machines, communicating only through the broker.

The task itself is just a function reference plus serialized arguments. When you call my_task.delay(user_id=42), nothing runs synchronously β€” a message gets serialized (JSON by default in most of these libraries) and pushed onto the broker. A worker somewhere, possibly on a different host, deserializes it, looks up the matching function, and calls it. This decoupling is the entire point: your web request returns in 50ms instead of blocking on a 30-second PDF export.

Where the four libraries actually diverge is everything downstream of that basic shape β€” which brokers they’ll talk to, whether they understand async def, how many extra processes you need to babysit, and what their guarantees are when a worker gets OOM-killed halfway through a task.

The Four Contenders at a Glance

CeleryDramatiqRQarq
BrokersRedis, RabbitMQ, SQS, moreRedis, RabbitMQRedis onlyRedis only
Native asyncNo (workaround needed)NoNoYes, async def first-class
Result backendOptional, several backendsOptional (Redis)Built-in via RedisBuilt-in via Redis
Config surfaceLargeSmall-mediumSmallSmall
Retry/backoffBuilt-in, highly configurableBuilt-in, decorator-basedManual or via rq-schedulerBuilt-in
Typical use caseLarge, polyglot, complex pipelinesSimpler apps wanting Celery-like powerSmall-to-medium apps, quick setupAsync-native FastAPI/asyncio apps

Celery is the incumbent for a reason β€” it’s been production-hardened since 2009 and its plugin ecosystem (Flower for monitoring, Beat for scheduling, Canvas for workflow composition) has no real equivalent. Dramatiq is what a lot of teams reach for when they want Celery’s reliability guarantees without Celery’s configuration surface. RQ trades features for simplicity β€” it’s the queue you can explain to a new hire in five minutes. arq is the odd one out: built specifically for asyncio codebases where wrapping every task in run_in_executor feels like fighting the framework.

If you’re mapping features to a broader python task queue comparison, the fork in the road is almost always: do you need Celery’s ecosystem, or are you paying a tax for features you’ll never touch?

Broker Support

This is the first filter, because it’s the least negotiable. RQ and arq are Redis-only, full stop β€” no RabbitMQ, no SQS. That’s a deliberate simplicity trade-off: fewer broker abstractions to maintain, but it also means if you’re already running RabbitMQ for other services, you’re adding Redis just for the task queue.

Celery supports Redis, RabbitMQ, and Amazon SQS out of the box, plus community transports for a handful of others. Dramatiq officially supports RabbitMQ and Redis, with RabbitMQ as its historically preferred broker because it maps more directly onto AMQP’s native acknowledgment model.

The practical implication: if you need RabbitMQ specifically β€” for its routing flexibility, its more mature dead-lettering primitives, or because it’s already your message bus for non-task traffic β€” Celery and Dramatiq are your only two options. If you’re Redis-only anyway (which is common; Redis is often already in the stack for caching), all four are on the table and the decision moves to the other axes.

# Celery: broker is just a URL
from celery import Celery
app = Celery("tasks", broker="redis://localhost:6379/0",
              backend="redis://localhost:6379/1")

@app.task
def send_email(to: str, subject: str) -> None:
    ...
# Dramatiq: broker is a module-level object
import dramatiq
from dramatiq.brokers.redis import RedisBroker

dramatiq.set_broker(RedisBroker(host="localhost"))

@dramatiq.actor
def send_email(to: str, subject: str) -> None:
    ...

Async Support: Where Celery Falls Short

This is where most celery alternatives arguments actually start. Celery predates modern asyncio by a decade and its worker model is fundamentally synchronous β€” it runs tasks in a pool of OS processes or threads (prefork, threads, eventlet, gevent), not in an event loop. You can call async def code from inside a Celery task, but you have to bridge it yourself with asyncio.run(), and that bridging cost β€” spinning up a fresh event loop per task invocation β€” adds real overhead if your tasks are doing lots of small awaited I/O calls rather than one big one.

Dramatiq and RQ have the same limitation: both run tasks in worker processes/threads with no first-class async def support baked into the actor/job execution path.

arq is the exception, and it’s the whole reason it exists. It’s built on asyncio natively β€” the worker itself is a coroutine, and your task functions are async def by design:

# arq: tasks are coroutines, no bridging needed
from arq import create_pool
from arq.connections import RedisSettings

async def send_email(ctx, to: str, subject: str) -> None:
    async with httpx.AsyncClient() as client:
        await client.post("https://api.mailer.example/send", json={"to": to})

class WorkerSettings:
    functions = [send_email]
    redis_settings = RedisSettings(host="localhost")

If your application is already asyncio-native β€” FastAPI, aiohttp, most modern async web stacks β€” arq removes an entire category of “why is this blocking the event loop” bugs, because there’s no sync/async boundary to trip over. If your app is Django or Flask running synchronously, this advantage mostly disappears; Celery’s synchronous worker model matches your app’s synchronous model just fine.

Operational Weight

“Operational weight” means: how many processes do you run, how much do you have to configure to get production-grade behavior, and how much do you need to understand to debug it at 3 a.m.

Celery is the heaviest by a wide margin. A production Celery deployment typically means: a broker (Redis or RabbitMQ), optionally a separate result backend, a celery worker process (often several, tuned by concurrency pool type), celery beat if you need scheduled tasks, and often Flower for monitoring. The configuration surface is enormous β€” task routing, queue priorities, acks_late, prefetch multipliers, connection pooling, serializer choice β€” all of which is powerful and almost all of which has a footgun attached if you don’t read the docs first.

Dramatiq deliberately trims this down. There’s no separate beat process (you’d reach for APScheduler or a cron-triggered enqueue instead), no result backend unless you opt into one, and its middleware system replaces most of what Celery does with signals and configuration flags. It’s the “80% of Celery’s reliability, 30% of the configuration” option.

RQ is the lightest of the four to actually operate. One Redis instance, one rq worker command, done. Its dashboard (rq-dashboard) is optional and trivial to add. The cost is fewer built-in guarantees β€” no native retries until relatively recently in its history, no first-class routing/priority system beyond named queues.

arq sits close to RQ operationally β€” a single Redis dependency, a single worker process started with arq worker.WorkerSettings β€” but with retry and cron-like scheduling built in via cron() decorators, so it doesn’t need a separate beat process either.

If you’re a small team, the operational weight axis often dominates every other consideration. A queue you can fully explain to yourself during an incident beats a more “correct” queue whose failure modes require reading source code to understand.

Monitoring and Observability

Operational weight also shows up in how you find out something’s wrong. Celery’s Flower gives you a web dashboard of active tasks, worker status, and historical throughput out of the box β€” genuinely useful, but it’s another process to deploy, secure behind auth, and keep running. Dramatiq leans on its middleware system instead: a Prometheus middleware ships with the library and exposes metrics you scrape with your existing monitoring stack rather than a bespoke dashboard. RQ’s rq-dashboard is a lightweight, optional Flask app that reads job state straight out of Redis β€” closer to a debugging tool than a full observability platform. arq doesn’t ship a dashboard at all; you’re expected to inspect job state programmatically (Job.status()) or build your own view on top of the Redis keys it maintains, which fits its minimal-footprint philosophy but means you’re on your own for anything beyond basic health checks.

None of these is “solved” the way, say, HTTP request tracing is β€” plan on writing at least a little glue code to get task failures into whatever alerting system your team already trusts, regardless of which queue you pick.

Failure Semantics

This is the axis people underweight until it costs them data. Every one of these libraries defaults to at-least-once delivery, not exactly-once β€” a worker can crash after finishing a task but before acknowledging it, and the broker will redeliver that task to another worker. If your tasks aren’t written to tolerate running twice, this will eventually bite you regardless of which library you pick; see making Celery tasks idempotent for the pattern to handle it.

Celery acknowledges tasks before execution by default (acks_early), which is actually a weaker guarantee β€” a crash mid-task means the message is gone and never retried. Setting task_acks_late = True flips this to acknowledge only after successful completion, trading “never lose a task” for “might run a task twice.” Celery’s retry system is separately configurable per-task via autoretry_for, retry_backoff, and max_retries.

Dramatiq acknowledges late by default β€” messages aren’t removed from the broker until the actor returns successfully β€” which is the safer default out of the box, though it means the twice-execution risk is live from day one unless your tasks are idempotent.

RQ requires explicit configuration for retries (Retry(max=3, interval=[10, 30, 60]) passed at enqueue time) and doesn’t have late acknowledgment in the same sense β€” a crashed worker’s job can be detected via its monitoring/heartbeat mechanism and requeued, but this is coarser-grained than Celery or Dramatiq’s per-message ack model.

arq tracks in-progress jobs and requeues them if a worker disappears past its job timeout, with retry count and backoff configurable per function.

None of these four gives you exactly-once semantics β€” that guarantee essentially doesn’t exist for distributed task execution without transactional coordination between the broker and your side effects, which none of these tools attempt. Plan your task design around at-least-once from the start rather than treating duplicate execution as a rare edge case; it isn’t rare, it’s just infrequent enough to be forgotten between incidents.

Decision Matrix

A rough heuristic, in order of how often it actually applies:

  • You need RabbitMQ, SQS, or complex workflow composition (chains, chords, groups) β†’ Celery. Nothing else in this list matches Canvas for building multi-step pipelines with fan-out/fan-in semantics.
  • You want Celery-like reliability with a smaller footprint and you’re on Redis or RabbitMQ β†’ Dramatiq. Good middle ground for teams that find Celery’s configuration surface genuinely painful but still want production-grade retry and middleware support.
  • You want the smallest possible setup and Redis is already in your stack β†’ RQ. Best fit for small-to-medium apps where “one Redis, one worker command” is the whole story you want to tell.
  • Your app is asyncio-native (FastAPI, aiohttp) and your tasks are I/O-bound coroutines β†’ arq. Avoids the sync/async bridging tax entirely; a poor fit if your app is synchronous end to end.

Team size and existing infrastructure usually decide this faster than any feature comparison. A five-person team running Django on a single Redis instance gains almost nothing from Celery’s RabbitMQ support or Canvas workflows β€” they gain configuration they’ll never use and a debugging surface they didn’t need to invite in.

Migration Notes

Moving between these libraries later is more mechanical than people expect, because the underlying shape β€” function plus serialized args plus a broker β€” is the same everywhere. The actual work is threefold: rewriting task definitions (decorator syntax differs but the function bodies rarely need to change), re-implementing any Celery-specific features you were relying on (Canvas workflows have no direct equivalent in RQ or arq β€” you’d hand-roll the chaining logic), and β€” the part that actually causes incidents β€” auditing every task for idempotency before you migrate, since a broker swap is exactly the kind of change that causes double-delivery during the cutover window.

The safest pattern is a parallel run: stand up the new queue alongside the old one, dual-write new task types to it while existing in-flight tasks drain from the old broker, and cut over queue-by-queue rather than all at once. Trying to migrate a live broker’s queued-but-not-yet-run messages directly (Celery to RQ, for instance) isn’t supported by any of these libraries β€” you drain the old queue naturally and start fresh on the new one, which means picking a low-traffic window rather than expecting a zero-downtime cutover.

Where to Go From Here

Picking the queue is the easy 20% of this problem β€” the other 80% is making tasks behave correctly once they’re running in production under real failure conditions. Three follow-ups worth reading regardless of which library you land on:

Wrapping Up

There’s no universally correct answer buried in a celery vs dramatiq benchmark, and there wasn’t going to be one β€” the right choice depends on which broker you’re already committed to, whether your app is synchronous or async-native, and how much operational surface your team actually wants to own. Celery earns its complexity when you need RabbitMQ, SQS, or genuine workflow composition. Dramatiq earns its place when you want most of that reliability without most of that configuration. RQ wins when simplicity is the feature. arq wins when your app is already built on asyncio and bridging sync task execution into it would be the actual complexity you’re trying to avoid.

Pick based on what you’re already running and how your tasks actually need to behave under failure β€” not based on which name shows up first in a search result.

Faizan Nadeem
Author
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

More articles