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 @task, usually in tasks.py; arguments and return value must survive JSON.

Defining tasks with @task

Run it later

my_task.enqueue(…​) (or await my_task.aenqueue(…​)); keep the returned TaskResult.

Enqueueing and the TaskResult

Choose priority, queue or start time

@task(priority=…​, queue_name=…​) or my_task.using(…​); run_after only on backends that support it.

Priorities, queues and run_after with using()

Read the outcome

TaskResult.status, return_value, errors, refresh(); get_result(id) only where the backend supports it.

Task results

Pick the engine

TASKS = {"default": {"BACKEND": …​}}; the built-ins only run inline or store, production needs a worker backend.

The TASKS setting and choosing a backend

Enqueue after a write

transaction.on_commit(partial(my_task.enqueue, …​)), never inside the transaction body.

Enqueueing after the transaction commits

A real worker today

django-tasks-db (manage.py db_worker) or django-tasks-rq; Celery stays a separate, mature option.

Real workers: third-party backends

Your own engine

Subclass BaseTaskBackend and implement enqueue(); set the supports_* flags honestly.

Writing your own backend

Test it

ImmediateBackend to run inline, DummyBackend to assert what was enqueued, captureOnCommitCallbacks for hooks.

Testing tasks

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.

sequenceDiagram autonumber participant B as Browser participant V as Django view participant D as Database participant Q as Queue store participant W as Worker process B->>V: POST /catalog/books/1/reviews/ V->>D: INSERT review (atomic block) D-->>V: commit V->>Q: notify_moderators.enqueue(review_id=7) via on_commit Q-->>V: TaskResult (status READY, id) V-->>B: 201 Created (no waiting for the email) Note over Q,W: later, outside the request cycle W->>Q: claim next READY task (queue, priority) Q-->>W: task path, args, kwargs W->>D: SELECT review 7 W->>W: run notify_moderators(review_id=7) W->>Q: save status SUCCESSFUL and return value B->>V: GET /catalog/tasks/id/ V->>Q: get_result(id) Q-->>V: TaskResult (status, return_value) V-->>B: 200 JSON
Figure 1. Request, enqueue, worker and result

Three consequences follow from that picture, and most of the pitfalls later on are one of them in disguise.

  1. 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).

  2. The arguments cross a process boundary as JSON, so only JSON-friendly values survive; a model instance, a datetime or a tuple does not arrive as it left.

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

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

priority

0

A whole number from -100 to 100; larger runs sooner. Only honoured by backends with supports_priority.

queue_name

"default"

Which queue the task goes to. Must be listed in the backend’s QUEUES unless QUEUES is [].

backend

"default"

The alias of the backend, as a key of TASKS.

takes_context

False

If true, the function receives a TaskContext as its first parameter, which must be named context.

**kwargs (6.1)

none

Forwarded to the backend’s task_class, for backend-specific options (see Writing your own backend).

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.

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_name must be one of the backend’s QUEUES (default ["default"]). Set QUEUES to [] 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_priority rejects any priority other than 0.

  • Start time. run_after is the earliest time the task may run: a timedelta (relative to now) or a timezone-aware datetime (a naive one raises InvalidTask when USE_TZ is true). It can only be set with using(), and only on a backend with supports_defer.

  • Async functions. An async def task is accepted only by backends with supports_async_task.

  • Context. A task with takes_context=True must name its first parameter context.

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.

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.

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

id

Unique string of fewer than 64 characters; the format depends on the backend (random strings on the built-in backends, UUIDs on django-tasks-db). Treat it as opaque.

status

A TaskResultStatus: READY (just enqueued, or ready to run again), RUNNING, FAILED (raised, or could not start) or SUCCESSFUL.

task, backend

The Task and the backend alias the result belongs to.

enqueued_at, started_at, last_attempted_at, finished_at

Timestamps: when it was enqueued, when its first attempt began, when the latest attempt began, when it finished (successfully or not).

