DEV Community

Žan Mažgon
Žan Mažgon

Posted on

Run a Python script on a schedule for free and write the results to Google Sheets (GitHub Actions + gspread)

You have a Python script that fetches some data. You want it to run every morning and drop the result into a Google Sheet, so you (or your boss) can look at it, chart it or share it. You don't want to rent a server or leave your laptop on.

This tutorial builds exactly that, end to end, with three free pieces:

  • GitHub Actions runs the script on a cron schedule.
  • A Google service account lets the script log in without a browser.
  • gspread writes the row.

The example logs yesterday's weather for a city (from Open-Meteo, a free API that needs no key) into a Weather tab. Swap fetch_yesterday() for your own data source and everything else stays the same.

Total time: about 20 minutes.

Step 1: Create a service account

A service account is a robot Google identity with its own e-mail address. That's what you want for unattended jobs: there's no user login, so there's no token that expires when nobody is around to click "Allow".

  1. In the Google Cloud console, create a project (or pick one).
  2. Under APIs & Services → Library, enable the Google Sheets API.
  3. Under IAM & Admin → Service accounts, create a service account. It doesn't need any project roles.
  4. Open it, go to Keys → Add key → Create new key → JSON. A file downloads. Rename it to service_account.json.

Console menus move around. If a label above doesn't match, search the console for it.

Treat that file like a password. It never goes into git.

Step 2: Share the Sheet with the robot

Create a Google Sheet. Open service_account.json, copy the client_email value (it looks like something@your-project.iam.gserviceaccount.com), and share the Sheet with that address as Editor.

Then copy the Sheet ID from its URL: it's the long string between /d/ and /edit.

Step 3: The script

Create a folder for the project with these two files.

requirements.txt:

gspread>=6,<7
requests>=2.28
Enter fullscreen mode Exit fullscreen mode

job.py:

"""Log yesterday's weather to a Google Sheet, one row per day."""
import json
import os

import gspread
import requests

LAT, LON, TZ = 46.05, 14.51, "Europe/Ljubljana"  # change to your city
TAB = "Weather"
HEADER = ["date", "t_max_c", "t_min_c", "precip_mm"]


def fetch_yesterday() -> list:
    r = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": LAT,
            "longitude": LON,
            "timezone": TZ,
            "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
            "past_days": 1,
            "forecast_days": 1,
        },
        timeout=30,
    )
    r.raise_for_status()
    d = r.json()["daily"]  # index 0 = yesterday, index 1 = today
    return [d["time"][0], d["temperature_2m_max"][0],
            d["temperature_2m_min"][0], d["precipitation_sum"][0]]


def get_client() -> gspread.Client:
    raw = os.environ.get("GOOGLE_SA_JSON")
    if raw:  # in GitHub Actions: the whole JSON key lives in a secret
        return gspread.service_account_from_dict(json.loads(raw))
    return gspread.service_account(filename="service_account.json")  # local run


def get_tab(sh: gspread.Spreadsheet) -> gspread.Worksheet:
    try:
        return sh.worksheet(TAB)
    except gspread.WorksheetNotFound:
        ws = sh.add_worksheet(title=TAB, rows=1000, cols=len(HEADER))
        ws.append_row(HEADER)
        return ws


def main() -> None:
    row = fetch_yesterday()
    sh = get_client().open_by_key(os.environ["SHEET_ID"])
    ws = get_tab(sh)

    if row[0] in ws.col_values(1):  # one read request
        print(f"{row[0]} is already in the sheet, nothing to do")
        return

    ws.append_row(row, value_input_option="RAW")  # one write request
    print(f"Logged {row}")


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

