Using Google Sheets as a database with Python: the practical guide
Read and write Google Sheets data from Python - without gspread, service accounts, or the google-auth library. Just requests.
Every Python tutorial for reading Google Sheets follows the same script: install gspread, install google-auth, open Google Cloud Console, create a project, enable the Sheets API, create a service account, download the key JSON, add it to .env, and share your sheet with the service account email. That is five steps before you read a single row of data.
For a lot of use cases, there is a better path. This guide shows you how to query and write Google Sheets from Python using nothing but the requests library - no Cloud Console, no key file, no OAuth dance.
When gspread is actually the right tool
Before going further: gspread is good software, and the official Sheets API is the right choice for certain workloads. If you are building multi-tenant Workspace automation, running bulk formatting operations, updating named ranges, or you already have a service account for other Google APIs in your project, use it. The full Sheets API surface is wide and gspread wraps it well.
The setup cost is worth it when you need that depth. It is not worth it when you want to pull a sheet into a pandas DataFrame, write a row from a webhook handler, or query filtered records from a Flask route. For those cases, a plain REST endpoint is faster to set up, easier to test, and easier to hand off to a teammate.
The alternative: SheetsAPI
SheetsAPI exposes any Google Sheet as a REST endpoint backed by Cloudflare Workers. Connect your sheet once in the dashboard, and every subsequent read or write is a standard HTTP call. It is MIT-licensed and free during the beta period.
The requirements.txt for every example in this post:
requests
That is the entire dependency list. httpx and pandas appear in later sections but are optional additions, not requirements.
Every endpoint follows this URL shape:
https://api.sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
The response envelope is always the same:
{
"data": [...],
"meta": { "total": 142, "limit": 20, "offset": 0 }
}Row objects use your sheet's first-row headers as keys. A sheet with columns name, email, status returns objects shaped like {"name": "Alice", "email": "alice@example.com", "status": "active"}. All values come back as strings - Google Sheets has no strict type system - so cast numerics after loading if you need them.
Read all rows
import requests
ENDPOINT = "https://api.sheetsapi.io/api/spreadsheets/YOUR_USER_KEY/users"
HEADERS = {"Authorization": "Bearer sk_YOUR_KEY"}
response = requests.get(ENDPOINT, headers=HEADERS)
response.raise_for_status()
rows = response.json()["data"]
print(f"{len(rows)} rows returned")Call raise_for_status() before touching .json(). It converts any 4xx or 5xx into an exception immediately rather than silently giving you a dict that does not look like you expect.
Filter with search
The search parameter accepts field:value syntax. Pass it as a query param:
response = requests.get(
ENDPOINT,
headers=HEADERS,
params={"search": "status:active"},
)
active_users = response.json()["data"]The match is case-insensitive and substring-based, so "name:ali" matches both Alice and Alicia. To combine conditions, separate them with a comma: "status:active,role:admin".
Paginate large sheets
limit controls page size; offset controls where to start. The meta.total field tells you how many rows exist in total, so you can compute whether there are more pages:
def fetch_all_rows(endpoint, headers, page_size=100):
rows = []
offset = 0
while True:
r = requests.get(
endpoint,
headers=headers,
params={"limit": page_size, "offset": offset},
)
r.raise_for_status()
body = r.json()
rows.extend(body["data"])
if offset + page_size >= body["meta"]["total"]:
break
offset += page_size
return rowsFor sheets under a few thousand rows, a single request with a high limit is fine. The loop above is useful when you are streaming data to another system or processing it in chunks to keep memory flat.
Return specific fields
If your sheet has many columns but you only need two or three, fields keeps the payload small:
response = requests.get(
ENDPOINT,
headers=HEADERS,
params={"fields": "name,email"},
)Each row object will contain only the keys you listed. This pays off noticeably when sheets have twenty-plus columns and you are doing high-frequency reads.
Write a new row
POST to the same endpoint with a JSON body. Keys must match your sheet's column headers:
new_user = {
"name": "Alice",
"email": "alice@example.com",
"status": "active",
}
response = requests.post(ENDPOINT, headers=HEADERS, json=new_user)
response.raise_for_status()
created = response.json()
print(f"Created row {created['id']}")The response returns the created row including its auto-assigned id, which corresponds to its 1-indexed row number in the sheet (not counting the header row). Column order in your dict does not matter - values are mapped by header name.
Update a row
PUT to the endpoint with the row id appended. You can send a partial object; only the keys you include are modified:
row_id = 3
updates = {"status": "inactive"}
response = requests.put(
f"{ENDPOINT}/{row_id}",
headers=HEADERS,
json=updates,
)
response.raise_for_status()
updated = response.json()The response returns the full updated row. To replace all fields atomically, send the complete object. To touch only specific columns, send only those keys.
Delete a row
response = requests.delete(f"{ENDPOINT}/{row_id}", headers=HEADERS)
response.raise_for_status()Deleting a row shifts all subsequent rows up in the sheet, so their IDs change. If you are running multiple deletes in a loop, sort by id descending and work from the bottom up, or re-fetch the sheet between operations.
Error handling
A minimal but practical pattern that covers the failure modes you will actually hit:
from requests.exceptions import HTTPError, RequestException
def safe_get(endpoint, headers, params=None):
try:
r = requests.get(endpoint, headers=headers, params=params, timeout=10)
r.raise_for_status()
return r.json()
except HTTPError as e:
status = e.response.status_code
if status == 401:
raise ValueError("Invalid or missing API key") from e
if status == 404:
raise ValueError("Sheet not found - check userKey and sheetName") from e
if status == 429:
raise RuntimeError("Rate limit hit - back off and retry") from e
raise
except RequestException as e:
raise RuntimeError(f"Network error: {e}") from eAlways set a timeout. Without it, a stalled connection will hang your process indefinitely. Ten seconds covers the vast majority of sheets; bump it for very large ones.
Using with pandas
One line converts the API response into a DataFrame:
import pandas as pd
import requests
response = requests.get(ENDPOINT, headers=HEADERS)
response.raise_for_status()
df = pd.DataFrame(response.json()["data"])
print(df.head())From there you have the full pandas surface: groupby, merge, pivot_table, to_csv, anything. For sheets that exceed a single page, combine with the pagination helper:
all_rows = fetch_all_rows(ENDPOINT, HEADERS)
df = pd.DataFrame(all_rows)Since all values come back as strings, cast columns explicitly after loading:
df["score"] = pd.to_numeric(df["score"], errors="coerce")
df["created_at"] = pd.to_datetime(df["created_at"], errors="coerce")errors="coerce" turns unparseable values into NaN rather than raising, which is usually what you want when the sheet has a few blank cells in a numeric column.
Using with async Python
If your application is already async - FastAPI, aiohttp, or anywhere you are inside an event loop - use httpx instead of requests. The interface is nearly identical:
import httpx
import asyncio
ENDPOINT = "https://api.sheetsapi.io/api/spreadsheets/YOUR_USER_KEY/users"
HEADERS = {"Authorization": "Bearer sk_YOUR_KEY"}
async def get_active_users():
async with httpx.AsyncClient() as client:
response = await client.get(
ENDPOINT,
headers=HEADERS,
params={"search": "status:active", "limit": 50},
timeout=10,
)
response.raise_for_status()
return response.json()["data"]
users = asyncio.run(get_active_users())For high-throughput async workloads, instantiate a single AsyncClient at application startup and reuse it across requests. Connection pooling makes a real difference when you are running many concurrent reads.
API key auth for private sheets
Public sheets can be read without any credentials. For sheets marked private in the dashboard, every request must include the Authorization header:
HEADERS = {"Authorization": "Bearer sk_YOUR_KEY"}Write operations - POST, PUT, DELETE - always require authentication regardless of sheet visibility. If you are building an application where multiple users each have their own sheets, issue separate keys per user rather than sharing one. This keeps rate limits isolated per user and makes key revocation surgical rather than global. See the docs for key scoping details.
Store credentials in environment variables
Never hardcode the endpoint URL or API key in source. A pattern that works identically across local development, CI, and production:
import os
import requests
SHEETS_USER_KEY = os.environ["SHEETS_USER_KEY"]
SHEETS_API_KEY = os.environ["SHEETS_API_KEY"]
SHEET_NAME = os.environ.get("SHEET_NAME", "users")
ENDPOINT = f"https://api.sheetsapi.io/api/spreadsheets/{SHEETS_USER_KEY}/{SHEET_NAME}"
HEADERS = {"Authorization": f"Bearer {SHEETS_API_KEY}"}A .env for local development (do not commit this file):
SHEETS_USER_KEY=your_user_key_here
SHEETS_API_KEY=sk_your_key_here
SHEET_NAME=usersLoad it during development with python-dotenv:
from dotenv import load_dotenv
load_dotenv()In production, inject these as real environment variables from your secrets manager - Fly.io secrets, Railway variables, GitHub Actions secrets, or Cloudflare Workers environment bindings all work. The application code is identical in every environment.
Rate limits
SheetsAPI is free during the beta period. The current limits are on the pricing page. At typical usage levels you will not approach them - the API runs on Cloudflare Workers, so individual requests are fast and latency is low regardless of where your Python process is running.
If you do hit a 429, use exponential backoff with jitter:
import time
import random
def with_backoff(fn, retries=4):
for attempt in range(retries):
try:
return fn()
except RuntimeError as e:
if "Rate limit" not in str(e) or attempt == retries - 1:
raise
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)Pass any zero-argument callable as fn:
result = with_backoff(lambda: safe_get(ENDPOINT, HEADERS, {"search": "status:active"}))The three dependencies you don't need
No Google Cloud project. No service account. No JSON key file.
Reading a Google Sheet from Python should be pip install requests and two function calls. With SheetsAPI that is exactly what it is - connect your sheet once in the dashboard, copy the endpoint URL, and every subsequent operation is a standard HTTP request.
The use cases page has examples of what people build on top of this: internal dashboards, Jupyter notebooks pulling live data, webhook processors writing form submissions to a sheet, and lightweight admin tools that let non-technical teammates update data without touching a database. If you have been putting off a project because the Google Sheets API setup felt like too much friction for what you needed, this removes that obstacle entirely.
Get started free - no credit card, no Google Cloud Console, no service account JSON.