Send an email on error in Python with SMTPHandler (tested)

Python can email you when a script fails, using only the standard library's logging.handlers.SMTPHandler. We ran it against a scripted SMTP server to measure what gets sent, what happens when the mail server is down, and how to also catch exceptions nobody handled.

The examples use a scripted SMTP server on localhost, so no real mail account is needed. It's the fake_smtp.py from our smtplib exceptions guide, with one addition: it saves each message it receives into a SENT list. Point mailhost at your real server (e.g. ("smtp.example.com", 587)) in production.

1. Mail errors (with the traceback) using SMTPHandler

import logging, logging.handlers
from fake_smtp import serve, SENT

serve(8964, {})
log = logging.getLogger("app")
mail = logging.handlers.SMTPHandler(
    mailhost=("127.0.0.1", 8964), fromaddr="app@example.com",
    toaddrs=["me@example.com"], subject="app error", timeout=5)
mail.setLevel(logging.ERROR)             # only errors get mailed
log.addHandler(mail)

log.warning("disk 85% full")             # not mailed
try:
    1 / 0
except ZeroDivisionError:
    log.exception("nightly job failed")  # mailed, with traceback

print("emails sent:", len(SENT))
print(SENT[0].split("\r\n\r\n", 1)[0])  # headers
print("--- body ---")
print(SENT[0].split("\r\n\r\n", 1)[1].strip())

Output:

emails sent: 1
From: app@example.com
To: me@example.com
Subject: app error
Date: Fri, 09 Oct 2026 15:18:08 +0100
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0
--- body ---
nightly job failed
Traceback (most recent call last):
  File "/tmp/sh/b1.py", line 14, in <module>
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero

Measured on Python 3.13.5: with the handler's level set to ERROR, the warning was not mailed and the log.exception() call sent exactly one email. Its body contained the message and the full traceback, and its headers had the From/To/Subject we configured. SMTPHandler is in the standard library (logging.handlers), so you don't need any package.

2. If the mail server is down, your program keeps going

import logging, logging.handlers, sys, time

log = logging.getLogger("app")
log.addHandler(logging.handlers.SMTPHandler(
    ("127.0.0.1", 8967), "app@example.com", ["me@example.com"], "app error"))
# port 8967: nothing listening, like a mail server that's down

t0 = time.monotonic()
log.error("payment import failed")
print(f"log.error() returned after {time.monotonic() - t0:.2f}s, no exception raised",
      file=sys.stderr)
print("program keeps running", file=sys.stderr)

Output:

--- Logging error ---
Traceback (most recent call last):
  File "/usr/lib/python3.13/logging/handlers.py", line 1088, in emit
    smtp = smtplib.SMTP(self.mailhost, port, timeout=self.timeout)
  File "/usr/lib/python3.13/smtplib.py", line 255, in __init__
    (code, msg) = self.connect(host, port)
                  ~~~~~~~~~~~~^^^^^^^^^^^^
  File "/usr/lib/python3.13/smtplib.py", line 341, in connect
    self.sock = self._get_socket(host, port, self.timeout)
                ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/smtplib.py", line 312, in _get_socket
    return socket.create_connection((host, port), timeout,
           ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
                                    self.source_address)
                                    ^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.13/socket.py", line 864, in create_connection
    raise exceptions[0]
  File "/usr/lib/python3.13/socket.py", line 849, in create_connection
    sock.connect(sa)
    ~~~~~~~~~~~~^^^^
ConnectionRefusedError: [Errno 111] Connection refused
Call stack:
  File "/tmp/sh/b2.py", line 9, in <module>
    log.error("payment import failed")
Message: 'payment import failed'
Arguments: ()
log.error() returned after 0.03s, no exception raised
program keeps running

Measured: with nothing listening on the mail port, log.error() did not raise. Logging printed --- Logging error --- with the ConnectionRefusedError to stderr and the program carried on. So a broken alert setup fails quietly unless someone reads stderr. Test it once on purpose after deploying. Each email is sent synchronously inside the logging call: refused took 0.03s here, but an unreachable server blocks for up to timeout, which defaults to 5.0 seconds. If that matters, put a QueueHandler in front of it.

3. Email on uncaught exceptions too

import logging, logging.handlers, sys
from fake_smtp import serve, SENT

serve(8964, {})
log = logging.getLogger("app")
mail = logging.handlers.SMTPHandler(("127.0.0.1", 8964), "app@example.com",
                                    ["me@example.com"], "app crashed")
mail.setLevel(logging.ERROR)
log.addHandler(mail)

def mail_uncaught(exc_type, exc, tb):
    log.critical("uncaught exception", exc_info=(exc_type, exc, tb))
    sys.__excepthook__(exc_type, exc, tb)   # still print it as usual

sys.excepthook = mail_uncaught
import atexit
atexit.register(lambda: print("emails sent before exit:", len(SENT),
                              "| subject line:", [l for l in SENT[0].splitlines()
                                                  if l.startswith("Subject")]))
{}["missing_key"]                           # nobody catches this

Output:

Traceback (most recent call last):
  File "/tmp/sh/b3.py", line 20, in <module>
    {}["missing_key"]                           # nobody catches this
    ~~^^^^^^^^^^^^^^^
KeyError: 'missing_key'
emails sent before exit: 1 | subject line: ['Subject: app crashed']

Measured: an exception nobody catches never reaches your logger on its own. Setting sys.excepthook to a function that logs at CRITICAL with exc_info sent the email (subject app crashed) and still printed the normal traceback through sys.__excepthook__. This covers the main thread only; threads use threading.excepthook (see our thread exceptions guide).

4. Login and TLS

For a real provider pass credentials=("user", "app-password") and secure=() for STARTTLS on port 587. In the 3.13 source, secure is only used when credentials are set, so secure=() on its own sends the email without TLS. Use an app password or a transactional mail service rather than your main account password, and keep it in an environment variable. If the server rejects the login you get the same --- Logging error --- output as in section 2, this time with SMTPAuthenticationError.

Frequently asked questions

How do I send an email when my Python script fails?

Add logging.handlers.SMTPHandler to your logger with level ERROR and call log.exception() in your except block. We measured one email containing the message and full traceback.

Does SMTPHandler raise if the mail server is down?

No. We measured log.error() returning normally while logging printed '--- Logging error ---' with ConnectionRefusedError to stderr.

How do I email uncaught exceptions?

Set sys.excepthook to a function that logs at CRITICAL with exc_info, then calls sys.__excepthook__. We measured it sending the email and still printing the traceback.

Does SMTPHandler slow down my program?

It sends synchronously inside the logging call, with a default timeout of 5.0 seconds. Use a QueueHandler/QueueListener if that's a problem.

How do I use Gmail or STARTTLS with SMTPHandler?

Pass credentials=(user, app_password) and secure=() with port 587. secure is ignored without credentials.

Written with AI assistance. Measured on 2026-10-09 on Debian with Python 3.13.5's logging.handlers.SMTPHandler against a scripted SMTP server on localhost: level filtering, the email body with the traceback, a mail server that is down, and sys.excepthook for uncaught exceptions. Every block was run verbatim and its output pasted from that run. No real mail server was contacted; the credentials/secure behaviour is from the 3.13 source. PlainHeaders is our extension; Python's logging module is built in.