The Python schedule Library in Production: Exceptions, Threads, and Restarts
Contents
The schedule library runs a job only when your own loop calls schedule.run_pending(), one job at a time, in that loop's thread, and it keeps its jobs and their last run times only in memory. An exception from any job escapes run_pending() and ends the loop unless you catch it inside the job. A long job delays every job behind it, and a restart forgets everything, so a run that was due while the process was down never happens. In production, catch exceptions in a wrapper around each job, move long jobs into threads, run the process under a supervisor, and have each job check in with a monitor outside the process.
This guide covers schedule 1.2.2, the current release on PyPI (checked October 30, 2026). Source links point to the 1.2.2 tag. For APScheduler, see Python scheduler monitoring; for a script that system cron starts, see Python script cron jobs.
How run_pending Decides What Runs#
Each job stores a next_run time. run_pending() collects the jobs whose next_run has arrived, sorts them by next_run, and calls each one in turn, waiting for it to return (run_pending, should_run). The library has no thread or timer of its own, and "out of the box it is not possible to run the schedule in the background" (background execution), so nothing runs between calls. The docs' loop looks like this:
import time
import schedule
from jobs.tasks import rotate_logs
schedule.every().hour.at(":15").do(rotate_logs)
while True:
schedule.run_pending()
time.sleep(1)Three details of that loop decide when your jobs actually run:
- Missed runs collapse into one. The
run_pendingdocstring calls it "intended behavior that run_pending() does not run missed jobs": a job due every minute, checked once an hour, runs once, not 60 times. - Interval jobs count from the end of the last run.
Job.run()calls your function, then computes the next run from the current time (Job.run,_schedule_next_run).every(10).minutesfirst runs ten minutes after the job is registered, and a job that takes three minutes then runs about every 13 minutes. The docs say so in bold: "Schedule does not account for the time it takes for the job function to execute" (index). .at()jobs are anchored to the clock.every().day.at("02:00")runs at 02:00 each day however long the last run took.
.at() takes a format that depends on the unit, and if the format or unit doesn't fit, it raises ScheduleValueError as soon as it's called, before .do() registers the job (at, do):
| Job | .at() format |
Example |
|---|---|---|
every().day, or a weekday such as .monday |
HH:MM or HH:MM:SS |
every().monday.at("09:30") |
every().hour |
:MM or MM:SS |
every().hour.at(":15") |
every().minute |
:SS |
every().minute.at(":17") |
every().second, every().week |
Not accepted | Raises ScheduleValueError at setup |
One Exception Stops the Loop#
"Schedule doesn't catch exceptions that happen during job execution. Therefore any exceptions thrown during job execution will bubble up and interrupt schedule's run_xyz function" (exception handling). In the loop above, the exception leaves while True, the process exits with a traceback, and every other job stops with it.
Wrapping schedule.run_pending() itself in try/except is worse. The exception leaves Job.run() before it records the run and computes the next one (lines 691-693), so the failed job stays due. On the next pass, a second later, it sorts first because its next_run is the oldest, runs again, and raises again, and the jobs sorted after it never get their turn. While it keeps failing, an hourly job becomes a once-a-second job that blocks every other job.
Catch the exception inside the job instead, as the docs recommend. This is their catch_exceptions decorator with logging instead of print and except Exception instead of a bare except, so Ctrl-C and sys.exit() still stop the process (KeyboardInterrupt and SystemExit inherit from BaseException, not Exception):
# jobs/runner.py
import functools
import logging
import schedule
log = logging.getLogger("jobs")
def catch_exceptions(cancel_on_failure=False):
def decorator(job_func):
@functools.wraps(job_func)
def wrapper(*args, **kwargs):
try:
return job_func(*args, **kwargs)
except Exception:
log.exception("Scheduled job %s failed", job_func.__name__)
if cancel_on_failure:
return schedule.CancelJob
return wrapper
return decoratorThe wrapped job returns normally after a failure, so Job.run() schedules its next normal run and the loop moves on. With cancel_on_failure=True, the scheduler removes the job when it returns CancelJob (_run_job), so one failure ends that job until the process restarts.
Jobs Run One at a Time#
"By default, schedule executes all jobs serially" (parallel execution). While one job runs, run_pending() doesn't return and nothing else is checked. If a report due at 01:30 takes 40 minutes, a backup set for .at("02:00") starts no earlier than 02:10, and an every(5).minutes job that came due in between runs once, not eight times.
Run Long Jobs in Threads#
The docs' workaround starts each run in its own thread:
import threading
import schedule
from jobs.tasks import sync_inventory
def run_threaded(job_func):
job_thread = threading.Thread(target=job_func)
job_thread.start()
schedule.every(10).minutes.do(run_threaded, sync_inventory)That changes four things, all of which follow from the scheduler only seeing run_threaded return:
- The loop stays on time.
run_pending()returns as soon as the thread starts, so other jobs run when they're due, and an interval job's next run counts from when its thread started. - An exception ends only its thread. Python passes it to
threading.excepthook, which prints the traceback to stderr by default, and the loop keeps going. Keepcatch_exceptionsfor a proper log line. CancelJobstops working. The scheduler seesrun_threaded's return value,None, not your job's.- Runs can overlap. A new thread starts every ten minutes whether or not the last one finished.
When a job must not overlap itself, skip the new run while the previous one holds a lock. Lock.acquire(blocking=False) returns False immediately instead of waiting:
# jobs/runner.py, continued
import threading
def run_threaded_once(job_func):
"""Start each run in a new thread, skipping it while the previous run is active."""
lock = threading.Lock()
def start():
if not lock.acquire(blocking=False):
log.warning("%s is still running; skipping this run", job_func.__name__)
return
def target():
try:
job_func()
finally:
lock.release()
threading.Thread(target=target, name=job_func.__name__).start()
return startTo cap the number of threads instead, the docs' second example puts jobs on a queue.Queue that one or more worker threads drain (parallel execution). To run the loop itself in a background thread of an existing application, see background execution. Either way, each Python process has its own default_scheduler (line 837), and nothing coordinates them, so a web app that starts the loop in each of four worker processes runs every job four times. Run the scheduler in one dedicated process; cron job running twice covers the other ways a job gets doubled.
A Restart Forgets Every Job#
Jobs live in a plain list in memory (Scheduler.__init__), and .do() computes each job's first run from the moment it's called (do). The docs list "Job persistence (remember schedule between restarts)" first among the reasons not to use schedule (index). After a crash, deploy, or reboot:
- An
.at()job skips the run it missed. A process that's down at 02:00 comes back with tomorrow's 02:00 as the next run, and today's never happens. - An interval job starts its interval over.
every(6).hoursfirst runs six hours after the restart. A service deployed every four hours never runs it at all. - Nothing catches up. There's no option to replay missed runs;
run_all()runs every job immediately, whether or not it was due (run_all).
If a missed run must happen after an outage, the job has to remember its own last success somewhere durable and decide at startup. Missed runs after downtime compares how other schedulers handle this.
Time Zones and DST in .at()#
Without a time zone, .at() uses the host's local time: the next run is computed from the naive datetime.datetime.now() (line 717). The same code runs at 02:00 UTC in a container set to UTC and at 02:00 Berlin time on a laptop set to Berlin. The docs add that jobs without a time zone "do not take clock-changes into account", so "a job that is set to run every 4 hours might execute after 3 realtime hours when DST goes into effect" (time zones).
.at() accepts a time zone as its second argument, as a string or a pytz time zone object:
schedule.every().day.at("02:00", "UTC").do(backup)
schedule.every().day.at("06:00", "America/New_York").do(send_digest)- Zones need pytz, an optional extra. schedule has no required dependencies and lists pytz under its
timezoneextra (pyproject.toml), so a plainpip install scheduledoesn't install it; usepip install "schedule[timezone]"orpip install pytz..at()imports pytz only when you pass a zone (lines 498-508), and without it that.at()call raisesModuleNotFoundError: No module named 'pytz'(FAQ). - Only
.at()takes a zone.next_runandlast_runare stored as naive times in the process's local zone either way (time zones). - DST is handled when a zone is given. For
.at("02:30", "Europe/Berlin"), on the night the clock skips from 02:00 to 03:00 the job runs once at 03:30; on the night 02:30 happens twice, it runs only the first time (time zones). - Use 1.2.2 if you pass a zone. Zones arrived in 1.2.0; 1.2.1 fixed "schedule was off when using .at with timezone", and 1.2.2 fixed more cross-timezone bugs (HISTORY).
.at("02:00", "UTC") runs at the same instant on any host and matches a UTC cron schedule exactly. Cron timezone covers when a local zone is really what you want.
Keep the Process Alive With a Supervisor#
The loop is the scheduler. If the process exits, from an uncaught exception, an out-of-memory kill, or a reboot, nothing runs and nothing logs that it didn't. Run it as a service that restarts it. With systemd:
# /etc/systemd/system/jobs.service
[Unit]
Description=Scheduled jobs (schedule library)
Wants=network-online.target
After=network-online.target
[Service]
User=jobs
WorkingDirectory=/srv/jobs
EnvironmentFile=/etc/jobs/jobs.env
ExecStart=/srv/jobs/.venv/bin/python -u -m jobs.scheduler
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.targetRestart=always restarts the service "regardless of whether it exited cleanly or not"; the default is no (systemd.service). EnvironmentFile= reads variables such as CRONRADAR_API_KEY from a file (systemd.exec), which keeps the key out of the unit and the repository; make that file readable only by root. -u unbuffers stdout for any print() output from your jobs; Python script cron jobs explains the buffering. Cron @reboot covers network-online.target, the start limit, and OnFailure= for the same kind of service.
On stop or restart, systemd sends SIGTERM, then SIGKILL if the process is still there after a timeout (systemd.service). Python installs default handlers only for SIGPIPE and SIGINT (signal), so SIGTERM ends the process, cutting off whatever job is running. Python script cron jobs shows how to turn SIGTERM into a clean exit. A supervisor brings the loop back, but because of the restart behavior above, it doesn't bring back the run that was due while the process was down.
Report Each Run With an HTTP Check-In#
None of the failures above alerts anyone: a dead loop leaves at most a traceback in the journal, and a run skipped after a restart or a job blocked behind a long one leaves nothing at all. A check-in from each run turns that silence into an alert from outside the process.
There's no installable CronRadar SDK for Python today; the Python integration docs list Python SDK connections as temporarily unavailable. Each job reports over HTTP, and HTTP check-ins don't discover jobs automatically: nothing reads schedule.get_jobs(), so give each job its own monitor key and a real schedule. This standard-library helper sends start, then complete or fail, with Basic authentication, a five-second timeout, and one run_id for both events. It logs monitoring errors instead of raising them and re-raises the job's own exception:
# jobs/cronradar.py
import base64
import functools
import logging
import os
import urllib.parse
import urllib.request
import uuid
log = logging.getLogger(__name__)
def _check_in(monitor_key, event, params):
query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
key = urllib.parse.quote(monitor_key, safe="")
url = f"https://cron.life/api/v1/ping/{key}/{event}?{query}"
try:
token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
request = urllib.request.Request(url, data=b"", method="POST")
# Unlike add_header(), this header is not copied to a redirect.
request.add_unredirected_header("Authorization", f"Basic {token}")
with urllib.request.urlopen(request, timeout=5):
pass
except Exception as error:
# A monitoring problem must never fail the job or change its result.
log.warning("CronRadar %s for %s not recorded (%s)", event, monitor_key, type(error).__name__)
def reported(monitor_key, cron_schedule, grace_period=None):
"""Report start, then complete or fail, for each run. Re-raises the job's exception."""
def decorator(job_func):
@functools.wraps(job_func)
def wrapper(*args, **kwargs):
params = {"schedule": cron_schedule, "run_id": uuid.uuid4().hex}
if grace_period is not None:
params["gracePeriod"] = grace_period
_check_in(monitor_key, "start", params)
try:
result = job_func(*args, **kwargs)
except Exception as error:
# The exception type only: never arguments, payloads, or messages.
_check_in(monitor_key, "fail", {**params, "message": type(error).__name__})
raise
_check_in(monitor_key, "complete", params)
return result
return wrapper
return decoratorThe scheduler module combines the three pieces. reported sits innermost so it sees the exception; catch_exceptions logs it with a traceback through logging instead of leaving it to threading.excepthook's stderr print; run_threaded_once keeps a long backup from holding up the sync. The sync runs on the hour's ten-minute marks instead of every(10).minutes, so it matches */10 * * * * instead of drifting away from it:
# jobs/scheduler.py
import logging
import time
import schedule
from jobs.cronradar import reported
from jobs.runner import catch_exceptions, run_threaded_once
from jobs.tasks import backup_database, sync_inventory
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
def monitored(job_func, monitor_key, cron_schedule, grace_period):
"""Report each run, log its exception, and run it in its own thread."""
reported_job = reported(monitor_key, cron_schedule, grace_period)(job_func)
return run_threaded_once(catch_exceptions()(reported_job))
backup = monitored(backup_database, "nightly-backup", "0 2 * * *", grace_period=600)
schedule.every().day.at("02:00", "UTC").do(backup)
sync = monitored(sync_inventory, "inventory-sync", "*/10 * * * *", grace_period=300)
for minute in range(0, 60, 10):
schedule.every().hour.at(f":{minute:02d}").do(sync)
if __name__ == "__main__":
while True:
schedule.run_pending()
time.sleep(1)The six sync jobs share one sync callable, so they share one lock. The first check-in with a schedule creates the monitor, and sending it on every check-in recreates a deleted one on the next run (HTTP API). schedule and gracePeriod apply only when a check-in creates the monitor; change them later in its settings.
What CronRadar Sees for Each Failure#
| What happened | Check-ins | CronRadar |
|---|---|---|
| The job raised | start, then fail |
Result failed, monitor critical, incident opened immediately |
| Loop dead, process down, or the run lost to a restart | None | warning after the expected time; critical with result timeout once past the grace period and at least two minutes late |
| A run skipped because the previous one still holds the lock | None for that mark | warning after the skipped mark; healthy again if the earlier run completes within the grace period, otherwise critical, shown as stuck because that run's start is still open |
| The job hung | start only |
critical as stuck, but only once the next scheduled time plus the grace period passes with no check-in, since the lock skips the runs after it; for the nightly backup, that's the next night |
| The process was killed mid-run and the supervisor restarted it | start only for that run |
Not flagged if the next run reports normally: its start moves the expected time on, and there is no maximum-runtime setting |
| The job returned but did nothing | start, then complete |
success, unless you report outcome values and configure checks on them |
Alerts go to email, Slack, webhook, Discord, or PagerDuty. For the last row, scheduled job silent success shows how to send counts with complete, so an empty run is recorded as an outcome failure (outcomefailed) and opens an incident.
Translate Each Job to a UTC Cron Schedule#
CronRadar reads schedule as standard five-field cron evaluated in UTC (schedule syntax), and after each check-in it expects the next run at the first cron time following the latest scheduled time at or before that check-in. A job that runs on the schedule's marks fits with a short grace period; one that drifts needs a long one.
| schedule job | schedule to send |
Grace period |
|---|---|---|
every().day.at("02:00", "UTC") |
0 2 * * * |
A few minutes |
every().monday.at("09:30", "UTC") |
30 9 * * 1 |
A few minutes |
every().hour.at(":15") |
15 * * * * |
A few minutes, if the host's UTC offset is a whole number of hours |
every().day.at("06:00", "America/New_York") |
0 10 * * * |
At least an hour plus the run's usual duration |
every(10).minutes |
*/10 * * * * |
At least the interval plus the run's duration; better, run on .at() marks |
every(5).to(10).minutes |
None | Randomized intervals (to) have no cron equivalent |
The New York row follows CronRadar's daylight-saving rule: send the earlier of the two UTC times, 10:00 during daylight time, with a grace period of at least an hour plus the run's duration. In winter the job runs at 11:00 UTC, and the monitor shows warning each day from 10:00 until that run completes. For how grace periods interact with run time, see cron grace period.
Frequently Asked Questions#
Why did my schedule loop stop running jobs?#
Most often a job raised an exception, which schedule doesn't catch: it escapes run_pending() and ends the while True loop. Wrap each job in a decorator that catches and logs exceptions, as the exception handling docs show, and run the process under a supervisor such as systemd with Restart=always.
Does the schedule library run missed jobs after a restart?#
No. Jobs exist only in memory, and each job's first run is computed when .do() is called, so a run that was due while the process was down never happens. An .at() job waits for its next time; an interval job waits a full interval from the restart.
How do I run schedule jobs in parallel?#
Start each run in its own thread, as the parallel execution docs show, or put jobs on a queue that worker threads drain. Threaded runs can overlap, so use a lock when a job must not run twice at once.
How do I set a time zone in Python schedule?#
Pass it as the second argument to .at(), such as every().day.at("02:00", "Europe/Berlin"). It requires pytz, which a plain pip install schedule doesn't install; use pip install "schedule[timezone]". Zones are available only in .at(). Without a zone, .at() uses the host's local time.
Is there a CronRadar SDK for the schedule library?#
No. Python SDK connections are temporarily unavailable, so each job reports with an HTTP check-in, and nothing discovers schedule jobs automatically. Give each job its own monitor key and a UTC cron schedule that matches when it runs.
Put this guide to work
Connect your Python job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
Python SDK connections are temporarily unavailable. Use manual HTTP reporting below. This setup does not discover jobs automatically.