cronuru
Guide

APScheduler

APScheduler is the default answer for cron-style scheduling inside a Python process. Its `CronTrigger` is more capable than crontab — eight fields including seconds and week-of-year, `xth y` weekday expressions, and real timezone handling — but it is *not* crontab-compatible, and the difference bites hardest in one specific place: APScheduler numbers weekdays with **0 = Monday**, while Unix cron uses **0 = Sunday**. This guide covers the trigger fields, `from_crontab()`, misfire behaviour, and the current state of the long-alpha 4.0 rewrite.

Updated

Install and version reality

pip install "apscheduler<4"

The version pin is deliberate. As of July 2026:

  • 3.11.3 is the current stable release (published 2026-06-28). This is what you should build on.
  • 4.0 is still a pre-release. The project’s own documentation warns that the v4.0 series may change in backwards-incompatible ways without any migration pathway and should not be used in production. There’s also currently no automatic import of schedules from a persistent 3.x job store, though the maintainer intends to fix that before the final release.

APScheduler 4 has been in this state for a long time, and a lot of blog posts written against early 4.0 alphas show APIs that no longer exist. If a tutorial imports from apscheduler import Scheduler or uses async with AsyncScheduler(), it’s 4.x material — the 3.x API is from apscheduler.schedulers.background import BackgroundScheduler.

One recent 3.x change worth knowing: 3.11.0 added ZoneInfo timezone support and deprecated pytz. If you’re passing pytz.timezone("Europe/Berlin"), switch to zoneinfo.ZoneInfo("Europe/Berlin") from the standard library.

Picking a scheduler class

APScheduler splits “how jobs are triggered” from “how the scheduler runs.” The trigger is nearly always CronTrigger; the scheduler class depends on your process shape:

ClassUse when
BackgroundSchedulerA normal sync app — runs in a background thread. The default choice.
BlockingSchedulerThe scheduler is the program. A dedicated worker container or python worker.py.
AsyncIOSchedulerInside an asyncio app — FastAPI, aiohttp, discord.py.
GeventScheduler / TornadoScheduler / QtSchedulerMatching that specific event loop.
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger

sched = BackgroundScheduler(timezone="UTC")

sched.add_job(send_digest, CronTrigger(hour=9, minute=0), id="digest")
sched.start()

The id= matters more than it looks. Without it APScheduler generates a random UUID, so on every restart with a persistent job store you accumulate duplicate jobs. Give every job a stable id and pass replace_existing=True when adding.

A caveat that catches web apps: BackgroundScheduler lives in one process. Run four Gunicorn workers and you get four schedulers, each firing every job — four digest emails. Either run the scheduler in a dedicated single process, or use a persistent job store with locking so only one instance claims each run.

add_job() and every default

Most APScheduler surprises are really default-value surprises, and the defaults are spread across three different places: the add_job() signature, the scheduler’s job_defaults, and the scheduler constructor. Here is all of it in one place, as of 3.11.3.

The signature:

add_job(func, trigger=None, args=None, kwargs=None, id=None, name=None,
        misfire_grace_time=undefined, coalesce=undefined,
        max_instances=undefined, next_run_time=undefined,
        jobstore='default', executor='default',
        replace_existing=False, **trigger_args)
ParameterDefaultNotes
triggerNoneNone means run once, now.
args / kwargsNonePositional and keyword arguments passed to func.
idNoneA random UUID is generated. Set it.
nameNoneFalls back to the function’s name.
misfire_grace_timeundefined1 secondNone means “run it no matter how late”.
coalesceundefinedTrueCollapse several missed firings into one.
max_instancesundefined1Concurrent runs of this job.
next_run_timeundefinedComputed from the trigger. Pass a datetime to force a first run.
jobstore'default'The MemoryJobStore unless you configured one.
executor'default'The ThreadPoolExecutor, max_workers=10.
replace_existingFalseWith a persistent store, re-adding the same id raises ConflictingIdError.

undefined is not None. It’s a sentinel object, and the distinction is load-bearing. undefined means “leave this unset and fill it from the scheduler’s job_defaults at schedule time”; None is an explicit value that gets used as-is. For misfire_grace_time the two are opposites — undefined resolves to a one-second grace window, while None means run it however late it is. If you want a job that always catches up, pass None deliberately:

sched.add_job(reconcile, CronTrigger(hour=3), id="reconcile",
              replace_existing=True, misfire_grace_time=None)

The three runtime-behaviour defaults — misfire_grace_time, coalesce, max_instances — are the ones worth tuning, and they get their own treatment in misfires, coalescing, overlap.

