Back to Scaling Python Applications guides

Job Scheduling in Python with APScheduler

Stanley Ulili
Updated on March 7, 2025

APScheduler is a Python library that enables you to schedule tasks to run at specific times or intervals, offloading work from your main application to run in the background.

It's perfect for handling time-consuming operations like data processing, API calls, sending notifications, generating reports, or performing system maintenance tasks.

APScheduler is highly versatile, supporting various scheduling paradigms including interval-based, cron-based, and one-time execution schedules.

This article will guide you through setting up APScheduler, exploring its features, and implementing best practices for effective task scheduling in Python applications.

Prerequisites

Before proceeding with the rest of this article, ensure you have Python 3.13+ and pip installed locally on your machine. This article assumes familiarity with basic Python concepts and asynchronous programming patterns.

Step 1 — Getting started with APScheduler

To follow along with this tutorial, create a new Python project to experiment with the scheduling concepts we'll be discussing.

Start by setting up a new Python virtual environment and installing APScheduler:

 
mkdir apscheduler-demo && cd apscheduler-demo
 
python3 -m venv venv

Activate the virtual environment:

 
source venv/bin/activate

Now install APScheduler:

 
pip install apscheduler

Create a new scheduler.py file in the root of your project directory, and add the following code:

scheduler.py
from apscheduler.schedulers.background import BackgroundScheduler

scheduler = BackgroundScheduler()

def initialize_scheduler():
    scheduler.start()
    return scheduler

This snippet imports the BackgroundScheduler from APScheduler and exports a function that initializes and returns a scheduler instance.

We'll build upon this foundational pattern as we explore more features.

Now, let's create a simple main.py file to use our scheduler:

main.py
from scheduler import initialize_scheduler
import time

def hello_job():
    print("Hello, APScheduler!")

scheduler = initialize_scheduler()
scheduler.add_job(hello_job, 'interval', seconds=5)

try:
    # Keep the main thread alive to allow the scheduler to run
    while True:
        time.sleep(1)
except (KeyboardInterrupt, SystemExit):
    scheduler.shutdown()

This snippet initializes and runs an APScheduler BackgroundScheduler instance. It imports initialize_scheduler from scheduler.py, defines a simple hello_job function that prints a message, and schedules it to run every 5 seconds.

The script keeps the main thread alive in a loop, ensuring the scheduler continues running, and handles graceful shutdown on exit.

Run the program with:

 
python main.py

You should see output like this:

Output
Hello, APScheduler!
Hello, APScheduler!
Hello, APScheduler!

The program will continue to print "Hello, APScheduler!" every 5 seconds until you stop it with Ctrl+C. This demonstrates the most basic use of APScheduler's interval-based scheduling.

Step 2 — Understanding APScheduler trigger types

Scheduling tasks effectively requires selecting the right scheduling pattern. APScheduler provides three distinct trigger types, each designed for specific timing needs. The trigger you choose determines exactly when your scheduled jobs will execute.

These three trigger mechanisms form the foundation of APScheduler's flexibility:

  • Date trigger: For one-time execution at a specific moment
  • Interval trigger: For recurring execution at regular time intervals
  • Cron trigger: For complex time-based schedules following cron syntax

Let's examine each trigger type to understand when and how to use them effectively.

Date trigger

The date trigger is ideal when you need a job to run exactly once at a specific point in time. Common use cases include:

  • Sending notifications for scheduled events
  • Publishing time-sensitive content at a precise release time
  • Executing one-time data migrations or conversions
  • Scheduling future system maintenance

This trigger is straightforward: specify the exact date and time for execution, and APScheduler handles the rest.

Create a new file called date_trigger.py:

date_trigger.py
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime, timedelta
import time

def one_time_job():
    print(f"Job executed at: {datetime.now().strftime('%H:%M:%S')}")

scheduler = BackgroundScheduler()
scheduler.start()

# Schedule job to run 10 seconds from now
run_date = datetime.now() + timedelta(seconds=10)
scheduler.add_job(one_time_job, 'date', run_date=run_date)

print(f"Job scheduled for: {run_date.strftime('%H:%M:%S')}")
print("Press Ctrl+C to exit")

try:
    while True:
        time.sleep(1)
except (KeyboardInterrupt, SystemExit):
    scheduler.shutdown()

In this example, you define a simple job function that prints the current time when executed. You schedule this job to execute exactly once, 10 seconds into the future, by providing a datetime object to the date trigger.

The script continues running until the scheduled task completes, at which point APScheduler automatically removes the job from the scheduler.

