Fetching latest headlines…
How to Schedule Python Scripts with Cron: A Beginner's Complete Guide
NORTH AMERICA
πŸ‡ΊπŸ‡Έ United Statesβ€’May 8, 2026

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

1 views0 likes0 comments
Originally published byDev.to

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

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

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!)

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)

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
# ...

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()

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

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

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

Your .env file:

DEVTO_TOKEN=abc123yourtoken
GUMROAD_TOKEN=xyz789yourtoken

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

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

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

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

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()

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

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

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

Comments (0)

Sign in to join the discussion

Be the first to comment!