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:
| Class | Use when |
|---|---|
BackgroundScheduler | A normal sync app — runs in a background thread. The default choice. |
BlockingScheduler | The scheduler is the program. A dedicated worker container or python worker.py. |
AsyncIOScheduler | Inside an asyncio app — FastAPI, aiohttp, discord.py. |
GeventScheduler / TornadoScheduler / QtScheduler | Matching 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)
| Parameter | Default | Notes |
|---|---|---|
trigger | None | None means run once, now. |
args / kwargs | None | Positional and keyword arguments passed to func. |
id | None | A random UUID is generated. Set it. |
name | None | Falls back to the function’s name. |
misfire_grace_time | undefined → 1 second | None means “run it no matter how late”. |
coalesce | undefined → True | Collapse several missed firings into one. |
max_instances | undefined → 1 | Concurrent runs of this job. |
next_run_time | undefined | Computed 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_existing | False | With 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
| Option | Default | Applies to |
|---|---|---|
timezone | the machine’s local timezone | all schedulers |
jobstores | one MemoryJobStore named default | all schedulers |
executors | one ThreadPoolExecutor(max_workers=10) named default | all schedulers |
job_defaults | misfire_grace_time=1, coalesce=True, max_instances=1 | all schedulers |
jobstore_retry_interval | 10 seconds | all schedulers |
daemon | True | BackgroundScheduler 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 ZoneInfo — BlockingScheduler(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.
| Field | Range | Notes |
|---|---|---|
year | 4-digit | Rarely used outside one-shot schedules |
month | 1–12 | Also jan–dec |
day | 1–31 | Day of month. Supports last, last fri |
week | 1–53 | ISO week number — no crontab equivalent |
day_of_week | 0–6 or mon–sun | 0 is Monday. See below |
hour | 0–23 | |
minute | 0–59 | |
second | 0–59 | No 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?
How many fields does CronTrigger.from_crontab() accept?
Can APScheduler run a job every 5 seconds?
Is APScheduler 4.0 ready to use?
Why did my APScheduler job not run after the process was busy or asleep?
Does BackgroundScheduler run as a daemon thread by default?
What are the default values for add_job() in APScheduler?
What executor and job store does APScheduler use if I do not configure any?
What's the difference between CronTrigger and IntervalTrigger?
Related
Every 5 Minutes
`CronTrigger(minute="*/5")` — the most common APScheduler schedule.
PatternWeekdays at 9 AM
Where the 0=Monday numbering actually changes your result.
ToolCron Parser
Check what a 5-field expression fires before handing it to from_crontab().
GuidePython Cron Scheduling
The wider landscape — schedule, Celery Beat, croniter, python-crontab.
GuideSub-Minute Scheduling
Which runtimes can go below one minute, and their real floors.
GuideHow Cron Expressions Work
The five standard fields, and how day-of-month and day-of-week combine.