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
- The Python Cron Parser Behind Airflow Is Not the Cron You Know
- Celery Beat: The Scheduler Your Django App Is Running With No Safety Net
- Cron Alternatives Compared: systemd Timers, Celery Beat, and More
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.