Back to Scaling Python Applications guides

Introduction to Django 6.0 Background Tasks

Stanley Ulili
Updated on December 7, 2025

Django 6.0 introduces native support for background tasks, eliminating the need for third-party libraries in most common scenarios. The new django.tasks module provides a standardized API for running work outside the request-response cycle, making it easier to send emails, process files, and handle time-consuming operations without blocking your users.

This guide introduces you to Django's background tasks framework through practical examples. You'll learn the core concepts, set up a working project, and build real tasks that run in the background. By the end, you'll understand when to use Django's built-in tasks versus reaching for more complex solutions like Celery.

Prerequisites

Before starting, ensure you have Python 3.10 or later installed on your system. You'll also need basic familiarity with Django's views and models.

Verify your Python version with:

 
python3 --version
Output
Python 3.11.5

Understanding why background tasks matter

When someone clicks a submit button on your Django site, their browser sends a request, Django does all the work, and then sends a response back. The browser waits the whole time. This works well for quick operations, but it becomes a problem when the work takes longer.

Here is how a signup request might look if everything runs in the request:

  • Create the user in the database, send a welcome email, generate a default profile picture, and notify your team’s Slack channel

Sending emails and talking to external services can be slow, and the user ends up staring at a loading spinner. In reality, they do not care when the email is sent. They just want to know their account exists so they can continue. Background tasks fix this by keeping the fast, essential work in the request and moving the slow, optional work to a separate worker process that runs in the background.

How Django approached background tasks

For years, Django developers solved background work with third party tools, which led to many different patterns and setups. There was no single, standard way to do it inside Django itself.

Most teams ended up with something like this:

  • Use Celery, Huey, or RQ with a message broker such as Redis or RabbitMQ, each with its own configuration and conventions

Developers repeatedly asked for a simpler, built in option for common tasks such as sending emails or processing uploads. Jake Howard’s Django Enhancement Proposal suggested a small, standard API for tasks, with swappable backends. Django 6.0 turned this idea into reality with the django.tasks module, giving projects a shared, native way to define and enqueue background work.

What Django’s task framework actually does

To use django.tasks effectively, it helps to see it as a thin layer that defines how you describe work, not how that work is executed. Django focuses on the API and leaves the heavy lifting to external workers and backends.

In practice, the framework gives you:

  • A standard way to define tasks, enqueue them, and hand them off to pluggable backends such as a database queue, Redis, or other systems

This keeps Django small while giving you a clear path to offload simple tasks without jumping straight into a full distributed system. If your project later needs advanced features such as task chaining or complex routing, you can still bring in tools like Celery. Django’s task framework is a solid baseline that covers most use cases and leaves the door open for more powerful solutions when you need them.

Setting up your Django project

Create a new directory for your project and navigate into it:

 
mkdir django-tasks-demo
 
cd django-tasks-demo

Set up a virtual environment to isolate your project dependencies:

 
python3 -m venv venv

Activate the virtual environment:

 
source venv/bin/activate

On Windows, use venv\Scripts\activate instead.

Install Django 6.0 and the database backend package:

 
pip install Django django-tasks

Create a new Django project:

 
django-admin startproject taskdemo .

The period at the end creates the project in the current directory instead of creating a subdirectory. Your project structure should look like this:

 
.
├── manage.py
├── taskdemo
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── venv

Create an app to hold your tasks:

 
python manage.py startapp accounts

Open taskdemo/settings.py and add your new app to INSTALLED_APPS:

taskdemo/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
'django_tasks',
'django_tasks.backends.database',
'accounts',
]

Run migrations to set up your database:

 
python manage.py migrate
Output
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, django_tasks, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  ...
  Applying django_tasks.0001_initial... OK

Defining your first background task

Create a new file for your tasks:

 
touch accounts/tasks.py

Open the file and define a task to send welcome emails:

accounts/tasks.py
from django.core.mail import send_mail
from django.tasks import task

@task
def send_welcome_email(user_email, username):
    subject = 'Welcome to TaskDemo'
    message = f'Hi {username}, thanks for joining TaskDemo!'
    from_email = 'noreply@taskdemo.com'

    send_mail(subject, message, from_email, [user_email])
    print(f'Welcome email sent to {user_email}')

