Cron Jobs with Python's schedule Library and APScheduler

System cron is great for independent scripts, but when your scheduled task needs to live inside a running Python application — sharing a database pool, an in-memory cache, or application config — an in-process scheduler is often simpler. Python has two popular options: the tiny schedule library for simple cases, and APScheduler for anything that needs real cron syntax or persistence. If you'd rather stick with the system crontab and just need the expression syntax, see our cron expression complete guide or generate one straight from plain English with the free Cron Expression Generator. Already have an expression and just need to know what it means? Use the Cron Expression Explainer instead.

schedule libraryschedule.every(10).minutes.do(sync_orders)while True: run_pending()blocks the main threadno persistenceVSAPSchedulerCronTrigger(minute="*/10")BackgroundScheduler()runs in a background threadoptional DB job storetrue cron-style syntax

Option 1: the schedule library (simple, readable, no cron syntax)

Install with pip install schedule. It uses a fluent, human-readable API instead of cron strings — great for small scripts and simple intervals:

import schedule
import time

def sync_orders():
    print("Syncing orders...")

def send_daily_report():
    print("Sending daily report...")

schedule.every(10).minutes.do(sync_orders)
schedule.every().day.at("09:00").do(send_daily_report)
schedule.every().monday.at("07:30").do(lambda: print("Weekly cleanup"))

while True:
    schedule.run_pending()
    time.sleep(1)

The catch: schedule requires you to keep a process alive running that while True loop — nothing runs unless your script is actively executing. It also has no built-in persistence, so scheduled state resets every time the process restarts, and there's no native way to write full cron expressions like 0 9 * * 1-5.

Option 2: APScheduler (real cron syntax, background execution, persistence)

Install with pip install apscheduler. APScheduler supports three trigger types — interval, date (one-off), and cron — and can run jobs in a background thread inside a long-running app like a Flask or FastAPI service:

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = BackgroundScheduler(timezone="Asia/Kolkata")

def sync_orders():
    print("Syncing orders...")

# Real cron-style fields as keyword arguments
scheduler.add_job(sync_orders, CronTrigger(minute="*/10"))

# 9:00 AM on weekdays — equivalent to cron "0 9 * * 1-5"
scheduler.add_job(
    lambda: print("Weekday report"),
    CronTrigger(hour=9, minute=0, day_of_week="mon-fri"),
)

# Parse a traditional 5-field cron string directly
scheduler.add_job(
    lambda: print("Midnight backup"),
    CronTrigger.from_crontab("0 0 * * *"),
)

scheduler.start()

Notice the timezone="Asia/Kolkata" argument on BackgroundScheduler — APScheduler is timezone-aware out of the box, unlike system cron, which just uses whatever clock the server happens to be set to. See how cron handles timezones for the pitfalls this avoids. And if you need more interval patterns beyond */10, our guide to every-5, 10, 15, and 30-minute cron expressions covers every common step value.

Persisting jobs across restarts

APScheduler can store job definitions in a database (SQLite, PostgreSQL, Redis) via a job store, so scheduled jobs survive an app restart instead of being redefined only in code:

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore

scheduler = BackgroundScheduler(
    jobstores={'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')}
)
scheduler.start()

Choosing between schedule, APScheduler, and system cron

  • schedule — quick scripts, single-server, don't need real cron syntax, comfortable keeping a process alive.
  • APScheduler — need real cron expressions, background execution inside a bigger app, or persistence across restarts.
  • System cron — jobs are standalone scripts, you want the OS itself (not your app process) to guarantee execution, and you don't need to share Python in-memory state.

A common production pattern on Linux is actually hybrid: use system cron to invoke a lightweight Python entrypoint script for reliability and OS-level guarantees, and use APScheduler only for schedules that must live and adapt inside a running web service (e.g. schedules configurable by end users at runtime). Building the same kind of hybrid in a Node.js service instead? See our comparison of cron vs setInterval in Node.js for the equivalent trade-offs.

