APScheduler
Scheduler Shutdown
Non-Memory Storage
Best Practices
Python Scheduling

APScheduler. What is the best practices to shutdown schedulers with any non memory storage?

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

The correct way to shut down an APScheduler scheduler with a persistent job store (SQLAlchemy, MongoDB, Redis) is to call scheduler.shutdown(wait=True) inside a try/finally block or a signal handler. Setting wait=True ensures all currently running jobs finish before the scheduler stops, which prevents data corruption in the job store. For production applications, combine this with signal handling (SIGTERM, SIGINT) so that container orchestrators and process managers can trigger a clean shutdown.

Why Persistent Stores Require Careful Shutdown

When APScheduler uses an in-memory job store, an abrupt shutdown simply loses the job data. That is acceptable because in-memory jobs are ephemeral by design.

With persistent stores, the situation is different. The job store contains:

  • Job definitions (function references, arguments, triggers)
  • Next run times
  • Job state (running, pending, paused)

If the scheduler process dies while a job is mid-execution and the store records the job as "running," the next process to start may see that job in an inconsistent state. Depending on the store and configuration, this can lead to:

  • Jobs that never execute again because they appear permanently "running"
  • Duplicate job executions if the store does not track running state
  • Corrupted trigger data if a job was updating its next run time when the process was killed

scheduler.shutdown(wait=True) avoids these problems by letting running jobs complete and then cleanly disconnecting from the store.

Basic Shutdown Pattern

The simplest pattern uses try/finally:

python
1from apscheduler.schedulers.background import BackgroundScheduler
2from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
3
4def process_report():
5    print("Generating report...")
6
7jobstores = {
8    'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')
9}
10
11scheduler = BackgroundScheduler(jobstores=jobstores)
12scheduler.add_job(process_report, 'interval', minutes=30, id='report_job')
13scheduler.start()
14
15try:
16    # Application logic runs here
17    while True:
18        pass  # or your main application loop
19except (KeyboardInterrupt, SystemExit):
20    pass
21finally:
22    scheduler.shutdown(wait=True)
23    print("Scheduler shut down cleanly")

The wait=True parameter (which is the default) blocks until all currently executing jobs finish. If you pass wait=False, the scheduler stops immediately without waiting, which is the same as an abrupt crash from the job store's perspective.

Signal-Based Shutdown for Production

In production, processes receive SIGTERM from container orchestrators (Kubernetes, Docker), systemd, or supervisord. Handle these signals explicitly:

python
1import signal
2import sys
3from apscheduler.schedulers.background import BackgroundScheduler
4from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
5
6scheduler = None
7
8def shutdown_handler(signum, frame):
9    print(f"Received signal {signum}, shutting down...")
10    if scheduler:
11        scheduler.shutdown(wait=True)
12    sys.exit(0)
13
14def sync_data():
15    print("Syncing data to external service...")
16
17def main():
18    global scheduler
19
20    signal.signal(signal.SIGTERM, shutdown_handler)
21    signal.signal(signal.SIGINT, shutdown_handler)
22
23    jobstores = {
24        'default': SQLAlchemyJobStore(url='postgresql://user:pass@localhost/mydb')
25    }
26
27    scheduler = BackgroundScheduler(jobstores=jobstores)
28    scheduler.add_job(sync_data, 'interval', minutes=5, id='sync_job',
29                      replace_existing=True)
30    scheduler.start()
31
32    try:
33        while True:
34            signal.pause()  # sleep until a signal arrives
35    except (KeyboardInterrupt, SystemExit):
36        scheduler.shutdown(wait=True)
37
38if __name__ == '__main__':
39    main()

The replace_existing=True parameter is important for persistent stores. Without it, restarting the application adds a duplicate job because the previous instance's job still exists in the database. With replace_existing=True, the existing job is updated rather than duplicated.

Handling Long-Running Jobs

If some jobs take a long time to complete, shutdown(wait=True) blocks indefinitely. There is no built-in timeout parameter on shutdown(). You can implement a timeout yourself:

python
1import threading
2
3def shutdown_with_timeout(scheduler, timeout=30):
4    """Shut down the scheduler, forcefully if jobs don't finish in time."""
5    shutdown_thread = threading.Thread(
6        target=scheduler.shutdown,
7        kwargs={'wait': True}
8    )
9    shutdown_thread.start()
10    shutdown_thread.join(timeout=timeout)
11
12    if shutdown_thread.is_alive():
13        print(f"Warning: shutdown did not complete within {timeout}s, "
14              "forcing immediate shutdown")
15        scheduler.shutdown(wait=False)

For Kubernetes deployments, set the pod's terminationGracePeriodSeconds to a value longer than your longest-running job. Kubernetes sends SIGTERM first, then waits for the grace period before sending SIGKILL.

yaml
1# Kubernetes pod spec
2spec:
3  terminationGracePeriodSeconds: 120  # 2 minutes for jobs to finish
4  containers:
5    - name: scheduler
6      image: myapp:latest

Using Event Listeners for Cleanup

APScheduler provides event hooks that let you track job lifecycle events. Use these to log and clean up during shutdown:

