Python notifications

Get a notification when your Python script finishes or crashes

Wrap the script in try/except/finally and call one notify() function in the finally block, so it fires after a clean finish, an exception or Ctrl-C. Send it to a Discord or Slack webhook, a Telegram bot, an ntfy topic (250 free messages a day) or Gmail (500 emails a day). Nothing inside the process runs after kill -9 or a hang, so those need an outside check.

5 min readNotifications and alerts

At a glance

  • A finally block runs after a clean exit, an exception and Ctrl-C; kill -9 and the out-of-memory killer skip it.
  • A plain SIGTERM skips it too, unless a signal handler calls sys.exit().
  • Discord, Slack and ntfy need one HTTP POST; Telegram needs a bot token and a chat_id.
  • notify-send only works on a local desktop; in a plain SSH session it fails.

Wrap the script so every exit sends a message

A notify() call on the last line only covers the happy path. When a scraper hits bad HTML or an ETL job dies two hours in, the exception aborts the run before that line executes, which is exactly when you needed the ping.

Wrap your main() call in try/except/finally instead. The finally block runs whether the script finishes cleanly or blows up (including Ctrl-C), so one notify() call covers both outcomes. Catch the exception to grab its text, re-raise it so you keep the traceback and the non-zero exit code, and put the elapsed time in the message.

Python
import time

if __name__ == "__main__":
    started = time.monotonic()
    error = None
    try:
        main()
    except BaseException as exc:
        if not (isinstance(exc, SystemExit) and exc.code in (0, None)):
            error = exc
        raise
    finally:
        minutes = (time.monotonic() - started) / 60
        if error is None:
            notify(f"nightly.py finished in {minutes:.0f}m")
        else:
            notify(f"nightly.py failed after {minutes:.0f}m: {error!r}")

You can also register a cleanup function with the atexit module. It runs at normal interpreter shutdown without re-indenting your entrypoint, but getting the exception details out of it is clunky compared to a plain try/except.

Neither finally nor atexit catches everything. The Python docs note that atexit handlers are not called when the program is killed by a signal not handled by Python, when a fatal internal error is detected, or when os._exit() is called, and finally has the same blind spots:

  • SIGKILL (kill -9 or the Linux out-of-memory killer) ends the process from the kernel side, with no chance to run cleanup code.
  • os._exit() terminates right away without unwinding the stack.
  • A fatal error in the interpreter itself aborts straight out.

A plain SIGTERM (what kill and docker stop send first) also skips both unless you catch it. Install a tiny handler that calls sys.exit(143): it turns the signal into a SystemExit exception that unwinds the stack and triggers your finally block.

Python
import signal
import sys

signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(143))

Choose where the message goes

Your notify(text) function only needs to make one outbound call. Pick whichever app you already keep open:

  • Discord webhook: create a webhook in a channel's settings, then POST JSON {"content": "..."} to its URL. See the Discord webhook guide.
  • Slack incoming webhook: enable incoming webhooks in a Slack app, pick a channel and POST JSON {"text": "..."}.
  • Telegram bot: get a bot token from @BotFather, find your chat_id and POST both to sendMessage.
  • ntfy: install the free Android or iOS app, subscribe to a topic name you make up and POST the message body to https://ntfy.sh/<topic>.
  • Email through Gmail: turn on 2-Step Verification, create an app password and send through smtp.gmail.com on port 465 over SSL.

The first four are plain HTTP POST requests. Pass timeout=10 on each call, and catch errors inside notify() so a flaky network at the end of a job never hides the job's own traceback.

Python
import os
import sys

import requests


def notify_discord(text):
    url = os.environ["DISCORD_WEBHOOK_URL"]
    requests.post(url, json={"content": text[:2000]}, timeout=10).raise_for_status()


def notify_slack(text):
    url = os.environ["SLACK_WEBHOOK_URL"]
    requests.post(url, json={"text": text}, timeout=10).raise_for_status()


def notify_telegram(text):
    url = f"https://api.telegram.org/bot{os.environ['TELEGRAM_BOT_TOKEN']}/sendMessage"
    payload = {"chat_id": os.environ["TELEGRAM_CHAT_ID"], "text": text[:4096]}
    requests.post(url, json=payload, timeout=10).raise_for_status()


def notify_ntfy(text):
    url = f"https://ntfy.sh/{os.environ['NTFY_TOPIC']}"
    requests.post(url, data=text.encode("utf-8"), timeout=10).raise_for_status()