Three details here are deliberate:

  • open_by_key, not open("My sheet"). Opening a sheet by title has to search Drive first. Opening it by ID goes straight to the Sheets API, which is one fewer thing that can fail.
  • The duplicate check. GitHub can occasionally run a scheduled job late, and you'll press "Run workflow" by hand while testing. Checking the date column first makes a second run harmless.
  • RAW. The date goes in as the literal text 2026-10-28, so the duplicate check compares text with text. (With USER_ENTERED, Sheets turns it into a real date that's displayed in your locale, and "2026-10-28" in col_values(1) stops matching.) Numbers are sent as JSON numbers, so they stay numbers either way.

Try it locally before you automate anything:

python -m venv .venv && source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install -r requirements.txt
SHEET_ID=your_sheet_id python job.py                 # Windows: set SHEET_ID=... then python job.py
Enter fullscreen mode Exit fullscreen mode

You should see Logged ['2026-10-28', ...] and a new Weather tab. If you get a 403 or SpreadsheetNotFound, the Sheet isn't shared with the client_email (step 2).

Step 4: Put it on GitHub

Create a private repository and add a .gitignore before your first commit:

service_account.json
.venv/
Enter fullscreen mode Exit fullscreen mode

Push job.py and requirements.txt. Then go to Settings → Secrets and variables → Actions → New repository secret and add two secrets:

  • GOOGLE_SA_JSON: paste the entire contents of service_account.json, unchanged.
  • SHEET_ID: the Sheet ID.

Paste the whole file. If you copy only the private_key field, or something rewrites its \n line breaks, you get Could not deserialize key data.

Step 5: The schedule

Create .github/workflows/daily.yml:

name: daily-weather-to-sheets

on:
  schedule:
    - cron: "23 6 * * *"            # 06:23 every day...
      timezone: "Europe/Ljubljana"  # ...in this time zone (without this line: UTC)
  workflow_dispatch:                # adds a "Run workflow" button

permissions:
  contents: read

concurrency:
  group: daily-weather              # never two runs at the same time

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 10             # a hung job fails instead of burning minutes
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
          cache: pip
      - run: pip install -r requirements.txt
      - run: python job.py
        env:
          GOOGLE_SA_JSON: ${{ secrets.GOOGLE_SA_JSON }}
          SHEET_ID: ${{ secrets.SHEET_ID }}
Enter fullscreen mode Exit fullscreen mode

Commit it to your default branch (scheduled workflows only run from there). Open the Actions tab, pick the workflow and press Run workflow. When that manual run is green, the schedule takes over.

Cron's five fields are minute, hour, day of month, month and day of week. Some useful ones:

Cron Runs
23 6 * * * every day at 06:23
23 6 * * 1-5 weekdays at 06:23
7 */6 * * * every 6 hours, at minute 7
30 8 1 * * the 1st of every month at 08:30

Things worth knowing before you rely on it

  • Time zones. GitHub added the timezone: key in March 2026. Leave it out and cron times are UTC, which is an hour off your local time for half the year.
  • Don't schedule on the hour. GitHub warns that scheduled runs can be delayed when load is high, especially at the start of every hour. Pick an odd minute like 23. If a few minutes' delay would hurt, use a real server.
  • 5 minutes is the minimum interval. Anything more frequent belongs on a server.
  • Public repos go to sleep. In public repositories, GitHub disables scheduled workflows after 60 days without repository activity. Use a private repo (which also keeps your logs private).
  • Cost. Public repositories run Actions for free. Private ones get a monthly free allowance on the Free plan (check GitHub's billing page for the current amount). A daily job that takes about a minute uses roughly 30 minutes a month.
  • Failures should be loud. If job.py raises, the run goes red and GitHub can e-mail you. Turn on notifications for failed workflows in your GitHub settings. Don't wrap main() in a try/except that only prints; that turns a broken job into a green one.
  • Batch as you grow. One row a day is two API requests. If you later write hundreds of rows, send them in one append_rows() or update() call instead of looping, because the Sheets API limit counts requests per minute, not cells.

If this setup still breaks after a few days, I wrote about the five errors behind most failures (403, invalid_grant after 7 days, 429, broken keys in CI, duplicate rows) in an earlier post.

Recap

  1. Service account + Sheets API, key saved as service_account.json
  2. Sheet shared with the client_email as Editor
  3. job.py opens the sheet by ID, checks for duplicates and appends one row
  4. Key and Sheet ID stored as repository secrets, never committed
  5. A workflow with schedule + workflow_dispatch, tested by hand once

That's a free, serverless daily job that runs whether your laptop is on or not.


A free one-page cheat sheet with fixes for the five most common Python → Google Sheets errors: https://whop.com/whop-0bab/fix-the-5-most-common-python-google-sheets-errors-free-1-page-cheat-sheet/.

Disclosure: I also sell a starter kit that takes this further (a 47-page guide, a tested toolkit with backoff and upsert helpers, a connection checker, ready-made workflows and three Sheet templates): https://whop.com/whop-0bab/python-google-sheets-running-on-autopilot-the-starter-kit/. Everything in this tutorial works without it.

Top comments (0)