Scheduler constructor defaults

OptionDefaultApplies to
timezonethe machine’s local timezoneall schedulers
jobstoresone MemoryJobStore named defaultall schedulers
executorsone ThreadPoolExecutor(max_workers=10) named defaultall schedulers
job_defaultsmisfire_grace_time=1, coalesce=True, max_instances=1all schedulers
jobstore_retry_interval10 secondsall schedulers
daemonTrueBackgroundScheduler only

Two of these cause most of the confusion.

daemon=True means the scheduler will not keep your process alive. BackgroundScheduler starts a thread with daemon=True, and a daemon thread doesn’t block interpreter shutdown. So this script schedules a job, starts the scheduler, and then exits before anything ever fires:

sched = BackgroundScheduler()
sched.add_job(job, CronTrigger(minute="*/5"))
sched.start()
# end of file -> interpreter exits -> daemon thread dies. Nothing runs.

That’s the right behaviour when the scheduler is a component of a longer-lived app — a Django or Flask process that stays up on its own. When the scheduler is the program, use BlockingScheduler instead; its start() blocks by design and there’s no daemon flag to get wrong.

The 10-thread executor is a real ceiling. Every job runs on that shared pool. Eleven jobs due at the same second means the eleventh waits for a free thread — and because it’s waiting, its misfire_grace_time is ticking. A pool that’s too small doesn’t queue work politely; it converts it into missed runs. If you schedule many slow jobs, size the pool to match:

from apscheduler.executors.pool import ThreadPoolExecutor

sched = BackgroundScheduler(
    executors={"default": ThreadPoolExecutor(max_workers=30)},
    timezone="UTC",
)

Timezone accepts a string as well as a ZoneInfoBlockingScheduler(timezone="Europe/Berlin") is valid and is resolved through the same path as a tzinfo object. Leave it unset and you inherit the machine’s local zone, which is the single most common reason a schedule behaves differently in a container than it did on a laptop.

CronTrigger’s eight fields

Unlike crontab’s positional string, CronTrigger takes keyword arguments — which is a genuine improvement, because you can’t miscount them. The full set, in order of significance:

year, month, day, week, day_of_week, hour, minute, second

That’s three more than crontab: year, week (ISO week-of-year, 1–53), and second.

FieldRangeNotes
year4-digitRarely used outside one-shot schedules
month1–12Also jandec
day1–31Day of month. Supports last, last fri
week1–53ISO week number — no crontab equivalent
day_of_week0–6 or monsun0 is Monday. See below
hour0–23
minute0–59
second0–59No crontab equivalent

Expressions inside each field are crontab-like: *, */a, a-b, a-b/c, x,y,z, plus two APScheduler extras — xth y (nth weekday of the month, e.g. 2nd fri) and last x (last fri, or bare last for the last day of the month).

The defaulting rule is the part people get wrong. Unspecified fields do not all become *. Instead: fields more significant than the least-significant one you named default to *, and fields less significant default to their minimum — except week and day_of_week, which always default to *.

So CronTrigger(day=1, minute=20) expands to:

year='*', month='*', day=1, week='*', day_of_week='*', hour='*', minute=20, second=0

— the 20th minute of every hour on the 1st, not 00:20 on the 1st. If you meant once a day, say hour=0 explicitly. This rule is the source of a lot of “why is my monthly job running 24 times” reports.

The 0 = Monday trap

This is the one to internalise, because it fails silently and shifts your schedule by a day.

APScheduler numbers weekdays 0 = Monday … 6 = Sunday. Standard Unix cron numbers them 0 = Sunday … 6 = Saturday.

This isn’t a documentation ambiguity — it’s acknowledged in the source itself, which calls it a historical mistake, notes it was rectified in the 4.x series, and explains it cannot be changed in 3.x without breaking every existing deployment.

The practical damage:

# You meant Sunday (crontab habit). You got Monday.
CronTrigger(day_of_week=0, hour=3)

# You meant Mon–Fri. You got Tue–Sat.
CronTrigger(day_of_week="1-5", hour=9)

The second one is nastier than the first, because “weekdays at 9am” running Tuesday through Saturday looks right four days out of five.

The fix is simple: never use numbers. APScheduler accepts the three-letter names in every position, including ranges and lists:

CronTrigger(day_of_week="mon-fri", hour=9)   # unambiguous weekdays
CronTrigger(day_of_week="mon-sat", hour=9)   # Monday through Saturday
CronTrigger(day_of_week="sun", hour=3)       # actually Sunday
CronTrigger(day_of_week="mon,wed,fri")       # lists work too

