Table of Contents
- What Django 6.0 Actually Brings
- A Task in Just a Few Lines
- Why a Common Interface Helps Anyway
- What Celery Still Brings to the Table
- Comparing Django Tasks and Celery
- What Would I Choose for a New Project?
- Can Celery Run Behind django.tasks?
- Two Things Remain Critical with Any Backend
- So, Can We Say Goodbye to Celery?
Meet the Author
2026-09-28
Background Tasks in Django 6.0Django 6.0 Task Framework: Do We Still Need Celery?
You want to send an email after registration. A fairly straightforward requirement, really. A little later, Redis, an additional worker, and a celery.py appear in your project. Add to that the question of who notices when the worker eventually gives up. Welcome to background tasks.

Celery has been an obvious choice for this for years. Still, not every project needs the full setup. With django.tasks, Django 6.0 has introduced its own task interface. It is tempting to consider removing Celery entirely with the next update.
I would wait on that.
Django 6.0 has been out since December 2025. In the Release Notes, you can see what actually shipped. And that differs in one crucial aspect from the idea of a ready-to-use built-in task queue.
What Django 6.0 Actually Brings
With django.tasks, you can define tasks and hand them over to a backend. Execution outside the request still requires additional infrastructure. Django itself does not ship with a production worker. I had to look that up first, because that is exactly what I would have expected.
The two built-in backends are:
- ImmediateBackend: Executes the task immediately. The calling code waits for execution.
- DummyBackend: Captures submitted tasks for testing, but does not execute them.
Without any other configuration, Django uses the ImmediateBackend. The tasks documentation makes this quite clear: calling .enqueue() does not mean the work actually runs in the background.
I check this before the first deployment. Otherwise, the function may now be called a task, but the user is still waiting on the mail server.
And What About the DatabaseBackend?
At first, I also thought of a backend that stores jobs via the Django ORM. In the original proposal, that was indeed planned. However, it is not part of the shipped scope of Django 6.0. The backend reference lists only Immediate and Dummy.
A draft and a release are two different things. For a concrete setup, what matters is the documentation of the version you are running, not what was once written in the DEP.
A Task in Just a Few Lines
Let's take the welcome email from the beginning:
# notifications/tasks.py
from django.core.mail import send_mail
from django.tasks import task
@task
def send_welcome_email(email):
return send_mail(
subject="Welcome aboard",
message="Your account is ready. You can get started.",
from_email="hello@example.com",
recipient_list=[email],
)
Handing it over to the backend looks like this:
send_welcome_email.enqueue("user@example.com")
The example assumes a configured Django mail backend. Whether the call executes directly or enqueues a genuine background job is determined by your task backend. If you truly want asynchronous execution, the decorator alone won't cut it. You will need an external backend with a queue and worker, exactly as Django describes under defining and enqueuing tasks.
With Celery, the equivalent entry point would be:
from celery import shared_task
from django.core.mail import send_mail
@shared_task
def send_welcome_email(email):
return send_mail(
subject="Welcome aboard",
message="Your account is ready. You can get started.",
from_email="hello@example.com",
recipient_list=[email],
)
send_welcome_email.delay("user@example.com")
Each snippet shows only the task definition, not a full deployment. In the Celery documentation on tasks, you will see the same entry point, plus broker and worker. Basing the decision on the decorator alone makes little sense either way.
Why a Common Interface Helps Anyway
This becomes especially interesting for reusable Django apps. An app can describe what work needs to be done. The project utilizing it decides on the appropriate backend. It is precisely this separation of task and execution that makes the interface viable for such packages.
I think this fits Django well. With a mail backend, after all, we also expect application code not to have to know every individual transport mechanism. In our article on django-o365, you will find a related principle.
Django provides the interface. With a production backend, a queue and worker are added.
However, this does not make backends completely interchangeable at will. Priorities, delayed execution, and retrieving results later depend on the supported capabilities. The API provides capability flags for this purpose. Checking those first before switching is well worth it.
What Celery Still Brings to the Table
As soon as jobs depend on one another or errors need targeted handling, the differences become much clearer.
Celery offers APIs for retries, automatic retries, and backoff. It also provides features like task rate limits. Here, the fine print in the task options is worth noting: such a limit applies per worker instance, not automatically across the entire deployment.
With Canvas, you can compose workflows. A chain executes tasks sequentially. A group runs multiple tasks in parallel. A chord executes a subsequent step after a group, provided the result backend meets the prerequisites.
Imagine importing data from multiple supplier systems. First, data is fetched and processed in parallel, and then a combined report needs to be created. You need an answer to when everything is truly finished and what happens if an error occurs. Such scenarios do not belong in django.tasks.
For recurring tasks, Celery Beat serves as a scheduler. Operations are involved here too: multiple schedulers running the same schedule can enqueue the same jobs multiple times. I have seen this in real-world setups, and the resulting silence can be unsettling.
For troubleshooting, tools like inspection commands, worker events, and Flower are available. They allow you to monitor workers and tasks. Defining meaningful alerts is still up to you, however.
If a project is already taking advantage of these features, switching involves far more work than simply swapping an import statement. In that case, migrating is usually not worthwhile.
Comparing Django Tasks and Celery
| Question | Django 6.0 Task Framework | Celery |
|---|---|---|
| Production worker included? | No, provided by external backend | Yes, run separately |
| Default without additional task configuration? | Immediate execution within caller | Requires broker and worker for background operation |
| Chains, groups, and chords? | No corresponding workflow API in core | Via Canvas |
| Retries and operational behavior? | Depends on selected backend | Dedicated APIs and configuration |
| Monitoring? | Check backend and custom integration | Inspection, events, e.g. Flower |
| Scaling? | Depends on queue, backend, and workers | Depends on broker, workers, and load profile |
A rigid division into "Django scales vertically, Celery horizontally" doesn't help here. django.tasks doesn't define the production system at all. How many workers you can reasonably operate is determined by the specific implementation, not the framework label.
What Would I Choose for a New Project?
For a small number of independent jobs, django.tasks combined with a suitable production backend is well worth considering. For example, for exports or image processing where no complex dependencies exist between jobs.
My questions would be quite pragmatic: Where do I see failed tasks? What happens if a worker crashes? How do retries work? Can we deploy and monitor the setup properly?
For an existing, smoothly running Celery setup, I would stick with it for now. A migration should solve a concrete problem. "It's in the framework now" is not enough reason for me.
If chains, chords, or specific Celery features are already integral to the application, that dependency should remain visible in the code. Placing a smaller interface in front of it adds little value if you constantly have to work around it.
We already discussed further options in our post on Alternatives to Celery for Django on Kubernetes. Even with Django 6.0, taking another look there before setting up Celery out of habit is definitely worthwhile.
Can Celery Run Behind django.tasks?
Via the backend API, an adapter for Celery is conceivable in principle. However, that does not mean Django ships with such an adapter or that existing Celery workflows will automatically work through it. The concrete integration must handle this translation.
Before a migration, it must be clear which versions and features an adapter supports. Particularly regarding results, errors, and retries, I want to know what behavior my application will get.
As a first candidate, I would pick an independent job. Then compare inputs and results, provoke errors, and verify monitoring. Existing queues also need to be drained in a controlled manner. Having two implementations dispatching the same email is rarely the desired migration outcome.
Two Things Remain Critical with Any Backend
Pass simple data. For Django Tasks, arguments and return values must be JSON-serializable. For database objects, only pass the ID to the task. The worker loads the object itself.
Mind transactions. A worker can start running before the new record has been committed. Django demonstrates transaction.on_commit() for this: only once the transaction has successfully completed is the task enqueued. This is also stated in the documentation on tasks and transactions. This is not an optional nice-to-have.
Even after that, a practical question remains: what happens if the commit succeeds, but handing off to the queue fails? For business-critical workflows, you need a strategy for this. An outbox pattern with a separate dispatcher is one possible solution.
And jobs should tolerate retries. If an external service has executed an action, but the response is lost in transit, retrying is not automatically harmless. That applies just as much to invoice delivery as it does to payment processing.
So, Can We Say Goodbye to Celery?
I wouldn't write Celery a farewell letter just yet.
Django 6.0 provides us with a unified entry point for background tasks. This is especially appealing when we want to decouple tasks from the concrete execution system. Deciding which underlying infrastructure fits remains a decision driven by the application.
For new projects with simple, independent background jobs, django.tasks is well worth it. You will still need an appropriate task queue and worker. Anyone already using Celery effectively has no immediate reason to rebuild everything because of Django 6.0.
I think it's great that Django is creating a common foundation here. Whether this ultimately leads to less effort will become evident in operations. A different decorator alone doesn't save us a worker, nor does it resolve failed jobs.





















