Deprecating or changing how django.core.mail handles fail_silently

[EDIT: just joining the discussion? Jump down to #comment 3 for the current proposal.]

I’m proposing we rework how Django’s mail APIs handle fail_silently, and seeking feedback and consensus from the group before opening a ticket.

The proposal is to hoist fail_silently handling out of individual EmailBackend implementations, and instead wrap the two places Django calls backend.send_messages() with a broad try/except to:

  • make fail_silently more predictable, by ignoring all exceptions consistently across all backends (including message serialization and address validation errors that currently may or may not be ignored, depending on the email backend in use)
  • address an API shape problem that will conflict with the upcoming dict-based EMAIL_PROVIDERS feature (ticket-35514, draft DEP 0018)

(This came up while drafting the EMAIL_PROVIDERS DEP, and it’s currently included in there. @nessita and I thought it might be helpful to break it into a separate task and discussion.)

Background

Most Django mail APIs take a fail_silently arg meant to ignore errors during sending. One use is avoiding cascading failures in error reporting, like in calls to mail_admins() from the logging AdminEmailHandler or mail_managers() from the BrokenLinkEmailMiddleware.

Although presented and described as a modifier to the email sending process, fail_silently is currently implemented as EmailBackend configuration (a backend __init__() param). This creates three problems:

  • Individual backend implementations apply their own, inconsistent interpretations of which errors to ignore. Django’s SMTP backend ignores only smtplib.SMTPException (plus OSError, but only during connection setup and SSL handshaking). But there are also several third-party email backend packages,[1] and each has a slightly different approach to fail_silently handling.

  • If you pass both connection and fail_silently to the same mail function, Django silently ignores fail_silently and instead fails loudly :upside_down_face:. (ticket-36894. The connection is already initialized, so it’s too late to apply fail_silently.)

  • For related reasons, this conflicts with the upcoming EMAIL_PROVIDERS feature. The proposed mail.providers[alias] would return a fully configured EmailBackend instance (like similar APIs for tasks, storages, caches and db connections). Since it’s already initialized, it’s too late to change fail_silently for that provider instance.

Proposal

To address all these issues, I propose[2] moving fail_silently handling out of individual EmailBackend implementations. Instead, we’d catch all exceptions where the message is being sent.

Specifically, we’d change EmailMessage.send():

  class EmailMessage:
      def send(self, fail_silently=False):
          if not self.recipients():
              return 0
-         return self.get_connection(fail_silently).send_messages([self])
+         connection = self.get_connection()  # no fail_silently
+         try:
+             return connection.send_messages([self])
+         except Exception:
+             if fail_silently:  # see compatibility note
+                 return 0
+             raise
  • Errors in get_connection() are never ignored, either before or after the change. (These are typically settings and configuration errors from the EmailBackend constructor. Despite the name, get_connection() doesn’t actually open any connections.)
  • After the change, all errors in connection.send_messages() would be ignored with fail_silently.
  • We’d make a similar change to send_mass_mail() (which is the only other Django code that calls send_messages() directly; everything else delegates to EmailMessage.send()).
  • When we implement EMAIL_PROVIDERS, the connection = self.get_connection() line will get replaced with connection = mail.providers[using] (with a new using argument for the provider alias).

This change would take effect immediately in Django 6.1, without deprecation. (It’s arguably a bug fix or cleanup: it makes EmailMessage.send(fail_silently=True) match the docs and most callers’ expectations.)

There are two related deprecations and a compatibility shim:

  • The fail_silently argument to django.core.mail.get_connection() would be deprecated in Django 6.1 and removed in Django 7.0. (Note that the entire get_connection() function would also be deprecated as part of EMAIL_PROVIDERS.)
  • The (inconsistent) implementations of fail_silently within Django’s built-in email backends would remain in place until Django 7.0, but would effectively be dead code in most cases. Initializing a built-in backend with fail_silently=True would cause a deprecation warning. (This covers code that uses get_connection(fail_silently=True) and then calls send_messages() directly on that connection.)
  • During the deprecation period, the line marked “see compatibility note” above would also need to check getattr(connection, "fail_silently", False), for compatibility with connections created outside EmailMessage.

Impact

Here are some examples of what this proposal would and would not change, for Django’s SMTP backend and fail_silently=True:

  • Configuration and backend initialization: no change (not ignored now, not ignored after)

    • EMAIL_BACKEND points to nonexistent class: ImportError in get_connection()
    • Conflicting settings EMAIL_USE_SSL = True and EMAIL_USE_TLS = True: ValueError in smtp.EmailBackend.__init()__
  • Message serialization and address validation: :warning: CHANGE (not ignored now, would be ignored after)

    • CRLF in from_email="from@\r\nexample.com": ValueError in EmailMessage.message()
    • Invalid encoding body="\udce9": UnicodeEncodeError in EmailMessage.message()
    • (The message is not sent in either case; the only impact is whether an error propagates out of the send call)
  • SMTP connection and communication: no change (ignored now, ignored after)

    • DNS failure on EMAIL_HOST: socket.gaierror in smtp.EmailBackend.open()
    • Bad EMAIL_HOST_USER/EMAIL_HOST_PASSWORD: SMTPAuthenticationError in smtp.EmailBackend.open()
    • Connection interrupted during transmission: SMTPServerDisconnected in smtp.EmailBackend._send()
  • RuntimeError, MemoryError: :warning: CHANGE (not ignored now, would be ignored after)

For third-party email backends the list would be different. Some already ignore message serialization errors with fail_silently=True, so this proposal wouldn’t change their resulting behavior. In general, anything detected at __init__() time would still be raised; everything in send_messages() would be suppressed.

One other change of note is the django.mail.outbox used during testing. Django’s locmem email backend doesn’t implement fail_silently, so no errors are ignored when it is in use. In a test case, this code would raise an error now but won’t after the change:

mail_admins("bad\r\nsubject", "message", fail_silently=True)

Alternatives

We could leave fail_silently implemented as it is now and instead improve the docs (ticket-36907). But that creates a big problem for EMAIL_PROVIDERS. There’s more background and several proposed options in an earlier revision of DEP 0018. After discussion with @nessita, this seemed the cleanest (and it’s now the only option in the latest DEP revision).


  1. I maintain one of them. ↩︎

  2. Proper attribution (and AI disclosure): I had actually proposed several other approaches for fail_silently with EMAIL_PROVIDERS, but wasn’t happy with any of them. Claude 4.6 Opus first suggested what’s proposed here, during a technical review of an early DEP 0018 draft. ↩︎

2 Likes

I guess one other option would be to deprecate fail_silently in all mail APIs and remove it completely in Django 7.0. Users that want to ignore email errors could provide their own exception handling (and choose exactly which errors they want to be silent).

I’ve never really understood why someone would want to ignore some-but-not-all errors when emailing managers about broken links, or especially when emailing admins with error logs. I haven’t used the logging AdminEmailHandler in production in a while, but when I did, I definitely didn’t want it to silently swallow errors sending those emails.

(I originally thought fail_silently was there to avoid an infinite loop in AdminEmailHandler. It’s not. Errors in logging handlers aren’t normally logged as errors, for exactly that reason. And even if they were, to prevent loops fail_silently would need to ignore all errors, not just SMTP errors.)

Is there some use case I’m missing?

I’d like to move this forward to make room for EMAIL_PROVIDERS. From that lack of responses, I gather nobody has strong opinions one way or the other. Which isn’t surprising: I’m pretty sure fail_silently doesn’t show up much in production Django code.

Unless there’s further (human) input, I’ll open a ticket to deprecate fail_silently for everything except mail_admins() and mail_managers()—option 3 below.

I poked into fail_silently usage through GitHub code search, then asked three AIs for a broader perspective on how it’s used in projects they’ve seen. (I hoped their training data might supply the answer to my earlier question, “Is there some use case I’m missing?” The AI analyses seemed to align with patterns I had spotted in my GitHub search.)

My takeaways:

  • The overwhelming majority of django.core.mail calls use the default fail_silently=False. (It’s unusual to ignore email errors in production. This decision just doesn’t impact that much code.)
  • Many uses of fail_silently=True were likely copied from older tutorials or references, for no good reason. (Two AI’s referred to “cargo culting.”)
  • Where fail_silently=True is intentional, callers seem to have different expectations for which errors would be silenced. Many want “don’t crash this path” (ignore all errors), but others seem to be saying “don’t bother me with network glitches” and would want other errors to be loud.
  • The exception is mail_admins(): when it’s called with fail_silently=True (which is also rare), it’s in error handling paths that want to avoid cascading errors. Callers expect it to suppress all exceptions. (The same argument probably applies to mail_managers(), but that function isn’t widely used.)
  • All uses of fail_silently expect it to apply to that one send—nobody thinks of it as EmailBackend configuration.

I see three options:

  1. Make fail_silently ignore all Exceptions (broad try/catch in EmailMessage.send() as described in the first post in this thread; GPT and Claude both wanted to refine this to not ignore MemoryError or RecursionError)

  2. Deprecate fail_silently and remove it in Django 7.0. Callers would need to replace fail_silently=True with their own exception handling, forcing them to be explicit about which (if any) exceptions they want to ignore (second post in this thread)

  3. Hybrid approach: Keep fail_silently for mail_admins() and mail_managers() (ignoring all Exceptions), but deprecate and remove it for send_mail(), send_mass_mail() and EmailMessage.send().

I’ve tried to map the AIs’ opinions to our usual voting scale:

Mike (human) Claude 4.6 Opus Gemini 3.1 Pro GPT 5.3 Codex
Option 1 +0 +1 +0 +1
Option 2 -1 -1 -1 -1
Option 3 +1 -0 +1 -1

Gemini and I both feel option 3 is pragmatic for real-world use, and easy to explain and document. (I think GPT’s a little alarmist about the impact of removing it, so I’m discounting its -1 on option 3.)

Full chat transcripts: * django.core.mail fail_silently usage.md · GitHub. (The options were discussed in a different order from above: 1=A, 2=C, 3=B.)