APScheduler is a Python library that runs functions on a schedule. We give it a function and a trigger, such as every five minutes or weekdays at 09:00, and it calls that function on time without cron, without a separate service, and without keeping a terminal window open. This tutorial builds a scheduler with three trigger types and persistent jobs.
Key takeaways
APScheduler runs Python functions on interval, cron, and one-off date triggers, with no system cron entry needed.
BackgroundScheduler fits scripts that keep working after the scheduler starts. BlockingScheduler fits a process that only schedules.
A SQLAlchemy job store in SQLite keeps jobs across restarts, so a reboot does not lose the schedule.
Job functions must live in an importable module, because APScheduler stores jobs by reference and cannot restore a function defined in main.
Setting replace_existing to True lets us re-run the setup script without a conflicting job id error.
What we build
We build a small demo scheduler with three jobs. The first collects fake sales numbers every five seconds and stores them in SQLite. The second prints a daily summary on weekdays at 09:00. The third fires a one-off reminder ten seconds after the script starts, then removes itself. The scheduler runs for about 25 seconds, shuts down cleanly, and prints the rows it stored. Then we restart it with no jobs added and check that the persistent jobs come back. Every line of output in this tutorial comes from a real run on our machine.
Prerequisites
We need Python 3.10 or newer and pip. No API keys, no accounts, and no external services. We tested this tutorial with Python 3.12.3 and APScheduler 3.11.3, installed from PyPI on 14 November 2026. The APScheduler documentation lists three trigger types, interval, cron, and date, and we use all three below.
Step 1: Install APScheduler
We create a project folder and install the library. APScheduler brings tzlocal for timezone handling and SQLAlchemy for the persistent job store, so one install covers everything below.
mkdir schedule-python-jobs
cd schedule-python-jobs
pip install apscheduler
We pin the versions we tested in a requirements file, so the setup stays repeatable.
apscheduler==3.11.3
SQLAlchemy==2.1.8
We check the install with a one line version print.
python -c "import apscheduler; print('apscheduler', apscheduler.__version__)"
apscheduler 3.11.3
Step 2: Put the job functions in their own module
We keep the work in jobs.py, separate from the scheduler setup. This is not tidiness for its own sake. APScheduler stores each job as a reference to its function, such as jobs:collect_metrics, and on restart it imports that reference. A function defined in the script we run, in main, cannot be imported back, and the scheduler drops the job. We learned this the hard way in our first run, and Step 5 shows the exact error. So we start with the correct layout.
"""The work our scheduled jobs do. Keep job functions in their own
importable module: APScheduler stores jobs by reference (jobs:collect_metrics),
so a restart can only restore them when the module path still resolves.
Functions defined in __main__ are lost on restart.
"""
import logging
import random
import sqlite3
from datetime import datetime
DB_PATH = "metrics.db"
log = logging.getLogger("demo")
def collect_metrics():
"""Pretend to pull numbers from an API and store them."""
orders = random.randint(18, 42)
revenue = round(orders * random.uniform(19.0, 49.0), 2)
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS metrics "
"(ts TEXT, orders INTEGER, revenue REAL)"
)
conn.execute("INSERT INTO metrics VALUES (?, ?, ?)",
(datetime.now().isoformat(timespec="seconds"),
orders, revenue))
log.info("Collected metrics: %s orders, $%s revenue", orders, revenue)
def send_daily_summary():
"""Read the stored rows and print the day's totals."""
with sqlite3.connect(DB_PATH) as conn:
rows = conn.execute(
"SELECT COUNT(*), SUM(orders), SUM(revenue) FROM metrics"
).fetchone()
log.info("Daily summary: %s runs, %s orders, $%s revenue",
rows[0], rows[1], round(rows[2] or 0, 2))
def one_off_reminder():
log.info("Reminder: weekly report is due. This job runs once, "
"then it is gone.")
Each function does one small thing and logs what it did. collect_metrics writes one row per run. send_daily_summary reads the table and totals it. one_off_reminder only logs a line. In a real project, collect_metrics would call an API and send_daily_summary would send an email, but the scheduling code stays exactly the same.
Step 3: Wire up the scheduler with three triggers
Now we write scheduler_demo.py. It creates a BackgroundScheduler with a SQLite job store, adds the three jobs, starts the scheduler, waits 25 seconds, then shuts down and prints the stored rows. BackgroundScheduler runs the jobs in a background thread, so the main thread stays free to sleep, print, and shut down cleanly. A script that does nothing but schedule would use BlockingScheduler instead, which blocks the main thread until we stop it.
import logging
import sqlite3
import time
from datetime import datetime, timedelta
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from jobs import collect_metrics, one_off_reminder, send_daily_summary
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("demo")
def build_scheduler():
jobstores = {"default": SQLAlchemyJobStore(url="sqlite:///jobs.sqlite")}
scheduler = BackgroundScheduler(jobstores=jobstores)
scheduler.add_job(collect_metrics, IntervalTrigger(seconds=5),
id="collect-metrics", replace_existing=True)
scheduler.add_job(send_daily_summary,
CronTrigger(day_of_week="mon-fri", hour=9, minute=0),
id="daily-summary", replace_existing=True)
scheduler.add_job(one_off_reminder,
DateTrigger(run_date=datetime.now()
+ timedelta(seconds=10)),
id="one-off-reminder", replace_existing=True)
return scheduler
if __name__ == "__main__":
scheduler = build_scheduler()
scheduler.start()
log.info("Scheduler started. Jobs scheduled:")
scheduler.print_jobs()
try:
time.sleep(25)
except KeyboardInterrupt:
log.info("Stopping on user request.")
scheduler.shutdown()
log.info("Scheduler stopped.")
with sqlite3.connect("metrics.db") as conn:
rows = conn.execute("SELECT * FROM metrics").fetchall()
print(f"\nStored {len(rows)} metric rows in metrics.db:")
for row in rows:
print(" ", row)
Three details matter here. First, each trigger matches a real pattern: IntervalTrigger for repeat work, CronTrigger for calendar work, DateTrigger for a single future run. Second, replace_existing set to True means we can run the script again without a ConflictingIdError for the same job id. Third, the job store is SQLite, so the schedule is written to jobs.sqlite on disk instead of living only in memory.
Step 4: Run it and read the real output
We run the demo from a clean folder and watch it for 25 seconds.
python scheduler_demo.py
The scheduler prints the three jobs it holds, with the next run time for each. The daily summary is next due Monday 16 November at 09:00, because our demo ran on a Saturday and the cron trigger only fires Monday to Friday. APScheduler reads the machine timezone by default, and our demo machine was set to UTC.
Jobstore default:
collect_metrics (trigger: interval[0:00:05], next run at: 2026-11-14 02:53:22 UTC)
one_off_reminder (trigger: date[2026-11-14 02:53:27 UTC], next run at: 2026-11-14 02:53:27 UTC)
send_daily_summary (trigger: cron[day_of_week='mon-fri', hour='9', minute='0'], next run at: 2026-11-16 09:00:00 UTC)
Then the jobs fire. The metrics job runs every five seconds, the reminder fires once at ten seconds and is removed right after, and the daily summary stays quiet because 09:00 never arrives during the demo.
2026-11-14 02:53:22,686 INFO Collected metrics: 40 orders, $1480.13 revenue
2026-11-14 02:53:27,632 INFO Reminder: weekly report is due. This job runs once, then it is gone.
2026-11-14 02:53:27,641 INFO Collected metrics: 35 orders, $806.8 revenue
2026-11-14 02:53:27,688 INFO Removed job one-off-reminder
2026-11-14 02:53:32,638 INFO Collected metrics: 20 orders, $836.07 revenue
2026-11-14 02:53:37,648 INFO Collected metrics: 39 orders, $1089.71 revenue
2026-11-14 02:53:42,645 INFO Collected metrics: 41 orders, $1282.3 revenue
After shutdown, the script prints the five rows it stored. The numbers are random by design, so our values will differ from yours, but the shape is the point: five runs in 25 seconds at a five second interval, and nothing lost.
Stored 5 metric rows in metrics.db:
('2026-11-14T02:53:22', 40, 1480.13)
('2026-11-14T02:53:27', 35, 806.8)
('2026-11-14T02:53:32', 20, 836.07)
('2026-11-14T02:53:37', 39, 1089.71)
('2026-11-14T02:53:42', 41, 1282.3)
How do jobs survive a restart?
We prove it with a second script, check_jobs.py, that opens the same job store, adds nothing, and lists what comes back.
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler(
jobstores={"default": SQLAlchemyJobStore(url="sqlite:///jobs.sqlite")})
scheduler.start()
print("Jobs restored after restart (nothing re-added):")
scheduler.print_jobs()
scheduler.shutdown()
The output shows both persistent jobs back in place, with the interval job already counting down to its next run. The one-off reminder is gone, exactly as it should be, because it already fired and removed itself.
Jobs restored after restart (nothing re-added):
Jobstore default:
collect_metrics (trigger: interval[0:00:05], next run at: 2026-11-14 02:53:47 UTC)
send_daily_summary (trigger: cron[day_of_week='mon-fri', hour='9', minute='0'], next run at: 2026-11-16 09:00:00 UTC)
Without the job store, a restart would start with an empty schedule and the daily summary would never fire. With it, the schedule is data on disk, and the scheduler just picks it up. For a server deployment we would point the same job store at PostgreSQL instead of SQLite, and the code change is one URL.
What happens if a job function moves?
Our first version of this demo kept the job functions in scheduler_demo.py itself, and the restart test failed. APScheduler tried to restore the jobs and logged an error for each one, then deleted them from the store.
Unable to restore job "collect-metrics" -- removing it
LookupError: Error resolving reference __main__:collect_metrics: error looking up object
Unable to restore job "daily-summary" -- removing it
LookupError: Error resolving reference __main__:send_daily_summary: error looking up object
The cause is simple. The stored reference pointed at main:collect_metrics, but on restart the new script was a different main with no such function. APScheduler could not import it, so it removed the job rather than crashing. Moving the functions into jobs.py fixed it, because jobs:collect_metrics resolves from any script in the folder. The rule to remember is that a persistent job is only as stable as its import path, so we never rename a job module without updating the stored jobs first.
What to try next
First, change the interval from five seconds to one hour and run the script as a real daily report: collect metrics through the day and let the 09:00 summary send the totals. Second, add misfire_grace_time and coalesce to the daily job, so a slow run that misses its slot fires once late instead of firing once per missed slot. Third, run the scheduler as a small service with BlockingScheduler and a process manager, so it restarts with the machine and keeps the same SQLite job store.
Originally published on LogicStack. Run the code examples right in your browser with our free interactive Python and SQL playgrounds — no setup, no sign-up.
Top comments (0)