Python Cron Scheduling — A Complete Guide
Python has a half-dozen scheduling libraries spanning from "three lines in a script" (`schedule`) to "distributed task queue with cron syntax" (Celery Beat). This guide compares APScheduler, schedule, Celery Beat, croniter, and python-crontab — and shows when each one is the right pick.
Updated
The landscape
| Library | What it does | When to use |
|---|---|---|
| APScheduler | Full-featured in-process scheduler, persistent stores, async support | Most “schedule jobs in this Python app” cases |
| schedule | In-memory scheduler with English-like DSL | Quick scripts, simple needs |
| Celery Beat | Distributed scheduler that dispatches to Celery workers | Production task queues, distributed work |
| croniter | Parse cron expressions, compute next runs | Validation, displaying next-run times |
| python-crontab | Read/write the system crontab from Python | Managing crontab entries programmatically |
| django-q2, django-rq, django-apscheduler | Django-flavored wrappers | Django-specific patterns |
The two big decisions:
- Single process vs distributed? APScheduler is in-process (with optional persistent storage). Celery Beat is distributed by design.
- Cron syntax or English-like? APScheduler and Celery support real cron expressions. The
schedulelibrary uses its own readable-but-different syntax.
APScheduler
The most flexible in-process scheduler. Supports cron, interval, and one-off date triggers, with multiple job stores and executor types.
pip install apscheduler
Basic blocking scheduler:
from apscheduler.schedulers.blocking import BlockingScheduler
scheduler = BlockingScheduler()
@scheduler.scheduled_job("cron", hour=9, minute=0, day_of_week="mon-fri")
def daily_standup():
send_standup()
@scheduler.scheduled_job("cron", minute="*/5")
def heartbeat():
print("Heartbeat:", datetime.now())
scheduler.start()
Async scheduler for FastAPI / asyncio apps:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI
app = FastAPI()
scheduler = AsyncIOScheduler()
async def refresh_cache():
await cache_client.invalidate("homepage")
@app.on_event("startup")
async def startup():
scheduler.add_job(refresh_cache, "cron", minute="*/15")
scheduler.start()
With a persistent job store (survives restarts):
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler(
jobstores={
"default": SQLAlchemyJobStore(url="postgresql://user:pass@host/db")
}
)
APScheduler accepts standard 5-field cron syntax via the CronTrigger.from_crontab() helper:
from apscheduler.triggers.cron import CronTrigger
scheduler.add_job(my_func, CronTrigger.from_crontab("*/5 * * * *"))
Or via individual field arguments (the more Pythonic way):
scheduler.add_job(
my_func,
"cron",
day_of_week="mon-fri",
hour=9,
minute=0,
timezone="America/New_York",
)
Key features worth knowing:
misfire_grace_time— how long after a missed firing the scheduler should still run the job. Default 1 second; bump to handle restarts and slow startups.max_instances— how many concurrent instances of the same job can run. Default 1. Set higher only if you’ve verified the job is parallelism-safe.coalesce— when multiple missed firings would happen at once (e.g., after a long pause), should they be coalesced into a single run? Defaults to True for cron triggers.
schedule
A tiny library with an English-like DSL. No cron syntax — uses its own scheduler.
pip install schedule
import schedule
import time
schedule.every(5).minutes.do(refresh_cache)
schedule.every().hour.do(send_heartbeat)
schedule.every().day.at("09:00").do(send_digest)
schedule.every().monday.do(weekly_report)
schedule.every().wednesday.at("13:15").do(midweek_check)
while True:
schedule.run_pending()
time.sleep(1)
What schedule is good for:
- Quick scripts where pulling in APScheduler feels like overkill
- Simple recurring tasks
- Readable code that non-Python-experts can understand
What it isn’t good for:
- Persistence across restarts (in-memory only)
- Real cron expressions (uses its own DSL —
every().monday.do(...)instead of0 0 * * 1) - Async support (it’s a polling loop)
- Distributed scheduling
For anything beyond a single-file script, use APScheduler instead.
Celery Beat
Celery is a distributed task queue. Celery Beat is its scheduler — it dispatches scheduled tasks into the queue, where Celery workers pick them up and execute.
pip install "celery[redis]"
tasks.py:
from celery import Celery
from celery.schedules import crontab
app = Celery("myapp", broker="redis://localhost:6379/0")
@app.task
def send_daily_digest():
# ... runs on a worker, not in beat
pass
app.conf.beat_schedule = {
"daily-digest": {
"task": "tasks.send_daily_digest",
"schedule": crontab(hour=9, minute=0, day_of_week="mon-fri"),
},
"heartbeat": {
"task": "tasks.heartbeat",
"schedule": crontab(minute="*/5"),
},
}
app.conf.timezone = "America/New_York"
Run beat (the scheduler) and a worker (the executor) in separate processes:
# Terminal 1
celery -A tasks beat
# Terminal 2 (one or more workers)
celery -A tasks worker --concurrency=4
When Beat fires daily-digest, it sends a message to the broker. A worker picks it up and runs send_daily_digest(). If you have ten workers, they share the work.
Celery’s crontab arguments mirror cron’s fields:
| crontab() arg | Cron field |
|---|---|
minute | minute |
hour | hour |
day_of_month | day-of-month |
month_of_year | month |
day_of_week | day-of-week (0–6, Sun=0; also mon–sun) |
You can also pass a string-style cron with crontab.from_string("*/5 * * * *").
Beat is single-instance. Don’t run two celery beat processes against the same broker — you’ll get duplicate firings. For high-availability, use the celery-singleton pattern or move scheduling outside Celery (e.g., have an external scheduler invoke celery.send_task() via Redis/RabbitMQ directly).
croniter
croniter parses cron expressions and computes next/previous fire times. It doesn’t run anything.
pip install croniter
from croniter import croniter
from datetime import datetime
base = datetime.now()
itr = croniter("*/5 * * * *", base)
print(itr.get_next(datetime)) # next fire time
print(itr.get_next(datetime)) # the one after that
print(itr.get_prev(datetime)) # back one
Use croniter when you need:
- “Next run time” for an admin UI
- Validate user-submitted cron expressions
- Compute how many times a schedule fires per day/week/month
- Build your own scheduler
croniter is the parsing engine behind Airflow, dbt, and many other Python projects. The expressions it accepts are standard 5-field Unix cron — see the Unix cron dialect reference.
python-crontab
Read and modify the system crontab from Python. Useful for installers and setup scripts.
pip install python-crontab
from crontab import CronTab
cron = CronTab(user="myuser")
# Add a new job
job = cron.new(command="/usr/local/bin/backup.sh", comment="nightly-backup")
job.setall("0 0 * * *")
cron.write()
# Read all jobs
for job in cron:
print(job.comment, job.slices)
# Remove a specific job
for job in cron.find_comment("nightly-backup"):
cron.remove(job)
cron.write()
This actually edits the user’s crontab file (or /etc/crontab if run as root). Useful for installers, deployment scripts, and tools that need to register or remove cron jobs as part of their setup.
For everything else, use APScheduler or Celery Beat — running scheduling inside your app is more portable and easier to test than relying on system crontab.
Django ecosystem
Django doesn’t have a built-in scheduler. The standard approaches:
APScheduler in a custom management command:
# management/commands/runscheduler.py
from django.core.management.base import BaseCommand
from apscheduler.schedulers.blocking import BlockingScheduler
class Command(BaseCommand):
def handle(self, *args, **options):
scheduler = BlockingScheduler()
scheduler.add_job(my_job, "cron", minute="*/5")
scheduler.start()
Run with python manage.py runscheduler. Deploy as a separate process alongside your web app.
django-apscheduler wraps APScheduler with Django ORM persistence:
pip install django-apscheduler
Stores jobs in the Django database, lets you view them in Django admin.
django-q2 (active fork of django-q) is a Celery-like task queue with cron-style scheduling, uses Django ORM. Lighter than Celery for small projects.
django-rq is Redis-based, similar to BullMQ in the Node world. Good if you already have Redis.
Celery is the heavyweight option — same as the non-Django pattern, just with django-celery-beat for storing the schedule in Django models.
For most Django apps: start with django-q2 if you want simplicity, jump to Celery if you need distributed processing or have many task types.
Choosing the right one
- A script that needs to run things periodically? →
schedule - A Python app with scheduled background work? → APScheduler
- An async Python app (FastAPI, aiohttp)? → APScheduler with
AsyncIOScheduler - Need persistence across restarts? → APScheduler + SQLAlchemyJobStore (or Redis/MongoDB store)
- Distributed processing across many workers? → Celery + Celery Beat
- Django? → django-q2 (simple) or Celery (complex)
- Parse cron expressions, don’t run anything? → croniter
- Manipulate the system crontab? → python-crontab
- Need SLA-grade reliability? → Move scheduling out of Python — use Kubernetes CronJob or AWS EventBridge Scheduler to invoke a Python HTTP endpoint or Lambda on a schedule.
The “external scheduler invokes Python HTTP endpoint” pattern is increasingly the production-grade answer for serious scheduling needs — Kubernetes or EventBridge handles the failure modes, your Python code stays focused on the actual work.
Frequently asked questions
Which Python scheduling library should I use?
What's the difference between APScheduler and Celery Beat?
Does APScheduler support async?
How do I survive restarts with APScheduler?
Can I use standard cron expressions with these libraries?
Related
Every 5 Minutes
`*/5 * * * *` — the most common Python scheduled-job interval.
ToolCron Parser
Verify your cron expression before adding it to APScheduler or Celery Beat.
GuideNode.js Cron Jobs
node-cron, agenda, bullmq — the Node equivalents.
GuideKubernetes CronJob
Run scheduled Python work as a K8s CronJob instead of in-process.
GuideAWS EventBridge Scheduler
Trigger Python Lambdas on a schedule.