Tutorial June 27, 2026

GitHub Actions as a Python Cron Job: How It Works and When to Stop Using It

A complete guide to running Python scripts on a schedule with GitHub Actions. Includes real YAML examples, free-tier limits, and why high-frequency jobs need a dedicated runner.

S
Shubham
10 min read

GitHub Actions is the most widely used free compute for running Python scripts on a schedule — mostly because it's already in every developer's workflow and the free minutes are generous. But it comes with real gotchas that catch developers off guard in production.

This guide is a practical walkthrough of everything you need to know:

  • How to write a proper schedule workflow in YAML
  • Running Python scripts with dependencies
  • Passing secrets into your scheduled job
  • Free tier limits that will eventually bite you
  • When GitHub Actions is the wrong tool for the job

A Minimal GitHub Actions Cron YAML

Create .github/workflows/daily-job.yml in your repository:

name: Daily Python Job

on:
  schedule:
    # Runs at 08:00 UTC every day
    - cron: "0 8 * * *"
  workflow_dispatch:  # Also allows manual trigger from GitHub UI

jobs:
  run-script:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run job
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: python jobs/daily_report.py

The workflow_dispatch trigger is crucial — it lets you test your workflow manually without waiting for the schedule to fire.

Your Python Script

The script doesn't need any special structure. It's just regular Python:

# jobs/daily_report.py
import os
import requests
from datetime import date

def run():
    api_key = os.environ["API_KEY"]
    db_url = os.environ["DATABASE_URL"]

    print(f"Starting daily report — {date.today()}")

    # Example: pull data and push summary to Slack
    response = requests.get(
        "https://api.yourservice.com/metrics",
        headers={"X-API-Key": api_key}
    )
    response.raise_for_status()
    metrics = response.json()

    # Push to Slack
    slack_webhook = os.environ.get("SLACK_WEBHOOK_URL")
    if slack_webhook:
        requests.post(slack_webhook, json={
            "text": f"Daily metrics: {metrics['total_users']} users, {metrics['revenue']} revenue"
        })

    print("Done.")
    return metrics

if __name__ == "__main__":
    run()

Passing Secrets

Never hardcode API keys. In GitHub Actions:

  1. Go to Settings → Secrets and variables → Actions
  2. Click New repository secret
  3. Add each key (DATABASE_URL, API_KEY, etc.)

Access them in your YAML via ${{ secrets.SECRET_NAME }} and in Python via os.environ["SECRET_NAME"].

Handling Dependencies with Caching

For heavy dependencies (like pandas, playwright, torch), caching speeds up your runs dramatically:

- name: Cache pip packages
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

- name: Install dependencies
  run: pip install -r requirements.txt

Without caching, a pandas + scikit-learn install takes 60–90 seconds on every run.

The Real Limitations of GitHub Actions for Cron Jobs

1. 5-minute minimum interval in practice

The GitHub Actions scheduler uses cron syntax, so technically * * * * * would fire every minute. But in practice, GitHub queues scheduled workflows and may delay them by up to 15 minutes during high-load periods. For anything requiring precision timing, this is a dealbreaker.

2. Scheduled workflows are disabled after 60 days of inactivity

If no one pushes to your repo for 60 days, GitHub automatically disables your scheduled workflow. You'll miss runs without any notification. This is a documented GitHub policy and has caught many developers off guard.

3. Public repo free minutes vs. Private repo limits

  • Public repos: Unlimited free minutes for scheduled workflows
  • Private repos: 2,000 minutes/month on the free plan, 3,000 on Pro

A 15-minute job running every hour = 24 runs × 15 minutes = 360 minutes/day = 10,800 minutes/month. That's 5× your private repo limit.

4. No per-run execution history in a searchable format

Workflow run logs are kept for 90 days. Searching across runs for a specific log line requires the GitHub CLI or API — there's no built-in "show me all runs where this print() appeared" dashboard.

5. No retry logic

If your script fails (non-zero exit code), the run is marked as failed. GitHub does not retry. You implement retries yourself, or you miss the execution entirely.

When GitHub Actions is the Right Choice

GitHub Actions works beautifully for scheduled tasks when:

  • Your job runs once a day or less (the timing imprecision doesn't matter)
  • Your repo is public (unlimited free minutes)
  • The job is part of your CI/CD pipeline — like a nightly test suite or a data backfill that's naturally tied to your codebase
  • You need matrix jobs across multiple OS or Python versions

When to Switch to a Dedicated Cron Service

Switch away from GitHub Actions when:

Situation Why GitHub Actions Falls Short
Sub-hourly schedules Up to 15-minute delay from the scheduler
Private repo, many jobs Free minute quota exhausted quickly
Long-running scraper (>10 min) Job timeout + minutes consumed quickly
Production critical (payment, reports) No guaranteed timing, no built-in retry
Standalone script, not tied to a codebase Heavyweight overhead for a simple cron

Migrating a GitHub Actions Cron to LiteLambda

If you hit any of the limits above, here's how to migrate your existing workflow:

  1. Take the Python script you call in the run: step
  2. Wrap your run() function as def handler(event, context):
  3. Paste the code into LiteLambda's editor
  4. Move your GitHub secrets to LiteLambda's Environment Variables section
  5. Copy the cron expression from your YAML into the Schedule field
# Before (GitHub Actions script)
def run():
    # your code here
    pass

# After (LiteLambda handler)
def handler(event, context):
    # same code here
    return {"status": "success"}

LiteLambda runs on a dedicated scheduler with guaranteed execution times, built-in retry logic, and a per-run log dashboard — without counting against any GitHub minute quota.

Deploy your Python cron job for free →

Skip the infrastructure setup.

Run this exact code in our secure, isolated Docker sandbox. It takes 10 seconds to deploy.

Run this script for free

No credit card required.