Names mean the same thing in both conventions, so they’re portable between your crontab and your Python. Make it a house rule.

from_crontab()

If you’re migrating an existing crontab, CronTrigger.from_crontab() parses the string directly:

from apscheduler.triggers.cron import CronTrigger

trigger = CronTrigger.from_crontab("*/15 9-17 * * mon-fri")
sched.add_job(poll, trigger)

Two hard rules:

It takes exactly five fields. minute hour day-of-month month day-of-week, standard crontab order. Anything else raises ValueError: Wrong number of fields; got N, expected 5. There’s no seconds field here — a 6-field Quartz or Spring expression will be rejected outright, which is at least a loud failure.

It does not translate the weekday numbering. This is the trap from the previous section, and from_crontab() makes it worse by looking like a compatibility shim. Feeding it a genuine crontab line with numeric weekdays silently shifts the schedule:

# In your real crontab, "0" is Sunday.
# APScheduler reads it as Monday. No error, wrong day.
CronTrigger.from_crontab("0 3 * * 0")

# Rewrite the weekday as a name first:
CronTrigger.from_crontab("0 3 * * sun")   # correct

So the safe migration procedure is: before calling from_crontab(), replace any numeric day-of-week value with its three-letter name. Run the original line through the cron parser if you’re unsure which day a number meant. Numbers in every other field are fine — the mismatch is weekday-only.

Also worth knowing: from_crontab() accepts a timezone= argument. Without it the trigger inherits the scheduler’s timezone, which may not be the server-local timezone your crontab assumed.

Sub-minute schedules

APScheduler is one of the schedulers that genuinely goes below a minute, because second is a first-class field:

CronTrigger(second="*/5")     # every 5 seconds, aligned to :00 :05 :10…
CronTrigger(second="*/30")    # every 30 seconds
CronTrigger(second="*")       # every second

But reach for IntervalTrigger instead unless you specifically need clock alignment:

from apscheduler.triggers.interval import IntervalTrigger

IntervalTrigger(seconds=5)     # every 5 seconds from start
IntervalTrigger(minutes=90)    # every 90 minutes — impossible in cron

IntervalTrigger also expresses schedules cron simply cannot, like “every 90 minutes,” which has no cron representation at all because the interval doesn’t divide evenly into an hour. See sub-minute scheduling for how APScheduler compares to other runtimes here — notably, Kubernetes CronJob and GitHub Actions can’t do this at all.

Misfires, coalescing, overlap

Three settings govern what happens when reality doesn’t cooperate. They’re the difference between a scheduler that quietly drops work and one you can trust.

misfire_grace_time (default 1 second) — how late a run may start and still be allowed. If your process was blocked, GC-paused, or restarting when the job was due, and it can’t start within the grace window, APScheduler skips the run and logs it. A one-second default means a brief hiccup silently loses a job:

sched.add_job(nightly_report, CronTrigger(hour=2),
              misfire_grace_time=3600)   # up to an hour late is fine

coalesce (default True) — if several firings were missed, run the job once rather than N times in a burst. Almost always what you want; the exception is jobs where each firing represents a distinct unit of work.

max_instances (default 1) — how many copies may run concurrently. The default already protects you from overlap: if a run is still going when the next is due, the new one is rejected with a “maximum number of running instances reached” warning. Raise it only if the job is genuinely reentrant.

A reasonable set of defaults for a persistent worker:

sched = BackgroundScheduler(
    timezone="UTC",
    job_defaults={
        "coalesce": True,
        "max_instances": 1,
        "misfire_grace_time": 300,
    },
)

And add a listener, because silent skips are the failure mode that will cost you:

from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MISSED

def on_problem(event):
    log.error("job %s: %s", event.job_id,
              "missed" if event.code == EVENT_JOB_MISSED else event.exception)

sched.add_listener(on_problem, EVENT_JOB_ERROR | EVENT_JOB_MISSED)

Timezones and DST

Set the scheduler’s timezone explicitly. If you don’t, APScheduler uses the machine’s local timezone — which means the same code fires at different absolute times on your laptop and in a container.

from zoneinfo import ZoneInfo

sched = BackgroundScheduler(timezone=ZoneInfo("America/New_York"))
sched.add_job(open_market, CronTrigger(hour=9, minute=30,
                                       day_of_week="mon-fri"))

Use zoneinfo.ZoneInfo, not pytz — pytz support was deprecated in 3.11.0.

