Most scraping jobs end in the same place: someone wants the rows in a spreadsheet. A recruiter wants new job posts, a marketer wants a list of competitor URLs, a founder wants leads they can sort and comment on. And often the reverse is needed too: a sheet of inputs (domains, keywords, product IDs) has to become JSON for the next script.
This post covers three ways to do it, and the details that trip people up along the way.
1. Reading a public sheet: the CSV export
If a sheet is shared as "Anyone with the link can view", you don't need an API key to read it. Every tab has a CSV export URL:
import csv, io, requests # pip install requests SHEET_ID = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms" # Google's public sample sheet url = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0" r = requests.get(url, timeout=30) r.raise_for_status() rows = list(csv.DictReader(io.StringIO(r.text))) print(len(rows), rows[0]) Output:
30 {'Student Name': 'Alexandra', 'Gender': 'Female', 'Class Level': '4. Senior', 'Home State': 'CA', 'Major': 'English', 'Extracurricular Activity': 'Drama Club'} gid is the tab ID from the URL (#gid=...). This is fine for quick reads, but it only works for public sheets, gives you formatted text only, and can't write anything.
2. Writing: the Sheets API with a service account
To write, you need the Google Sheets API. The usual setup for scripts and servers is a service account:
- Create a Google Cloud project, enable the Sheets API, create a service account and download its JSON key.
- Share the target sheet with the service account's email (
...@....iam.gserviceaccount.com) as Editor, just like you'd share with a person. Untick "Notify people". - Call
spreadsheets.values.append(add rows) orvalues.update(overwrite) with your rows.
The parts that take longer than expected:
- Headers. Your rows are objects, but the API takes a 2D array. You need to build a header row, keep column order stable between runs, and add new columns when a later run has more fields.
- Nested data. Scraped rows have arrays and objects (
tags: ["a", "b"],address.city). Cells hold strings, so you need to flatten them: join them, store them as JSON, or expand them into numbered columns. - Duplicates. Appending the same scrape twice doubles your sheet. Deduplicating means reading the existing column first.
- Formula injection. With
valueInputOption=USER_ENTERED, a scraped value like=IMPORTXML(...)or=HYPERLINK(...)becomes a live formula in your sheet. UseRAWfor untrusted data. - Limits. A sheet holds 10 million cells and a cell holds 50,000 characters, and the API returns 429 if you write too fast, so you need batching and retries.
- Replace safely. "Clear the tab, then write" loses your data if the write fails halfway. Write first, then clear the leftover rows.
3. The hosted option
Disclosure: I packaged this as a hosted tool, Google Sheets Import & Export on Apify, with the source on GitHub. It handles the list above: header merging, dot-path columns (address.city), array handling (join, json or expand), deduplicateBy, RAW by default, and write-then-clear replace. It costs $0.01 per 1,000 rows plus $0.00005 per run.
You share your sheet with the tool's service account (the email is in the input form) or paste your own service account key.
Scrape, then append to a sheet without duplicates
This scrapes 50 URLs from a sitemap, then appends them to a tab, skipping any URL already in the sheet:
import os from apify_client import ApifyClient # pip install apify-client client = ApifyClient(os.environ["APIFY_TOKEN"]) SHEET = "https://docs.google.com/spreadsheets/d/<your-sheet-id>/edit" # shared with the tool as Editor scrape = client.actor("swillz/sitemap-url-extractor").call( run_input={"startUrls": [{"url": "https://docs.apify.com"}], "maxUrls": 50}, max_total_charge_usd=0.05, ) export = client.actor("swillz/google-sheets-import-export").call( run_input={ "operation": "write", "spreadsheet": SHEET, "sheetName": "docs-urls", "datasetId": scrape.default_dataset_id, "columns": ["url", "lastmod", "sitemapUrl"], "writeMode": "append", "deduplicateBy": ["url"], }, max_total_charge_usd=0.05, ) print(export.status_message) (On 1.x versions of the client, use run["defaultDatasetId"] and run["statusMessage"].)
The first run prints Wrote 50 rows to docs-urls (append) and creates the tab if it doesn't exist. Running the same export again prints:
0 rows written: All 50 rows already exist in the sheet (deduplicateBy: url). Skipped duplicates aren't charged. The run's OUTPUT record has the counts (rowsWritten, duplicatesSkipped, sheetCreated, lastRow), so a script can check what happened without opening the sheet.
You can also write rows directly with rawData instead of a dataset:
{ "operation": "write", "spreadsheet": "https://docs.google.com/spreadsheets/d/<your-sheet-id>/edit", "sheetName": "companies", "writeMode": "replace", "rawData": [ {"company": "Stripe", "domain": "stripe.com", "tags": ["payments", "api"]}, {"company": "Apify", "domain": "apify.com", "tags": ["scraping"]} ] } With the default arrayHandling: "join", the tags cell becomes payments, api.
Read a sheet back as JSON
read = client.actor("swillz/google-sheets-import-export").call( run_input={ "operation": "read", "spreadsheet": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", "range": "A1:F3", }, max_total_charge_usd=0.05, ) for row in client.dataset(read.default_dataset_id).iterate_items(): print(row) Output:
{'Student Name': 'Alexandra', 'Gender': 'Female', 'Class Level': '4. Senior', 'Home State': 'CA', 'Major': 'English', 'Extracurricular Activity': 'Drama Club'} {'Student Name': 'Andrew', 'Gender': 'Male', 'Class Level': '1. Freshman', 'Home State': 'SD', 'Major': 'Math', 'Extracurricular Activity': 'Lacrosse'} Row 1 becomes the field names (headerRow: true). Set valueRender: "UNFORMATTED_VALUE" to get raw numbers instead of $1.50-style text.
Without code: run it after another scraper
In the Apify Console, open any scraper's Integrations tab, add an Actor integration that runs the Sheets tool when the run succeeds, and set datasetId to {{resource.defaultDatasetId}}. Every scheduled scrape then lands in your sheet, deduplicated.
Let an AI agent "save it to my sheet" (MCP)
Load a scraper and the Sheets tool in the same Apify MCP session:
{ "mcpServers": { "apify": { "url": "https://mcp.apify.com?tools=swillz/sitemap-url-extractor,swillz/google-sheets-import-export" } } } Then:
"List the URLs in docs.apify.com's sitemap and append them to my sheet https://docs.google.com/spreadsheets/d/.../edit, tab 'docs', without duplicates."
The agent runs the first tool, passes its dataset ID to the second with deduplicateBy: ["url"], and reports the counts. If the sheet isn't shared yet, the error message includes the service-account email, so the agent can tell you exactly who to share it with.
Wrap-up
- For public, read-only sheets, the CSV export URL is enough.
- To write, use a service account and share the sheet with it like a person.
- Flatten nested fields, keep a stable header, dedupe on append, and use
RAWfor scraped text. - When replacing a tab, write first and clear afterwards.