The @task decorator marks this function as a background task. Django can now execute it outside the normal request-response cycle. The function looks like any other Python function, which keeps your code simple and testable.

The decorator accepts optional arguments to control task behavior:

accounts/tasks.py
from django.tasks import task

@task(priority=10, queue_name='emails')
def send_welcome_email(user_email, username):
    # task implementation
    pass

The priority argument ranges from -100 to 100, with higher numbers running first. The queue_name argument lets you separate different types of work into dedicated queues.

Configuring the task backend

Django's task framework needs a backend to store and execute tasks. The database backend works well for most applications and doesn't require additional infrastructure like Redis or RabbitMQ.

Add the database backend configuration to your settings:

taskdemo/settings.py
TASKS = {
    'default': {
        'BACKEND': 'django_tasks.backends.database.DatabaseBackend',
        'QUEUES': ['default', 'emails', 'images'],
    }
}

The QUEUES setting defines which queue names your tasks can use. Django validates queue names when you enqueue tasks, catching typos early.

Configuring email for testing

Before testing your view, configure Django to print emails to the console instead of trying to send them through an SMTP server. Add this to your settings file:

taskdemo/settings.py
# Add at the end of the file
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

This backend prints email content to your terminal, which is perfect for development and testing.

Enqueuing tasks from views

Create a view that registers users and sends welcome emails in the background:

accounts/views.py
from django.http import HttpResponse
from django.contrib.auth.models import User
from django.views.decorators.csrf import csrf_exempt
from django.db import IntegrityError
from .tasks import send_welcome_email

@csrf_exempt
def register(request):
    if request.method == 'POST':
        username = request.POST.get('username')
        email = request.POST.get('email')
        password = request.POST.get('password')

        try:
            user = User.objects.create_user(
                username=username,
                email=email,
                password=password
            )

            result = send_welcome_email.enqueue(
                user_email=user.email,
                username=user.username
            )

            print(f'Task ID: {result.id}')

            return HttpResponse(f'Registration successful! Task ID: {result.id}')
        except IntegrityError:
            return HttpResponse('Username already exists. Please choose another.', status=400)

    return HttpResponse('Send POST request to register')

The @csrf_exempt decorator allows testing with curl by bypassing Django's CSRF protection. In production, you'd handle CSRF tokens properly through forms or API authentication.

The .enqueue() method adds the task to your configured backend. The view returns immediately without waiting for the email to send. Notice how you pass keyword arguments that match your task function's parameters.

The enqueue() method returns a TaskResult object containing a unique task ID that you can store to check the task's status later.

Add a URL pattern for your view:

taskdemo/urls.py
from django.contrib import admin
from django.urls import path
from accounts import views
urlpatterns = [ path('admin/', admin.site.urls),
path('register/', views.register),
]

Running the development server

Start Django's development server to test your view:

 
python manage.py runserver
Output
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
December 07, 2025 - 15:30:22
Django version 6.0, using settings 'taskdemo.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Open another terminal, activate your virtual environment, and send a test request:

 
curl -X POST http://127.0.0.1:8000/register/ \
  -d "username=alice" \
  -d "email=alice@example.com" \
  -d "password=secret123"
Output
Registration successful! Task ID: d3Lw462fPX3rEwTC0YJfn76mjc8ZlEr4

Check your server terminal. You'll see the email content printed because you configured the console email backend:

Output
Task id=d3Lw462fPX3rEwTC0YJfn76mjc8ZlEr4 path=accounts.tasks.send_welcome_email state=RUNNING
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Subject: Welcome to TaskDemo
From: noreply@taskdemo.com
To: alice@example.com

Hi alice, thanks for joining TaskDemo!
-------------------------------------------------------------------------------
Welcome email sent to alice@example.com
Task id=d3Lw462fPX3rEwTC0YJfn76mjc8ZlEr4 path=accounts.tasks.send_welcome_email state=SUCCESSFUL

The database backend stores the task and executes it. For true background processing where tasks don't block the request-response cycle, you need to run a separate worker process.

Running the task worker

