Django’s email sending, when done synchronously, blocks your web server’s request-response cycle, making your app feel sluggish and unresponsive.

Let’s see it in action. Imagine a user signs up. Normally, Django would send a welcome email right then and there, tying up the web worker until the email server responds.

# views.py (synchronous, bad)
from django.core.mail import send_mail
from django.shortcuts import render, redirect
from .forms import RegistrationForm

def register(request):
    if request.method == 'POST':
        form = RegistrationForm(request.POST)
        if form.is_valid():
            user = form.save()
            subject = 'Welcome to Our Service!'
            message = f'Hi {user.username}, welcome aboard!'
            sender_email = 'noreply@example.com'
            recipient_list = [user.email]
            send_mail(subject, message, sender_email, recipient_list) # BLOCKS HERE
            return redirect('registration_success')
    else:
        form = RegistrationForm()
    return render(request, 'register.html', {'form': form})

Now, with Celery, that same signup process becomes lightning fast from the user’s perspective. The web worker just needs to tell Celery to send the email and then immediately returns a response.

# tasks.py
from celery import shared_task
from django.core.mail import send_mail

@shared_task
def send_welcome_email_task(user_email, username):
    subject = 'Welcome to Our Service!'
    message = f'Hi {username}, welcome aboard!'
    sender_email = 'noreply@example.com'
    send_mail(subject, message, sender_email, [user_email])
    return "Email sent successfully"

# views.py (asynchronous, good)
from django.shortcuts import render, redirect
from .forms import RegistrationForm
from .tasks import send_welcome_email_task # Import our Celery task

def register(request):
    if request.method == 'POST':
        form = RegistrationForm(request.POST)
        if form.is_valid():
            user = form.save()
            # Instead of calling send_mail directly, we call our Celery task
            send_welcome_email_task.delay(user.email, user.username) # FIRE AND FORGET
            return redirect('registration_success')
    else:
        form = RegistrationForm()
    return render(request, 'register.html', {'form': form})

This pattern is called fire-and-forget. Your web application creates a "task" (the email sending) and hands it off to Celery. Celery, running as a separate process (or multiple processes), picks up this task from a message broker (like Redis or RabbitMQ) and executes it independently. This frees up your web server to handle more incoming requests, drastically improving your application’s responsiveness and scalability.

To get this working, you need a few moving parts:

  1. Celery: The distributed task queue itself. You install it (pip install celery redis) and configure it in your Django project.
  2. Message Broker: A system that Celery uses to pass messages (tasks) between your Django app and its worker processes. Redis is a common and easy-to-set-up choice.
  3. Celery Worker: A separate process that runs your tasks. You start it with a command like celery -A your_project_name worker -l info -P eventlet. The -A points to your Celery app instance, -l info sets the logging level, and -P eventlet (or gevent) enables non-blocking I/O for better performance with network-bound tasks like email.
  4. Django Configuration: You need to tell Django where to find Celery and how to connect to your broker. This typically involves a celery.py file in your project’s root directory and updating settings.py.

Your settings.py will look something like this:

# settings.py

# Celery settings
CELERY_BROKER_URL = 'redis://localhost:6379/0' # Or your message broker URL
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' # Optional, for storing results
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'UTC' # Important for scheduling tasks

And your celery.py (in the same directory as manage.py):

# celery.py
import os

from celery import Celery

# Set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'your_project_name.settings')

app = Celery('your_project_name')

# Using a string here means the worker will not have to
# pickle the object in which it is assigned.
# - namespace='CELERY' means all celery-related configuration keys
#   should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')

# Load task modules from all registered Django app configs.
app.autodiscover_tasks()

@app.task
def debug_task():
    print('Request: {0!r}'.format(self.request))

The core idea is that send_mail is a blocking operation. It establishes a connection to your SMTP server, sends the email, and waits for an acknowledgment. If your SMTP server is slow, or if there’s a network hiccup, your web request can hang for seconds, or even minutes. By delegating this to Celery, the web process returns control immediately, and a separate worker process handles the potentially slow I/O.

When you call .delay() on a Celery task, Celery serializes the function name and its arguments, then pushes this message onto the queue managed by your broker. A Celery worker process, constantly monitoring the queue, picks up the message, deserializes it, and executes the actual Python function (send_mail in this case) in its own process. The worker then reports the result back to the backend if configured.

A common misconception is that Celery itself sends the email. It doesn’t; it’s just the orchestrator. It reliably executes the code you give it, which in this case is Django’s send_mail function. The actual email delivery is still handled by your configured EMAIL_HOST, EMAIL_PORT, etc., in settings.py.

The most surprising thing about asynchronous email sending with Celery is how much it hides the underlying complexity of distributed systems. You write Python code in your Django app, and Celery handles the message queuing, worker management, and retries, abstracting away the network communication and process management that would otherwise be a nightmare to build from scratch.

The next big step is handling failures gracefully. What happens if the email worker crashes mid-send, or if the SMTP server returns an error? Celery has built-in retry mechanisms and error handling you’ll want to configure to ensure emails are eventually sent.

Want structured learning?

Take the full Django course →