is_finished

True for FAILED and SUCCESSFUL.

attempts

How many times the task has run; a currently running attempt does not count yet.

worker_ids

The ids of the workers that executed it.

return_value

The function’s (JSON-normalised) return value. Raises ValueError while the task is unfinished or after it failed.

errors

A list of TaskError objects, one per failed execution, each with exception_class and traceback (a string).

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

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.

The status lifecycle

stateDiagram-v2 [*] --> READY: enqueue() READY --> RUNNING: a worker claims it RUNNING --> SUCCESSFUL: function returned RUNNING --> FAILED: function raised RUNNING --> READY: backend schedules a retry SUCCESSFUL --> [*] FAILED --> [*]
Figure 2. Status transitions of a TaskResult

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

BACKEND

"" (empty string)

Import path of the backend class: one of the two built-ins, or any BaseTaskBackend subclass.

QUEUES

["default"]

The queue names the backend accepts. [] switches the name check off.

OPTIONS

{}

Extra parameters for the backend; which ones exist depends on the backend (a custom backend reads them from self.options).

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.

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

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])

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

supports_defer

Yes

No

run_after is allowed.

supports_async_task

Yes

Yes

async def task functions are allowed.

supports_get_result

No

No

Results can be fetched from another thread after enqueueing.

supports_priority

Yes

Yes

Tasks run in priority order (on the built-ins the value has no effect; it is accepted so tests can use them).

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.

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 = True the 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 no atomic block, on_commit runs the callable immediately, so the pattern is safe to use unconditionally.

  • On a rollback the enqueue never happens. The test test_rollback_means_no_task in 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 time aenqueue() is called the row is committed and no on_commit is needed. transaction.atomic() cannot wrap await calls. If the operation needs a transaction, put it in a plain function (like create_review() above) and call it with sync_to_async(); the on_commit inside 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 def or async def. An async def task needs a backend with supports_async_task (both built-ins have it); the built-in backends run a plain def task from async code in a thread. In a script: @task async def fetch_title(isbn): …​ enqueued with await fetch_title.aenqueue("123") returned SUCCESSFUL with the value title-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.

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

django-tasks-db 0.13.0

Your database (ORM model); manage.py db_worker

django_tasks_db.DatabaseBackend

Classifiers up to Django 6.0; works with 6.1.1 as run on this page.

django-tasks-rq 0.12.0

Redis through RQ; manage.py rqworker

django_tasks_rq.RQBackend

Classifiers up to Django 6.0; configuration checked, not run (no Redis).

django-tasks 0.12.0

The backport of the API for Django 4.2-5.2 plus the immediate and dummy backends

django_tasks.backends…​

For Django earlier than 6.0 (see Moving from the django-tasks backport or from Celery).

huey

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

--queue-name

Queues to process, comma-separated; '*' means all. Defaults to default — see the pitfall below.

--exclude-queues

Queues to skip.

--interval

Seconds to wait before polling again when the queue is empty (default 1).

--batch

Process what is outstanding, then exit; combine with --max-tasks.

--max-tasks

Stop after this many tasks.

--reload / --no-reload

Reload on code changes; the default follows DEBUG. Not for production, because it can stop a task uncleanly.

--backend

The TASKS alias to serve (default default).

--worker-id

A worker id that must be unique across the pool (default: generated).