The date trigger also accepts ISO format strings (like '2023-12-31 23:59:00') or Unix timestamps. After the job executes once, it's automatically removed from the scheduler.

Run this script with:

 
python date_trigger.py
Output

Job scheduled for: 11:59:03
Press Ctrl+C to exit
Job executed at: 11:59:03

As you can see, the date trigger executed the scheduled job precisely at the defined time.

Now, let's move on to the interval trigger.

Interval trigger

The interval trigger executes jobs at regular intervals (e.g., every 5 minutes, every 2 hours). This trigger is perfect for:

  • Polling external APIs or services
  • Refreshing cached data periodically
  • Processing queue items at regular intervals
  • Performing regular system health checks
  • Sending periodic reports or notifications

Unlike the date trigger, interval jobs continue running indefinitely at the specified frequency until explicitly stopped or until the application shuts down.

Create a file called interval_trigger.py:

interval_trigger.py
from apscheduler.schedulers.background import BackgroundScheduler
import time

def scheduled_task():
    print(f"Interval job executed at: {time.strftime('%H:%M:%S')}")

scheduler = BackgroundScheduler()
scheduler.start()

# Run job every 5 seconds
scheduler.add_job(scheduled_task, 'interval', seconds=5)

print("Job scheduled to run every 5 seconds")
print("Press Ctrl+C to exit")

try:
    while True:
        time.sleep(1)
except (KeyboardInterrupt, SystemExit):
    scheduler.shutdown()

In this example, a simple scheduled task is executed every 5 seconds. The script initializes a background scheduler, defines a function that prints the current time, and schedules this function to run at 5-second intervals.

The script then runs continuously, allowing the scheduler to execute the task repeatedly until interrupted.

Run the interval example with:

 
python interval_trigger.py

You will see:

Output
Job scheduled to run every 5 seconds
Press Ctrl+C to exit
Interval job executed at: 12:02:08
Interval job executed at: 12:02:13
Interval job executed at: 12:02:18
Interval job executed at: 12:02:23

The key benefits of the interval trigger include:

  • Simple configuration - just specify the desired time interval
  • Consistent timing between executions (5 seconds means exactly 5 seconds)
  • Support for multiple time units (seconds, minutes, hours, days, weeks)
  • Optional start and end time constraints

You can also combine multiple time units (like hours=1, minutes=30 for a 90-minute interval) and control when the interval starts and stops using the start_date and end_date parameters.

Cron trigger

The cron trigger provides sophisticated schedule definitions based on calendar time patterns, similar to Unix cron jobs. This trigger is the most powerful and flexible, perfect for:

  • Running jobs at specific times of day (9 AM daily)
  • Scheduling tasks for specific days of the week (every Monday)
  • Executing jobs during business hours only
  • Implementing complex schedules like "first Monday of each month"
  • Handling month-end processing or reporting

The cron trigger uses a syntax where you specify combinations of time components (second, minute, hour, day, month, day of week, and optionally year).

Create a file called cron_trigger.py:

cron_trigger.py
from apscheduler.schedulers.background import BackgroundScheduler
import time

def cron_task():
    print(f"Cron job executed at: {time.strftime('%H:%M:%S')}")

scheduler = BackgroundScheduler()
scheduler.start()

# Run job every minute at the 15-second mark
scheduler.add_job(cron_task, 'cron', second=15)

print("Job will execute at the 15-second mark of every minute")
print("Press Ctrl+C to exit")

try:
    while True:
        time.sleep(1)
except (KeyboardInterrupt, SystemExit):
    scheduler.shutdown()

In this simple example, the job executes precisely at the 15-second mark of every minute. Cron triggers offer exceptional precision for wall-clock scheduling requirements, making them ideal for business applications with time-sensitive processes.

Run the cron example with:

 
python cron_trigger.py
Output
Job will execute at the 15-second mark of every minute
Press Ctrl+C to exit
Cron job executed at: 12:05:15
Cron job executed at: 12:06:15
Cron job executed at: 12:07:15
Cron job executed at: 12:08:15

The cron trigger supports fields with specific acceptable values and special characters:

Field Required Values Special Characters Description
second No 0-59 * , - / Seconds within a minute
minute Yes 0-59 * , - / Minutes within an hour
hour Yes 0-23 * , - / Hours within a day
day Yes 1-31 * , - / ? L W Days within a month
month Yes 1-12 or jan-dec * , - / Months within a year
dayofweek Yes 0-6 or mon-sun * , - / ? L # Days within a week
year No 4-digit year * , - / 4-digit year values