The database backend stores tasks in your database but executes them in the same process by default. To run tasks in a separate background worker, stop your development server with Ctrl+C and start the worker:

 
python manage.py db_worker
Output
Starting database task worker...
Polling for tasks every 1.0 seconds
Press Ctrl+C to stop

The worker runs in a loop, checking for new tasks and executing them as they arrive. Keep this running in a separate terminal while you test your application.

In your original terminal, start the development server again:

 
python manage.py runserver

Now when you register a user with a different username, the view returns immediately and the worker picks up the email task:

 
curl -X POST http://127.0.0.1:8000/register/ \
  -d "username=bob" \
  -d "email=bob@example.com" \
  -d "password=secret123"

You'll see output in the worker terminal showing the task execution:

Output
Executing task: send_welcome_email
Content-Type: text/plain; charset="utf-8"
Subject: Welcome to TaskDemo
From: noreply@taskdemo.com
To: bob@example.com

Hi bob, thanks for joining TaskDemo!
-------------------------------------------------------------------------------
Welcome email sent to bob@example.com
Task completed successfully

The view returns instantly while the worker handles the email in the background. This separation means your users never wait for slow operations.

Checking task status

You can query task status using the task ID returned by enqueue(). Create a view that checks whether a task finished:

accounts/views.py
from django.http import HttpResponse, JsonResponse
from django.contrib.auth.models import User from django.views.decorators.csrf import csrf_exempt from django.db import IntegrityError
from django.tasks import default_task_backend
from .tasks import send_welcome_email @csrf_exempt def register(request): ... return HttpResponse('Send POST request to register')
def task_status(request, task_id):
try:
result = default_task_backend.get_result(task_id)
return JsonResponse({
'id': result.id,
'status': result.status.name,
'finished': result.is_finished,
'attempts': result.attempts,
})
except Exception:
return JsonResponse({'error': 'Task not found'}, status=404)

Add the URL pattern:

taskdemo/urls.py
from django.contrib import admin
from django.urls import path
from accounts import views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('register/', views.register),
path('task/<str:task_id>/', views.task_status),
]

After registering a user and getting a task ID, you can check its status:

 
curl http://127.0.0.1:8000/task/<your_task_id>/
Output
{"id": "d3Lw462fPX3rEwTC0YJfn76mjc8ZlEr4", "status": "SUCCESSFUL", "finished": true, "attempts": 1}

The status changes from READY to RUNNING to SUCCESSFUL as the worker processes the task. If you check the status immediately after enqueuing a task, you might catch it in the READY or RUNNING state before it completes.

Register another user and quickly check the task status:

 
curl -X POST http://127.0.0.1:8000/register/ \
  -d "username=charlie" \
  -d "email=charlie@example.com" \
  -d "password=secret123"
Output
Registration successful! Task ID: x9Y2mKpQrS5tVwNzL8hJfD3gB6nC4eA1

Immediately check the status:

 
curl http://127.0.0.1:8000/task/<your_task_id>/

If you're fast enough, you might see:

Output
{"id": "x9Y2mKpQrS5tVwNzL8hJfD3gB6nC4eA1", "status": "RUNNING", "finished": false, "attempts": 1}

Wait a moment and check again to see it completed:

Output
{"id": "x9Y2mKpQrS5tVwNzL8hJfD3gB6nC4eA1", "status": "SUCCESSFUL", "finished": true, "attempts": 1}

This status checking mechanism lets you build user interfaces where people can monitor their background jobs, similar to upload progress bars or report generation status indicators.

Final thoughts

Django 6.0’s task framework gives you a built-in, lightweight way to move slow work out of the request-response cycle. In this guide, you:

  • Set up a Django project, defined a background task, configured the database backend, ran a worker, and exposed an endpoint to check task status

For most everyday jobs like sending emails or processing uploads, django.tasks is all you need. When your project grows and you require advanced workflows or large-scale processing, you can still step up to tools like Celery without throwing away what you learned here.

Got an article suggestion? Let us know
Next article
Containerizing Django Applications with Docker
This article provides step-by-step instructions for deploying your Django applications using Docker and Docker Compose
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.