At a glance
- A
finallyblock runs after a clean exit, an exception and Ctrl-C;kill -9and 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-sendonly 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.
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 -9or 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.
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 yourchat_idand POST both tosendMessage. - 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.comon 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.
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.
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)| Method | What you set up | Where it arrives | Limits to know |
|---|---|---|---|
| Discord webhook | Webhook URL from channel settings | Discord on phone and desktop | content up to 2,000 characters; HTTP 429 with retry_after when rate limited |
| Slack incoming webhook | Webhook URL from a Slack app | Slack on phone and desktop | 1 message per second, short bursts allowed |
| Telegram bot | Bot token from @BotFather plus your chat_id | Telegram on phone and desktop | Text 1 to 4,096 characters; about 1 message per second per chat |
| ntfy.sh | A topic subscribed in the app or web app | ntfy on Android, iOS or browser | 250 messages a day, 4,096 bytes each; topics are public |
| Gmail via smtplib | 2-Step Verification and an app password | Any email client | Sending stops after more than 500 emails in a day |
| Desktop popup | Nothing: notify-send or osascript | The machine running the script | Needs 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.
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.
pip install tqdm-liveSources
- Discord developer docs: Execute Webhook docs.discord.com
- Slack: Sending messages using incoming webhooks docs.slack.dev
- Slack: Rate limits docs.slack.dev
- Telegram Bot API: sendMessage core.telegram.org
- Telegram Bots FAQ: message limits core.telegram.org
- ntfy docs: Publishing and limitations docs.ntfy.sh
- Google: Gmail sending limits support.google.com
- Google: Sign in with app passwords support.google.com
- Python docs: atexit docs.python.org
- Requests docs: Timeouts requests.readthedocs.io