The DST behaviour follows the usual cron rules, and it’s worth knowing which way each break goes:

  • Spring forward — a job scheduled for 02:30 in a zone that jumps 02:00 → 03:00 has no 02:30 that day. It doesn’t run.
  • Fall back — 01:30 happens twice. The job runs once, on the first occurrence.

If a job must run exactly once per day regardless, schedule it in UTC and convert inside the job, or pick an hour that no zone shifts across (04:00–05:00 local is a common safe window).

For jobs that need to be aligned rather than merely daily — market opens, business-hours polling — keep the local timezone and accept the DST edge cases, since local alignment is the entire point. See business hours for that pattern.

Frequently asked questions

Does APScheduler's day_of_week use the same numbering as crontab?
No — and this is the single most common APScheduler bug. APScheduler's `CronTrigger` treats **0 as Monday**; standard Unix cron treats **0 as Sunday**. The maintainer calls it a historical mistake in the source docstring. It is fixed in the unreleased 4.x series but cannot be changed in 3.x for backwards-compatibility reasons. Use the string names — `mon`, `tue`, `sat` — instead of numbers, and the ambiguity disappears entirely.
How many fields does CronTrigger.from_crontab() accept?
Exactly five — minute, hour, day of month, month, day of week — in standard crontab order. Anything else raises `ValueError: Wrong number of fields`. Note that it does *not* accept a seconds field, and critically it does **not** translate the weekday numbering: a `0` in your crontab string means Sunday to cron but arrives as Monday in APScheduler 3.x.
Can APScheduler run a job every 5 seconds?
Yes. `CronTrigger(second="*/5")` works because `second` is a real field on the trigger. But `IntervalTrigger(seconds=5)` is usually the better choice — it expresses the intent directly, avoids the field-defaulting rules, and reads more clearly. Use `CronTrigger` only when you need firings aligned to specific wall-clock seconds.
Is APScheduler 4.0 ready to use?
No. As of July 2026 the stable line is **3.11.3** (released 2026-06-28) and 4.0 is still a pre-release — the project's own docs state the 4.0 series may change in a backwards-incompatible way without a migration path and should not be used in production. There is also currently no automatic import of schedules from a persistent 3.x job store. Build on 3.11 today.
Why did my APScheduler job not run after the process was busy or asleep?
It misfired. Each job has a `misfire_grace_time` (default 1 second) — if the scheduler can't run the job within that window of its scheduled time, it's skipped and logged rather than run late. Raise it (`misfire_grace_time=3600`) to allow late runs, and set `coalesce=True` so several missed firings collapse into one catch-up run instead of a burst.
Does BackgroundScheduler run as a daemon thread by default?
Yes — `daemon` defaults to `True`, so the scheduler thread will **not** keep your process alive. This is the classic "my jobs never ran" bug: a script that calls `sched.start()` and then reaches the end of the file exits immediately, taking the scheduler with it. Either keep the main thread busy, or use `BlockingScheduler`, which *is* the program. Pass `BackgroundScheduler(daemon=False)` only if you want the scheduler to block interpreter shutdown.
What are the default values for add_job() in APScheduler?
In 3.11.x: `jobstore="default"`, `executor="default"`, `replace_existing=False`, and `misfire_grace_time`, `coalesce`, `max_instances` and `next_run_time` all default to the sentinel `undefined` — which is *not* `None`. `undefined` means "fill this in from the scheduler's `job_defaults` when the job is scheduled," and those resolve to `misfire_grace_time=1` second, `coalesce=True` and `max_instances=1`. Passing `None` explicitly is a different instruction: for `misfire_grace_time` it means run the job no matter how late it is.
What executor and job store does APScheduler use if I do not configure any?
A `MemoryJobStore` named `default`, and a `ThreadPoolExecutor` named `default` with `max_workers=10`. The memory job store means every schedule is lost on restart — add a persistent store (`SQLAlchemyJobStore`, `RedisJobStore`, `MongoDBJobStore`) if schedules must survive a deploy. The 10-thread pool is a real concurrency ceiling: eleven long-running jobs due at once means the eleventh waits for a free thread, and it may misfire while it waits.
What's the difference between CronTrigger and IntervalTrigger?
`CronTrigger` fires when the wall clock matches a pattern — "every day at 09:00", "every 15th minute". `IntervalTrigger` fires a fixed duration after the previous firing — "every 90 seconds from whenever we started". Use CronTrigger when alignment to the clock matters (reports, business hours) and IntervalTrigger for polling, where only the gap matters.