← All skills

Data · API

Google Sheets API

All 17 core and values methods, all 74 batchUpdate request types, and the OAuth flow from a client ID to a working refresh token — plus the three behaviours that quietly cost people data: append writes somewhere else, reads come back ragged, and the wrong field mask erases cells.

Skill name
gsheets-api
Triggers on
Complete endpoint-level reference for the Google Sheets API v4 and the Drive API methods that go with it — all 17 core and values methods, all 74 batchUpdate request types, and the OAuth installed-app flow from a client ID and secret to a working refresh token. Use this skill whenever the user mentions Google Sheets, a spreadsheet, gspread, googleapis, GOOGLE_CLIENT_ID, a service account, or asks to create a sheet, write or append rows, read a range, format cells, add a chart or conditional formatting, share a spreadsheet, or export a list to Sheets. Use it BEFORE writing any Sheets call, because append writes somewhere other than where you point it, reads come back ragged, and the wrong field mask silently erases data.
Read time
6 min · 5 files · free to use and edit
Download full skill

This skill ships 5 files. The references are where the method lives — SKILL.md on its own will point at files you do not have, so take the archive rather than the markdown.

  • SKILL.md
  • references/auth-and-drive.md
  • references/batchupdate.md
  • references/core-and-values.md
  • scripts/gsheets_auth.py

Prefer just the instructions? Download SKILL.md alone.

Use it in your assistant

Claude Code — drop the file in your skills folder and it loads on the next session. Use ~/.claude/skills for every project, or .claude/skills inside a repo to keep it to that project.

mkdir -p ~/.claude/skills
curl -L https://growsteady.io/skills/gsheets-api/archive | tar xz -C ~/.claude/skills

Claude apps (web and desktop) — Settings → Capabilities → Skills → add a skill. Extract the archive and upload the whole gsheets-api folder, references included (zip it if an archive is asked for).

No install— paste the file into a Claude Project's custom instructions with “Copy as prompt”. Same behaviour, scoped to that project. Note that a paste carries the instructions only: this skill's references do not come with it, so use a real install if you want the full method.

Two things make this API deceptively hard. Its most-used write method, append, does not write where you tell it. And its formatting layer has a field mask where the intuitive value is a data-erasing operation. Both fail quietly, on valid input, with a 200 response.

Read the reference for the surface you need:

  • `references/auth-and-drive.md` — OAuth installed-app flow, the scope matrix, service accounts, every Drive method Sheets can't do alone, quotas and the 403 reason catalogue. Read this first if nothing is authenticated yet.
  • `references/core-and-values.md` — all 17 methods on spreadsheets, spreadsheets.values, sheets.copyTo, and developerMetadata, plus A1/GridRange notation and the six behavioral enums.
  • `references/batchupdate.md` — all 74 batchUpdate request types in 13 groups, which 27 return replies, the field mask, atomicity, and a worked end-to-end example.

Getting authenticated

A client ID and secret are not access. They identify your app; they don't authorize a Google account. You need a one-time consent that mints a refresh token, which is what scripts actually use.

scripts/gsheets_auth.py does this: run it once, approve in the browser, and it writes token.json. Every run after that is non-interactive.

Four things to get right, because each one costs an hour otherwise:

Ask for `drive.file` and nothing else. It is sufficient for spreadsheets.create, values.update, batchUpdate, and Drive's files.create, files.copy, files.list, permissions.create, and files.export — everything needed to build and share sheets you create. It is also the only non-sensitive scope in the set, so it needs no Google verification even in production. spreadsheets is sensitive; drive and even drive.metadata.readonly are restricted. Escalate to spreadsheets only when you must open a sheet a human created.

Publish the consent screen to production. While it sits in Testing, refresh tokens expire after 7 days — the script works all week and dies with invalid_grant on Monday, which reads like a code bug and isn't. Publishing is free and instant if you only use drive.file.

Pass both `access_type="offline"` and `prompt="consent"`. Offline alone issues a refresh token only on the very first authorization; a second run returns an access token with refresh_token: null, and the failure surfaces days later. Assert the refresh token is non-empty before persisting.

Enable both APIs in the Cloud project. Enabling Sheets does not enable Drive, and you need Drive.

invalid_grant is never retryable. It maps to seven different terminal causes — revoked access, six months unused, the 100-token-per-account limit, the Testing expiry. Re-authorize rather than retrying.

The two silent data-loss bugs

`append`'s `range` is a search hint, not a destination. The API finds the last table your range intersects and writes at the row below it, in that table's first column. From Google's own worked example: A1 writes to A3, while Sheet1, B4, B2:D4, and A3:G10 all write to B7.

Two consequences that bite GTM work specifically. A single stray blank row splits a table, so the next append lands hundreds of rows away. And the destination column follows the table, not the sheet — appending to a table that starts at column B shifts every field one column right, silently misaligning an entire enriched list.

