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 -0 -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.)

Hi Mike, a human here to talk to you. I think option 3 sounds the best way forward. It’s weird to reproduce a language feature (try/except) as an argument.

We can make Django-upgrade drop fail_silently=False from the functions it will be deprecated from, at least. That won’t help folks who set it to True but I’m imagining it will help for cases where IDES or copy pasta populated the argument unnecessarily.

1 Like

Hi Mike, you were on my list, sorry for not reaching out sooner! Your notes were super helpful, as I’m new to this area of Django.

Is there some use case I’m missing?

My impressions are:

  • fail_silently was used to rhyme with contrib.messages, which exposes this same argument for the use case of a reusable app (such as the admin!), that cannot guarantee that contrib.messages is installed. I see the use case for email as basically the same: I’m writing a reusable app: I’d like to send some mail, so long as my users have email configured at all.
  • I also see a use case for test suites.
  • I like that API. (I don’t think it’s a weird abstraction.)
  • Thanks for the ticket about the imprecise wording in the docs. But leaving the imprecision to the side, my impression is that once again, just like-fail-silently in contrib.messages only catching the error for the messages app not being installed, the email backends should only be catching connection errors. SMTP connection errors became a subclass of OSError in Python 3.4. I agree with generalizing the catch as far as OSError, but not further.
  • I’m unclear what we gain from the hybrid approach of deprecating the arg in some places and not others (and unclear how this unblocks the DEP).

So, in summary, I’d vote for option 1 with catching OSError.

What do you think?

1 Like

(I forgot to add: I understand OSError might be not workable for 3rd party backends that raise their connection errors directly from Exception, so we could fiddle with that, and possibly ship an exception that could be subclassed. This would be pointless if no 3rd party backends would be willing to make that change, so I’m eager to hear your forecast on that, Mike!) Catching all exceptions is of course still an option.

I’m not sure that the parallel with contrib.messages holds, for two reasons:

  1. IMHO the concerns are different: messages.fail_silently guards against the messages middleware not being installed/available, which could be seen as a deployment/configuration condition. Email’s fail_silently suppresses runtime errors (SMTP down, connection refused, etc) and these are not the same kind of problem, so I’m thinking that the API analogy doesn’t transfer cleanly.

  2. Perhaps more fundamentally, I think fail_silently was designed for a single-backend world, where “degrade gracefully if mesages/email isn’t set up” made sense. But Django is moving away from that pattern into using a dictionary of named backends configs (DATABASES, CACHES, STORAGES, TASKS, and soon email with EMAIL_PROVIDERS). In this format, each backend is intentionally configured; its absence is a hard error. The ambiguity that fail_silently was presumably designed to handle over no longer exists.

Given this, I’m inclined to vote for option 2 (deprecate entirely) as my first choice, but if that one is considered too aggressive, I’d go with option 3 as a compromise.

IMHO the classical use case for keeping fail_silently is mail_admins() in an error handler, but I think that is better served by an explicit try/except at the call site, where the caller actually knows the context and intent. At this point, fail_silently=True is just a bundled try/except with worse readability and hidden behavior.

2 Likes

:light_bulb:Ah-hah! That’s what I had been missing, and totally makes sense to me. It would explain why Django uses fail_silently=True in AdminLogHandler and BrokenLinksMiddleware, as well as several examples I saw in the wild.

And it happens to work that way with Django’s default email settings, but…

It’s an expensive way to detect “not configured”: With only default settings, sending mail tries to connect to the SMTP server on localhost without SMTP auth, then tries to transmit a message (typically) from webmaster@localhost or root@localhost. Depending on whether there’s a local SMTP server and how it’s set up, that will raise SMTPConnectError, SMTPAuthenticationError, SMTPSenderRefused, or perhaps TimeoutError after a long pause.

All of these are indeed ignored by fail_silently=True as implemented today. But there are much easier ways to skip sending when email isn’t configured.

fail_silently hides errors that are real problems in configuration, as well as transient issues that prevent mail from getting sent. These are all silent:

  • misconfigured EMAIL_HOST = "smtp.gmail.comm"
  • wrong EMAIL_HOST_PASSWORD and similar auth problems
  • some forms of invalid/unusable DEFAULT_FROM_EMAIL or SERVER_EMAIL
  • broken cert store when using SSL/TLS SMTP
  • dropped network connection while sending

If the caller’s intent is to send email so long as it’s configured, swallowing these errors seems like a mistake. And it’s kind of a bug that AdminLogHandler and BrokenLinksMiddleware ignore them.

fail_silently doesn’t help with recipient errors: I saw a lot of code that seemed to be saying, “send this, but I don’t care if the to address bounces.” But problems with recipient mailboxes aren’t detected at send time[1] (email is store-and-forward, and delivery problems are reported out of band).

If the caller’s intent is ignoring typo emails and “mailbox full” errors, those are already “silent.” Using fail_silently=True is unnecessary and can mask other problems.

fail_silently doesn’t suppress all exceptions: If the caller’s intent is preventing error cascades (e.g., when calling mail_admins() from an error handler), what we’re doing now is insufficient.

To unblock EMAIL_PROVIDERS, we need a plan where the per-send fail_silently modifier is not implemented as backend configuration (not passed to EmailBackend.__init()__). Any of the options in this thread would solve that.

I was hoping to make fail_silently=True behave according to the caller’s intent. For send_mail() and EmailMessage.send(), I don’t think it’s possible to know that intent: everything I listed above is common. So getting rid of fail_silently and forcing callers to decide what they want felt safest. (Gemini invoked “explicit is better than implicit” in its support of this option.)

For mail_admins() and mail_managers(), every use of fail_silently=True I found was inside other error reporting. I thought the intent was to avoid cascades, so catching all exceptions—including configuration errors—seemed reasonable. But I’m rethinking that in light of your observation about the contrib.messages parallel and @nessita’s accurate observation that “fail_silently=True is just a bundled try/except with worse readability and hidden behavior.”

I think I have a new proposal, which I’ll put in a separate message.


  1. Unless you’re dealing with a local SMTP server delivering to a local mailbox, which might raise SMTPRecipientsRefused or SMTPResponseException when sending. (I’d think local delivery is rare.)

    With third party backends, a few ESPs’ APIs enforce recipient block lists and other policies at send time. But most handle that with out of band notification, just like SMTP bounces. ↩︎

2 Likes