Skip to content

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

Anurag Verma

14 min read

Django 6.0's built-in Tasks framework compared with Celery

Sponsored

Share

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 a TaskResult you can check later.
  • Backends — pluggable execution engines. Django ships with ImmediateBackend (runs synchronously, great for testing) and DummyBackend (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:

BackendBrokerBest ForMaturity
django-tasks-dbPostgreSQL (your existing DB)Small-to-medium projects, simple queuesStable
django-tasks-celeryRedis/RabbitMQ (via Celery)Projects already running Celery infrastructureBeta
django-tasks-rqRedis (via RQ)Teams already running RedisPre-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:

  1. Project A — SaaS dashboard. 12 Celery tasks, mostly email and PDF generation. Standard stuff.
  2. Project B — E-commerce platform. 8 Celery tasks, order processing and inventory sync.
  3. 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:

  1. Chords and chains do not exist. Celery’s chord(tasks, callback) and chain(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.

  2. 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.

  3. 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.

  4. The silent retry failure. This is the worst one. Take a task that uses Celery’s self.request.retries to 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:

FactorDjango Tasks (DB)Celery
Setup complexity5 minutes30-60 minutes
Infrastructure neededPostgreSQL (you already have it)Redis or RabbitMQ + worker process
Task throughput ceiling~50/min comfortablyThousands/min
Chords/chainsNot built-inFirst-class support
Periodic tasksThird-party extensionCelery Beat (mature)
Retry with backoffManual (see below)Built-in decorator
MonitoringDjango admin (basic)Flower (excellent)
Learning curve15 minutes2-4 hours
Community maturityMonths old12+ 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

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored