Sending email — send_mail, EmailMessage, and the console backend
Django's email API is deliberately backend-agnostic — the same send_mail() call works against a real SMTP server in production and a backend that just prints to the console in development, and getting that swap right is most of what matters here.
3 min read
send_mail: the simple case
from django.core.mail import send_mail
send_mail(
subject="Welcome to the app",
message="Thanks for signing up.",
from_email="noreply@example.com",
recipient_list=["ada@example.com"],
)send_mail covers the common case: one plain-text email, one or more recipients. It returns the number of successfully delivered messages (0 or 1 here), and raises on connection failure unless fail_silently=True is passed — which is almost never what's actually wanted, since silently swallowing a failed signup-confirmation email is a real, hard-to-notice bug.
The backend setting: where the email actually goes
# settings.py — development: prints the full email to the terminal, sends NOTHING
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# settings.py — production: an actual SMTP connection
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.sendgrid.net"
EMAIL_PORT = 587
EMAIL_HOST_USER = "apikey"
EMAIL_HOST_PASSWORD = os.environ["SENDGRID_API_KEY"]
EMAIL_USE_TLS = TrueEMAIL_BACKEND is what makes the exact same send_mail() call behave completely differently depending on environment — the console backend prints the full email (subject, body, headers) to stdout instead of sending anything, which is the standard local-dev setup: no real SMTP credentials needed, no risk of accidentally emailing a real address while testing, and immediate visibility into exactly what would have been sent. Swapping to smtp.EmailBackend in production is a settings-only change; application code calling send_mail never needs to know which backend is active.
locmem: the backend tests actually use
# settings.py (test settings, or set automatically by Django's test runner)
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"from django.core import mail
def test_signup_sends_welcome_email():
signup(email="ada@example.com")
assert len(mail.outbox) == 1
assert mail.outbox[0].subject == "Welcome to the app"
assert "ada@example.com" in mail.outbox[0].toDjango's test runner automatically switches to the locmem backend during tests — sent messages accumulate in django.core.mail.outbox, a plain list, instead of going anywhere real. This is what makes asserting "the right email was sent, to the right person, with the right content" possible in a test without an actual mail server, a network call, or any risk of really emailing someone during a test run.
EmailMessage: when send_mail isn't flexible enough
from django.core.mail import EmailMessage
email = EmailMessage(
subject="Your invoice",
body="See attached.",
from_email="billing@example.com",
to=["ada@example.com"],
cc=["accounting@example.com"],
reply_to=["support@example.com"],
)
email.attach_file("invoice.pdf")
email.send()send_mail has no parameters for CC, BCC, reply-to, custom headers, or attachments — EmailMessage is the lower-level class that supports all of them, at the cost of being a bit more verbose to construct. send_mail is really a thin convenience wrapper around building and sending an EmailMessage; reaching for EmailMessage directly is the right move the moment an email needs anything beyond "plain text, some recipients."
HTML email: EmailMultiAlternatives
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
text_body = render_to_string("emails/welcome.txt", {"user": user})
html_body = render_to_string("emails/welcome.html", {"user": user})
email = EmailMultiAlternatives(
subject="Welcome",
body=text_body, # the PLAIN-TEXT fallback
from_email="noreply@example.com",
to=[user.email],
)
email.attach_alternative(html_body, "text/html") # the HTML version, attached ON TOP
email.send()A proper HTML email includes both an HTML version and a plain-text fallback — for email clients that don't render HTML, and for spam filters that penalize HTML-only mail. EmailMultiAlternatives is built specifically for this: the plain-text body is the base message, and .attach_alternative(html, "text/html") adds the HTML version as an alternative representation of the same email, rendered from a template exactly like a webpage would be (covered in the templates lesson).
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' do?
2. What does django.core.mail.outbox do during tests?
3. Why reach for EmailMessage instead of send_mail?
4. Why does a proper HTML email include both an HTML body AND a plain-text fallback via EmailMultiAlternatives?