# APScheduler Running Twice: Gunicorn Workers, Reloaders, and --preload

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/apscheduler-duplicate-runs

> Why APScheduler 3.x runs jobs once per Gunicorn or Uvicorn worker and twice under Flask and Django reloaders, what --preload does, and what each fix costs.

**APScheduler runs each job once per scheduler, and a `BackgroundScheduler` started when your app loads creates one scheduler in every process that loads it: each Gunicorn or Uvicorn worker, both processes of Flask's debug reloader, and both processes of Django's `runserver` autoreloader.** APScheduler 3.x has no coordination between processes, so every one of them fires the job. The fix is to run the schedule in exactly one process, either a dedicated one or the single web worker that wins a lock, and to guard the development reloaders separately.

This guide covers APScheduler 3.11.3, the current release on [PyPI](https://pypi.org/project/APScheduler/3.11.3/) (checked October 30, 2026); the 4.0 line is still pre-release there, at 4.0.0a6. Server behavior is checked against the source of Gunicorn 26.2.0, Uvicorn 0.54.0, Flask 3.1.3 with Werkzeug 3.1.9, and Django 6.1.2. For APScheduler's general failure modes and events, see [Python scheduler monitoring](https://cronradar.com/blog/python-scheduler-monitoring); for duplicates caused by crontab, systemd, or Kubernetes, see [why a cron job runs twice](https://cronradar.com/blog/cron-job-running-twice).

## Count the Schedulers First

Log the process every time a scheduler starts. `start()` dispatches `EVENT_SCHEDULER_STARTED` once it is running ([base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L215)), so add the listener before calling it:

```python
import logging
import os

from apscheduler.events import EVENT_SCHEDULER_STARTED

log = logging.getLogger("scheduler")


def log_scheduler_start(event):
    log.warning("APScheduler started: pid=%s parent=%s", os.getpid(), os.getppid())


scheduler.add_listener(log_scheduler_start, EVENT_SCHEDULER_STARTED)
scheduler.start()
```

Then compare the duplicate runs:

| What the log shows                                                     | Cause                                             |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| Several start lines with different PIDs, one per web worker            | One scheduler per worker process                  |
| Two start lines in development, one PID the parent of the other        | The Flask or Django reloader                      |
| One start line, but the job runs back to back right after a restart    | Catch-up of missed run times                      |
| One start line, and the job runs more times per tick after each deploy | Extra copies of the job in a persistent job store |

The last two have one scheduler and are covered under [when one scheduler runs the job twice](#when-one-scheduler-runs-the-job-twice).

## Why Every Web Worker Runs Its Own Scheduler

`BackgroundScheduler.start()` starts a thread named `APScheduler` in the calling process and returns ([background.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/background.py#L29-L37)). Each process that runs that code gets its own scheduler object, its own job store, and its own executor. Nothing connects them. The [APScheduler 3.x FAQ](https://github.com/agronholm/apscheduler/blob/3.11.3/docs/faq.rst?plain=1#L74-L91) is direct about the shared-database version of this:

> Sharing a persistent job store among two or more processes will lead to incorrect scheduler behavior like duplicate execution or the scheduler missing jobs, etc. This is because APScheduler does not currently have any interprocess synchronization and signalling scheme...

The [user guide](https://github.com/agronholm/apscheduler/blob/3.11.3/docs/userguide.rst?plain=1#L47) adds: "Job stores must never be shared between schedulers." With the default in-memory store, every worker holds a full private copy of every job, so each one runs all of them. Moving to `SQLAlchemyJobStore` doesn't fix this. It replaces independent copies with schedulers racing over the same rows.

The FAQ entry on web applications says to "First read through the previous section" ([faq.rst](https://github.com/agronholm/apscheduler/blob/3.11.3/docs/faq.rst?plain=1#L93-L96)), which is the job store entry above. How many processes you get depends on the server.

### Gunicorn

Without `--preload`, each worker loads your WSGI app after it has been forked ([workers/base.py](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/workers/base.py#L147-L149)), so code at import time, or in an app factory, runs once per worker. `-w 4` means four schedulers. The worker count defaults to the `WEB_CONCURRENCY` environment variable when it is set ([config.py](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L717-L736)), so a platform that sets it can turn one scheduler into several without a change to your command.

Worker restarts start schedulers too. With `--max-requests`, a worker restarts after that many requests ([config.py](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L813-L830)), and its replacement loads the app again, starting a new scheduler as the old one exits with its worker.

### Uvicorn

`uvicorn app:app --workers 4` starts four worker processes from a `spawn` multiprocessing context ([\_subprocess.py](https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/_subprocess.py#L18)), and each one imports the app in its own server ([server.py](https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/server.py#L96-L99)). As with Gunicorn, the worker count defaults to `WEB_CONCURRENCY` when you don't pass `--workers` ([config.py](https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/config.py#L358-L359)). Moving `scheduler.start()` from module level into a FastAPI or Starlette lifespan handler doesn't help: the lifespan is created in that same per-worker server, right after the app loads.

With `--reload` or more than one worker, the Uvicorn parent process doesn't load the app at all; only the children do ([main.py](https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/main.py#L614-L620)). Under `uvicorn --reload`, you therefore have one scheduler at a time, replaced on each reload.

## What --preload Does to the Scheduler Thread

With `--preload`, Gunicorn loads the app in the master process before forking any workers ([arbiter.py](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/arbiter.py#L163-L164)), a setting its docs describe as a way to "save some RAM resources as well as speed up server boot times" ([config.py](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L1101-L1115)). A scheduler started at import now starts its thread in the master. Workers are created with `os.fork()` ([arbiter.py](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/arbiter.py#L734)), and POSIX specifies that the child of a multi-threaded process contains "a replica of the calling thread" only ([fork](https://pubs.opengroup.org/onlinepubs/9799919799/functions/fork.html)).

The result looks like a fix and isn't one:

- **Jobs run only in the master**, because only the master has a scheduler thread. The duplicates disappear by accident.
- **Each worker holds a copy of the scheduler with no thread behind it.** Its state was copied from the master, so it still reports itself as started. A job a request handler adds with `add_job()` goes into that worker's copy, where nothing will run it. With a persistent store, the row reaches the database, but the master's scheduler isn't notified, per the FAQ above.
- **Every fork is a fork of a multi-threaded process.** Since Python 3.12, `os.fork()` raises a `DeprecationWarning` when it detects other threads, and the docs say "it has never been safe to mix threading with `os.fork()` on POSIX platforms" ([os.fork](https://docs.python.org/3/library/os.html#os.fork)). Gunicorn forks again every time it replaces a worker.

Treat `--preload` as a memory setting. If the scheduler belongs in the master, it belongs in its own process instead.

## Development Reloaders Start a Second Scheduler

These cases run two schedulers on a laptop even when production runs one.

### Flask: flask run --debug and app.run(debug=True)

`flask run --debug` turns the reloader on, because `--reload` defaults to the debug flag ([cli.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/cli.py#L983-L984)). The command loads your app before it starts the server ([cli.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/cli.py#L963)). Werkzeug's reloader then starts a new interpreter with the same arguments and `WERKZEUG_RUN_MAIN=true`, waits for it, and starts another when a file change makes it exit with code 3 ([\_reloader.py](https://github.com/pallets/werkzeug/blob/3.1.9/src/werkzeug/_reloader.py#L266-L278)). That child loads the app again.

So a scheduler started at import or in `create_app()` runs in the parent for the whole session and in the child, with the child's copy replaced on every reload. `app.run(debug=True)` behaves the same way, because `use_reloader` defaults to the debug setting ([app.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/app.py#L653)).

Werkzeug exposes the check as `is_running_from_reloader()`, which tests that environment variable ([serving.py](https://github.com/pallets/werkzeug/blob/3.1.9/src/werkzeug/serving.py#L955-L961)). `--debug` sets `FLASK_DEBUG` early so that factories can read it ([cli.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/cli.py#L468-L481)), and the app's `DEBUG` config starts from that value ([sansio/app.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/sansio/app.py#L495)):

```python
from flask import Flask
from werkzeug.serving import is_running_from_reloader


def create_app():
    app = Flask(__name__)
    # ... register blueprints and jobs ...
    if not app.debug or is_running_from_reloader():
        scheduler.start()
    return app
```

Under `flask run --debug`, or with `FLASK_DEBUG=1` set, this skips the reloader parent and nothing else. It makes the scheduler wait for the reloader in debug mode, so `flask run --debug --no-reload` starts no scheduler at all. It does nothing about Gunicorn workers, where `app.debug` is false and every worker passes the check.

The guard doesn't work with `app.run(debug=True)`. `get_debug_flag()` reads only `FLASK_DEBUG` ([helpers.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/helpers.py#L28-L33)), and `app.run()` sets `app.debug` from its `debug` argument only when it is called ([app.py](https://github.com/pallets/flask/blob/3.1.3/src/flask/app.py#L630-L632)), after `create_app()` has returned. The factory sees `app.debug` as false in both processes, and both start a scheduler. `flask run --reload` without `--debug` fails the same way. For `app.run(debug=True)`, set `FLASK_DEBUG=1` in the environment before the script starts, or pass `use_reloader=False`.

### Django: runserver and AppConfig.ready()

Unless you pass `--noreload`, `manage.py runserver` runs `django.setup()` in the parent process first ([management/\_\_init\_\_.py](https://github.com/django/django/blob/6.1.2/django/core/management/__init__.py#L393-L395)), which calls every `AppConfig.ready()`. The autoreloader then runs the command again in a child with `RUN_MAIN=true` ([autoreload.py](https://github.com/django/django/blob/6.1.2/django/utils/autoreload.py#L273-L274), [autoreload.py](https://github.com/django/django/blob/6.1.2/django/utils/autoreload.py#L681-L692)), and `ready()` runs again there. A scheduler started in `ready()` runs twice.

It also starts in places you didn't intend. Django's docs warn that `ready()` "will run during startup of every management command" ([applications](https://docs.djangoproject.com/en/6.1/ref/applications/#django.apps.AppConfig.ready)), so `migrate`, `shell`, and `collectstatic` each start a scheduler too, one that lasts as long as the command does.

Checking `os.environ.get("RUN_MAIN") == "true"` skips the parent, but `RUN_MAIN` is an internal name in `django.utils.autoreload` ([autoreload.py](https://github.com/django/django/blob/6.1.2/django/utils/autoreload.py#L27)), not a documented setting, and it isn't set under Gunicorn at all. The durable fix is to take the scheduler out of `ready()` and start it from a management command run as its own process, the [dedicated scheduler process](#a-dedicated-scheduler-process) below. [Django cron monitoring](https://cronradar.com/blog/django-cron-monitoring#can-i-use-django-apscheduler-with-gunicorn) gives the same advice for django-apscheduler.

## The Fixes, and What Each Costs

Every fix trades duplicate runs for a different failure: zero schedulers. Decide which you would rather detect.

### A Dedicated Scheduler Process

This is the FAQ's workaround: "Run the scheduler in a dedicated process" ([faq.rst](https://github.com/agronholm/apscheduler/blob/3.11.3/docs/faq.rst?plain=1#L85-L87)). Use `BlockingScheduler`, which the user guide recommends "when the scheduler is the only thing running in your process" ([userguide.rst](https://github.com/agronholm/apscheduler/blob/3.11.3/docs/userguide.rst?plain=1#L66-L67)), in an entry point such as `python -m myapp.scheduler`, deployed beside the web service. Remove every `start()` call from the web app. [Python scheduler monitoring](https://cronradar.com/blog/python-scheduler-monitoring#monitor-a-scheduled-python-job-with-cronradar) has a complete worker of this kind.

What it costs:

- **One more process to deploy, supervise, and restart.** If it's down, nothing runs and nothing errors.
- **No more `add_job()` from request handlers.** The web app can't reach the scheduler's in-memory jobs. The FAQ suggests a remote access mechanism such as RPyC, gRPC, or an HTTP server; many teams define jobs in code instead.
- **It must stay at one copy.** Scaling the scheduler service to two replicas brings the duplicates back. [Distributed cron](https://cronradar.com/blog/distributed-cron#option-1-one-scheduler-process-restarted-by-the-orchestrator) covers why a one-replica Deployment can briefly be two during a rolling update.

### One Web Worker Wins a Lock

Keep the scheduler inside the web app, and let only the worker that gets an exclusive lock start it. On a single host, `fcntl.flock` with `LOCK_NB` fails immediately with an `OSError` instead of waiting ([fcntl](https://docs.python.org/3/library/fcntl.html#fcntl.flock)):

```python
import fcntl
import logging
import os

log = logging.getLogger("scheduler")
_lock_file = None  # Keep the file open: closing it releases the lock.


def start_scheduler_if_first(scheduler, lock_path):
    global _lock_file
    lock_file = open(lock_path, "w")
    try:
        fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        lock_file.close()
        log.info("pid=%s: another process runs the scheduler", os.getpid())
        return False
    _lock_file = lock_file
    scheduler.start()
    log.info("pid=%s: running the scheduler", os.getpid())
    return True
```

Call it where you used to call `scheduler.start()`. The kernel releases the lock when every descriptor for that open file is closed, including when the process exits ([flock(2)](https://man7.org/linux/man-pages/man2/flock.2.html)), so a crashed holder can't leave a stale lock.

What it costs:

- **One scheduler per host, not per deployment.** Each machine or container has its own lock file. Two app servers mean two schedulers.
- **The scheduler lives and dies with one web worker.** When Gunicorn restarts the holder, for `--max-requests` or a timeout, the scheduler goes with it. It comes back only when a newly booted worker takes the lock. Workers that lost the race at startup don't try again.
- **Take the lock after the fork.** Under `--preload`, this code runs once, in the master, and flock(2) notes that descriptors duplicated by `fork(2)` "refer to the same lock," so every worker shares the master's.

Across hosts, PostgreSQL's `pg_try_advisory_lock(key bigint)` does the same thing: it "will either obtain the lock immediately and return true, or return false without waiting" ([advisory lock functions](https://www.postgresql.org/docs/18/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS)). A session-level advisory lock "is held until explicitly released or the session ends" ([advisory locks](https://www.postgresql.org/docs/18/explicit-locking.html#ADVISORY-LOCKS)), so the winning worker must keep that connection open for its whole life, outside any pool that might close or reuse it. If the connection drops, the lock is released while the scheduler thread keeps running, and the next worker to boot can win it too.

### Run One Web Worker

A single web worker also gives you one scheduler per host: `-w 1` for Gunicorn, `--workers 1` for Uvicorn. Pass the count explicitly, because without the flag both servers use `WEB_CONCURRENCY` when it is set ([Gunicorn](https://github.com/benoitc/gunicorn/blob/26.2.0/gunicorn/config.py#L717-L736), [Uvicorn](https://github.com/encode/uvicorn/blob/0.54.0/uvicorn/config.py#L358-L359)). The price is serving all traffic from one process, and the scheduler still restarts whenever that worker restarts.

| Fix                                   | Removes                                    | Costs                                                             |
| ------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| Dedicated `BlockingScheduler` process | All duplicates, on any number of web hosts | One more service; no runtime `add_job()` from the web app         |
| `flock` lock in each worker           | Duplicates on one host                     | One scheduler per host; restarts with its worker                  |
| PostgreSQL advisory lock              | Duplicates across hosts                    | A connection held open for life; a dropped one can allow a second |
| Reloader guard                        | The dev reloader's extra scheduler         | Nothing in production                                             |
| One web worker                        | Duplicates on one host                     | All requests on one process                                       |

## When One Scheduler Runs the Job Twice

`max_instances`, `coalesce`, and `misfire_grace_time` act inside one scheduler. None of them can see another process.

**`max_instances`** (default 1) is counted in a dictionary held by each executor ([executors/base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/executors/base.py#L34), [submit check](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/executors/base.py#L71-L72)). It stops a slow run from overlapping the next one in the same scheduler. Four workers with `max_instances=1` can still run four copies at once.

**Catch-up after a restart** is the same-PID, back-to-back pattern. It needs a job that stayed in a persistent store while the scheduler was down and wasn't added again at startup: adding it again with `replace_existing=True` overwrites the stored row with a next run time computed from now ([base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L1067-L1081)). Even then, the 3.x default `coalesce=True` ([base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L911-L913)) submits only the latest missed time ([base.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/schedulers/base.py#L1192-L1194)), so a burst of runs means `coalesce=False` together with a long `misfire_grace_time` or `misfire_grace_time=None`. [Missed runs after downtime](https://cronradar.com/blog/missed-runs-after-downtime#apschedulers-defaults-drop-the-missed-run) covers the defaults and whether catching up is what you want.

**Copies of the job** are the pattern that grows with each deploy. A job added without an `id` gets a random one ([job.py](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/job.py#L65)). In a persistent store, adding it at startup therefore adds a new row each time, which the user guide flags in bold: you "**MUST** define an explicit ID for the job and use `replace_existing=True` or you will get a new copy of the job every time your application restarts!" ([userguide.rst](https://github.com/agronholm/apscheduler/blob/3.11.3/docs/userguide.rst?plain=1#L279-L281)). Add both, then remove the old copies, which `scheduler.get_jobs()` lists under their random IDs.

Even with one scheduler, a run can repeat after a crash or a lock that changes hands. [Idempotent scheduled jobs](https://cronradar.com/blog/idempotent-scheduled-jobs) shows how to make a second run of the same occurrence do nothing.

## Report Each Run to CronRadar

Python SDK connections are temporarily unavailable ([Python integration](https://docs.cronradar.com/integrations/python)), so each job reports with HTTP check-ins. HTTP check-ins don't discover jobs automatically; give each job its own monitor key and its real schedule. Keep the API key in a `CRONRADAR_API_KEY` environment variable from your deployment's secret store, with a separate CronRadar application and key for each environment.

[Python scheduler monitoring](https://cronradar.com/blog/python-scheduler-monitoring#monitor-a-scheduled-python-job-with-cronradar) has the standard-library check-in helper, which uses HTTP Basic auth with the key as username and an empty password, a five-second timeout, and logs monitoring errors instead of raising them. It also has a wrapper that reports a start, then a completion or a failure, and re-raises the job's exception. For duplicate runs, change only how that wrapper builds its `run_id`: include the host and PID, so each run in the monitor's history shows which process ran it.

```python
import os
import socket
import uuid


def new_run_id():
    # At most 128 characters; identifies the process, never the job's data.
    return f"{socket.gethostname()[:60]}-{os.getpid()}-{uuid.uuid4().hex[:12]}"
```

In that post's `monitored_backup()`, replace `run_id = uuid.uuid4().hex` with `run_id = new_run_id()`. The wrapper already sends the same `run_id` with the start and with the completion or failure, and the API accepts up to 128 characters for it ([API reference](https://docs.cronradar.com/api-reference)).

If one expression feeds both `CronTrigger.from_crontab()` and the monitor's `schedule`, which CronRadar reads as five-field cron in UTC ([schedule syntax](https://docs.cronradar.com/schedules)), avoid numeric weekdays: `from_crontab()` in APScheduler 3.x treats 0 as Monday, while standard cron treats it as Sunday ([cron trigger](https://github.com/agronholm/apscheduler/blob/3.11.3/src/apscheduler/triggers/cron/__init__.py#L129-L134)).

What this shows, and what it doesn't:

- **Duplicates are visible, not alerted.** CronRadar records every run it receives and doesn't alert on extra runs. Each `run_id` appears as its own run in the monitor's history, with its Execution ID, so four runs at the same scheduled time from four PIDs is the per-worker pattern above.
- **Zero schedulers is alerted.** This is the failure every fix above introduces: a dedicated process that didn't restart, or a lock holder that was recycled. No `start` arrives, the monitor shows `warning` once the scheduled time passes, and `critical` after the grace period, with an alert by email, Slack, webhook, Discord, or PagerDuty.
- **A failed run alerts immediately.** `fail` records the result `failed` and opens an incident.

Don't send an `Idempotency-Key` derived from the scheduled time to collapse the duplicates. A repeated key returns the original ping without reapplying state ([API reference](https://docs.cronradar.com/api-reference)), so the dashboard would show one run while the job still ran twice.

## Frequently Asked Questions

### Why does my APScheduler job run once per Gunicorn worker?

Without `--preload`, each Gunicorn worker loads your app after it is forked, so a scheduler started at import or in an app factory starts once per worker: `-w 4` means four schedulers. Without `-w`, the count comes from `WEB_CONCURRENCY` when it is set. Log `os.getpid()` when the scheduler starts to count them.

### How do I run APScheduler with Gunicorn and multiple workers?

Run the scheduler outside Gunicorn, as one `BlockingScheduler` process, which is what the APScheduler FAQ recommends. If it must live in the web app, start it only in the worker that wins an exclusive lock, and accept that it restarts whenever that worker does.

### Does gunicorn --preload fix duplicate APScheduler jobs?

It hides them. The scheduler thread starts in the master, and forked workers don't get a copy of it, so jobs run only in the master. Each later fork is a fork of a multi-threaded process, which Python's documentation calls unsafe, and a job added from a worker goes into a scheduler copy with no thread to run it.

### Why does APScheduler run twice with flask run --debug?

The debug reloader loads your app in a parent process, then again in a child process started with `WERKZEUG_RUN_MAIN=true`. With `flask run --debug`, start the scheduler only when `not app.debug or is_running_from_reloader()`, or run a dedicated scheduler process. That check fails under `app.run(debug=True)`, where `app.debug` is still false when the factory runs; there, set `FLASK_DEBUG=1` before the script starts or pass `use_reloader=False`.

### Does max_instances=1 stop duplicate runs?

Only inside one scheduler. Each process counts its own running instances, so it stops overlap of a slow run but not a second process starting the same job.
