Tasks Framework
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; the Tasks framework itself is new in Django 6.0
(released 2025-12-03), so there is no 5.2 LTS equivalent in Django itself and the text says how to get the same API on
5.2 with the django-tasks backport (0.12.0, 2026-02-06). Every listing was run in a scratch project on Python 3.13 and
Django 6.1.1 with SQLite: manage.py check (also under -W error) and a suite of 20 tests pass, the web examples were
exercised with curl against runserver and Uvicorn, and the database-backed worker (django-tasks-db 0.13.0) was run
as a real second process. No Redis server was available, so the RQ backend (django-tasks-rq 0.12.0) was only
configured and checked, and its behaviour is described from its documentation. A request should answer quickly; sending
an email, resizing an image or calling a slow partner API should not hold the browser. Django 6.0 added a small, standard
API for describing that work (a task), putting it in a queue and looking up its outcome (a result) — and
deliberately left running it to someone else. This page covers the API, the TASKS setting, the two built-in
development backends, third-party workers, enqueueing safely with transaction.on_commit, writing a backend, testing,
and the design rules that keep background work reliable. In the Bookshelf application that grows across this section the
running example is "email the moderators when a reader submits a review".
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Describe background work |
A module-level function decorated with |
|
Run it later |
|
|
Choose priority, queue or start time |
|
|
Read the outcome |
|
|
Pick the engine |
|
|
Enqueue after a write |
|
|
A real worker today |
|
|
Your own engine |
Subclass |
|
Test it |
|
Where tasks sit in the request cycle
A request travels through middleware to a view and back out as a response (the full path is in The Request/Response Cycle). Everything a view does before it returns is paid for by the user. The Tasks framework splits that work in two: the view describes the job and writes it into a queue store (a database table, a Redis list, a broker), returns a response straight away, and a worker — a separate process that is not part of the request cycle at all — picks the job up later, runs it, and records the result in the same store. Django owns the first half (definition, validation, queueing, result objects) and the documentation is explicit that it "does not provide a worker mechanism"; the second half is a backend.
Three consequences follow from that picture, and most of the pitfalls later on are one of them in disguise.
-
The worker is another process with its own database connection, its own copy of your settings and no
request, no session, no logged-in user and no middleware. Everything the job needs must travel in its arguments (or be loaded from the database by id). -
The arguments cross a process boundary as JSON, so only JSON-friendly values survive; a model instance, a
datetimeor a tuple does not arrive as it left. -
The queue store may be a different system from your database, so "I saved the row and enqueued the job" is two operations that are not atomic together, unless you order them yourself — that is the job of
on_commit.
Source: Background Task fundamentals
The running example
The listings come from one scratch project (project config, app catalog). The relevant settings are below;
SECRET_KEY is read from the environment, with a freshly generated key as the fallback so the example never carries a
secret, and MAILERS is the Django 6.1 way to say "print emails to the console".
# config/settings.py (excerpt)
import os
from django.core.management.utils import get_random_secret_key
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") or get_random_secret_key()
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"catalog",
]
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"}}
# Django's default: run every task immediately, in the calling thread.
TASKS = {
"default": {
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
"QUEUES": ["default", "emails"],
}
}
Source: Django: Models
The models are deliberately small. moderators_notified_at exists so the example task can be made safe to run twice
(see Task design rules).
# catalog/models.py
from django.conf import settings
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
isbn = models.CharField(max_length=13, unique=True)
def __str__(self):
return self.title
class Review(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
APPROVED = "approved", "Approved"
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveSmallIntegerField()
body = models.TextField(blank=True)
status = models.CharField(max_length=10, choices=Status, default=Status.PENDING)
moderators_notified_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.reviewer} on {self.book}: {self.rating}/5"
Project layout, settings and models are explained in Project Structure & Apps, Settings & Configuration and Models & Fields.
Defining tasks with @task
A task is a plain function decorated with django.tasks.task(). The decorator returns a Task object, not the function:
you call .enqueue() on it, and calling it like a function is not part of the API. The function must be defined at
module level so a worker in another process can find it again by its import path; by convention it lives in the app’s
tasks.py, but nothing enforces that.
# catalog/tasks.py
import logging
from django.core.mail import send_mail
from django.tasks import task
from django.utils import timezone
from .models import Review
logger = logging.getLogger(__name__)
@task
def add(a, b):
return a + b
@task(queue_name="emails", takes_context=True)
def notify_moderators(context, review_id):
"""Email the moderators about a new review; safe to run twice."""
try:
review = Review.objects.select_related("book").get(pk=review_id)
except Review.DoesNotExist:
logger.warning("Review %s is gone, nothing to do", review_id)
return "gone"
if review.moderators_notified_at is not None: # idempotency guard
return "already-notified"
logger.info("attempt %s for result %s", context.attempt, context.task_result.id)
send_mail(
subject=f"New review for {review.book.title}",
message=f"Rating {review.rating}/5 is waiting for moderation.",
from_email=None,
recipient_list=["moderators@example.com"],
)
Review.objects.filter(pk=review.pk).update(moderators_notified_at=timezone.now())
return "sent"
Source: Defining Tasks
The decorator accepts the following arguments. @task can be used bare or with parentheses.
| Argument | Default | Meaning |
|---|---|---|
|
|
A whole number from -100 to 100; larger runs sooner. Only honoured by backends with |
|
|
Which queue the task goes to. Must be listed in the backend’s |
|
|
The alias of the backend, as a key of |
|
|
If true, the function receives a |
|
none |
Forwarded to the backend’s |
Validation happens twice, when the decorator runs (at import time) and again at enqueue(). Each of these raises as soon
as the decorator runs: at import time for the module-level ones, so a broken definition stops manage.py check and the
server before any request arrives, and when outer() is called for the nested one:
from django.tasks import task
def outer():
@task
def inner(): # InvalidTask: not defined at module level
pass
@task(takes_context=True)
def bad(x): # InvalidTask: first argument must be named "context"
pass
@task(run_after=None) # TypeError: run_after is set per call with using()
def bad2():
pass
@task(backend="nope") # InvalidTaskBackend: no such alias in TASKS
def bad3():
pass
Running those four definitions in a script gave, in order, InvalidTask: Task function must be defined at a module level.,
InvalidTask: Task takes context but does not have a first argument of 'context'., TypeError: run_after cannot be
defined statically with the @task decorator. Use .using(run_after=…) to set it dynamically. and
InvalidTaskBackend: The connection 'nope' doesn’t exist. The attributes you can read back from a Task are priority,
backend, queue_name, run_after and name (the function’s name, not guaranteed unique). A Task is immutable;
Priorities, queues and run_after with using() shows how to derive a changed copy.
Source: The task decorator and Task
Enqueueing and the TaskResult
enqueue() hands the task and its arguments to the backend and returns a TaskResult straight away. In a shell with the
default ImmediateBackend, where the work runs before enqueue() returns:
>>> from catalog.tasks import add
>>> result = add.enqueue(2, 3)
>>> result.status
<TaskResultStatus.SUCCESSFUL: 'SUCCESSFUL'>
>>> result.return_value
5
>>> result.attempts
1
>>> result.is_finished
True
>>> add.priority, add.queue_name, add.backend, add.run_after, add.name
(0, 'default', 'default', None, 'add')
In an async context use aenqueue(), the async variant with the same arguments; every Task and backend method that
can block has an a-prefixed twin (aget_result(), arefresh()). See Sync and async for which to
call where.
Source: Enqueueing Tasks and Task.enqueue()
Arguments and return values are JSON
Arguments are serialized when the task is enqueued, and the function receives what comes out of a json.dumps() /
json.loads() round trip. Return values are serialized the same way. So an argument must be JSON-serializable and keep
its type after the round trip:
>>> import datetime
>>> add.enqueue(datetime.datetime.now(), 1)
Traceback (most recent call last):
...
TypeError: Unsupported type: <class 'datetime.datetime'>
>>> from django.tasks import task
>>> @task()
... def double_dictionary(key):
... return {key: key * 2}
>>> result = double_dictionary.enqueue((1, 2, 3))
>>> result.status
<TaskResultStatus.FAILED: 'FAILED'>
>>> result.errors[0].traceback.splitlines()[-1]
"TypeError: unhashable type: 'list'"
The first call fails at enqueue time with a TypeError; the exact message on 6.1.1 is the one above (the documentation
shows a differently worded TypeError, so match on the type, not the text). The second is subtler: a tuple is valid
JSON (it becomes an array), so enqueue() accepts it, but the function receives the list [1, 2, 3], which cannot be a
dictionary key, so the task fails inside the worker. Model instances, Decimal, UUID, datetime, date, sets and
tuples all need converting: pass the primary key, an ISO string, a list. Datetimes and decimals as strings, parsed in the
function, is the usual answer.
Priorities, queues and run_after with using()
Task attributes cannot be assigned. To change one for a single call, using() returns a modified copy and leaves the
original alone. It accepts priority, backend, queue_name and run_after.
>>> from datetime import timedelta
>>> from django.utils import timezone
>>> add.using(priority=10).priority
10
>>> add.priority
0
>>> add.using(queue_name="nope").enqueue(1, 2)
InvalidTask: Queue 'nope' is not valid for backend.
>>> add.using(priority=101).enqueue(1, 2)
InvalidTask: priority must be a whole number between -100 and 100.
>>> add.using(run_after=timezone.now() + timedelta(minutes=1)).enqueue(1, 2)
InvalidTask: Backend does not support run_after.
The rules the backend enforces at enqueue(), in the order they matter in practice:
-
Queues.
queue_namemust be one of the backend’sQUEUES(default["default"]). SetQUEUESto[]to disable the check. A typo therefore fails loudly instead of parking jobs in a queue nobody reads. -
Priority. A whole number between -100 and 100, larger means sooner. A backend without
supports_priorityrejects any priority other than 0. -
Start time.
run_afteris the earliest time the task may run: atimedelta(relative to now) or a timezone-awaredatetime(a naive one raisesInvalidTaskwhenUSE_TZis true). It can only be set withusing(), and only on a backend withsupports_defer. -
Async functions. An
async deftask is accepted only by backends withsupports_async_task. -
Context. A task with
takes_context=Truemust name its first parametercontext.
Each of those is a property of the backend, which is why the same code can be valid with DummyBackend (it supports
run_after) and invalid with the default ImmediateBackend (it does not). The flags are listed in Feature flags.
Source: Modifying Tasks, Task.using() and QUEUES
Task context and attempts
A task that must know how it is being run declares takes_context=True; Django then passes a TaskContext as the first
argument. Its two attributes are task_result (the TaskResult currently being run, so the task can log its own id) and
attempt (the number of execution attempts, starting at 1). The notify_moderators task above uses both. Under the
ImmediateBackend the log line reads, with the id elided:
INFO attempt 1 for result <result id>
attempt is how a task can tell a retry from a first run, which matters for idempotency (see Task design rules). The
framework does not retry anything by itself: whether a failed task is run again, how often and with what delay is the
backend’s policy. The built-in backends never retry, and django-tasks-db marks a failed task as FAILED.
Source: Task context and TaskContext
Task results
Every enqueue() returns a TaskResult, "a specific execution of a task". Its attributes are read-only snapshots taken
when the result was created or last refreshed.
| Attribute | Meaning |
|---|---|
|
Unique string of fewer than 64 characters; the format depends on the backend (random strings on the built-in backends,
UUIDs on |
|
A |
|
The |
|
Timestamps: when it was enqueued, when its first attempt began, when the latest attempt began, when it finished (successfully or not). |
|
|
|
How many times the task has run; a currently running attempt does not count yet. |
|
The ids of the workers that executed it. |
|
The function’s (JSON-normalised) return value. Raises |
|
A list of |
Two details worth remembering. return_value is a property that raises unless the status is SUCCESSFUL, so a task
that returns None is distinguishable from one that has not run:
>>> from django.tasks import task
>>> @task
... def explode(label):
... raise ValueError(f"cannot process {label}")
>>> result = explode.enqueue("x")
>>> result.status
<TaskResultStatus.FAILED: 'FAILED'>
>>> result.errors[0].exception_class
<class 'ValueError'>
>>> result.errors[0].traceback.splitlines()[-1]
'ValueError: cannot process x'
>>> result.return_value
Traceback (most recent call last):
...
ValueError: Task failed
Source: Django: Task results
exception_class is only the type of the exception (the message is inside the traceback string), so do not branch on
exception arguments. And an exception inside a task does not propagate to the code that enqueued it: with the
ImmediateBackend enqueue() returns normally with a FAILED result (and the traceback is logged), so check
result.status instead of wrapping enqueue() in try.
Looking a result up later
The result you got at enqueue time does not update itself. To look at it from another request or process, keep the id and
call get_result(), on the task (which also checks that the result belongs to that task) or on the backend (which accepts
any task’s result). refresh() (arefresh()) updates an object you already hold. Both need a backend with
supports_get_result; the built-in backends do not officially have it, and ImmediateBackend.get_result() raises
NotImplementedError:
>>> result = add.enqueue(1, 1)
>>> add.get_result(result.id)
NotImplementedError: This backend does not support retrieving or refreshing results.
With a real backend the pattern is the one from the diagram: the view that enqueued returns the id, and a second view
reports progress. This is the task_status view of the example, run against the database backend in Real workers: third-party backends:
# catalog/views.py (excerpt)
from django.http import Http404, JsonResponse
from django.tasks.exceptions import TaskResultDoesNotExist
from django.views.decorators.http import require_GET
from .tasks import notify_moderators
@require_GET
def task_status(request, result_id):
try:
result = notify_moderators.get_result(result_id)
except TaskResultDoesNotExist:
raise Http404("unknown task result")
body = {"id": result.id, "status": result.status, "attempts": result.attempts}
if result.status == "SUCCESSFUL":
body["return_value"] = result.return_value
return JsonResponse(body)
get_result() raises TaskResultDoesNotExist for an unknown id and TaskResultMismatch when the id belongs to a different
task. Do not poll aggressively from the browser; for user-visible progress prefer a push channel (see the Channels and SSE
pages of this section when they land) or a small, rate-limited polling endpoint.
Source: Task results, Task result reference and Task.get_result()
The status lifecycle
READY is also "ready to be executed again", which is how a backend that retries represents a retry; SUCCESSFUL and
FAILED are final and make is_finished true. DummyBackend results stay in READY forever, because nothing ever runs
them.
The TASKS setting and choosing a backend
TASKS maps aliases to backend configurations, exactly like DATABASES and CACHES. It must define a "default"
alias; any number of others may follow. If you define nothing, Django uses the default shown in The running example:
TASKS = {
"default": {
"BACKEND": "django.tasks.backends.immediate.ImmediateBackend",
}
}
| Key | Default | Meaning |
|---|---|---|
|
|
Import path of the backend class: one of the two built-ins, or any |
|
|
The queue names the backend accepts. |
|
|
Extra parameters for the backend; which ones exist depends on the backend (a custom backend reads them from
|
Several aliases are useful when jobs have different needs, for instance a local, immediate backend for cheap work and a
durable one for emails. Select one per task with backend="alias" or per call with using(backend=…). In code,
task_backends is the connection handler and default_task_backend a proxy for the "default" alias:
>>> from django.tasks import default_task_backend, task_backends
>>> task_backends["default"]
<django.tasks.backends.immediate.ImmediateBackend object at 0x...>
>>> default_task_backend.queues, default_task_backend.options, default_task_backend.alias
({'default', 'emails'}, {}, 'default')
>>> default_task_backend.supports_defer, default_task_backend.supports_priority
(False, True)
>>> [b.alias for b in task_backends.all()]
['default', 'json']
Omitting the "default" alias is an error: any module that decorates a task with the default backend fails on import with
InvalidTaskBackend: The connection 'default' doesn’t exist. Backends are also asked to validate their own configuration
by manage.py check.
Settings in general, and how to split them per environment, are in Settings & Configuration.
Source: TASKS, Configuring a Task backend and Retrieving backends
The built-in backends
Django ships two backends and says plainly that they are "for development and testing only". Neither has a worker.
ImmediateBackend
The default. enqueue() runs the function right there, in the calling thread, and returns a finished result. It lets you
adopt the API in an application that has no queue infrastructure yet, and it is a good test backend because nothing needs
to be started. It also means that the request pays for the work: a view that enqueues an email under this backend sends
it before it responds. Sequence of events in the sync view of the example, from the Uvicorn server log:
Task id=<result id> path=catalog.tasks.notify_moderators state=RUNNING
Content-Type: text/plain; charset="utf-8"
Subject: New review for Don Quijote
To: moderators@example.com
Rating 4/5 is waiting for moderation.
Task id=<result id> path=catalog.tasks.notify_moderators state=SUCCESSFUL
INFO: 127.0.0.1:51701 - "POST /catalog/books/1/reviews/ HTTP/1.1" 201 Created
Source: Django: Immediate backend
The task started, printed the email and succeeded before the response line. Priorities are accepted and ignored; results cannot be fetched later.
DummyBackend
Never executes anything. enqueue() stores the result, which stays READY, in the backend’s results list, in order,
and clear() empties it. That makes it the natural backend for asserting "this code enqueued that task":
>>> from django.test import override_settings
>>> from django.tasks import default_task_backend
>>> DUMMY = {"default": {"BACKEND": "django.tasks.backends.dummy.DummyBackend"}}
>>> with override_settings(TASKS=DUMMY):
... result = add.enqueue(2, 3)
... result.status
... len(default_task_backend.results), default_task_backend.results[0].args
<TaskResultStatus.READY: 'READY'>
(1, [2, 3])
Source: Django: Dummy backend
Unlike the immediate backend it supports run_after, and within the same thread get_result() works, although the
reference lists supports_get_result as false because results are not visible from another thread. Its priority
support exists only so it can be a drop-in test replacement.
Feature flags
Backends declare what they support through class attributes, so code can adapt:
| Flag | DummyBackend |
ImmediateBackend |
Means |
|---|---|---|---|
|
Yes |
No |
|
|
Yes |
Yes |
|
|
No |
No |
Results can be fetched from another thread after enqueueing. |
|
Yes |
Yes |
Tasks run in |
The source of both backends is the shortest explanation of the contract: it lives in django/tasks/backends/ in the Django
repository, and the docs recommend reading it before writing your own.
Source: Immediate execution, Dummy backend and Feature flags
Enqueueing after the transaction commits
This is the rule that most background-job bugs break. Workers use a separate database connection, so a job enqueued
inside an open transaction can start before the commit and look for a row it cannot see yet — or run for a transaction
that then rolls back. The documented fix is to register the enqueue with transaction.on_commit(), binding the arguments
with functools.partial because on_commit takes a callable with no arguments:
# catalog/services.py
from functools import partial
from django.db import transaction
from .models import Review
from .tasks import notify_moderators
def create_review(book, reviewer, rating, body=""):
"""Write the review, and enqueue the follow-up only if the write commits."""
with transaction.atomic():
review = Review.objects.create(
book=book, reviewer=reviewer, rating=rating, body=body
)
transaction.on_commit(partial(notify_moderators.enqueue, review_id=review.pk))
return review
The sync view that uses it, plus the CSRF-protected curl call that exercises it (the first request sets the
cookie, the second sends the token; the 403 shows the protection is on):
# catalog/views.py (excerpt)
import json
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_POST
from .models import Book
from .services import create_review
@require_POST
def submit_review(request, book_id):
book = get_object_or_404(Book, pk=book_id)
data = json.loads(request.body)
review = create_review(book, _reviewer(), data["rating"], data.get("body", ""))
return JsonResponse({"review": review.pk}, status=201)
$ curl -s -c cookies.txt http://127.0.0.1:47302/catalog/csrf/
{"csrf": true}
$ TOKEN=$(awk '$6 == "csrftoken" {print $7}' cookies.txt)
$ curl -s -b cookies.txt -H "X-CSRFToken: $TOKEN" -H 'Content-Type: application/json' \
-d '{"rating": 4}' -w ' [%{http_code}]\n' http://127.0.0.1:47302/catalog/books/1/reviews/
{"review": 1} [201]
$ curl -s -b cookies.txt -H 'Content-Type: application/json' \
-d '{"rating": 4}' -o /dev/null -w 'no token: %{http_code}\n' http://127.0.0.1:47302/catalog/books/1/reviews/
no token: 403
(_reviewer() in the scratch project returns a fixed demo user; in a real view use request.user of a logged-in
reader.) How on_commit works, what robust=True does and how ATOMIC_REQUESTS interacts with it are in
Transactions, Raw SQL & Multiple Databases;
two points matter for tasks specifically:
-
With
ATOMIC_REQUESTS = Truethe whole view is one transaction, so the callback runs when the view returns without raising — still before the client sees the response — and is discarded if it raises. With autocommit and noatomicblock,on_commitruns the callable immediately, so the pattern is safe to use unconditionally. -
On a rollback the enqueue never happens. The test
test_rollback_means_no_taskin Testing tasks asserts exactly that.
on_commit closes the "enqueue before commit" gap but not the opposite one: the process can die between the commit and the
enqueue, losing the job. When a lost job is unacceptable use a transactional outbox, as described in
The Transactional Outbox Pattern (a
database-backed task backend such as django-tasks-db has the same effect, because enqueueing is then a row in your own
database).
Sync and async
The framework is async-aware at every layer, with a-prefixed twins for each blocking call: aenqueue(), aget_result(),
arefresh(). For backends that do not implement the async variants, the base class falls back to running the sync code
in a thread, so they are always safe to await. The docs call the async support for backends "developing", so a
third-party backend may implement only part of it.
# catalog/views.py (excerpt)
from django.contrib.auth import get_user_model
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .models import Book, Review
from .tasks import notify_moderators
@require_POST
async def submit_review_async(request, book_id):
book = await Book.objects.aget(pk=book_id)
data = json.loads(request.body)
reviewer, _ = await get_user_model().objects.aget_or_create(username="reader")
review = await Review.objects.acreate(
book=book, reviewer=reviewer, rating=data["rating"], body=data.get("body", "")
)
result = await notify_moderators.aenqueue(review_id=review.pk)
return JsonResponse(
{"review": review.pk, "task_result": result.id, "status": result.status},
status=202,
)
Under Uvicorn with the immediate backend, curl answers with the task already finished (the id is elided):
$ curl -s -b cookies.txt -H "X-CSRFToken: $TOKEN" -H 'Content-Type: application/json' \
-d '{"rating": 4}' -w ' [%{http_code}]\n' http://127.0.0.1:47302/catalog/books/1/reviews/async/
{"review": 2, "task_result": "<result id>", "status": "SUCCESSFUL"} [202]
Notes for async code:
-
The async ORM methods (
acreate(),aget()) run in autocommit, so by the timeaenqueue()is called the row is committed and noon_commitis needed.transaction.atomic()cannot wrapawaitcalls. If the operation needs a transaction, put it in a plain function (likecreate_review()above) and call it withsync_to_async(); theon_commitinside it still fires when the thread’s transaction commits. The scratch project has such a view (submit_review_atomic) and a test that captures its callback. -
The task function itself may be
deforasync def. Anasync deftask needs a backend withsupports_async_task(both built-ins have it); the built-in backends run a plaindeftask from async code in a thread. In a script:@task async def fetch_title(isbn): …enqueued withawait fetch_title.aenqueue("123")returnedSUCCESSFULwith the valuetitle-123. -
The worker, not the web server, decides the execution model. An ASGI web server (see the deployment pages of this section) says nothing about how the worker runs tasks.
Source: Asynchronous support (Tasks) and Asynchronous support
Real workers: third-party backends
Because Django does not run tasks, a production site needs a backend that supplies a durable queue and a worker. Django’s documentation points to its Community Ecosystem page and to the Django Packages grid, and these are the candidates it names:
| Package | Queue store and worker | Backend path | State on 2026-10-03 |
|---|---|---|---|
|
Your database (ORM model); |
|
Classifiers up to Django 6.0; works with 6.1.1 as run on this page. |
|
Redis through RQ; |
|
Classifiers up to Django 6.0; configuration checked, not run (no Redis). |
|
The backport of the API for Django 4.2-5.2 plus the immediate and dummy backends |
|
For Django earlier than 6.0 (see Moving from the django-tasks backport or from Celery). |
|
Redis, SQLite or Postgres; its own worker |
Listed on the ecosystem page as usable directly or as a backend |
Not run here. |
Two practical remarks about this table. Until django-tasks 0.12.0 (2026-02-06) the database and RQ backends were bundled
in it; they are now separate packages, which is why tutorials written in 2025 import django_tasks.backends.database and
the current names above differ. And Celery, the long-time default for Django background work, is a different design
(it has its own task decorator and message protocol rather than being a django.tasks backend); see
Celery Integration for when to choose which.
A database-backed worker, end to end
django-tasks-db is the smallest real setup: no extra service, the queue is a table next to your data. Installation is
three edits and a migration.
$ python -m pip install django-tasks-db
# config/settings.py (changes)
INSTALLED_APPS += ["django_tasks_db"]
TASKS = {
"default": {
"BACKEND": "django_tasks_db.DatabaseBackend",
"QUEUES": ["default", "emails"],
}
}
$ python manage.py migrate
$ python manage.py db_worker --queue-name '*'
Starting worker worker_id=<worker id> queues=*
The worker is an ordinary management command, so it is started, supervised and scaled like any other process of your
deployment (see Management Commands & Shell for the command
machinery). The options of db_worker as printed by --help on 0.13.0:
| Option | Meaning |
|---|---|
|
Queues to process, comma-separated; |
|
Queues to skip. |
|
Seconds to wait before polling again when the queue is empty (default 1). |
|
Process what is outstanding, then exit; combine with |
|
Stop after this many tasks. |
|
Reload on code changes; the default follows |
|
The |
|
A worker id that must be unique across the pool (default: generated). |
|
Skip the small delay at startup. |
With the worker and runserver both running (TASKS_BACKEND=django_tasks_db.DatabaseBackend selects it in the scratch
settings), the first request enqueues, returns at once, and the worker log shows the job being done:
$ curl -s -b cookies.txt -H "X-CSRFToken: $TOKEN" -H 'Content-Type: application/json' \
-d '{"rating": 4}' -w ' [%{http_code}]\n' http://127.0.0.1:47301/catalog/books/1/reviews/async/
{"review": 4, "task_result": "<result id>", "status": "READY"} [202]
$ curl -s http://127.0.0.1:47301/catalog/tasks/<result id>/
{"id": "<result id>", "status": "SUCCESSFUL", "attempts": 1, "return_value": "sent"}
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:47301/catalog/tasks/nope/
404
Note the READY in the first answer: the task was queued, not run, and the worker finished it a moment later in another
process. The worker’s own output, trimmed:
Task id=<result id> path=catalog.tasks.notify_moderators state=RUNNING
Subject: New review for Don Quijote
To: moderators@example.com
...
Task id=<result id> path=catalog.tasks.notify_moderators state=SUCCESSFUL
Priorities are real here. Five tasks were enqueued — shout("low") with priority -5, shout("high") with 50,
shout("normal"), one with run_after an hour ahead, and a failing one — and db_worker --batch ran them in this order:
high, normal, the failing task (enqueued after normal, same priority, so first in first out), low. The delayed task stayed
READY, and the failure was stored and retrievable from another process:
>>> r = explode.get_result(result_id)
>>> r.status, r.attempts, r.errors[0].exception_class.__name__
(<TaskResultStatus.FAILED: 'FAILED'>, 1, 'ValueError')
>>> r.errors[0].traceback.splitlines()[-1]
'ValueError: cannot process x'
>>> r.refresh(); r.status
<TaskResultStatus.FAILED: 'FAILED'>
Housekeeping. Results accumulate in the table. manage.py prune_db_task_results deletes finished results by age
(--min-age-days, default 14; --failed-min-age-days for failures; --queue-name; --dry-run). Run it on a schedule:
prune_db_task_results --dry-run --min-age-days 0 --queue-name '*' printed Would delete 7 task result(s) in the scratch
database. The task id is a UUID by default and can be changed with the id_function option (a dotted path or callable,
such as uuid.uuid7 on Python 3.14).
Pitfall: a queue nobody serves. The worker defaults to the queue named default. The example task lives in the emails
queue, so a plain db_worker --batch started and exited at once, leaving the result READY with zero attempts; only
--queue-name emails (or '*') ran it. The same task, run for a review that was already notified, returned
already-notified and sent nothing — the idempotency guard doing its job.
Source: django-tasks-db and Third-party backends
An RQ backend
django-tasks-rq stores tasks in Redis and runs them with RQ workers, through django-rq. The configuration, which
passes manage.py check in the scratch project:
# rq_settings.py
from config.settings import *
INSTALLED_APPS += ["django_rq", "django_tasks_rq"]
# Every queue named in TASKS must also exist in django-rq's RQ_QUEUES.
RQ_QUEUES = {name: {"HOST": "localhost", "PORT": 6379, "DB": 0} for name in ("default", "emails")}
TASKS = {
"default": {"BACKEND": "django_tasks_rq.RQBackend", "QUEUES": ["default", "emails"]},
}
The worker is python manage.py rqworker --job-class django_tasks_rq.Job (the custom job class is required, per the
package’s documentation). RQ has no native priorities, so a task with priority 100 goes to the front of the queue and every
other priority to the back. With no Redis server, add.enqueue(1, 2) raised redis.exceptions.ConnectionError, which is
the point where this example stops being runnable offline. One more observation from running it: with django-tasks-rq
0.12.0 the object @task returns is an instance of django_tasks.base.Task (the backport’s class), because the backend
is built on the backport, not on django.tasks.Task; code that does isinstance(x, django.tasks.Task) should not assume
otherwise. Redis itself is covered in the Redis section of this site (start from
Redis administration, monitoring and troubleshooting).
Writing your own backend
A backend is a class that inherits BaseTaskBackend and "at a minimum" implements enqueue(task, args, kwargs), returning a
TaskResult. BaseTaskBackend supplies the rest: validate_task() (the checks of Priorities, queues and run_after with using()), aenqueue() (which
calls enqueue() in a thread), get_result() and aget_result() (which raise NotImplementedError until you override
them), the queues and options attributes read from TASKS, and the supports_* flags (all false until you set them).
Three fixed points when you build one:
-
Call
self.validate_task(task)first inenqueue(), so every backend rejects bad input the same way. -
Set a
supports_*flag to true only if you implement the behaviour; Django and callers trust the flags. -
A backend’s
task_class(defaultTask) is what@taskinstantiates. Since Django 6.1,@taskforwards unknown keyword arguments to it, so a backend can add its own per-task options.
The teaching backend below appends each enqueued task to a JSON Lines file (a stand-in for "write to a broker"), and its
task_class carries a max_attempts option:
# catalog/backends.py
import json
from dataclasses import dataclass
from pathlib import Path
from uuid import uuid4
from django.tasks import Task, TaskResult, TaskResultStatus
from django.tasks.backends.base import BaseTaskBackend
from django.utils import timezone
@dataclass(frozen=True, slots=True, kw_only=True)
class RetryingTask(Task):
"""A Task that also carries backend-specific data (Django 6.1: task(**kwargs))."""
max_attempts: int = 1
class JsonLinesBackend(BaseTaskBackend):
"""Teaching backend: appends each enqueued task to a JSON Lines file."""
task_class = RetryingTask
supports_priority = False
def __init__(self, alias, params):
super().__init__(alias, params)
self.path = Path(self.options["path"])
def enqueue(self, task, args, kwargs):
self.validate_task(task)
result = TaskResult(
task=task,
id=uuid4().hex,
status=TaskResultStatus.READY,
enqueued_at=timezone.now(),
started_at=None,
finished_at=None,
last_attempted_at=None,
args=args,
kwargs=kwargs,
backend=self.alias,
errors=[],
worker_ids=[],
)
line = {
"id": result.id,
"task": task.module_path,
"queue": task.queue_name,
"max_attempts": task.max_attempts,
"args": result.args,
"kwargs": result.kwargs,
}
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(line) + "\n")
return result
# config/settings.py (add the alias) and catalog/tasks.py (use it)
TASKS = {
"default": {...}, # as before
"json": {
"BACKEND": "catalog.backends.JsonLinesBackend",
"OPTIONS": {"path": BASE_DIR / "tasks.jsonl"},
},
}
@task(backend="json", max_attempts=3)
def archive_review(review_id):
return f"archived {review_id}"
A real worker claims jobs atomically, records status, retries and handles shutdown. The run_json_tasks command below is
only the smallest loop that proves the file is a usable queue:
# catalog/management/commands/run_json_tasks.py
import json
from django.core.management.base import BaseCommand
from django.tasks import task_backends
from django.utils.module_loading import import_string
class Command(BaseCommand):
help = "Run every task recorded by the JsonLinesBackend (a teaching worker)."
def add_arguments(self, parser):
parser.add_argument("--backend", default="json")
def handle(self, *args, backend, **options):
path = task_backends[backend].path
for line in path.read_text(encoding="utf-8").splitlines():
job = json.loads(line)
task = import_string(job["task"]) # a Task instance
for attempt in range(1, job["max_attempts"] + 1):
try:
value = task.call(*job["args"], **job["kwargs"])
except Exception as exc:
self.stderr.write(f"{job['id'][:8]} attempt {attempt} failed: {exc}")
else:
self.stdout.write(f"{job['id'][:8]} {task.name} -> {value!r}")
break
path.write_text("", encoding="utf-8")
>>> from catalog.tasks import archive_review
>>> type(archive_review).__name__, archive_review.max_attempts, archive_review.backend
('RetryingTask', 3, 'json')
>>> archive_review.enqueue(7).status
<TaskResultStatus.READY: 'READY'>
>>> archive_review.using(priority=5).enqueue(8)
InvalidTask: Backend does not support setting priority of tasks.
$ python manage.py run_json_tasks
26a77163 archive_review -> 'archived 7'
Two honest caveats. The worker uses Task.call() and the backend uses Task.module_path; neither is listed in the 6.1
reference (they exist in the 6.1.1 source, which the docs themselves recommend as the reference implementation), so treat
them as implementation details that may change, and prefer public API where you can. And this loop ignores takes_context,
priorities and result storage, all of which a production backend must handle — study django/tasks/backends/ and
django-tasks-db before building one.
Source: Base backend, BaseTaskBackend.enqueue() and The task decorator
Testing tasks
Pick the backend for the question you are asking. Run the task inline to test its behaviour (ImmediateBackend), or store
the call to test the code that enqueues it (DummyBackend); both need only override_settings(TASKS=…), which Django
reacts to by rebuilding the backend connections. The suite of the scratch project (20 tests, also under -W error):
# catalog/tests.py (excerpt)
import json
from django.contrib.auth import get_user_model
from django.core import mail
from django.db import transaction
from django.tasks import default_task_backend, TaskResultStatus
from django.tasks.exceptions import InvalidTask, TaskResultDoesNotExist, TaskResultMismatch
from django.test import TestCase, override_settings
from django.urls import reverse
from .models import Book, Review
from .tasks import add, notify_moderators
DUMMY = {"default": {"BACKEND": "django.tasks.backends.dummy.DummyBackend", "QUEUES": ["default", "emails"]}}
IMMEDIATE = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend", "QUEUES": ["default", "emails"]}}
@override_settings(TASKS=DUMMY)
class DummyTests(TestCase):
def setUp(self):
default_task_backend.clear()
self.book = Book.objects.create(title="Don Quijote", isbn="9780142437230")
self.user = get_user_model().objects.create(username="reader")
def test_results_are_stored_not_run(self):
result = add.enqueue(2, 3)
self.assertEqual(result.status, TaskResultStatus.READY)
self.assertEqual(default_task_backend.results[0].args, [2, 3])
with self.assertRaisesMessage(ValueError, "Task has not finished yet"):
result.return_value
def test_view_enqueues_after_commit(self):
url = reverse("catalog:submit-review", args=[self.book.pk])
with self.captureOnCommitCallbacks(execute=True) as callbacks:
response = self.client.post(url, json.dumps({"rating": 5}), content_type="application/json")
self.assertEqual(response.status_code, 201)
self.assertEqual(len(callbacks), 1)
stored = default_task_backend.results[0]
self.assertEqual(stored.task.name, "notify_moderators")
self.assertEqual(stored.kwargs, {"review_id": response.json()["review"]})
self.assertEqual(stored.task.queue_name, "emails")
def test_rollback_means_no_task(self):
try:
with transaction.atomic():
review = Review.objects.create(book=self.book, reviewer=self.user, rating=1)
transaction.on_commit(lambda: notify_moderators.enqueue(review_id=review.pk))
raise RuntimeError("boom")
except RuntimeError:
pass
self.assertEqual(len(default_task_backend.results), 0)
@override_settings(TASKS=IMMEDIATE)
class NotifyModeratorsTests(TestCase):
def setUp(self):
self.book = Book.objects.create(title="La Galatea", isbn="9780000000011")
self.user = get_user_model().objects.create(username="reader")
self.review = Review.objects.create(book=self.book, reviewer=self.user, rating=5)
def test_sends_once(self):
first = notify_moderators.enqueue(review_id=self.review.pk)
second = notify_moderators.enqueue(review_id=self.review.pk)
self.assertEqual((first.return_value, second.return_value), ("sent", "already-notified"))
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "New review for La Galatea")
def test_missing_review(self):
self.assertEqual(notify_moderators.enqueue(review_id=0).return_value, "gone")
Why captureOnCommitCallbacks(execute=True): Django’s TestCase wraps each test in a transaction that is rolled back, so
on_commit callbacks never fire on their own; the context manager collects them and, with execute=True, runs them on
exit. Without it the second test above would find an empty results list — the scratch suite has the negative test
test_nothing_enqueued_without_commit_callbacks_run to show it. (TransactionTestCase commits for real, so callbacks
run normally.) The idempotency test enqueues the same review twice and asserts one email.
To test against the real worker code path without a second process, run the worker in batch mode inside the test:
from django.core.management import call_command
from .tasks import shout
DATABASE = {"default": {"BACKEND": "django_tasks_db.DatabaseBackend", "QUEUES": ["default", "emails"]}}
@override_settings(TASKS=DATABASE)
class DatabaseBackendTests(TestCase):
def test_worker_batch_runs_tasks_in_priority_order(self):
low = shout.using(priority=-5).enqueue("low")
high = shout.using(priority=50).enqueue("high")
self.assertEqual(low.status, TaskResultStatus.READY)
call_command("db_worker", "--batch", "--queue-name", "*", "--no-reload", "--no-startup-delay", verbosity=0)
low.refresh()
high.refresh()
self.assertEqual((low.return_value, high.return_value), ("LOW", "HIGH"))
self.assertLess(high.started_at, low.started_at)
Run them with python -W error manage.py test; the suite passed all 20 tests, including the ones that exercise the failure
cases of Priorities, queues and run_after with using() and the 6.1 pickling support:
def test_pickle_round_trip(self): # new in 6.1
result = add.enqueue(1, 2)
self.assertEqual(pickle.loads(pickle.dumps(add)), add)
self.assertEqual(pickle.loads(pickle.dumps(result)).return_value, 3)
General Django testing (the test client, factories, pytest-django) has its own page in this section.
Task design rules
The framework hands you the mechanism; reliability is a matter of how the tasks are written. These rules are the
background-job advice that long predates django.tasks (the asynchronous-task-queue chapter of Two Scoops of Django
was written about Celery and holds as is), restated for this API.
-
Pass identifiers, not objects. A model instance cannot cross the JSON boundary and would be stale if it could. Pass the primary key and load the row inside the task, as
notify_moderators(review_id)does. Pass what to do (an id), not what to know. -
Keep arguments small and JSON-native. Strings, numbers, booleans, lists and dicts of those. Convert
Decimal, dates andUUIDyourself; remember a tuple comes back as a list (Arguments and return values are JSON). -
Handle "the row is gone". Between enqueue and execution a user or a cascade may delete the object. The example logs and returns
"gone"instead of failing forever. -
Make the task idempotent. Any queue can deliver a job twice (a crash after the work and before the result is saved, a retry, an operator re-enqueueing). Guard the side effect with state you control — a
moderators_notified_atcolumn, a unique constraint, an idempotency key sent to the payment or email provider — so the second run is a no-op. The example returnedalready-notifiedthe second time and the test asserts a single email. -
Enqueue after the commit. Always
on_commitwhen a task reads what the surrounding transaction writes (Enqueueing after the transaction commits). -
Keep tasks short and single-purpose. A long job should be split into smaller tasks (one per item) so a failure repeats little work and several workers can share it. Wrap external calls in explicit timeouts, because a hung task occupies a worker.
-
Do not use the request. No
HttpRequest, norequest.userobject, no reading of the session. Pass the user’s id and whatever values you need; build absolute URLs fromSiteor settings, since the worker has noHostheader. -
Use
context.attemptfor retries only if your backend retries. The attempt number lets a task log or change behaviour on later tries; the built-in backends never retry, so decide your retry policy in the backend or worker you chose. -
Do not return big payloads. The return value is stored in the queue store as JSON; return a status or an id, and keep the data (a PDF, an export) in storage.
-
Set a retention policy for results (
prune_db_task_resultsfor the database backend), or the table grows without bound. -
Observe failures. A
FAILEDresult is just a row. Log with theloggingmodule (see the logging page when it lands), alert on a growingREADYbacklog or on failures, and run more than zero workers per queue. -
Treat
ImmediateBackendas development-only. Code that works there can still break on a real backend: unserializable arguments fail at enqueue either way, but ordering, transactions andget_result()do not behave the same.
The same advice, with the delivery guarantees (at-most-once, at-least-once) behind it, is developed in Delivery Guarantees and, for the FastAPI equivalent of "do this after the response", in Settings, Lifespan & Background Tasks.
Source: Enqueueing Tasks and Tasks and transactions
Moving from the django-tasks backport or from Celery
From the backport (Django 4.2-5.2). django-tasks implements the same API under another import path, so a project on 5.2
LTS can write today the code that will run unchanged on 6.x. The differences are mechanical:
Backport (django-tasks) |
Django 6.0+ (django.tasks) |
Notes |
|---|---|---|
|
nothing to install |
The backport is an app. |
|
|
Same decorator, same |
|
|
Same for the dummy backend. |
|
|
The exceptions were renamed in Django’s own module; catch the right one. |
|
|
Separate packages since 2026-02-06. |
The backport’s default (when TASKS is missing) is its own immediate backend, so the switch is a search-and-replace plus
the settings paths. The exception names come from the 6.1.1 source of django.tasks.exceptions and the backport’s source
and README; the path rows from their READMEs.
From Celery. There is no automatic migration, because Celery is a different system with its own decorators, broker,
result backend, retries, scheduling and monitoring. The practical approach is incremental: keep Celery for jobs that
need its features (periodic schedules, chords, rate limits, fine retry control) and write new simple jobs with
@task, or move them one by one, behind the same enqueue() calls in your services. The trade-offs and the Celery
setup are in Celery Integration.
Source: django-tasks on PyPI and Django’s Tasks framework
Pitfalls
-
Expecting Django to run the jobs. Without a worker backend nothing runs asynchronously: the default backend runs the task inline, so the request slows down, and
DummyBackendnever runs anything. -
Enqueueing inside the transaction. The worker may not see the row (or the transaction may roll back). Use
on_commitwithfunctools.partial. -
Non-JSON arguments.
datetime,Decimal,UUID, model instances and sets fail at enqueue time (TypeError); tuples pass and become lists, failing inside the task. -
Defining tasks anywhere but module level. Nested functions, lambdas and methods raise
InvalidTask. -
Naming the context parameter anything but
contextwhentakes_context=True. -
Queue names missing from
QUEUES(or a worker that serves onlydefault): the task is rejected, or parked in a queue nobody reads. -
Relying on
get_result()with the built-in backends. The immediate backend raisesNotImplementedError; the dummy backend’s results are only visible in the same thread. -
Assuming
run_afterand priorities work everywhere. They are backend features: checksupports_deferandsupports_priority; RQ has almost no priority at all. -
Treating
exception_classas the whole error. It is the type; readtracebackfor the message. -
Reading
return_valuetoo early or after a failure. It raisesValueError; checkstatusoris_finishedfirst, and callrefresh()on a result you held since enqueue time. -
Forgetting that tests do not commit.
on_commitcallbacks never run insideTestCase; usecaptureOnCommitCallbacks(execute=True). -
Calling the sync API from async code. Use
aenqueue(),aget_result()andarefresh(); andatomiccannot wrapawait. -
Assuming the third-party backends track Django’s release train. On 2026-10-03
django-tasks-dbanddjango-tasks-rqdeclare Django 6.0 as their newest classifier; pin versions and test an upgrade before you rely on one. -
Relying on undocumented internals such as
Task.call(),module_pathor the task signals: they exist in the source but not in the 6.1 reference.
Where the books are outdated
All four reference books were written for Django 1.11 to 4 and predate the Tasks framework entirely, so none of them
describes django.tasks; the pattern they teach for "do this after the response" is an external task queue.
-
Pages that tell you to install and configure Celery (
celery.py,@shared_task,CELERY_*settings, a broker) for every background job are still valid for Celery, but Django now has a standard, broker-neutral API for the simple cases: start with@taskandenqueue()and add Celery only when you need its feature set. -
Examples that call
task.delay()orapply_async()have an equivalentenqueue()here, but the argument rules differ: JSON only, no pickle, validated at enqueue time. -
Hand-rolled workers (a cron-run management command that polls a table, or a Channels background worker) are now a
django-tasks-dbdb_workeraway. -
Advice to enqueue inside the view right after
save()predates widespread use oftransaction.on_commit(Django 1.9) and is wrong whenever the worker needs the row; see Enqueueing after the transaction commits. -
Tutorials that import
django_tasks.backends.database(the 2025 backport) refer to code that moved todjango-tasks-dbindjango-tasks0.12.0. -
The books' testing advice for Celery (
CELERY_ALWAYS_EAGER) maps toImmediateBackendhere and, more usefully, toDummyBackendfor asserting what was enqueued.
What changed recently
| Version | Change |
|---|---|
6.0 (2025-12-03) |
The Tasks framework is introduced: |
|
The backport stops bundling the database and RQ backends; they become |
6.1 (2026-09-02) |
|
|
Current release of the database backend; its classifiers stop at Django 6.0 but it ran under 6.1.1 in the scratch project. |
The 6.0 and 6.1 rows come from the release notes ("Background Tasks" in 6.0, "Tasks" in 6.1); the package rows from their PyPI pages.