def notify(text):
    try:
        notify_discord(text)
    except Exception as exc:
        print(f"notification failed: {exc!r}", file=sys.stderr)

For email without extra dependencies, the standard library smtplib connects to smtp.gmail.com:465 over SSL with your Gmail address and the app password.

Python
import os
import smtplib
from email.message import EmailMessage


def notify_email(text):
    msg = EmailMessage()
    msg["Subject"] = text.splitlines()[0][:80]
    msg["From"] = os.environ["GMAIL_ADDRESS"]
    msg["To"] = os.environ["GMAIL_ADDRESS"]
    msg.set_content(text)
    with smtplib.SMTP_SSL("smtp.gmail.com", 465, timeout=10) as smtp:
        smtp.login(os.environ["GMAIL_ADDRESS"], os.environ["GMAIL_APP_PASSWORD"])
        smtp.send_message(msg)
MethodWhat you set upWhere it arrivesLimits to know
Discord webhookWebhook URL from channel settingsDiscord on phone and desktopcontent up to 2,000 characters; HTTP 429 with retry_after when rate limited
Slack incoming webhookWebhook URL from a Slack appSlack on phone and desktop1 message per second, short bursts allowed
Telegram botBot token from @BotFather plus your chat_idTelegram on phone and desktopText 1 to 4,096 characters; about 1 message per second per chat
ntfy.shA topic subscribed in the app or web appntfy on Android, iOS or browser250 messages a day, 4,096 bytes each; topics are public
Gmail via smtplib2-Step Verification and an app passwordAny email clientSending stops after more than 500 emails in a day
Desktop popupNothing: notify-send or osascriptThe machine running the scriptNeeds a local desktop session; fails in a plain SSH session

Desktop notifications on the same machine

When the job runs on the laptop in front of you and the terminal is buried under browser windows, you don't need a webhook. Call notify-send on Linux or osascript on macOS through subprocess.run to pop a native banner.

These only work on the machine you're sitting at. notify-send needs an active desktop session, so inside an SSH session on a remote server it fails with Cannot autolaunch D-Bus without X11 $DISPLAY. For remote boxes, use one of the phone channels above.

Python
import platform
import subprocess


def notify_desktop(title, body):
    if platform.system() == "Darwin":
        script = f'display notification "{body}" with title "{title}"'
        subprocess.run(["osascript", "-e", script], check=False)
    else:
        subprocess.run(["notify-send", title, body], check=False)

Hangs never reach your finally block

Everything above assumes the script actually finishes or crashes. A script that hangs (a network call with no timeout, a deadlock, a stuck worker) never exits, so no finally block or atexit handler runs and you get no message at all.

HTTP calls are a common culprit: requests has no default timeout, and its docs warn that a missing timeout "can cause your program to hang indefinitely". The GNU timeout command puts a hard ceiling on the whole run and exits with status 124 when it fires. For more ways to spot a hang, see how to tell if a Python script is stuck.

FAQ

How do I get notified when a Python script crashes?

Wrap your entrypoint in try/except/finally, record the exception in the except clause before re-raising it, and call your notification function inside finally. Because finally runs on normal exits and unhandled exceptions, the alert fires with the error message right before the process ends.

Why did my notification not fire when the script was killed?

If the process got SIGKILL (kill -9 or the Linux out-of-memory killer), called os._exit() or hit a fatal interpreter error, Python stops without running finally blocks or atexit handlers. A plain SIGTERM also skips cleanup unless you register a signal handler that calls sys.exit().

Can I get a phone notification without installing a new app?

Yes, use an app that is already on your phone. Send yourself an email through Gmail's SMTP server with an app password, or post to Discord, Slack or Telegram if you already use one of them.

Does notify-send work over SSH?

Not in a plain SSH session. notify-send needs a local desktop session, so on a headless or remote Linux machine it fails with Cannot autolaunch D-Bus without X11 $DISPLAY. Use a webhook, ntfy or email for remote servers.

Which of these options are free?

All of them are free within their limits: Discord webhooks, Slack incoming webhooks, Telegram bots, Gmail SMTP on a personal account (up to 500 emails a day) and the public ntfy.sh server with its free apps (250 messages a day).

Check on long runs from your phone

Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.

Get a live linkpip install tqdm-live

Sources