Sooner or later every Python web app needs to do work outside the request: send the email after returning the response, generate the report, process the upload, retry the flaky third-party call. Doing that work inline makes your app slow and fragile; doing it properly means a background job system. The trouble is choosing one Celery, RQ, Dramatiq, and APScheduler all "run background tasks," but they solve different problems. This guide compares them honestly so you pick the right tool the first time.
How to choose with confidence
- The core model: a broker, a queue, and worker processes
- Celery: the powerful, feature-rich, heavier incumbent
- RQ: the simple, Redis-only option that is easy to reason about
- Dramatiq: a modern middle ground with great defaults
- APScheduler: for scheduling, which is a different problem entirely
Info
First, separate two different needs
Task queues (Celery, RQ, Dramatiq) run work triggered by your app "process this now, off the request path." Schedulers (APScheduler) run work triggered by time "do this every night at 2am." Many apps need both, and conflating them is the root of a lot of confusion.
The shared mental model
Task queues all share the same shape. Your app enqueues a job (a function name plus arguments) into a broker usually Redis or RabbitMQ. Separate worker processes pull jobs off the queue and execute them, handling retries, failures, and results. Your web process stays fast because it only enqueues; the slow work happens elsewhere.
web app ──enqueue──▶ [ BROKER: Redis/RabbitMQ ] ──▶ worker pool ──▶ done
(fast) holds the queue (does the slow work)
Same shape for Celery, RQ, and Dramatiq.
They differ in features, simplicity, and operational weight.
Celery: the feature-rich incumbent
Celery is the long-standing default the most powerful and the most widely deployed. It supports multiple brokers, complex workflows (chains, groups, chords), scheduled tasks via its beat scheduler, rate limiting, retries, and a deep configuration surface. That power is also its cost: Celery has a reputation for configuration complexity and operational sharp edges, and the breadth can be overwhelming for a simple need.
# tasks.py
from celery import Celery
app = Celery("app", broker="redis://localhost:6379/0", backend="redis://localhost:6379/1")
@app.task(bind=True, max_retries=3, autoretry_for=(ConnectionError,), retry_backoff=True)
def send_receipt(self, order_id: int) -> None:
order = Order.objects.get(id=order_id) # pass an ID, not the object
email.send(order.email, render_receipt(order))
# Enqueue from your web app — returns instantly, work happens on a worker.
send_receipt.delay(order_id=42)
# Run workers: celery -A tasks worker --concurrency=8
# Periodic: celery -A tasks beat (Celery's built-in scheduler)
✓ Pros
- Most features and the largest ecosystem and community
- Complex workflows: chains, groups, chords, callbacks
- Multiple broker backends; mature, battle-tested at scale
- Built-in periodic scheduling via beat
✕ Cons
- Configuration complexity is real easy to misconfigure
- Heavier operationally than simpler alternatives
- Overkill when you just need "run this function later"
- Debugging its edge cases has a learning curve
RQ: simple by design
RQ (Redis Queue) is the antithesis of Celery's breadth: it is small, readable, and does one thing run Python functions on workers via Redis. If you can read its source in an afternoon (you nearly can), you can reason about its behaviour in production. The trade-off is fewer features and Redis-only, but for a large share of apps that is exactly enough.
from redis import Redis
from rq import Queue
# Any plain function is a job — no decorator required.
def send_receipt(order_id: int) -> None:
order = Order.objects.get(id=order_id)
email.send(order.email, render_receipt(order))
queue = Queue(connection=Redis())
queue.enqueue(send_receipt, 42, retry=Retry(max=3)) # enqueue and move on
# Run a worker: rq worker
✓ Pros
- Minimal, easy to learn, easy to debug
- Redis-only one dependency you probably already run
- Transparent behaviour; little hidden magic
- Great fit for straightforward "do this later" tasks
✕ Cons
- Fewer advanced features than Celery or Dramatiq
- Redis only — no RabbitMQ option
- Complex workflows need more manual assembly
- Less suited to very high-throughput, elaborate pipelines
Dramatiq: the modern middle ground
Dramatiq was designed to keep most of Celery's usefulness while being simpler and more reliable by default. It offers sensible behaviour out of the box automatic retries, message age limits, and reliability features that you have to assemble or tune elsewhere. For teams who find Celery too heavy but RQ too bare, Dramatiq is often the sweet spot.
import dramatiq
# Retries and sane defaults are built in; you opt into specifics per actor.
@dramatiq.actor(max_retries=3, time_limit=30_000)
def send_receipt(order_id: int) -> None:
order = Order.objects.get(id=order_id)
email.send(order.email, render_receipt(order))
send_receipt.send(42) # enqueue
# Run workers: dramatiq tasks
✓ Pros
- Clean API with reliable, sensible defaults
- Built-in retries and message handling done well
- Supports Redis and RabbitMQ
- Less operational surprise than Celery for many teams
✕ Cons
- Smaller community than Celery (though healthy)
- Fewer exotic workflow primitives than Celery
- Still needs a broker and worker ops like the others
- Less ubiquitous, so fewer ready-made integrations
APScheduler: a different tool for a different job
APScheduler is not a task queue it is a scheduler. Its job is to run code on a time-based trigger: every hour, every weekday at 9am, in thirty minutes. It is the in-process answer to "do this on a schedule." Importantly, it does not, by itself, give you a distributed worker pool with retries and queuing; for scheduled work that is also heavy or must scale, the common pattern is to let a scheduler simply enqueue jobs into a task queue.
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
scheduler = BackgroundScheduler()
# Don't do the heavy work here — just drop a job onto the real task queue.
@scheduler.scheduled_job(CronTrigger(hour=2, minute=0)) # every day at 02:00
def nightly_reports():
for tenant_id in active_tenant_ids():
generate_report.delay(tenant_id) # Celery/RQ/Dramatiq does the actual work
scheduler.start()
Tip
Schedule, then enqueue
The cleanest pattern for heavy recurring work is to use a scheduler (APScheduler, Celery beat, or even system cron) to do nothing but drop a job onto your task queue at the right time. The scheduler decides "when"; the queue and its workers handle "how" with retries, concurrency, and scale.
| Dimension | Celery | RQ | Dramatiq | APScheduler |
|---|---|---|---|---|
| Type | Task queue | Task queue | Task queue | Scheduler |
| Best for | Power & ecosystem | Simplicity | Modern balance | Time-based triggers |
| Broker | Redis / RabbitMQ / + | Redis only | Redis / RabbitMQ | In-process |
| Complexity | High | Low | Medium | Low |
| Retries built in | Yes (configurable) | Basic | Yes (good defaults) | Not applicable |
The decision guide
Just need "run this function later," simply?
Reach for RQ. Minimal moving parts, Redis you likely already have, behaviour you can fully understand.
Want modern defaults and reliability without Celery's weight?
Choose Dramatiq. It hits the balance of features and simplicity for most growing apps.
Need complex workflows, multiple brokers, or maximum ecosystem?
Celery remains the answer accept the configuration cost in exchange for its power.
Need time-based scheduling?
Use APScheduler (or cron/Celery beat) and have it enqueue into your task queue for anything heavy.
queues run on-demand work; schedulers run time-based work pick per need
Operational truths for all of them
Whatever you choose, the same realities apply: make tasks idempotent, because they will occasionally run more than once; keep task arguments small and serializable (pass an ID, not a giant object); configure retries with backoff for transient failures and a dead-letter path for permanent ones; and monitor your queue depth, because a silently growing backlog is how background systems fail invisibly.
✓ Pros
- Idempotent tasks that tolerate being run twice
- Small, serializable arguments pass IDs, refetch inside the task
- Retries with backoff plus a dead-letter destination
- Monitoring on queue depth, failure rate, and worker health
✕ Cons
- No passing huge objects or live connections as task args
- No assuming exactly-once execution
- No unbounded retries that hammer a failing dependency
- No running a queue blind to its backlog
! Common mistakes to avoid
-
✕Passing large objects or live connections as task arguments.
✓Pass a small ID and refetch inside the task; arguments must be small and serializable.
-
✕Assuming a task runs exactly once.
✓Make tasks idempotent they will occasionally run more than once.
-
✕Unbounded retries that hammer a failing dependency.
✓Retry with backoff and send permanent failures to a dead-letter queue.
-
✕Running the queue blind to its backlog.
✓Monitor queue depth, failure rate, and worker health a silent backlog is how these systems fail.
? Frequently asked questions
What is the difference between a task queue and a scheduler? +
A task queue (Celery, RQ, Dramatiq) runs work triggered by your app, off the request path. A scheduler (APScheduler) runs work triggered by time. Many apps need both — and the clean pattern is to have the scheduler enqueue jobs into the queue.
Celery vs RQ vs Dramatiq — which should I pick? +
RQ for simplicity (Redis-only, easy to reason about), Dramatiq for modern defaults and reliability without Celery's weight, and Celery for complex workflows, multiple brokers, and the largest ecosystem.
When is Celery overkill? +
When you just need "run this function later." Its power comes with configuration complexity and operational sharp edges that a simpler tool like RQ avoids.
How do I run heavy scheduled work? +
Use a scheduler (APScheduler, Celery beat, or cron) to do nothing but enqueue a job onto your task queue at the right time; the queue and its workers handle retries, concurrency, and scale.
Why must tasks be idempotent? +
Because at-least-once delivery means a task can run twice (a worker can crash after doing the work but before acknowledging it). Idempotent tasks make a duplicate run a harmless no-op.
Success
Match the tool to the shape of the work
There is no universally best option RQ for simplicity, Dramatiq for modern balance, Celery for power and ecosystem, APScheduler for time-based triggers. Identify whether your need is on-demand or scheduled, simple or elaborate, and the right choice becomes obvious. Then apply the operational basics and your background system becomes the dependable, invisible workhorse it should be.
Practice on the go
Learn Python, the free Android app
Every topic in this series lives in the app too: bite-size lessons, runnable examples, quizzes, mini projects, and an offline Python playground that runs on your phone.
Comments
0No comments yet. Be the first to share your thoughts.