--no-startup-delay

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.

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 in enqueue(), 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 (default Task) is what @task instantiates. Since Django 6.1, @task forwards 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.

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.

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

  2. Keep arguments small and JSON-native. Strings, numbers, booleans, lists and dicts of those. Convert Decimal, dates and UUID yourself; remember a tuple comes back as a list (Arguments and return values are JSON).

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

  4. 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_at column, a unique constraint, an idempotency key sent to the payment or email provider — so the second run is a no-op. The example returned already-notified the second time and the test asserts a single email.

  5. Enqueue after the commit. Always on_commit when a task reads what the surrounding transaction writes (Enqueueing after the transaction commits).

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

  7. Do not use the request. No HttpRequest, no request.user object, no reading of the session. Pass the user’s id and whatever values you need; build absolute URLs from Site or settings, since the worker has no Host header.

  8. Use context.attempt for 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.

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

  10. Set a retention policy for results (prune_db_task_results for the database backend), or the table grows without bound.

  11. Observe failures. A FAILED result is just a row. Log with the logging module (see the logging page when it lands), alert on a growing READY backlog or on failures, and run more than zero workers per queue.

  12. Treat ImmediateBackend as development-only. Code that works there can still break on a real backend: unserializable arguments fail at enqueue either way, but ordering, transactions and get_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.

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

INSTALLED_APPS += ["django_tasks"]

nothing to install

The backport is an app.

from django_tasks import task

from django.tasks import task

Same decorator, same Task and TaskResult.

django_tasks.backends.immediate.ImmediateBackend

django.tasks.backends.immediate.ImmediateBackend

Same for the dummy backend.

InvalidTaskError, InvalidTaskBackendError

InvalidTask, InvalidTaskBackend

The exceptions were renamed in Django’s own module; catch the right one.

django_tasks.backends.database and .rq (before 0.12.0)

django_tasks_db, django_tasks_rq

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.

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 DummyBackend never runs anything.

  • Enqueueing inside the transaction. The worker may not see the row (or the transaction may roll back). Use on_commit with functools.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 context when takes_context=True.

  • Queue names missing from QUEUES (or a worker that serves only default): the task is rejected, or parked in a queue nobody reads.

  • Relying on get_result() with the built-in backends. The immediate backend raises NotImplementedError; the dummy backend’s results are only visible in the same thread.

  • Assuming run_after and priorities work everywhere. They are backend features: check supports_defer and supports_priority; RQ has almost no priority at all.

  • Treating exception_class as the whole error. It is the type; read traceback for the message.

  • Reading return_value too early or after a failure. It raises ValueError; check status or is_finished first, and call refresh() on a result you held since enqueue time.

  • Forgetting that tests do not commit. on_commit callbacks never run inside TestCase; use captureOnCommitCallbacks(execute=True).

  • Calling the sync API from async code. Use aenqueue(), aget_result() and arefresh(); and atomic cannot wrap await.

  • Assuming the third-party backends track Django’s release train. On 2026-10-03 django-tasks-db and django-tasks-rq declare 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_path or 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 @task and enqueue() and add Celery only when you need its feature set.

  • Examples that call task.delay() or apply_async() have an equivalent enqueue() 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-db db_worker away.

  • Advice to enqueue inside the view right after save() predates widespread use of transaction.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 to django-tasks-db in django-tasks 0.12.0.

  • The books' testing advice for Celery (CELERY_ALWAYS_EAGER) maps to ImmediateBackend here and, more usefully, to DummyBackend for asserting what was enqueued.

What changed recently

Table 1. Tasks framework changes by version
Version Change

6.0 (2025-12-03)

The Tasks framework is introduced: django.tasks, the @task decorator, Task, TaskResult, TaskContext, TaskResultStatus, the TASKS setting with BACKEND, QUEUES and OPTIONS, ImmediateBackend and DummyBackend, enqueue()/aenqueue(), get_result(), using(), takes_context, and the base class for third-party backends. Django provides no worker.

django-tasks 0.12.0 (2026-02-06)

The backport stops bundling the database and RQ backends; they become django-tasks-db and django-tasks-rq.

6.1 (2026-09-02)

@task accepts arbitrary keyword arguments, forwarded to the backend’s task_class; Task and TaskResult instances can be pickled and unpickled. The release also deprecates the EMAIL_* settings in favour of MAILERS, which the email example of this page uses.

django-tasks-db 0.13.0 (2026-08-28)

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.