Django's Tasks API Ships Without a Worker
Halfway through a Django 6.0 upgrade on a client’s compliance SaaS, I opened a branch called kill-celery. The plan was two hours of work: swap every @shared_task for the new built-in @task, drop the broker, delete the worker service, ship a smaller stack. I got as far as pasting a TASKS block into settings before I hit the sentence that closed the branch. Django’s Tasks framework, in its own words, “does not provide a worker mechanism to run Tasks” — execution “must be handled by infrastructure outside Django, such as a separate process or service.”
That is not a rough edge to be smoothed over in a point release. It is the declared scope of the feature, and it is the part most upgrade write-ups skip past on their way to the code sample.
What actually shipped
Django 6.0 arrived on December 3, 2025 with an API that standardises how background work is declared and enqueued, not how it is executed. You decorate a function, call .enqueue(), and get a TaskResult back:
# billing/tasks.py
from django.core.mail import send_mail
from django.tasks import task
@task(queue_name="emails", priority=10)
def email_users(emails, subject, message):
return send_mail(subject, message, None, emails)
# settings.py
TASKS = {
"default": {
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
"QUEUES": ["default", "emails"],
}
}
Two backends come in the box: ImmediateBackend, which runs the function inline the moment you enqueue it, and DummyBackend, which stores the result and never runs anything. The reference docs mark both as development and testing tools and say production systems should rely on backends that supply a worker process and a durable queue.
So the shape of the feature is: Django owns the interface, somebody else owns the execution. That is the same arrangement as DATABASES, CACHES and the storage API, and it is exactly what DEP 14, written by Jake Howard, set out to do — “provide an interface and base implementation for long-running background tasks in Django”. The interface landed in core in September 2025 and shipped in 6.0.
The part that is not there
Read the API surface as a list of what a production queue needs, and the gaps are obvious. There is no worker. There is no retry policy — a task that raises has its exception and traceback recorded in TaskResult.errors and stops there. There is no periodic scheduling, no dead-letter queue, no concurrency controls, no durable storage at all unless the backend you pick provides it.
run_after exists on the task object and takes a timedelta or a timezone-aware datetime, and backends advertise whether they honour it through capability flags like supports_defer, supports_priority and supports_get_result. A one-shot delay is genuinely useful, but it is not a cron replacement, and a flag that tells you a backend cannot defer is not a scheduler. One review of the framework put it as “not a queue, not a worker system, and not a scheduler”, which reads as harsh until you check it against the reference page and find it accurate.
If you were waiting for the next release to close the gap, it did not. Django 6.1 gave the task() decorator a **kwargs passthrough to the backend’s task class and made Task and TaskResult picklable. No database backend, no worker command, no retries. The same release is where the interesting work went elsewhere: model field fetch modes, database-level on_delete options, and the dictionary-based MAILERS setting that deprecates the whole EMAIL_* family.
What I put behind the interface
Three options are worth considering, and the choice is mostly about what infrastructure you are already paying for.
A database backend, for small services. django_tasks_db.DatabaseBackend stores tasks in your existing database and ships a db_worker management command that processes them, plus a prune_db_task_results command for housekeeping — it lives in a separate package outside Django core. One team running it in production points at the operational win: because the queue is ORM rows, scheduled, completed and errored tasks are all visible in the Django admin without adding a monitoring stack. For a service that sends transactional email and runs the occasional long export, that is the whole requirement, and it removes Redis from the deployment diagram entirely.
The trade is durability and throughput. Every enqueue is a write and every poll is a query against the same database serving your web requests, so a task volume that would be trivial for a broker becomes contention for your connection pool.
Celery, when you already run a broker. If the project has Redis or RabbitMQ up for other reasons, the queue is already paid for, and Celery brings retries with backoff, beat scheduling, routing, rate limits and chords — none of which the Django interface will grow soon. Migrating to django.tasks here buys you a slightly nicer decorator and costs you all of that.
RQ, in the middle. Redis-backed, far less machinery than Celery, and a reasonable landing spot for a service that has outgrown database polling but does not need workflow primitives.
What I ended up doing on the compliance SaaS: kept Celery for the FMCSA sync jobs that need retries and a schedule, and moved two fire-and-forget email paths to django.tasks behind the database backend. The kill-celery branch became narrow-celery, which is a less satisfying name and a much smaller diff.
The transaction trap, which is now yours to handle
This is the failure mode I have debugged on three separate projects, and the new API does not save you from it. Enqueue a task inside transaction.atomic() and the worker — running in a different process, on a different connection — can pick the job up before your transaction commits and fail to find the row it was told to process. The Tasks docs are explicit about it and prescribe the standard fix:
from functools import partial
from django.db import transaction
from billing.tasks import generate_invoice_pdf
def close_period(request):
with transaction.atomic():
invoice = Invoice.objects.create(period=request.POST["period"])
transaction.on_commit(partial(generate_invoice_pdf.enqueue, invoice_id=invoice.pk))
Note the partial. on_commit takes a callable with no arguments, so the common bug is calling .enqueue(...) immediately and handing on_commit the result. Celery users get a shortcut here instead: delay_on_commit() was added in Celery 5.4 and wraps the hook for you, at the cost of not returning a task ID, since nothing is sent to the broker until the transaction finishes. Django’s interface has no equivalent, so the partial pattern is the one to standardise on in review.
The other constraint worth internalising early: task arguments and return values are serialized to JSON, so process_data.enqueue(datetime.now()) raises TypeError: Object of type datetime is not JSON serializable. Pass primary keys and ISO strings, re-fetch inside the task. That is good practice with any queue — it keeps tasks idempotent and avoids acting on a stale copy of an object — but here the framework enforces it rather than letting a pickle backend quietly hide the problem.
Why the interface is still worth adopting
I was ready to write this feature off as premature, and I have changed my mind on one point. The value is not that Django runs your tasks. It is that your application code stops naming the queue vendor.
Two concrete payoffs. First, tests: point TASKS at DummyBackend and assertions run against enqueued tasks without a broker, an eager-mode flag, or a mocked .delay. Second, migration cost: swapping the database backend for a Redis-backed one is a settings change plus a worker process, not a sweep through every module that imported shared_task.
That is a real architectural gain for a codebase that expects to outgrow its first queue — which, in my experience, is most SaaS backends around the point the first customer asks for scheduled reports.
The practical rule I would give anyone upgrading: adopt @task and .enqueue() for new background work, keep whatever runs your existing jobs, and do not delete the broker until you have picked and deployed a backend that has a worker in it. The API is stable and the ecosystem underneath it is not finished.
Sources
- Django’s Tasks framework — topic guide (6.1)
- Tasks reference: backends, capability flags, TaskResult (6.1)
- Django 6.0 released — 3 December 2025
- Django 6.1 release notes
- Django Enhancement Proposal 14: Background Workers
- django-tasks-db — ORM-based backend and db_worker command
- Using Django Tasks in production — Better Simple
- Django 6.0 Tasks: a framework without a worker — Loopwerk
- First steps with Django — Celery documentation