# The Python schedule Library in Production: Exceptions, Threads, and Restarts

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/python-schedule-library

> How schedule 1.2.2 runs jobs: run_pending, why one exception stops the loop, serial runs, lost runs after restarts, .at() time zones, and threads.

**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](https://pypi.org/project/schedule/1.2.2/) (checked October 30, 2026). Source links point to the [1.2.2 tag](https://github.com/dbader/schedule/tree/1.2.2). For APScheduler, see [Python scheduler monitoring](https://cronradar.com/blog/python-scheduler-monitoring); for a script that system cron starts, see [Python script cron jobs](https://cronradar.com/blog/python-cron-job).

## 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`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L89-L101), [`should_run`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L666-L672)). 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](https://github.com/dbader/schedule/blob/1.2.2/docs/background-execution.rst)), so nothing runs between calls. The docs' loop looks like this:

```python
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_pending` docstring](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L90-L98) 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`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L674-L698), [`_schedule_next_run`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L700-L748)). `every(10).minutes` first 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](https://github.com/dbader/schedule/blob/1.2.2/docs/index.rst)).
- **`.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`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L471-L559), [`do`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L644-L664)):

| 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](https://github.com/dbader/schedule/blob/1.2.2/docs/exception-handling.rst)). 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](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L691-L693)), 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`](https://docs.python.org/3/library/exceptions.html#KeyboardInterrupt) and [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) inherit from `BaseException`, not `Exception`):

```python
# 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 decorator
```

The 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`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L172-L175)), 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](https://github.com/dbader/schedule/blob/1.2.2/docs/parallel-execution.rst)). 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:

```python
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`](https://docs.python.org/3/library/threading.html#threading.excepthook), which prints the traceback to stderr by default, and the loop keeps going. Keep `catch_exceptions` for a proper log line.
- **`CancelJob` stops working.** The scheduler sees `run_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)`](https://docs.python.org/3/library/threading.html#threading.Lock.acquire) returns `False` immediately instead of waiting:

```python
# 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 start
```

To 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](https://github.com/dbader/schedule/blob/1.2.2/docs/parallel-execution.rst)). To run the loop itself in a background thread of an existing application, see [background execution](https://github.com/dbader/schedule/blob/1.2.2/docs/background-execution.rst). Either way, each Python process has its own `default_scheduler` ([line 837](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L837)), 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](https://cronradar.com/blog/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__`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L86-L87)), and `.do()` computes each job's first run from the moment it's called ([`do`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L644-L664)). The docs list "Job persistence (remember schedule between restarts)" first among the reasons not to use schedule ([index](https://github.com/dbader/schedule/blob/1.2.2/docs/index.rst)). 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).hours` first 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`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L103-L120)).

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](https://cronradar.com/blog/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](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L717)). 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](https://github.com/dbader/schedule/blob/1.2.2/docs/timezones.rst)).

`.at()` accepts a time zone as its second argument, as a string or a pytz time zone object:

```python
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 `timezone` extra ([pyproject.toml](https://github.com/dbader/schedule/blob/1.2.2/pyproject.toml)), so a plain `pip install schedule` doesn't install it; use `pip install "schedule[timezone]"` or `pip install pytz`. `.at()` imports pytz only when you pass a zone ([lines 498-508](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L498-L508)), and without it that `.at()` call raises `ModuleNotFoundError: No module named 'pytz'` ([FAQ](https://github.com/dbader/schedule/blob/1.2.2/docs/faq.rst)).
- **Only `.at()` takes a zone.** `next_run` and `last_run` are stored as naive times in the process's local zone either way ([time zones](https://github.com/dbader/schedule/blob/1.2.2/docs/timezones.rst)).
- **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](https://github.com/dbader/schedule/blob/1.2.2/docs/timezones.rst)).
- **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](https://github.com/dbader/schedule/blob/1.2.2/HISTORY.rst)).

`.at("02:00", "UTC")` runs at the same instant on any host and matches a UTC cron schedule exactly. [Cron timezone](https://cronradar.com/blog/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:

```ini
# /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.target
```

`Restart=always` restarts the service "regardless of whether it exited cleanly or not"; the default is `no` ([systemd.service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)). `EnvironmentFile=` reads variables such as `CRONRADAR_API_KEY` from a file ([systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#EnvironmentFile=)), which keeps the key out of the unit and the repository; make that file readable only by root. [`-u`](https://docs.python.org/3/using/cmdline.html#cmdoption-u) unbuffers stdout for any `print()` output from your jobs; [Python script cron jobs](https://cronradar.com/blog/python-cron-job) explains the buffering. [Cron @reboot](https://cronradar.com/blog/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](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)). Python installs default handlers only for SIGPIPE and SIGINT ([signal](https://docs.python.org/3/library/signal.html#general-rules)), so SIGTERM ends the process, cutting off whatever job is running. [Python script cron jobs](https://cronradar.com/blog/python-cron-job) 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](https://docs.cronradar.com/integrations/python) 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:

```python
# 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 decorator
```

The 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:

```python
# 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](https://docs.cronradar.com/api-reference)). `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](https://cronradar.com/blog/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](https://docs.cronradar.com/schedules)), 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`](https://github.com/dbader/schedule/blob/1.2.2/schedule/__init__.py#L561-L574)) 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](https://cronradar.com/blog/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](https://github.com/dbader/schedule/blob/1.2.2/docs/exception-handling.rst) 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](https://github.com/dbader/schedule/blob/1.2.2/docs/parallel-execution.rst) 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.
