WatchCron

Creating & Monitoring Cron Jobs in Python

Most Python scheduling problems start the same way: a developer writes a script, tests it locally, adds it to crontab, and walks away. Two weeks later someone notices the nightly database export hasn't run since Thursday. The script itself is fine. Cron ran it. But cron ran it with the system Python, which doesn't have pandas installed, and the import error went to /dev/null because nobody redirected stderr.

This guide covers every practical way to schedule Python code on a repeating timer: system crontab, in-process schedulers like APScheduler and the schedule library, Celery Beat for distributed setups, and container-friendly approaches for Docker. Each has a use case where it wins. Each has a way to silently fail that you won't notice until it costs you something.

System crontab: the 80% solution

For a single script on a single server, crontab is still the right answer. No daemons to babysit, no broker to configure, no library version to track. You write one line and the OS handles the rest. If you need a refresher on cron expression syntax, we have a full guide on that.

Here's a backup script that dumps a Postgres database every night at 3 AM:

0 3 * * * /opt/app/venv/bin/python /opt/app/backup_db.py >> /var/log/backup.log 2>&1

Notice the full path to the Python binary inside the virtualenv. Not python3, not ~/venv/bin/python. The absolute path. That single detail prevents about 60% of the "it works in my terminal but not in cron" tickets I've seen.

Why your script works manually but fails in cron

Cron doesn't load your shell profile. No .bashrc, no .zshrc, no conda activation, no pyenv shims. The PATH cron sees is usually just /usr/bin:/bin. Your script might depend on environment variables set in .env files that your shell sources automatically on login. Cron doesn't know those files exist.

The fix is blunt: spell everything out.

# Set env vars directly in crontab
DB_HOST=10.0.1.5
DB_NAME=production
PYTHONPATH=/opt/app

0 3 * * * /opt/app/venv/bin/python /opt/app/backup_db.py >> /var/log/backup.log 2>&1

Or source your .env file before running:

0 3 * * * . /opt/app/.env && /opt/app/venv/bin/python /opt/app/backup_db.py >> /var/log/backup.log 2>&1

Both work. The inline approach is easier to debug because crontab -l shows everything in one place.

Redirect stderr or lose the error

Cron captures stdout and stderr separately, and by default it tries to email the output to the crontab owner. On most servers, local mail delivery isn't configured. So the output vanishes. The 2>&1 at the end merges stderr into the log file. Without it, your Python tracebacks disappear into the void.

Quick test to confirm your cron entry works:

crontab -l
0 3 * * * /opt/app/venv/bin/python /opt/app/backup_db.py >> /var/log/backup.log 2>&1

Then check the log after the next scheduled run. If the file is empty or missing, cron either didn't fire (check grep CRON /var/log/syslog) or the path is wrong.

APScheduler: cron-style scheduling inside a Python app

Sometimes the scheduled task lives inside a running application: a Flask API that regenerates a cache every hour, a Django app that sends digest emails at 9 AM. You don't want a separate crontab entry because the task needs the app's database connection, configuration, and imports.

APScheduler (version 3.11.3 as of mid-2026) embeds a scheduler directly in your Python process. It supports cron-style triggers, interval triggers, and one-off date triggers.

from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = BackgroundScheduler()

def expire_stale_sessions():
    # ... your cleanup logic
    print(f"Cleaned up sessions at {datetime.now()}")

scheduler.add_job(
    expire_stale_sessions,
    CronTrigger(hour=2, minute=30),  # daily at 02:30
    id='session-cleanup',
    replace_existing=True,
)

scheduler.start()
# Your app continues running — scheduler runs in a background thread

BackgroundScheduler spawns a thread alongside your main application. BlockingScheduler is the alternative when the scheduler IS your application (a standalone worker script).

One gotcha that burned an afternoon

The weekday numbering doesn't match standard cron. In POSIX cron, 0 is Sunday. In APScheduler 3.x, 0 is Monday. Wasted a good chunk of time debugging why a Sunday job ran on Monday before I caught this. If you're porting a crontab expression like 0 3 * * 0 (Sunday at 3 AM), the equivalent is CronTrigger(hour=3, day_of_week='sun'). Use the string name to avoid confusion.

Job persistence is available through database stores (SQLAlchemy, Redis, MongoDB) so schedules survive process restarts. For most single-process apps, the default in-memory store is enough. Add a persistent store when you're running multiple app instances behind a load balancer and need to ensure the job runs exactly once.

One note: the 4.0 rewrite has been in alpha since 2024 (currently 4.0.0a6). Stick with the 3.x line for production. The API will change significantly when 4.0 stabilizes.

The schedule library: five lines and a while loop

Dan Bader's schedule library is the simplest in-process scheduler you'll find. Zero dependencies. Readable syntax. It does one thing.

import schedule
import time

def check_disk_space():
    # ... check and alert if low
    pass

def run_backup():
    # ... dump database to S3
    pass

schedule.every(30).minutes.do(check_disk_space)
schedule.every().day.at("03:00").do(run_backup)
schedule.every().wednesday.at("13:15").do(send_weekly_report)

while True:
    schedule.run_pending()
    time.sleep(1)

That while True loop is the whole runtime. If the process dies, the schedules are gone. No persistence, no catch-up on missed runs, no concurrency (a slow job blocks everything behind it). No timezone support either.

Use this for prototypes, dev-environment tasks, or scripts you'll supervise with systemd or supervisord. Don't use it when you need reliability guarantees. A missed run at 3 AM because the server rebooted won't be retried.

Celery Beat: for teams already running Celery

If your application already uses Celery for background tasks (email sending, image processing, webhook delivery), Celery Beat adds periodic scheduling without introducing another tool. You define schedules in the same config, and Beat pushes task messages onto the broker at the right times. Workers execute them.

# celery_app.py
from celery import Celery
from celery.schedules import crontab

app = Celery('myapp', broker='redis://localhost:6379/0')

app.conf.beat_schedule = {
    'nightly-cleanup': {
        'task': 'tasks.cleanup_old_records',
        'schedule': crontab(hour=3, minute=0),
    },
    'health-ping-every-5m': {
        'task': 'tasks.health_check',
        'schedule': 300.0,  # every 5 minutes
    },
}
app.conf.timezone = 'UTC'
# Two separate processes — always
celery -A celery_app beat --loglevel=info    # scheduler (exactly one)
celery -A celery_app worker --loglevel=info  # can run multiple

The critical constraint: exactly one Beat process per schedule. Two Beat instances means every task fires twice. In production, use a process manager (systemd, supervisord, Docker entrypoint) that guarantees a single Beat instance. django-celery-beat adds database-backed schedules if you need to change intervals at runtime without redeploying.

Celery Beat is the right tool when you need retry logic, task routing, result backends, or distributed execution across multiple workers. For a single Python script that runs nightly, it's overkill: you'd be running Redis, a Beat process, and at least one worker just to replace a crontab line.

Managing crontab entries from Python

The python-crontab library (v3.2.0) lets you read, create, and modify crontab entries programmatically. It doesn't execute anything itself. It writes to the same crontab file that the system cron daemon reads.

from crontab import CronTab

cron = CronTab(user=True)

# Create a new job
job = cron.new(command='/opt/app/venv/bin/python /opt/app/sync.py')
job.setall('*/15 * * * *')  # every 15 minutes
job.set_comment('Data sync')
cron.write()

# List all jobs
for j in cron:
    print(f"{j.slices} {j.command}  # {j.comment}")

# Remove by comment
cron.remove_all(comment='Data sync')
cron.write()

Handy for deployment scripts, CLI admin tools, or installers that need to register cron entries as part of setup. The library handles the crontab file format so you don't have to parse it yourself.

Running Python cron jobs in Docker

Standard cron inside a container is a mess. It strips environment variables, swallows output, and doesn't respond to SIGTERM (so docker stop hangs for 10 seconds before the kill signal). The container logs show nothing because cron writes to syslog internally. Spent a frustrating evening figuring out why a perfectly working script produced zero log output in a container before discovering this.

Supercronic fixes all of this. It's a Go binary that reads standard crontab syntax, runs in the foreground as PID 1, preserves environment variables from docker run -e, and sends all output to stdout/stderr where Docker's logging driver picks it up.

FROM python:3.12-slim

# Install supercronic (check github.com/aptible/supercronic/releases for latest)
ARG SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.33/supercronic-linux-amd64
RUN curl -fsSL "$SUPERCRONIC_URL" -o /usr/local/bin/supercronic \
    && chmod +x /usr/local/bin/supercronic

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ /opt/app/
COPY crontab /etc/crontab

CMD ["supercronic", "/etc/crontab"]

The crontab file:

0 3 * * * cd /opt/app && python backup_db.py
*/5 * * * * python /opt/app/health_check.py

No special setup for the Python path because the container only has one Python. Environment variables from Docker Compose or -e flags arrive intact. And when you run docker logs, you see the actual script output.

