Back to blog

Why APScheduler Silently Stops Running Your Python Jobs

APScheduler runs inside your own process, with a one-second misfire grace and an in-memory job store by default. Here is how it silently drops scheduled jobs.

CronGuard TeamCron Job Monitoring Experts
6 min read
A dark computer screen filled with brightly coloured lines of minified JavaScript source code, showing function definitions and return statements

The Scheduler That Lives Inside Your App

cron is a daemon. systemd runs timers as children of PID 1. APScheduler is neither: it is a library you import, and the scheduler runs inside the same Python process as your application. No separate service to check, no systemctl status, no crontab. When the process is alive the jobs fire; when it is not, they do not, and nothing outside notices.

That is the appeal, and the trap. The scheduler is a guest in your process, and it fails the way a guest does: quietly, leaving no trace on the host.

What Actually Shipped in 2026

3.11.3 Is the Line You Are Running

The stable series is still 3.x. Version 3.11.3 landed on 28 June 2026, a maintenance release on an architecture that has not changed in years. Run pip install apscheduler without pinning a pre-release and this is what you get.

4.0 Has Been Alpha Since 2025

APScheduler 4.0 is a ground-up redesign that is still not stable. The latest pre-release, 4.0.0a6, shipped on 27 April 2025 with nothing since. The README is blunt: the v4.0 series "may change in a backwards incompatible fashion without any migration pathway, so do NOT use this release in production." The rewrite turns single-node job stores into shared data stores and adds event brokers so several schedulers cooperate, fixing the single-process weakness this post is about. It is alpha, so plan around 3.x.

The Defaults That Drop Jobs

APScheduler's defaults assume a scheduler that is always up and on time. Production is neither, and each default turns a late or busy process into missed work with no error.

misfire_grace_time Is One Second

Every job has a misfire_grace_time, and the default is one second. Miss the scheduled time by more than that and the run is skipped as a misfire, leaving one log line:

Run time of job "sync_orders (trigger: cron[...], next run at: ...)" was missed by 0:00:04

A second is nothing. A garbage-collection pause, a load spike, or a redeploy that briefly overlaps two instances blows past it. The job does not run, the log line scrolls away, and the schedule moves on to the next fire time.

max_instances Is One

Only one instance of a job may run at a time by default. If a run is still going at the next fire time, APScheduler does not queue the new one. It drops it:

Execution of job "generate_report" skipped: maximum number of running instances reached (1)

A job that occasionally overruns does not just overlap. It loses every fire that lands while the previous run is still working, for as long as the overrun lasts.

coalesce Collapses a Backlog

coalesce defaults to true. If several fire times passed while the scheduler was down, it runs the job once on recovery instead of once per slot. Usually what you want. But ten missed runs and one look identical afterward: a single catch-up, no count of what was dropped.

MemoryJobStore Forgets Everything on Restart

The default job store is in-memory. Jobs added in code at startup come back, but anything scheduled at runtime and every job's run history live only in that process, and a restart wipes them. Use a real store instead, SQLAlchemyJobStore against Postgres, RedisJobStore, or MongoDBJobStore, so a restart does not reset your schedule.

The Failure Nobody Sees: The Scheduler Thread Dies

BackgroundScheduler runs its loop in a daemon thread. A daemon thread does not keep the interpreter alive and does not raise into your main code when it stops, so if the process exits the scheduler goes with it, and no external supervisor is watching to notice.

AsyncIOScheduler has the mirror-image trap: it shares your event loop, so one blocking call stalls the scheduler too, and every fire time past the grace window becomes a misfire. Either way, the thing that runs your jobs is a passenger in a process built for something else, and the schedule is the first casualty when that process turns unhealthy.

Detecting the Absence of a Run

You cannot diagnose in-process fragility from inside the same process. Watch the schedule from outside and alert on the absence of a successful run. That is the dead man's switch pattern: the job checks in when it finishes, and if the check-in does not arrive on time you get paged, whatever the cause, a misfire, a full job store, a blocked loop, or a process that died at 3 a.m.

Wire the check-in into the job itself, and add a listener so misfires and errors are more than log noise:

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.events import EVENT_JOB_MISSED, EVENT_JOB_ERROR
import urllib.request

scheduler = BackgroundScheduler(
    jobstores={"default": SQLAlchemyJobStore(url="postgresql://localhost/jobs")},
    job_defaults={"misfire_grace_time": 300, "coalesce": True, "max_instances": 1},
)

def report_run(job):
    # Only reached when the job body finished without raising
    urllib.request.urlopen("https://cronguard.app/api/ping/your-monitor-id", timeout=10)

def on_problem(event):
    urllib.request.urlopen("https://cronguard.app/api/fail/your-monitor-id", timeout=10)

scheduler.add_listener(on_problem, EVENT_JOB_MISSED | EVENT_JOB_ERROR)

Raise misfire_grace_time past a normal hiccup, set max_instances on purpose, persist the job store, and let an external monitor judge whether the schedule is healthy. The process can lie about its own state. A missing ping cannot.

Frequently Asked Questions About APScheduler Reliability

Why did my APScheduler job not run even though there was no error? The usual cause is the one-second default misfire_grace_time. If the scheduler cannot run the job within that second, because of load or a redeploy, the run is skipped as a misfire with only a warning in the logs. Raise misfire_grace_time past a normal hiccup.

What happens if an APScheduler job runs longer than its interval? By default max_instances is one, so any fire time that arrives while a previous run is still going is dropped, not queued. A long-running job loses every scheduled run until it finishes. Increase max_instances only if concurrent runs are safe.

Do APScheduler jobs survive a process restart? Only if you configure a persistent job store. The default MemoryJobStore keeps everything in the process, so a restart wipes runtime-added jobs and all run history. Use SQLAlchemyJobStore, RedisJobStore, or MongoDBJobStore so the schedule outlives the process.

Should I use APScheduler 4.0 in production? Not yet. As of late 2026 the 4.0 series is still a pre-release, and the project warns it may change in a backwards incompatible way without a migration path. Stay on stable 3.x and migrate once 4.0 ships a final release.

How do I get alerted when APScheduler stops firing jobs altogether? An in-process scheduler cannot reliably report that it has died, so monitor it from outside. Have each job check in with an external monitor when it finishes, and alert on the absence of that check-in.

Further reading


Conclusion: APScheduler stops running your jobs quietly because it lives inside your application rather than beside it. A one-second misfire window, a single-instance default, an in-memory job store, and a scheduler thread that dies with the process all fail without an error anyone reads. Set the defaults on purpose, persist the job store, and monitor the schedule from outside the process so the absence of a run becomes an alert instead of a surprise.

Sources: APScheduler user guide, APScheduler version history, and the APScheduler README pre-release warning.

Share

Related posts

Colorful syntax-highlighted source code on a black screen viewed at a slight angle, with a database query and functions like parseFloat and forEach visible in orange, blue and pink
ReliabilityThe Python Cron Parser Behind Airflow Is Not the Cron You Know
A fiber-optic patch panel in a rack, green connectors and yellow cables looping across rows of numbered ports
ReliabilityWhen a Scheduled Job Runs Long: Overlap, Skip, or Replace
A laptop screen at an angle in a dark room lit blue and purple, showing a code editor with a file tree on the left and JavaScript import statements open in the main pane
ReliabilityBullMQ v6 Dropped Repeatable Jobs: Your Recurring Jobs Will Stop

Set up your first monitor.
It'll take 30 seconds.