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
United States
NORTH AMERICA
Related News
How Braze’s CTO is rethinking engineering for the agentic area
10h ago
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
21h ago

Implementing Multicloud Data Sharding with Hexagonal Storage Adapters
15h ago

DeepMind’s CEO Says AGI May Be ~4 Years Away. The Last Three Missing Pieces Are Not What Most People Think.
15h ago

CCSnapshot - A Claude Code Configs Transfer Tool
21h ago