Scheduling & Cron
Python scripts become reliable jobs when scheduled with system cron, systemd timers, or in-process schedulers like APScheduler. The script itself must be idempotent, logged, and exit with meaningful codes.
Search across all documentation pages
Python scripts become reliable jobs when scheduled with system cron, systemd timers, or in-process schedulers like APScheduler. The script itself must be idempotent, logged, and exit with meaningful codes.
# crontab -e
0 2 * * * cd /opt/jobs && /opt/jobs/.venv/bin/python backup.py >>/var/log/backup.log 2>&1from apscheduler.schedulers.blocking import BlockingScheduler
sched = BlockingScheduler()
sched.add_job(lambda: print("tick"), "interval", minutes=5)
sched.start()When to reach for this:
Cron-friendly script with lock file, logging, and APScheduler alternative for dev.
import fcntl
import logging
import sys
from contextlib import contextmanager
from pathlib import Path
LOCK = Path("/tmp/daily-sync.lock")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("daily-sync")
@contextmanager
def single_instance(lock_path: Path):
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("w") as f:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log.warning("another instance running")
raise SystemExit(0)
yield
def job() -> int:
log.info("sync start")
# ... work ...
log.info("sync done")
return 0
def main() -> int:
with single_instance(LOCK):
return job()
if __name__ == "__main__":
raise SystemExit(main())What this demonstrates:
0 when skipping duplicate run - cron does not alert falsely| Tool | Model |
|---|---|
| cron | OS-level, one shot per schedule |
| systemd timer | OS-level with dependency hooks |
| APScheduler | In-process, Blocking/Background |
schedule lib | Simple interval loops |
PATH and cd to project rootfrom apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
scheduler.add_job(send_heartbeat, "cron", hour=3)
scheduler.start() # non-blocking in web app - shutdown on exitcd to app dir or absolute paths everywhere.TZ= in crontab if needed.| Alternative | Use When | Don't Use When |
|---|---|---|
| Celery beat | Distributed task queue exists | Simple single-host cron sufficient |
| Airflow/Prefect | DAG dependencies complex | One script nightly |
| CloudWatch Events | AWS-native schedule | On-prem only workloads |
cron for production batch on servers; APScheduler for dev daemons or embedded infrequent tasks in long-running process.
Run manually with same env as crontab; pytest the job() function without scheduler.
Dedicated service account with least permissions - not root unless absolutely required.
External heartbeat monitor (Dead Man's Snitch, healthchecks.io) expecting ping after job.
APScheduler 3.x supports async executors in some setups; cron invokes sync entrypoint - use asyncio.run(main()) inside.
Cron appends to log file - configure logrotate on that path separately from Python logging handlers.
Dependency ordering, journald integration, and easier unit testing on modern Linux.
Set cron interval > p99 runtime; add lock to handle overruns gracefully.
Prefer UTC schedules for global systems; document local-time jobs around DST transitions.
Scheduled scripts need idempotency, dry-run, and explicit exit codes - see Robust Scripts page.
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.
Reviewed by Chris St. John·Last updated Jul 19, 2026