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.
Top comments (0)