Special character meanings:

  • *: Any value (wildcard)
  • ,: Value list separator (e.g., "1,3,5")
  • -: Range of values (e.g., "1-5")
  • /: Step values (e.g., "*/5" means every 5th value)
  • ?: No specific value (used when you specify dayofmonth or dayofweek)
  • L: Last day (of month or week)
  • W: Weekday (closest weekday to the given day)
  • #: Nth occurrence of a weekday in the month (e.g., "3#2" = 2nd Tuesday)

Here are practical examples demonstrating cron expressions:

 
# Every weekday at 9 AM
scheduler.add_job(job_function, 'cron', day_of_week='mon-fri', hour=9)

# Every 5 minutes during business hours
scheduler.add_job(job_function, 'cron', 
                  day_of_week='mon-fri', 
                  hour='9-17', 
                  minute='*/5')

# First day of every month at midnight
scheduler.add_job(job_function, 'cron', day=1, hour=0, minute=0)

Understanding these trigger types thoroughly is essential for effective task scheduling with APScheduler. Choose the date trigger for one-time events, the interval trigger for consistent time spacing, and the cron trigger for calendar-based schedules.

You're right. Let's reorganize the content to break these components into separate steps, while ensuring they flow logically and connect with each other. Here's a revised structure:

Step 3 — Understanding schedulers in APScheduler

After exploring the different trigger types, it's essential to understand the core component of APScheduler's architecture: the scheduler itself. The scheduler is responsible for coordinating all other elements and managing the execution of your jobs.

APScheduler provides several scheduler implementations designed to integrate with different application frameworks. So far in this tutorial, you've been using the BackgroundScheduler, but depending on your application's needs, you might want to choose a different scheduler type.

Let's examine the available scheduler types:

 
# Import different scheduler types
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.tornado import TornadoScheduler
from apscheduler.schedulers.gevent import GeventScheduler
from apscheduler.schedulers.twisted import TwistedScheduler

Each scheduler type serves a specific purpose:

  • BackgroundScheduler: Runs in a separate thread, ideal for most applications that need to perform other operations alongside scheduled tasks
  • BlockingScheduler: Runs in the foreground blocking the main thread, suitable for scripts where scheduling is the only function
  • AsyncIOScheduler: Integrates with asyncio event loops for asynchronous applications
  • TornadoScheduler: Designed for Tornado web applications
  • GeventScheduler: Works with Gevent for cooperative multitasking
  • TwistedScheduler: Integrates with Twisted event-driven networking framework

Let's see the difference between the two most commonly used schedulers by creating a simple example with a BlockingScheduler:

blocking_scheduler.py
from apscheduler.schedulers.blocking import BlockingScheduler
import time
from datetime import datetime

def timed_job():
    print(f"Job executed at: {datetime.now().strftime('%H:%M:%S')}")

scheduler = BlockingScheduler()
scheduler.add_job(timed_job, 'interval', seconds=5)

print("Starting BlockingScheduler...")
print("Notice that no code after scheduler.start() will execute until the scheduler is shut down")
scheduler.start()

print("This line will never be reached while the scheduler is running")

In this example, you create a simple job (timed_job) that prints the current time every 5 seconds using a BlockingScheduler.

Notice that once you call scheduler.start(), your script will remain stuck (or "blocked") at that point, continuously executing scheduled jobs.

Any code placed after scheduler.start() won't run until you manually stop or shut down the scheduler. This demonstrates the main difference from a BackgroundScheduler, which allows code after the scheduler initialization to run concurrently.

Run this script to see how a blocking scheduler behaves:

 
python blocking_scheduler.py

You should see output like this:

Output
Starting BlockingScheduler...
Notice that no code after scheduler.start() will execute until the scheduler is shut down
Job executed at: 12:28:02
Job executed at: 12:28:07
Job executed at: 12:28:12
Job executed at: 12:28:17

Notice that unlike our previous examples with BackgroundScheduler, the code after scheduler.start() never executes because the scheduler blocks the main thread.

The BackgroundScheduler we've been using throughout this tutorial is more versatile since it runs in a separate thread, allowing your main application to continue its regular operation.

This makes it suitable for web applications, APIs, or any program where task scheduling is just one part of its functionality.

Choosing the right scheduler type is important because it determines how your scheduled tasks integrate with the rest of your application. For most applications, the BackgroundScheduler is a good default choice. However, if your application is built on a specific framework like asyncio or Tornado, the matching scheduler will provide better integration.

In the next step, you'll persist your scheduled jobs using job stores, allowing them to survive application restarts.

Step 4 — Persisting jobs with job stores