Common Mistakes with schedule and APScheduler

  • Blocking the main thread with schedule. The while True: run_pending() loop is synchronous — if sync_orders() takes 30 seconds, nothing else in that loop runs until it finishes, including other due jobs.
  • Forgetting the while True loop entirely. Calling schedule.every(10).minutes.do(...) only registers the job; nothing fires until something actually calls schedule.run_pending() repeatedly, usually inside that loop.
  • Wrong string format for every().day.at(). It must be a 24-hour "HH:MM" or "HH:MM:SS" string like "09:00" — not "9am", not a datetime object.
  • An unhandled exception in one job kills the whole loop. If a scheduled function raises, schedule propagates it out of run_pending() — wrap each job function (or the loop body) in a try/except so one bad job doesn't stop every other schedule.
  • Confusing BackgroundScheduler with BlockingScheduler in APScheduler. BackgroundScheduler starts a separate thread and returns immediately — in a short script with nothing else keeping the process alive, it will exit right after scheduler.start(). Use BlockingScheduler for standalone scripts, BackgroundScheduler inside a Flask/FastAPI app that already keeps the process running.
  • Running schedule or APScheduler across multiple worker processes. Each Gunicorn/uWSGI worker defines and runs its own copy of the schedule independently — with 4 workers, a "every 10 minutes" job actually fires 4 times. Use a single dedicated worker, an external lock (Redis, a DB row), or move the schedule out of the web workers entirely.

Putting It Together: A Resilient Job Wrapper

A slightly more realistic version of the schedule example above — one that survives a failing job instead of taking the whole loop down with it, and logs failures instead of losing them silently:

import logging
import schedule
import time

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("scheduler")

def safe(job):
    """Wrap a job so one exception doesn't kill the while-loop."""
    def wrapped():
        try:
            job()
        except Exception:
            log.exception("Scheduled job %s failed", job.__name__)
    return wrapped

def sync_orders():
    print("Syncing orders...")
    # e.g. requests.get(...) could raise here — safe() catches it

schedule.every(10).minutes.do(safe(sync_orders))
schedule.every().day.at("09:00").do(safe(lambda: print("daily report")))

while True:
    schedule.run_pending()
    time.sleep(1)

The safe() wrapper is the difference between one flaky API call taking down every scheduled job in the process versus just that one run failing and getting logged. It costs four lines and removes an entire class of "why did the scheduler stop running at 3 AM" incidents.

Frequently Asked Questions

What is the difference between Python schedule and APScheduler?

The schedule library is lightweight and uses a simple fluent API for basic interval-based jobs, but it requires your own while loop and has no persistence. APScheduler is more powerful, supports true cron-style expressions, can persist jobs to a database, and can run jobs in background threads without a manual loop.

Can APScheduler use real cron expressions?

Yes. APScheduler's CronTrigger accepts standard cron-style fields (minute, hour, day, month, day_of_week) as keyword arguments, and also supports a shorthand CronTrigger.from_crontab() method that parses a traditional 5-field cron string directly.

Should I use Python scheduling instead of system cron?

Use Python scheduling libraries when your jobs need to share in-process state, Python objects, or a database connection pool with the rest of your application, or when you need cross-platform scheduling that doesn't depend on the host having cron installed (e.g. Windows). Use system cron when jobs are independent scripts and you want the OS to guarantee they run even if your app crashes.

What is the correct format for schedule.every().day.at()?

The at() method expects a 24-hour time string, either "HH:MM" or "HH:MM:SS", such as "09:00" or "23:30:15". Formats like "9:00am" or a datetime object will raise a ScheduleValueError — always pass a zero-padded 24-hour string.

How do I stop Python's schedule library while loop safely?

Use a flag variable checked on every iteration (e.g. while not stop_event.is_set()) instead of an unconditional while True, so a signal handler, another thread, or a test harness can request a clean exit. Wrap the loop body in a try/except so one job raising an exception does not silently kill the loop and stop every other scheduled job.

Try the Free Cron Expression Generator

Get a valid cron expression in plain English, then drop it straight into CronTrigger.from_crontab() or your system crontab.

Related articles