cronuru
Guide

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

LibraryWhat it doesWhen to use
APSchedulerFull-featured in-process scheduler, persistent stores, async supportMost “schedule jobs in this Python app” cases
scheduleIn-memory scheduler with English-like DSLQuick scripts, simple needs
Celery BeatDistributed scheduler that dispatches to Celery workersProduction task queues, distributed work
croniterParse cron expressions, compute next runsValidation, displaying next-run times
python-crontabRead/write the system crontab from PythonManaging crontab entries programmatically
django-q2, django-rq, django-apschedulerDjango-flavored wrappersDjango-specific patterns

The two big decisions:

  1. Single process vs distributed? APScheduler is in-process (with optional persistent storage). Celery Beat is distributed by design.
  2. Cron syntax or English-like? APScheduler and Celery support real cron expressions. The schedule library 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 of 0 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() argCron field
minuteminute
hourhour
day_of_monthday-of-month
month_of_yearmonth
day_of_weekday-of-week (0–6, Sun=0; also monsun)

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?
For a single Python process running scheduled jobs: APScheduler (feature-rich, persistent stores) or schedule (simpler, in-memory only). For distributed scheduling across many workers: Celery Beat. For computing next-run times: croniter. For managing the system crontab from Python: python-crontab. Django projects can use django-q2 or APScheduler directly.
What's the difference between APScheduler and Celery Beat?
APScheduler runs jobs in the same Python process that scheduled them (or a small set of cooperating processes via persistent stores). Celery Beat is the scheduler half of Celery — it dispatches work to a distributed pool of Celery workers via a message broker (Redis, RabbitMQ). Use APScheduler for in-process scheduling; use Celery Beat when you already have Celery workers or when you need distributed task processing.
Does APScheduler support async?
Yes. APScheduler has an `AsyncIOScheduler` for asyncio applications and a `TornadoScheduler` for Tornado. Job functions can be sync or async — APScheduler handles both. For FastAPI integration, instantiate `AsyncIOScheduler` and call `scheduler.start()` from your startup event.
How do I survive restarts with APScheduler?
Configure a persistent JobStore — SQLAlchemyJobStore (any SQL database), MongoDBJobStore, or RedisJobStore. With a persistent store, scheduled jobs are saved to the database and resumed after a restart. The default MemoryJobStore loses everything on restart.
Can I use standard cron expressions with these libraries?
Yes. APScheduler's CronTrigger and Celery's crontab schedule both accept standard 5-field Unix cron syntax. croniter and python-crontab also parse standard expressions. schedule (the library) uses its own English-like DSL instead of cron expressions.