Web Development · Engineering
Django 6.0 Tasks Framework: Can It Replace Celery?
What breaks when you move from Celery to Django 6.0's Tasks framework, which workloads migrate cleanly, and when you should stay on Celery.
Anurag Verma
14 min read
Sponsored
Django 6.0 ships a built-in Tasks framework, and for a lot of projects that raises an obvious question: can you finally take Celery out? For simple workloads the move is genuinely smooth, the kind where you finish early and feel suspicious. For complex ones it can go badly, and the classic way it goes badly is a retry that quietly stops retrying, so the nightly report emails stop for two days before anyone notices.
This is not the changelog blog post that tells you what the Tasks framework is and why it is exciting. You can read the Django docs for that. This post covers what breaks, what surprises you, and when you should absolutely not rip out Celery.
If you are running Celery in production and wondering whether Django 6.0’s Tasks framework means you can finally ditch the broker, the answer is: probably yes, but not the way you think.
What the Tasks Framework Actually Is (And What It Is Not)
On first read, the Django 6.0 release notes look almost too good to be true: Django shipping a built-in async task system after years of the community asking for one.
Here is the core of it. You define a task:
# tasks.py
from django.tasks import task
@task()
def send_welcome_email(user_id: int):
user = User.objects.get(id=user_id)
# Your email sending logic here
send_templated_email(
to=user.email,
template="welcome",
context={"name": user.first_name}
)
And you enqueue it:
# views.py
from .tasks import send_welcome_email
def register_view(request):
user = User.objects.create_user(...)
send_welcome_email.enqueue(user.id) # Returns immediately
return redirect("dashboard")
That is it. No broker configuration. No CELERY_BROKER_URL. No separate worker process to forget to start. No celery -A proj worker -l info in another terminal tab that you inevitably close by accident.
But here is what it is not: It is not a drop-in Celery replacement. The Tasks framework is deliberately simple. Django’s philosophy has always been “batteries included, but the right batteries.” They built the 80% case and left escape hatches for the rest.
The key concepts:
@task()decorator — marks a function as a task. It can still be called normally (synchronous), or enqueued for background execution.enqueue()— pushes the task to whatever backend you have configured. Returns aTaskResultyou can check later.- Backends — pluggable execution engines. Django ships with
ImmediateBackend(runs synchronously, great for testing) andDummyBackend(discards, great for CI). Third-party backends provide the real workers. TaskResult— a handle to track status, retrieve return values, or check for errors.
The thing that trips people up: there is no built-in production backend in Django itself, as Django’s own Tasks documentation spells out. Django provides the framework and the API. You need a third-party backend for actual background execution. This is intentional — the Django team did not want to ship and maintain a message broker or worker pool.
The Third-Party Backend Ecosystem
This is where things get interesting. Within weeks of the Django 6.0 release, the community shipped several backends, echoing the same broker debate we mapped out in our Celery vs BullMQ vs Sidekiq comparison:
| Backend | Broker | Best For | Maturity |
|---|---|---|---|
django-tasks-db | PostgreSQL (your existing DB) | Small-to-medium projects, simple queues | Stable |
django-tasks-celery | Redis/RabbitMQ (via Celery) | Projects already running Celery infrastructure | Beta |
django-tasks-rq | Redis (via RQ) | Teams already running Redis | Pre-1.0 |
The one most people actually want is django-tasks-db. It uses your existing PostgreSQL database as the task queue. Yes, I know — using your database as a message broker sounds like a terrible idea, and five years ago it was. But PostgreSQL’s SKIP LOCKED and LISTEN/NOTIFY have matured to the point where this is genuinely viable for most workloads.
# settings.py
INSTALLED_APPS = [
# ...
"django_tasks",
"django_tasks_db",
]
TASKS = {
"default": {
"BACKEND": "django_tasks_db.DatabaseBackend",
"QUEUES": ["default", "email", "reports"],
}
}
Then you run the worker:
python manage.py db_worker --queue default --queue email
This is the “aha” moment. One manage.py command instead of a whole Celery ecosystem. No Redis. No RabbitMQ. No celerybeat. Just your Django project and your PostgreSQL database.
For the django-tasks-celery adapter — this is the bridge for teams that cannot migrate overnight. It routes Django Tasks API calls to your existing Celery infrastructure. You get the new API without changing your deployment:
# settings.py -- gradual migration path
TASKS = {
"default": {
"BACKEND": "django_tasks_celery.CeleryBackend",
}
}
What a migration looks like at three levels of complexity
The easiest way to see where the Tasks framework fits is to walk through three typical Celery setups:
- Project A — SaaS dashboard. 12 Celery tasks, mostly email and PDF generation. Standard stuff.
- Project B — E-commerce platform. 8 Celery tasks, order processing and inventory sync.
- Project C — Data pipeline. 47 Celery tasks, heavy use of chords, chains, periodic tasks, and retry logic.
Project A: The Easy Win
Twelve tasks, no chains, no chords, no periodic scheduling. This is close to a literal find-and-replace:
Before (Celery):
from celery import shared_task
@shared_task(bind=True, max_retries=3)
def generate_monthly_report(self, org_id: int, month: str):
try:
report = build_report(org_id, month)
upload_to_s3(report)
notify_admin(org_id, report.url)
except S3Error as exc:
raise self.retry(exc=exc, countdown=60)
After (Django Tasks):
from django.tasks import task
@task(queue="reports")
def generate_monthly_report(org_id: int, month: str):
report = build_report(org_id, month)
upload_to_s3(report)
notify_admin(org_id, report.url)
Wait — where did the retry logic go? We will get to that. It is the step that is easiest to miss.
A migration like this can fit in an afternoon, including testing. Redis comes out of the Docker Compose file, the Celery worker service goes away, and so does the bill for the Redis instance. Not life-changing money, but the simpler infrastructure is a real gain.
Project B: Minor Surprises
An e-commerce project like this is mostly straightforward, but two issues tend to come up:
Issue 1: Task serialization differences. Celery uses pickle or JSON serialization with its own type handling. Django Tasks uses JSON by default, and it is stricter about what you can pass. A task that passes a Decimal object directly will break:
# This worked with Celery's pickle serializer
process_refund.delay(order_id=123, amount=Decimal("49.99"))
# Django Tasks with JSON backend -- Decimal is not JSON serializable
process_refund.enqueue(order_id=123, amount=Decimal("49.99")) # TypeError!
The fix is simple — pass strings and convert inside the task — but it is the kind of thing that only shows up when you actually run the code with production data.
Issue 2: No countdown parameter. Celery lets you do task.apply_async(countdown=300) to delay execution by 5 minutes. Django Tasks has no countdown. Instead you set run_after on the task with .using(), and the backend has to support deferred tasks (the database backend does):
from datetime import timedelta
from django.utils import timezone
# Schedule for 5 minutes from now
send_shipping_notification.using(
run_after=timezone.now() + timedelta(minutes=5)
).enqueue(order_id=123)
Still straightforward, but it needs more careful testing than the simple case.
Project C: where it gets hard
This is the one with 47 tasks, and it is where “migrate to Django Tasks” stops being a one-size-fits-all proposition.
What broke:
-
Chords and chains do not exist. Celery’s
chord(tasks, callback)andchain(task1, task2, task3)have no equivalent in Django Tasks. A data pipeline that uses chords extensively (“run these 8 data fetch tasks in parallel, then when all complete, run the aggregation task”) has to rewrite that logic manually using task status polling and a coordinator task. -
Periodic tasks need a different solution. Celery Beat handles the periodic work (hourly syncs, daily reports, weekly cleanups). Django Tasks has no built-in scheduler, so you need a third-party extension such as
django-tasks-scheduler, which is brand new and worth testing hard before you rely on it. -
Priority queues work differently. A pipeline that relies on Celery’s priority routing so payment processing tasks jump ahead of report generation will feel this. The database backend supports basic queue separation but not intra-queue priority in the same way.
-
The silent retry failure. This is the worst one. Take a task that uses Celery’s
self.request.retriesto implement exponential backoff, and migrate it without re-implementing the retry logic. The task fails on a transient database timeout, and instead of retrying, it just… stops. If that is a nightly report that fails on a Friday night, the client may well notice before you do on Monday morning. It is the same failure mode we cover in our dead letter queue guide — a message that fails and has nowhere defined to go.
A pipeline like this can easily take several times the original estimate. The pragmatic route is the django-tasks-celery bridge for the complex pipeline tasks, with only the simple fire-and-forget tasks moving to the database backend.
When Django Tasks Replaces Celery (And When It Does Not)
Here is a practical decision framework:
Use Django Tasks (with database backend) when:
- Your tasks are fire-and-forget (emails, notifications, webhooks)
- You have fewer than ~50 tasks executing per minute
- You do not need chords, chains, or complex workflows
- You want to eliminate Redis/RabbitMQ from your stack
- Your team is small and values simplicity over flexibility
Stay on Celery when:
- You use chords, chains, or groups extensively
- You process hundreds of tasks per minute (database-as-queue has limits)
- You need priority queues with sophisticated routing
- You have complex retry patterns with dead letter queues
- Your Celery setup is already stable and well-understood
Use the Celery bridge when:
- You want the new API but cannot change infrastructure
- You are migrating gradually over months
- Some tasks are simple (move to database backend) and some are complex (keep on Celery)
Here is the decision as a table, because tables are easier to argue about in code reviews:
| Factor | Django Tasks (DB) | Celery |
|---|---|---|
| Setup complexity | 5 minutes | 30-60 minutes |
| Infrastructure needed | PostgreSQL (you already have it) | Redis or RabbitMQ + worker process |
| Task throughput ceiling | ~50/min comfortably | Thousands/min |
| Chords/chains | Not built-in | First-class support |
| Periodic tasks | Third-party extension | Celery Beat (mature) |
| Retry with backoff | Manual (see below) | Built-in decorator |
| Monitoring | Django admin (basic) | Flower (excellent) |
| Learning curve | 15 minutes | 2-4 hours |
| Community maturity | Months old | 12+ years |
Production Patterns That Actually Work
These patterns cover the gaps that come up most often when moving off Celery.
Pattern 1: Retry with Exponential Backoff
Django Tasks does not have Celery’s @shared_task(max_retries=3, default_retry_delay=60). You build it yourself:
from django.tasks import task
from datetime import timedelta
from django.utils import timezone
@task(queue="email")
def send_transactional_email(user_id: int, template: str, attempt: int = 0):
max_attempts = 5
if attempt >= max_attempts:
# Log to error tracking, notify ops
logger.error(f"Email task failed after {max_attempts} attempts: {user_id}")
capture_exception(f"Email delivery failed for user {user_id}")
return
try:
user = User.objects.get(id=user_id)
deliver_email(user.email, template)
except (SMTPError, ConnectionError) as exc:
# Exponential backoff: 30s, 60s, 120s, 240s, 480s
delay = timedelta(seconds=30 * (2 ** attempt))
logger.warning(f"Email attempt {attempt + 1} failed, retrying in {delay}: {exc}")
send_transactional_email.using(
run_after=timezone.now() + delay,
).enqueue(
user_id=user_id,
template=template,
attempt=attempt + 1,
)
Is this more code than Celery’s decorator? Yes. Is it more explicit about what happens on failure? Also yes. There is a good case for preferring this: when a task’s retry logic is spelled out, new team members understand it immediately instead of having to look up what default_retry_delay and retry_backoff do.
Pattern 2: Transaction-Safe Enqueueing
This one is subtle. If you enqueue a task inside a database transaction, and the transaction rolls back, the task still runs — because the task queue is a separate system from your application database.
Unless you use the database backend. Then your task enqueue is part of the same database transaction:
from django.db import transaction
def create_order(request):
with transaction.atomic():
order = Order.objects.create(
user=request.user,
items=cart_items,
total=calculate_total(cart_items),
)
# This enqueue is part of the same transaction!
# If the transaction rolls back, the task is never enqueued.
send_order_confirmation.enqueue(order_id=order.id)
charge_payment.enqueue(order_id=order.id)
return redirect("order-success", order_id=order.id)
This is genuinely better than Celery. With Celery + Redis, you either enqueue before the commit (risky — task runs but DB change might not exist yet) or enqueue after the commit (using transaction.on_commit, which is fragile and easy to forget). With the database backend, it just works.
Pattern 3: Graceful Degradation
What happens when your task worker goes down? With Celery, tasks pile up in Redis until memory runs out. With the database backend, tasks pile up in PostgreSQL, which handles disk-backed queuing gracefully:
from django.tasks import task
@task(queue="notifications")
def send_push_notification(user_id: int, message: str):
try:
deliver_push(user_id, message)
except PushServiceUnavailable:
# Notification service is down -- degrade gracefully
# Fall back to email instead of retrying push indefinitely
send_email_notification.enqueue(user_id=user_id, message=message)
Pattern 4: Testing Is Actually Pleasant
This is the sleeper hit of the Tasks framework. Testing Celery tasks always felt hacky — you either mock the .delay() call or use CELERY_ALWAYS_EAGER=True (which hides async-specific bugs). Django Tasks has the ImmediateBackend that runs tasks synchronously in your test:
# settings/test.py
TASKS = {
"default": {
"BACKEND": "django.tasks.backends.ImmediateBackend",
}
}
# test_tasks.py
from django.test import TestCase, override_settings
class OrderTasksTest(TestCase):
def test_order_confirmation_sent(self):
order = OrderFactory.create()
# This runs synchronously in tests -- no mocking needed
send_order_confirmation.enqueue(order_id=order.id)
# Assert the email was actually sent
self.assertEqual(len(mail.outbox), 1)
self.assertIn(order.id, mail.outbox[0].body)
@override_settings(TASKS={"default": {"BACKEND": "django.tasks.backends.DummyBackend"}})
def test_view_enqueues_task(self):
# DummyBackend discards -- just verify the view does not crash
response = self.client.post("/orders/create/", data={...})
self.assertEqual(response.status_code, 302)
Compare this to the Celery testing dance of @patch('myapp.tasks.send_email.delay') everywhere. Night and day.
Where each workload lands
Putting the three setups side by side:
Project A (simple SaaS): Can run entirely on django-tasks-db, with Redis removed completely. A new developer can add a task without reading anything beyond the Django docs. That is the biggest win — onboarding. Celery’s learning curve is real. “What is a broker? Why do I need Redis? What is this celery -A command?” None of that exists anymore.
Project B (e-commerce): django-tasks-db for most tasks, with a manual coordinator pattern replacing any Celery chain.
Project C (data pipeline): A hybrid — simple tasks on the database backend, complex pipeline tasks still on Celery via the bridge adapter. The pipeline tasks can move once the third-party ecosystem matures, but there is no rush.
Infrastructure savings: Dropping Redis from a project means one fewer thing to monitor, patch, and pay for. The cost savings are modest, but the operational simplicity gain is significant.
My prediction: Within two years, most new Django projects will use the Tasks framework instead of Celery. Celery will remain important for high-throughput and complex workflow use cases, but for the vast majority of “run this thing in the background” needs, Django Tasks with the database backend is simply better. It is simpler, it is transactionally safe, and it is one less thing to explain to new developers.
The Django team got this right. They did not try to replace Celery — they built the thing that 80% of projects actually needed.
This is the first post in our Django in 2026 series. Next up: Django + HTMX — building interactive apps without the JavaScript bloat.
Running a Django project and wondering whether the Tasks framework makes sense for your workload? CODERCOPS builds Django applications. Get in touch for an honest assessment of your stack, or check out our other engineering deep dives.
Frequently asked questions
- Does Django 6.0's Tasks framework replace Celery?
- Not entirely. It replaces Celery well for fire-and-forget tasks under roughly 50 executions per minute with no chords, chains, or complex retry logic. Projects with high throughput, chord/chain workflows, or sophisticated priority routing should stay on Celery or use the `django-tasks-celery` bridge.
- What backend should I use with Django Tasks in production?
- `django-tasks-db` is the one most teams want -- it uses your existing PostgreSQL database via `SKIP LOCKED` row locking, so there is no Redis or RabbitMQ to run. `django-tasks-celery` is a bridge for teams that want the new API without touching their existing Celery infrastructure yet.
- How do I handle retries with Django Tasks, since there's no built-in decorator?
- You implement it manually inside the task function: track an `attempt` count as a task argument, and on failure re-enqueue the task with an `eta` set to a backoff delay (for example `30 * (2 ** attempt)` seconds). It is more code than Celery's `max_retries` decorator, but the failure path is explicit.
- What breaks when migrating a complex Celery pipeline to Django Tasks?
- Chords and chains have no equivalent, so parallel-then-aggregate pipelines need a manual coordinator task polling status. Periodic tasks need a third-party scheduler extension. Priority queues lose their intra-queue ordering. And forgetting to reimplement retry logic means a failing task silently stops instead of retrying, which can take down something like a nightly report until someone notices.
- Is the database backend fast enough for production task queues?
- For fire-and-forget workloads under about 50 tasks/minute, yes. For workloads processing hundreds of tasks per minute, database-as-queue has real throughput limits and Celery with Redis or RabbitMQ is still the better fit.
Sources
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored