DEV Community

Cover image for How to Schedule Python Scripts with Cron: A Beginner's Complete Guide
German Yamil
German Yamil

Posted on

How to Schedule Python Scripts with Cron: A Beginner's Complete Guide

How to Schedule Python Scripts with Cron: A Beginner's Complete Guide

The best automation script is one that runs itself.

Cron is the Unix scheduler built into every macOS and Linux machine. No SaaS, no cloud subscriptions, no third-party scheduler โ€” just a text file that tells your system when to run your code.

Here's everything you need to use it correctly.


๐ŸŽ Free: AI Publishing Checklist โ€” 7 steps in Python ยท Full pipeline: germy5.gumroad.com/l/xhxkzz (pay what you want, min $9.99)


The crontab syntax

โ”Œโ”€ minute       (0-59)
โ”‚  โ”Œโ”€ hour      (0-23)
โ”‚  โ”‚  โ”Œโ”€ day    (1-31)
โ”‚  โ”‚  โ”‚  โ”Œโ”€ month  (1-12)
โ”‚  โ”‚  โ”‚  โ”‚  โ”Œโ”€ weekday (0=Sun, 1=Mon ... 6=Sat)
โ”‚  โ”‚  โ”‚  โ”‚  โ”‚
*  *  *  *  *  command
Enter fullscreen mode Exit fullscreen mode

Common patterns:

# Every day at 10am
0 10 * * * python3 /path/to/script.py

# Every hour
0 * * * * python3 /path/to/script.py

# Every 15 minutes
*/15 * * * * python3 /path/to/script.py

# Every Monday at 9am
0 9 * * 1 python3 /path/to/script.py

# First of every month at midnight
0 0 1 * * python3 /path/to/script.py
Enter fullscreen mode Exit fullscreen mode

Use crontab.guru to verify any expression before running it.

Step 1: Open your crontab

crontab -e   # opens in your default editor
crontab -l   # list current jobs
crontab -r   # remove all jobs (careful!)
Enter fullscreen mode Exit fullscreen mode

If this is your first time, you'll be asked to choose an editor. Choose nano for simplicity.

Step 2: Write your script correctly

Before scheduling, your script needs two things:

1. Absolute paths โ€” not relative:

# Wrong โ€” cron runs from a different directory
with open("state.json") as f:    # FileNotFoundError at runtime
    data = json.load(f)

# Right โ€” always resolve relative to the script itself
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(BASE_DIR, "state.json")

with open(STATE_FILE) as f:
    data = json.load(f)
Enter fullscreen mode Exit fullscreen mode

2. A shebang line so cron knows which Python to use:

#!/usr/bin/env python3
"""
publish_queue.py โ€” publishes one article per day.
Cron: 0 10 * * * /usr/bin/python3 /Users/me/scripts/publish_queue.py
"""
import os, json, requests
# ...
Enter fullscreen mode Exit fullscreen mode

Step 3: Log everything

Cron jobs run silently. Without logging, you won't know if your script ran, crashed, or succeeded.

#!/usr/bin/env python3
import logging
import os
from datetime import datetime

# Log next to the script file
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(BASE_DIR, "publish.log")

logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger(__name__)

def main():
    log.info("Script started")
    try:
        # ... your actual work ...
        log.info("Published: My Article Title")
    except Exception as e:
        log.error(f"Failed: {e}", exc_info=True)
        raise

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Your crontab entry should also capture stdout/stderr:

0 10 * * * /usr/bin/python3 /Users/me/scripts/publish_queue.py >> /Users/me/scripts/publish.log 2>&1
Enter fullscreen mode Exit fullscreen mode

The >> file 2>&1 part appends both stdout and stderr to your log file.

Step 4: Environment variables

Cron runs with a minimal environment โ€” none of your shell aliases, PATH entries, or exported variables are available.

The problem:

import os
token = os.environ["DEVTO_TOKEN"]  # KeyError โ€” cron doesn't have this
Enter fullscreen mode Exit fullscreen mode

Solution 1: Use a .env file

# Load environment from a file at the top of your script
def load_env(path: str) -> None:
    """Load KEY=value pairs from a file into os.environ."""
    with open(path) as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                key, _, value = line.partition("=")
                os.environ[key.strip()] = value.strip().strip('"').strip("'")

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
load_env(os.path.join(BASE_DIR, ".env"))

token = os.environ["DEVTO_TOKEN"]  # works
Enter fullscreen mode Exit fullscreen mode

Your .env file:

DEVTO_TOKEN=abc123yourtoken
GUMROAD_TOKEN=xyz789yourtoken
Enter fullscreen mode Exit fullscreen mode

Solution 2: Set variables directly in crontab

# At the top of your crontab file
DEVTO_TOKEN=abc123yourtoken
GUMROAD_TOKEN=xyz789yourtoken

0 10 * * * python3 /path/to/publish_queue.py >> /path/to/publish.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Step 5: Fix the PATH problem

Cron's PATH is usually just /usr/bin:/bin. Commands like python3 may not be found if you installed Python via Homebrew or pyenv.

The fix: use the full path to python3.

# Find your python3 path in terminal:
which python3
# /usr/local/bin/python3  (Homebrew)
# /opt/homebrew/bin/python3  (Apple Silicon Homebrew)
# /usr/bin/python3  (system Python)

# Then use that full path in crontab:
0 10 * * * /opt/homebrew/bin/python3 /Users/me/scripts/publish.py >> /Users/me/scripts/pub.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Or set PATH explicitly at the top of your crontab:

PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin

0 10 * * * python3 /Users/me/scripts/publish.py >> /Users/me/scripts/pub.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Step 6: Virtual environments

If your script uses third-party packages (requests, pillow, etc.), you need to point cron at your venv's Python:

# Create and activate venv (one time)
python3 -m venv /Users/me/scripts/.venv
source /Users/me/scripts/.venv/bin/activate
pip install requests pillow

# In crontab โ€” use the venv python directly
0 10 * * * /Users/me/scripts/.venv/bin/python /Users/me/scripts/publish.py >> /Users/me/scripts/pub.log 2>&1
Enter fullscreen mode Exit fullscreen mode

No need to activate the venv in crontab โ€” using the venv's python directly is equivalent.

Complete working example

The two cron jobs that power the publishing pipeline:

#!/usr/bin/env python3
"""
auto_publish_queue.py
Reads publish_queue.json and publishes the next pending article to Dev.to.

Cron: 0 10 * * * /path/to/.venv/bin/python /path/to/auto_publish_queue.py
"""
import os, json, re, logging, requests
from datetime import date

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
QUEUE_FILE = os.path.join(BASE_DIR, "publish_queue.json")
LOG_FILE = os.path.join(BASE_DIR, "queue.log")

logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s %(message)s",
)
log = logging.getLogger(__name__)

TOKEN = os.environ.get("DEVTO_TOKEN", "")

def load_queue():
    with open(QUEUE_FILE) as f:
        return json.load(f)

def save_queue(q):
    with open(QUEUE_FILE, "w") as f:
        json.dump(q, f, indent=2)

def publish(filepath):
    with open(os.path.join(BASE_DIR, filepath)) as f:
        content = f.read()
    match = re.match(r'^---\n(.*?)\n---\n', content, re.DOTALL)
    fm = {}
    for line in match.group(1).splitlines():
        if line.startswith('tags: '):
            fm['tags'] = [t.strip() for t in line[6:].split(',')]
        elif ': ' in line:
            k, _, v = line.partition(': ')
            fm[k.strip()] = v.strip().strip('"')
    headers = {"api-key": TOKEN, "Content-Type": "application/json"}
    resp = requests.post("https://dev.to/api/articles", headers=headers, json={
        "article": {
            "title": fm["title"],
            "body_markdown": content,
            "published": True,
            "tags": fm.get("tags", []),
            "description": fm.get("description", ""),
        }
    })
    resp.raise_for_status()
    return resp.json()

def main():
    q = load_queue()
    if not q["pending"]:
        log.info("Queue empty โ€” nothing to publish")
        return

    item = q["pending"][0]
    log.info(f"Publishing: {item['title']}")

    result = publish(item["filename"])
    url = f"https://dev.to{result['path']}"
    log.info(f"Published: {url}")

    q["pending"].pop(0)
    q["published"].append({
        "filename": item["filename"],
        "title": item["title"],
        "date": str(date.today()),
        "url": url,
        "id": result["id"],
    })
    save_queue(q)

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Crontab setup:

# Edit with: crontab -e
DEVTO_TOKEN=your_token_here

# 9am: ping RSS aggregators
0 9 * * * /path/to/.venv/bin/python /path/to/daily_ping.py >> /path/to/ping.log 2>&1

# 10am: publish next article from queue
0 10 * * * /path/to/.venv/bin/python /path/to/auto_publish_queue.py >> /path/to/queue.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Debugging checklist

When a cron job doesn't run or fails silently:

# 1. Check if cron is running (macOS)
sudo launchctl list | grep cron

# 2. Check system cron log (macOS)
log show --predicate 'process == "cron"' --last 1h

# 3. Check your log file
tail -f /path/to/your/script.log

# 4. Test the exact command cron will run
/path/to/python3 /path/to/script.py >> /tmp/test.log 2>&1
cat /tmp/test.log

# 5. Check permissions
ls -la /path/to/script.py  # must be readable
chmod +x /path/to/script.py
Enter fullscreen mode Exit fullscreen mode

Common errors:

Error Cause Fix
Script never runs Cron daemon not running sudo launchctl load /System/Library/LaunchDaemons/com.vix.cron.plist
python3: not found PATH too minimal Use full path: /usr/bin/python3 or /opt/homebrew/bin/python3
ModuleNotFoundError Wrong Python (no venv) Use venv's Python: /path/.venv/bin/python
FileNotFoundError Relative path in script Use os.path.abspath(__file__) to build absolute paths
KeyError on env var Shell env not loaded Set vars at top of crontab or load from .env file

My two cron jobs publish one article per day and ping RSS aggregators โ€” same pattern, different scripts: germy5.gumroad.com/l/xhxkzz โ€” pay what you want, min $9.99.


Further Reading

Top comments (0)