Serverless alternative: AWS Lambda + EventBridge

For tasks under 15 minutes that don't need a running server, Lambda with an EventBridge Scheduler trigger replaces both the server and the cron daemon. You deploy a function, set a schedule, and AWS runs it.

def lambda_handler(event, context):
    result = run_daily_export()
    return {'statusCode': 200, 'records_exported': result}

EventBridge cron expressions use six fields (they add a year) and require ? for unused day fields:

# Daily at 03:00 UTC
cron(0 3 * * ? *)

The cost for a daily job is effectively zero (free tier covers 1M invocations/month). The tradeoff: cold starts add 1-3 seconds, no persistent filesystem, and the 15-minute execution limit is hard. Long-running ETL jobs need a different approach (ECS tasks, Step Functions, or a plain server with crontab).

Monitoring your Python cron jobs for silent failures

Whichever scheduling method you pick, the failure mode is the same: the job stops running and nobody notices. A heartbeat monitor watches for the ping that should arrive after each successful run. If the ping doesn't come, you get an alert.

With urllib (zero dependencies, works everywhere):

import urllib.request

PING_URL = "https://monitoring.example.com/ping/YOUR-CHECK-ID"

def ping_on_success():
    try:
        urllib.request.urlopen(PING_URL, timeout=10)
    except Exception:
        pass  # monitoring failure shouldn't break the actual job

if __name__ == '__main__':
    run_backup()
    ping_on_success()

Or from crontab directly, chained with && so the ping only fires on success:

0 3 * * * /opt/app/venv/bin/python /opt/app/backup_db.py && curl -fsS --retry 3 https://monitoring.example.com/ping/YOUR-CHECK-ID > /dev/null

For jobs where you also want to track duration and catch failures explicitly:

import urllib.request

PING_URL = "https://monitoring.example.com/ping/YOUR-CHECK-ID"

# Signal start
urllib.request.urlopen(f"{PING_URL}/start", timeout=10)

try:
    run_backup()
    # Signal success
    urllib.request.urlopen(PING_URL, timeout=10)
except Exception:
    # Signal failure
    urllib.request.urlopen(f"{PING_URL}/fail", timeout=10)
    raise

WatchCron's heartbeat monitoring works exactly this way: you point your script at a ping URL, set an expected schedule, and if the ping doesn't arrive within the grace period, alerts fire to Slack, email, or whatever channels you've configured. Setup takes about two minutes.

Choosing the right Python scheduled task approach

A single Python script on a VPS that runs once a day: system crontab. Nothing else comes close for simplicity, and there's nothing to install.

Scheduling inside a running Flask or Django app: APScheduler with BackgroundScheduler. It shares the app's process and configuration without needing an external cron entry.

Quick prototype or development task: the schedule library. Accept the limitations (no persistence, no concurrency) and move on.

Distributed system with existing Celery workers: Celery Beat. Don't introduce it just for scheduling though; the infrastructure overhead (broker + workers + beat process) only makes sense if you're already running Celery for other things.

Docker containers: Supercronic as a drop-in cron replacement. It handles environment variables, logging, and graceful shutdown the way containers expect.

Serverless, short tasks: Lambda + EventBridge. Zero infrastructure to maintain, but the 15-minute limit and cold starts mean it's not for everything.

Whatever you choose, add monitoring. A dead man's switch catches the failures that your scheduling tool won't tell you about: the server that rebooted, the container that didn't restart, the Lambda that timed out. The scheduling part is the easy problem. Knowing when it breaks is the hard one.

Frequently asked

For most cases, system crontab is the simplest and most reliable option. Add one line to your crontab with the full path to your virtualenv Python binary and your script. Use APScheduler if the task needs to run inside an already-running Python application, and Celery Beat if you already have Celery infrastructure for distributed task execution.

Cron does not activate your virtualenv or load your shell profile. It runs with the system Python by default, which lacks your project dependencies. Fix this by using the full path to the Python binary inside your virtualenv: /path/to/venv/bin/python /path/to/script.py.

Use Supercronic instead of standard cron. It runs in the foreground as PID 1, preserves environment variables from Docker, sends output to stdout/stderr for Docker logging, and handles SIGTERM gracefully. Install it in your Dockerfile and point it at a standard crontab file.

Add a heartbeat ping at the end of your script that sends an HTTP request to a monitoring service after each successful run. If the expected ping does not arrive within the configured window, the monitoring service sends an alert. Chain it with && in crontab so the ping only fires when the script exits successfully.