Django 6.1 added a setting called MAILERS, meant to replace EMAIL_BACKEND and the ten or so EMAIL_* settings that go with it. I assumed a new config system built from scratch in 2026 would also tidy up how connections get made, so I measured it before deciding whether to migrate anything. Sending 50 emails cost exactly 50 SMTP connections under the old settings, and exactly 50 SMTP connections under the new ones. The number only dropped when I stopped using the documented pattern and reused a connection object by hand, a trick that worked identically before MAILERS existed.
MAILERS is not experimental. Django 6.1 reached stable in August 2026, so this is current, not a beta feature.
What MAILERS actually is
Before 6.1, a project had one global email backend, set with EMAIL_BACKEND, plus flat settings for host, port, credentials and TLS. MAILERS turns that into a dictionary of named backends, each with its own BACKEND class and OPTIONS:
MAILERS = {
"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {"host": "smtp.example.com", "port": 587, "use_tls": True},
},
}
You then pick a mailer with using="default" on send_mail(), or get a backend instance directly with mail.mailers["default"]. The old EMAIL_BACKEND and friends still work, but Django 6.1's release notes say they're deprecated and will be replaced in a future release.
I set up a throwaway Django 6.1 project (it needs Python 3.12 or newer; my system default was 3.11 and the install failed outright until I pointed the venv at 3.12) and a local SMTP server built on aiosmtpd that logs every EHLO and every DATA command, so I'd have a real connection count rather than a guess from timing alone.
Finding 1: MAILERS does not reduce connections by itself
I sent 50 emails three ways, three runs each, against the local server:
| Pattern | Connections | Fastest of 3 runs |
|---|---|---|
send_mail(), legacy EMAIL_BACKEND
|
50 | 154ms |
send_mail(..., using="default"), MAILERS configured |
50 | 151ms |
Manually holding mail.mailers["default"] open across all 50 sends |
1 | 92ms |
The first two rows are indistinguishable. Reading Django's source explains why: mail.mailers[alias] calls create_connection(), which builds a brand new backend instance on every access, same as the old get_connection() did. MAILERS is a configuration format, not a connection pool. If your code calls send_mail(using="default") in a loop, you get a new TCP connection, a new EHLO and a new STARTTLS handshake every single time, exactly like before.
The only way to cut that in the third row was to grab the connection object once and pass it to every message, which is the same with get_connection() as connection: pattern that has worked in Django for over a decade. Nothing about MAILERS makes that easier or harder; it is just still available under a different accessor. For a project sending one email per request, this finding changes nothing. For a batch job sending hundreds of notifications, migrating to MAILERS and leaving the sending code untouched will not save you anything, and it is easy to assume it will, because the feature's entire pitch is "a modern config system".
Finding 2: the same bad setting is a warning in one place and a fatal error in the other
I passed a misspelt option (tiemout instead of timeout) to the SMTP backend two ways.
Direct construction, the old pattern:
WARNING: Directly creating EmailBackend instances is deprecated. Use mail.mailers instead.
WARNING: django.core.mail.backends.smtp.EmailBackend.__init__() does not support 'tiemout'.
In Django 7.0, BaseEmailBackend will raise a TypeError for unknown keyword arguments.
The backend still got constructed. The typo was silently ignored and the object worked.
The same typo inside MAILERS["default"]["OPTIONS"]:
InvalidMailer: MAILERS['default']: Unknown options 'tiemout'.
No object, no fallback, nothing sent. This is a real improvement and it is the strongest argument for migrating: a misconfigured mailer fails at startup instead of silently dropping an option and sending with defaults you didn't choose. It is also a trap if you are copying an existing EMAIL_HOST_* settings block into OPTIONS by hand, because any key that doesn't match the backend's constructor argument names now breaks the whole mailer rather than being ignored.
Finding 3: using refuses to be combined with the arguments it replaces
using exists to replace the connection, fail_silently, auth_user and auth_password arguments to send_mail(). Passing both raises immediately, not a warning:
send_mail("s", "b", "from@x.com", ["to@x.com"], using="default", fail_silently=True)
# TypeError: 'fail_silently' is not compatible with 'using'.
send_mail("s", "b", "from@x.com", ["to@x.com"], using="default", connection=my_conn)
# TypeError: 'connection' is not compatible with 'using'.
send_mail("s", "b", "from@x.com", ["to@x.com"], using="default", auth_user="bob")
# TypeError: 'auth_user' and 'auth_password' are not compatible with 'using'.
# Set 'username' and 'password' OPTIONS in MAILERS instead.
For a straightforward migration this is fine; you delete the old arguments and move the values into OPTIONS. It is a problem if your codebase builds the connection or fail_silently arguments conditionally somewhere upstream of the send_mail() call, because adding using to one call site without auditing every caller turns a working call into a crash the first time that code path is exercised with both set.
Finding 4: the new safety check only watches the people who already migrated
Django 6.1 adds a deploy check, mail.E001, that fails manage.py check --deploy if your default mailer is a console, dummy, file-based or in-memory backend; mail.W001 warns if MAILERS has no default entry at all. I confirmed both fire with real text:
ERRORS:
?: (mail.E001) Your MAILERS setting uses a development-only email backend in the
'default' entry (django.core.mail.backends.console.EmailBackend).
HINT: Use a production-ready email backend, such as the SMTP backend,
otherwise email will not be sent.
Then I ran the same check against a project that never touched MAILERS at all, left EMAIL_BACKEND set to the console backend, and otherwise looked production-ready (DEBUG = False, security middleware installed, a real SECRET_KEY):
$ python manage.py check --deploy
System check identified some issues:
WARNINGS:
?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True...
System check identified 2 issues (0 silenced).
Nothing about mail. The check only reads settings.MAILERS, so a project still on the deprecated settings gets zero protection against shipping the console backend to production, which is precisely the mistake the new check exists to catch. The safety net only covers people who already did the migration work; the people most likely to make this mistake, because they haven't touched their email config in years, get nothing new.
Finding 5: the documented warnings mostly don't fire
The release notes say the deprecated settings "will continue to work but will issue deprecation warnings". I configured a project on pure EMAIL_BACKEND settings, with no MAILERS anywhere, and called send_mail() with warnings.simplefilter("always") active:
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
send_mail("s", "b", "from@example.com", ["to@example.com"])
print(len(w)) # 0
Zero warnings. Calling get_connection() directly in the same project does warn:
RemovedInDjango70Warning: get_connection() is deprecated. See 'Migrating email
to mailers' in Django's documentation for recommended replacements.
The difference is in django/utils/deprecation.py: Django's warning helper deliberately skips the warning when the call originates from inside Django's own code, specifically to stop one deprecated feature from cascading warnings when it's implemented on top of another. send_mail() calls get_connection() internally, so that call is "inside Django" and produces nothing. You only get warned if your own code calls get_connection() or constructs a backend directly. The overwhelming majority of Django projects only ever call send_mail(), which means the overwhelming majority of projects on the old settings will see no warning at all when they upgrade to 6.1, despite what the release notes imply.
To be fair to the release notes, one documented claim did hold up exactly as written: with no MAILERS defined, mail.mailers["default"] correctly falls back to building a backend from the legacy EMAIL_HOST/EMAIL_PORT settings. I pointed those at a fake host and confirmed the returned object carried the right host and port.
What I got wrong on the way
My first version of the test server counted connections with a handle_connect hook, which doesn't exist in aiosmtpd's handler API; it accepted the method name silently and simply never called it, so every run reported zero connections regardless of what Django did. I switched to counting handle_EHLO calls instead, since every SMTP client sends exactly one EHLO per connection, and the first run with that version failed every send with 503 Error: send HELO first. The real bug was mine: aiosmtpd's "new-style" EHLO hook expects the handler to set session.host_name itself, which the built-in handler does automatically but a custom one has to do explicitly. Without that line the server never considered the connection to have said hello, even though it had.
Run it yourself
This needs Python 3.12+. The full connection-count test:
python3.12 -m venv venv && ./venv/bin/pip install django==6.1 aiosmtpd
cat > smtp_test_server.py << 'EOF'
import asyncio, json, sys, time
from aiosmtpd.controller import Controller
class CountingHandler:
def __init__(self):
self.connections = 0
async def handle_EHLO(self, server, session, envelope, hostname, responses):
self.connections += 1
print(json.dumps({"event": "connect", "n": self.connections})); sys.stdout.flush()
session.host_name = hostname
return responses
async def handle_DATA(self, server, session, envelope):
print(json.dumps({"event": "message"})); sys.stdout.flush()
return "250 OK"
async def main():
Controller(CountingHandler(), hostname="127.0.0.1", port=1025).start()
while True:
await asyncio.sleep(3600)
asyncio.run(main())
EOF
./venv/bin/python smtp_test_server.py > server.log 2>&1 &
cat > send_test.py << 'EOF'
import time, django
from django.conf import settings
settings.configure(
MAILERS={"default": {"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {"host": "127.0.0.1", "port": 1025, "timeout": 5}}},
)
django.setup()
from django.core.mail import send_mail
t0 = time.monotonic()
for i in range(50):
send_mail(f"s{i}", "b", "from@example.com", ["to@example.com"], using="default")
print(f"{time.monotonic() - t0:.3f}s")
EOF
./venv/bin/python send_test.py
grep -c '"event": "connect"' server.log
On my machine this prints 50 connections every time, whether using="default" is there or not.
What to do with this
If you're on Django 6.1 and still using EMAIL_BACKEND, don't expect an upgrade alone to warn you about anything, and don't expect MAILERS to change your SMTP connection count if you migrate the settings but not the sending code. The two real wins are stricter validation of OPTIONS at startup, which will catch typos the old settings silently swallowed, and the new mail.E001 deploy check, which is worth having but only protects you after you've already defined MAILERS["default"]. If your production settings module still carries the console or dummy backend by accident, under the old settings, check --deploy will not tell you. Run python manage.py check --deploy against your actual production settings after migrating, not before, and grep your codebase for get_connection( and direct backend construction before you upgrade, since those are the two call sites where behaviour, not just configuration, has changed.
Top comments (4)
Measuring the connection count instead of assuming is the useful part here. For anyone migrating, send_mass_mail (or passing a list to send_messages on one backend instance) already shares a single connection, so a per-call send_mail loop is probably the real cost in most apps. Have you found a clean way to keep one connection per named mailer inside a Celery worker without leaking it across tasks?
good call on send_mass_mail, and it takes
using=now too, sosend_mass_mail(datatuple, using="default")gets you the one connection version without touching get_connection at all. that really should've been a row in the table.on Celery, honestly I didn't test it, so this is what I'd try rather than something I measured. I wouldn't keep one open across tasks. the SMTP backend's
open()only checks whether it already has a connection object, not whether the socket is still alive, so once the server drops an idle connection the next task fails with SMTPServerDisconnected.what I'd do instead is batch inside the task: build the list of messages, then
so the connection opens and closes within that one task and there's nothing left to leak. if you really need it held across tasks, open it per worker process in
worker_process_initand catch the disconnect and reconnect, but that's a lot more moving parts than most apps need.Thanks for the detailed walkthrough, and for being upfront that the Celery part is untested. Batching inside a single task so the connection opens and closes there makes a lot of sense, and it sidesteps the stale-socket problem on idle connections. Good to know send_mass_mail also accepts using= now; we'll add that to our notes for the one-connection case.
np, thanks for the send_mass_mail nudge, I added it to the post