Read the real destination from updates.updatedRange in the response, never from tableRange (which is the pre-append state and is empty when no table was found). Set insertDataOption: "INSERT_ROWS" unless you specifically want OVERWRITE, which is the default and destroys whatever sits below the detected table.

*`fields: "" erases data.** Google's field-mask semantics are "masked but absent means cleared." So repeatCell with fields: ""` to bold a header row wipes the values, number formats, notes, and validation in that range. Omitting `fields` is a hard 400, which pushes people straight to `""` as the fix that makes the error go away — and it destroys the sheet.

Name the exact paths instead: fields: "userEnteredFormat.textFormat.bold". The mask root is implied, so never prefix it with cell.. Only 17 of the 74 requests take a mask.

Reading data back

Responses are ragged and sometimes have no data key at all. Trailing empty rows and trailing empty cells per row are omitted, while interior empties come back as "". So row[4] is undefined rather than "", and a header-zip loop silently drops fields off the end of short rows. An entirely empty range returns a response with no `values` key, so naive code raises a KeyError instead of handling an empty result. ValueRange.range echoes what you requested and tells you nothing about the actual shape — pad every row to the header length before using it.

Set valueRenderOption: "UNFORMATTED_VALUE" on any read you intend to compute with. The default is FORMATTED_VALUE, which returns locale-formatted strings like "1,234.50" and "50.00%" that break parseFloat.

Dates are serial numbers with a 1899-12-30 epoch, naive wall-clock in the sheet's timezone. Treating them as UTC is the classic "everything is two hours early" bug.

Writing well

`valueInputOption` is required and it moves. It's a query param on values.update and values.append, but a body field on values.batchUpdate — passed as a query param there it is silently ignored and the call 400s.

The choice is lossy either way. USER_ENTERED parses like a human typing: "007" becomes 7, "1/2" becomes a date, and numbers parse per the spreadsheet's locale, so 1.234 differs between en_US and de_DE. RAW preserves strings exactly but leaves formulas as inert text. For enriched GTM data — phone numbers with leading zeros, IDs, version strings — RAW is usually right, and you set number formats separately via batchUpdate.

Batch aggressively. The quota is 60 requests per minute per user — one per second — separately for reads and writes. A loop writing one row at a time will hit it. One values.batchUpdate carrying many ranges costs one request.

Build a whole sheet in one atomic call. You can't consume a reply mid-batch, but sheetId, chartId, namedRangeId, bandedRangeId, slicerId, and filterViewId are all client-assignable. Pre-assign them and a create-sheet-write-header-freeze-format-chart sequence collapses into a single batchUpdate that either fully applies or doesn't. The worked example in references/batchupdate.md does exactly this.

Requests apply in order, so index shifts are real. Deleting rows or rules must go descending, and moveDimension.destinationIndex is computed before the removal.

Traps that look like bugs in your code

  • `sheetId: 0` is real and falsy. if sheet_id: silently skips the first sheet. Test against None.
  • GridRange ↔ A1 is a two-sided off-by-one. GridRange is 0-indexed and half-open; A1 is 1-indexed and inclusive. startRowIndex = a1Row - 1 but endRowIndex = a1EndRow — different adjustments on the two ends. Getting the end wrong silently drops the last row. Omitting sheetId targets sheet 0, not "the current sheet."
  • `spreadsheets.create` cannot set a parent folder. Its body has no parents field. To create inside a folder, use Drive's files.create with mimeType: application/vnd.google-apps.spreadsheet, then write content via Sheets.
  • `drive.file` cannot see files it didn't create. files.list omits them silently and files.get by ID returns 404, not 403 — so it reads as "deleted" rather than "not permitted."
  • Drive reports rate limits as 403, not always 429 (rateLimitExceeded, userRateLimitExceeded). Retry logic keyed only on 429 fails permanently against Drive.
  • `Color` is deprecated everywhere in favor of ColorStyle, which also unlocks themeColor. If both are set, ColorStyle wins. Components are floats 0–1, not 0–255.
  • *The `ByDataFilter methods reject .readonly scopes even for reads** — a read-only app on spreadsheets.readonly will 403 on batchGetByDataFilter`.
  • `includeGridData` is silently ignored whenever a fields mask is set.

Appends are not idempotent

values.* has no ETag and no If-Match. Concurrent appends race with last-write-wins, and a retried append duplicates rows — a timeout-retry on a 4,000-row append writes them twice.

This is the same hazard as the GTM APIs in this repo, so apply the same discipline: cap the batch, dry-run at small N, and on a timeout reconcile by reading the sheet back before retrying. If rows must be deduplicable after the fact, write a stable key column you can group on.

Service accounts

Reach for one when the job is unattended and there's no human to consent. The gotcha that wastes the most time: a sheet created by a service account is owned by that service account and does not appear in any human's Drive. Service accounts also have zero storage quota of their own. The fix is to create the file in a Shared Drive, or create it and immediately grant a human ownership or edit access via permissions.create. references/auth-and-drive.md covers four approaches.