python
1from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_ERROR, EVENT_JOB_MISSED
2
3def job_listener(event):
4    if event.exception:
5        print(f"Job {event.job_id} failed: {event.exception}")
6    elif event.code == EVENT_JOB_MISSED:
7        print(f"Job {event.job_id} missed its scheduled run")
8    else:
9        print(f"Job {event.job_id} completed successfully")
10
11scheduler.add_listener(job_listener,
12                       EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED)

The EVENT_JOB_MISSED event is particularly important for persistent stores. If the scheduler was down during a job's scheduled time, APScheduler fires a misfire event on startup. Configure the misfire_grace_time to control how stale a missed run can be before it is skipped:

python
1scheduler.add_job(sync_data, 'interval', minutes=5,
2                  id='sync_job',
3                  misfire_grace_time=300,  # allow up to 5 minutes late
4                  replace_existing=True)

Comparison: Job Store Options

Job StoreBackendPersistenceBest For
MemoryJobStoreIn-process dictNone (lost on exit)Development, testing
SQLAlchemyJobStoreAny SQL databaseFullProduction with existing SQL infrastructure
MongoDBJobStoreMongoDBFullDocument-oriented architectures
RedisJobStoreRedisDepends on Redis persistenceHigh-throughput, low-latency scheduling

All persistent stores require the same shutdown discipline. The only difference is in connection cleanup. SQLAlchemy pools are closed by the store's shutdown() method. MongoDB and Redis connections are also released during shutdown.

AsyncIO Scheduler Shutdown

If you use AsyncIOScheduler, the shutdown pattern integrates with the asyncio event loop:

python
1import asyncio
2from apscheduler.schedulers.asyncio import AsyncIOScheduler
3from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
4
5async def async_task():
6    print("Running async task...")
7
8async def main():
9    jobstores = {
10        'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')
11    }
12
13    scheduler = AsyncIOScheduler(jobstores=jobstores)
14    scheduler.add_job(async_task, 'interval', seconds=60, id='async_task',
15                      replace_existing=True)
16    scheduler.start()
17
18    try:
19        await asyncio.Event().wait()  # run forever
20    except (KeyboardInterrupt, SystemExit):
21        pass
22    finally:
23        scheduler.shutdown(wait=True)
24
25asyncio.run(main())

Flask and Django Integration

In web frameworks, the scheduler typically starts when the application starts and shuts down with the application:

python
1# Flask example
2from flask import Flask
3from apscheduler.schedulers.background import BackgroundScheduler
4from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
5import atexit
6
7app = Flask(__name__)
8
9def cleanup_sessions():
10    print("Cleaning expired sessions...")
11
12scheduler = BackgroundScheduler(jobstores={
13    'default': SQLAlchemyJobStore(url='sqlite:///jobs.sqlite')
14})
15scheduler.add_job(cleanup_sessions, 'interval', hours=1,
16                  id='session_cleanup', replace_existing=True)
17scheduler.start()
18
19# Register shutdown hook
20atexit.register(lambda: scheduler.shutdown(wait=True))
21
22@app.route('/')
23def index():
24    return "OK"

The atexit handler ensures the scheduler shuts down when the Flask process exits. For production WSGI servers (Gunicorn, uWSGI), also handle SIGTERM as shown in the signal-based shutdown section, because atexit does not fire on SIGKILL.

Common Pitfalls

  • Forgetting replace_existing=True: Without this, every application restart adds a duplicate job to the persistent store. After 10 restarts, you have 10 copies of the same job running simultaneously.
  • Using wait=False to avoid slow shutdowns: This defeats the purpose of graceful shutdown. If jobs take too long, implement a timeout wrapper rather than abandoning running jobs.
  • Not handling signals: In containerized environments, SIGTERM is the primary shutdown mechanism. Without a signal handler, the process receives SIGKILL after the grace period, which is equivalent to pulling the power cord.
  • Running the scheduler in multiple worker processes: WSGI servers like Gunicorn spawn multiple workers. If each worker starts a scheduler, you get N copies of every job. Use --workers 1 for the scheduler process, or run it as a separate service.
  • Ignoring misfire events: If the scheduler was down for an hour and a job runs every 5 minutes, there are 12 missed runs. Without misfire_grace_time, APScheduler may try to execute all 12 at once on startup, depending on the coalescing setting.
  • Not logging shutdown events: When troubleshooting "why did jobs stop running," having log entries for scheduler startup and shutdown is invaluable. Always log the shutdown reason (signal number, exception, or normal exit).

Summary

  • Always call scheduler.shutdown(wait=True) in a try/finally block or signal handler when using persistent job stores.
  • Use replace_existing=True when adding jobs to prevent duplicates after application restarts.
  • Handle SIGTERM and SIGINT for clean shutdown in containerized and daemon deployments.
  • Set misfire_grace_time to control behavior for jobs missed during downtime.
  • For long-running jobs, implement a timeout wrapper around shutdown(wait=True) and align it with your container's termination grace period.
  • Register the scheduler shutdown with atexit in web frameworks, but also handle signals for production WSGI deployments.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions