Send Django Email in the Background
Send Django email outside the request cycle: queue emails with Celery after commit, retry provider failures with backoff, use django-celery-email or a custom backend, keep templates rendered in the task, and avoid duplicate sends.
Problem statement
Calling send_mail inside a view makes the user wait for the email provider: usually 100-500 ms, sometimes seconds, and when the provider or SMTP server is down, the signup or checkout itself fails with a 500. Email is a side effect that can happen a moment later. Moving it to a background task keeps requests fast and makes delivery resilient with retries, as long as tasks are queued after the transaction commits and are safe to retry.
- Write a Celery task that takes IDs, renders the template, and sends the email.
- Queue it with
transaction.on_commit(lambda: send_welcome.delay(user.pk)). - Retry transient failures:
autoretry_for=(SMTPException, AnymailAPIError, ConnectionError),retry_backoff=True,max_retries=5. - Guard against duplicates with a sent flag or an idempotency record.
- For existing code that calls
send_maileverywhere, use a queued email backend (django-celery-email) as a stepping stone. - Route email tasks to their own queue so slow reports never delay them.
Step-by-step solution
1. An email task
# accounts/tasks.py
from smtplib import SMTPException
from celery import shared_task
from django.contrib.auth import get_user_model
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
@shared_task(bind=True, autoretry_for=(SMTPException, ConnectionError, TimeoutError),
retry_backoff=True, retry_backoff_max=600, max_retries=6, acks_late=True)
def send_welcome(self, user_id):
user = get_user_model().objects.get(pk=user_id)
ctx = {"user": user}
msg = EmailMultiAlternatives(
subject="Welcome to Example",
body=render_to_string("emails/welcome.txt", ctx),
to=[user.email],
)
msg.attach_alternative(render_to_string("emails/welcome.html", ctx), "text/html")
msg.send()
Pass primary keys and render inside the task, so the email reflects committed data and arguments stay small and serialisable. With Anymail, add anymail.exceptions.AnymailAPIError to autoretry_for.
2. Queue after commit
from django.db import transaction
def signup(request):
user = create_user(request.POST)
transaction.on_commit(lambda: send_welcome.delay(user.pk))
return redirect("dashboard")
Queuing before commit lets the worker run before the user row exists (DoesNotExist), or send an email for a signup that was rolled back.
3. Avoid duplicate sends
Retries and at-least-once delivery mean a task can run twice. For one-time emails, record that the email was sent:
@shared_task(bind=True, autoretry_for=(SMTPException,), retry_backoff=True)
def send_receipt(self, order_id):
with transaction.atomic():
order = Order.objects.select_for_update().get(pk=order_id)
if order.receipt_sent_at:
return
build_receipt_email(order).send()
order.receipt_sent_at = timezone.now()
order.save(update_fields=["receipt_sent_at"])
A crash between send() and save() can still cause one duplicate; that is usually acceptable for email, unlike payments.
4. Queue existing send_mail calls
If the codebase calls send_mail in many places, a Celery-backed email backend queues every message without touching call sites:
EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend"
CELERY_EMAIL_BACKEND = "anymail.backends.postmark.EmailBackend"
This is a quick win, but it queues already-rendered messages and does not wait for commit; migrate important emails to explicit tasks over time.
5. Give email its own queue
CELERY_TASK_ROUTES = {"accounts.tasks.send_*": {"queue": "email"}, "orders.tasks.send_*": {"queue": "email"}}
Run a small worker for -Q email so a backlog of report jobs never delays password reset emails.
Verification check: sign up in staging and confirm the response is fast and the welcome email arrives within seconds; stop the email provider (or point it at an invalid host) and confirm tasks retry and succeed once it returns.
Explanation
Django's email API is synchronous: send() opens a connection to SMTP or an HTTP API and waits. A Celery task moves that wait to a worker process and adds retry semantics, turning a transient provider failure into a delayed email instead of a failed request. transaction.on_commit ensures the task only exists if the data it describes was saved. Because Celery delivers at least once, one-time emails benefit from a small idempotency check.
Edge cases / notes
- Password reset emails from
PasswordResetViewcallsend_maildirectly; subclass the form'ssend_mailto queue a task, or use a queued backend. - Large attachments should be stored and referenced by ID, not passed as task arguments.
- Locale. Activate the user's language in the task (
translation.override(user.language)) before rendering. - Testing. With
CELERY_TASK_ALWAYS_EAGERand thelocmembackend, tests can assert onmail.outbox.
Next steps
- Choose a provider with Send Django Email Through SendGrid, Postmark, or Mailgun With Anymail.
- Make tasks safe to repeat with Make Celery Tasks Idempotent and Safe to Retry.
FAQ
How do I send Django email asynchronously?
Send it from a Celery task queued with transaction.on_commit, passing IDs and rendering inside the task.
What if the email provider is down?
The task retries with backoff, so the email is delayed rather than lost, and the user's request still succeeds.
Can I make all send_mail calls asynchronous at once?
Yes, with a Celery-backed email backend such as django-celery-email, as a first step.
Will retries send duplicate emails?
Rarely, but possible. Record a sent flag for one-time emails such as receipts.