Option 1 — plain cron (simplest)
If your script is idempotent and short-lived, you usually do not need a Python scheduler at all. Let cron run it:
# crontab -e 0 9 * * 1-5 /usr/bin/python3 /opt/app/report.py >> /var/log/report.log 2>&1
💡 Always use the absolute interpreter path (/usr/bin/python3) and activate your venv inside the script or call /opt/app/venv/bin/python directly — cron has a minimal PATH.
Option 2 — the schedule library
For a long-running process, the tiny schedule library reads nicely but is interval-based (no real cron syntax):
import schedule, time
def job():
print("running")
schedule.every().day.at("09:00").do(job)
schedule.every(15).minutes.do(job)
while True:
schedule.run_pending()
time.sleep(1)Option 3 — APScheduler (real cron triggers)
APScheduler supports true cron expressions, timezones, and persistence — the best in-process option:
from apscheduler.schedulers.blocking import BlockingScheduler
sched = BlockingScheduler(timezone="UTC")
# every weekday at 09:00 == cron 0 9 * * 1-5
@sched.scheduled_job("cron", day_of_week="mon-fri", hour=9, minute=0)
def report():
...
sched.start()You can also pass a crontab string with CronTrigger.from_crontab("0 9 * * 1-5").
Option 4 — Celery beat (distributed)
Already running Celery? Use beat to schedule tasks across workers:
from celery import Celery
from celery.schedules import crontab
app = Celery("tasks", broker="redis://localhost:6379/0")
app.conf.beat_schedule = {
"report": {
"task": "tasks.report",
"schedule": crontab(hour=9, minute=0, day_of_week="1-5"),
},
}Timezones
Unlike raw cron, Python schedulers can be timezone-aware — pass an IANA zone name (from zoneinfo/pytz, e.g. America/New_York):
# APScheduler — per scheduler, or per trigger
from apscheduler.triggers.cron import CronTrigger
sched = BlockingScheduler(timezone="America/New_York")
sched.add_job(report, CronTrigger.from_crontab("0 9 * * 1-5", timezone="America/New_York"))# Celery beat app.conf.timezone = "America/New_York" app.conf.enable_utc = False
💡 The lightweight schedule library has no timezone support — it runs in the process’s local time, so set the host/container TZ instead.
Monitor it free with cron.watch
In-process schedulers fail silently when the whole process dies — exactly when you most need to know. A schedule is only a promise — cron.watch tells you if the job actually ran, alerts you when it silently fails, skips, or runs long, and keeps a history of every run.
import requests
def report():
try:
do_work()
finally:
# ping cron.watch whether it worked or not (send exit status too)
requests.get("https://cron.watch/s/your-id", timeout=5)