crontab.in
monitor with cron.watch
python scheduling

Scheduling jobs in Python.

From a one-line crontab to in-process schedulers — pick the lightest tool that fits, and see the cron expression behind each.

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 promisecron.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)
Get cron.watch free ↗ Analyze your crontab →

More scheduling guides