By default, APScheduler keeps all jobs in memory, which means they're lost when your application restarts. You'll typically want to persist your jobs for production applications to ensure they continue running according to schedule, even after application downtime.

Job stores are the APScheduler components responsible for persisting your scheduled jobs. Let's see how to configure and use them.

First, let's install the SQLAlchemy package which you'll use for your persistent job store:

 
pip install sqlalchemy

Now, let's update our scheduler.py file to include job store configuration:

scheduler.py
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore

# Define job stores
jobstores = {
    'default': MemoryJobStore(),  # In-memory store (no persistence)
    'persistent': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')  # SQLite-based persistent store
}

# Create scheduler with job stores configuration
scheduler = BackgroundScheduler(jobstores=jobstores)

def initialize_scheduler():
    scheduler.start()
    return scheduler

This configuration sets up two job stores:

  • A memory store (default) for temporary jobs
  • A SQLite store for jobs that need to persist across application restarts

Now, let's create an application that demonstrates how persistent job stores work:

persistent_jobs.py
from scheduler import initialize_scheduler
import time
from datetime import datetime

def volatile_job():
    print(f"[{datetime.now().strftime('%H:%M:%S')}] Volatile job (in-memory)")

def persistent_job():
    print(f"[{datetime.now().strftime('%H:%M:%S')}] Persistent job (in SQLite)")

# Initialize scheduler with our job stores
scheduler = initialize_scheduler()

# Check if persistent job already exists (after restart)
existing_job = scheduler.get_job('persistent_job', jobstore='persistent')
if not existing_job:
    print("First run - adding persistent job")
    # Add a job to the persistent store
    scheduler.add_job(
        persistent_job, 
        'interval', 
        seconds=15, 
        id='persistent_job',
        jobstore='persistent'  # Specify the job store
    )
else:
    print("Job already exists - loaded from SQLite")

# Always add the volatile job (it won't survive restarts)
scheduler.add_job(
    volatile_job, 
    'interval', 
    seconds=10, 
    id='volatile_job'
)

print("Scheduler started with both volatile and persistent jobs")
print("The persistent job will survive application restarts")
print("Press Ctrl+C to exit")

try:
    # Keep the main thread alive
    while True:
        time.sleep(1)
except (KeyboardInterrupt, SystemExit):
    scheduler.shutdown()
    print("Scheduler shut down")

In this example, you create an application demonstrating how APScheduler's persistent job stores work. The script initializes two types of scheduled jobs:

  • A volatile job (volatile_job) is stored in memory and will be lost when the application stops or restarts.
  • A persistent job (persistent_job) uses SQLite to store its configuration. This job survives application restarts, automatically reloading its schedule when the application is started again.

Run this script:

 
python persistent_jobs.py

You should see output like this:

Output
First run - adding persistent job
Scheduler started with both volatile and persistent jobs
The persistent job will survive application restarts
Press Ctrl+C to exit
[12:36:54] Volatile job (in-memory)
[12:36:59] Persistent job (in SQLite)
[12:37:04] Volatile job (in-memory)
[12:37:14] Volatile job (in-memory)
[12:37:14] Persistent job (in SQLite)

When running the script, the scheduler first checks if the persistent job already exists. If it's the first run, the job is added; otherwise, it recognizes and loads the existing persistent job from SQLite.

This clearly illustrates the difference between volatile (temporary) and persistent (durable) job scheduling.

Now, stop the script with Ctrl+C and run it again:

 
python persistent_jobs.py

This time, you should see:

Output
[12:37:45] Persistent job (in SQLite)
Job already exists - loaded from SQLite
Scheduler started with both volatile and persistent jobs
The persistent job will survive application restarts
Press Ctrl+C to exit

Notice that the persistent job was loaded from the SQLite database, while the volatile job had to be added again.

APScheduler supports several job stores:

  • MemoryJobStore: Stores jobs in memory (no persistence)
  • SQLAlchemyJobStore: Stores jobs in SQL databases (SQLite, PostgreSQL, MySQL)
  • MongoDBJobStore: Stores jobs in MongoDB
  • RedisJobStore: Stores jobs in Redis

Your scheduled jobs can now reliably persist in a database.

Final thoughts

Throughout this tutorial, we've explored Python task scheduling with APScheduler. You now have the tools to control exactly when your code runs - whether it's one-time events, regular intervals, or complex calendar-based schedules.

Want to dive even deeper? the official APScheduler documentation offers extensive coverage of additional options and advanced scheduling techniques.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
Containerizing Django Applications with Docker
This article provides step-by-step instructions for deploying your Django applications using Docker and Docker Compose
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github