
Google Sheets API JavaScript: Create, Update, Delete Rows
To do CRUD on a Google Sheet from JavaScript, use the Sheets API v4 through the official googleapis Node.js client. Insert rows with spreadsheets.values.append, read them with values.get, change them with values.update or values.batchUpdate, and delete a row with a deleteDimension request sent to spreadsheets.batchUpdate. Run this on a server, authenticated as a service account that the spreadsheet is shared with.
This post is about the write side. If you still need a Google Cloud project, credentials and your first read, start with Google Sheets API JS integration basics and come back. Everything below uses Node.js 18 or later and ES modules, and was checked against Google's Sheets API docs in October 2026.
The example sheet
We'll use one tab named Tasks with a header row:
| A | B | C | D | |
|---|---|---|---|---|
| 1 | id | title | status | updated_at |
| 2 | t1 | Write docs | open | 2026-10-01T09:00:00Z |
| 3 | t2 | Fix login bug | done | 2026-10-02T14:30:00Z |
The spreadsheet ID is the long string in its URL: https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit.
Setup: client and auth
npm install googleapis
Create a service account in Google Cloud, download its JSON key, enable the Google Sheets API on the project, then share the spreadsheet with the service account's email (it looks like name@project.iam.gserviceaccount.com) as an Editor. Forgetting that share is the most common cause of 403 The caller does not have permission.
// sheets.js
import { google } from 'googleapis';
const auth = new google.auth.GoogleAuth({
keyFile: process.env.GOOGLE_APPLICATION_CREDENTIALS, // path to the key JSON
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
export const sheets = google.sheets({ version: 'v4', auth });
export const spreadsheetId = process.env.SHEET_ID;
Keep the key out of Git and out of the browser. A service-account key in front-end code gives anyone full access to every sheet shared with it. For a browser-only app where each user edits their own sheets, use the browser quickstart with Google Identity Services and the user's own OAuth token instead.
Create: append a row
values.append finds the table in the range you give and writes after its last row (reference).
import { sheets, spreadsheetId } from './sheets.js';
export async function createTask(id, title) {
const res = await sheets.spreadsheets.values.append({
spreadsheetId,
range: 'Tasks!A:D',
valueInputOption: 'RAW',
insertDataOption: 'INSERT_ROWS',
requestBody: {
values: [[id, title, 'open', new Date().toISOString()]],
},
});
return res.data.updates.updatedRange; // e.g. "Tasks!A4:D4"
}
Two options matter:
valueInputOption:RAWstores the input as-is.USER_ENTEREDparses it the way the Sheets UI would, so=SUM(A1:A3)becomes a formula and2026-10-11becomes a date. UseRAWfor user-supplied text so nobody can inject formulas.insertDataOption:INSERT_ROWSinserts new rows for the data. The default,OVERWRITE, writes into whatever empty cells follow the table.
To append many rows, pass them all in one values array. One request with 500 rows is far cheaper than 500 requests.
Read: find a row by its id
Updates and deletes need a row number, and the API doesn't have a "WHERE" clause. Read the column, then find the row:
export async function findRow(id) {
const res = await sheets.spreadsheets.values.get({
spreadsheetId,
range: 'Tasks!A2:A', // ids only, below the header
});
const rows = res.data.values ?? [];
const index = rows.findIndex(r => r[0] === id);
return index === -1 ? null : index + 2; // 1-based sheet row number
}
The + 2 is because the range starts at row 2 and arrays are zero-based. Empty trailing rows aren't returned at all, so check for undefined rather than assuming a length.
Update: one range or many
Change specific cells with values.update:
export async function setStatus(id, status) {
const row = await findRow(id);
if (!row) throw new Error(`Task ${id} not found`);
await sheets.spreadsheets.values.update({
spreadsheetId,
range: `Tasks!C${row}:D${row}`,
valueInputOption: 'RAW',
requestBody: { values: [[status, new Date().toISOString()]] },
});
}
To update several ranges in one call, use values.batchUpdate. Note that valueInputOption moves inside requestBody here:
await sheets.spreadsheets.values.batchUpdate({
spreadsheetId,
requestBody: {
valueInputOption: 'RAW',
data: [
{ range: 'Tasks!C2', values: [['done']] },
{ range: 'Tasks!C3', values: [['open']] },
],
},
});
The response's totalUpdatedCells tells you how many cells changed.
Delete: clear values vs remove the row
There are two different "deletes", and they hit two different endpoints.
Clear the values but keep the empty row in place, with values.clear (reference). Formatting stays.
await sheets.spreadsheets.values.clear({
spreadsheetId,
range: `Tasks!A${row}:D${row}`,
});
Remove the row so the rows below shift up, with a deleteDimension request to spreadsheets.batchUpdate (row and column samples). This needs the numeric sheetId of the tab (not its name), and the indexes are zero-based with endIndex exclusive:
async function getSheetId(title) {
const res = await sheets.spreadsheets.get({
spreadsheetId,
fields: 'sheets.properties(sheetId,title)',
});
const sheet = res.data.sheets.find(s => s.properties.title === title);
if (!sheet) throw new Error(`No tab named ${title}`);
return sheet.properties.sheetId;
}
export async function deleteTask(id) {
const row = await findRow(id);
if (!row) return false;
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [{
deleteDimension: {
range: {
sheetId: await getSheetId('Tasks'),
dimension: 'ROWS',
startIndex: row - 1, // sheet row 5 -> index 4
endIndex: row,
},
},
}],
},
});
return true;
}
Row numbers shift after a delete. If you remove several rows, sort them from the bottom up and send all the deleteDimension requests in one batchUpdate. Otherwise each delete moves the rows you haven't deleted yet.
Create a whole spreadsheet
spreadsheets.create makes a new file:
const res = await sheets.spreadsheets.create({
requestBody: {
properties: { title: 'Tasks 2026' },
sheets: [{ properties: { title: 'Tasks' } }],
},
});
console.log(res.data.spreadsheetId, res.data.spreadsheetUrl);
When a service account creates it, the service account owns it. You won't see it in your own Drive until it's shared with you through the Drive API. It's usually simpler to create the sheet by hand and share it with the service account.
Which method for which job
| Task | Method | Endpoint |
|---|---|---|
| Add rows | spreadsheets.values.append |
values |
| Read a range | spreadsheets.values.get / batchGet |
values |
| Change cells | spreadsheets.values.update |
values |
| Change many ranges | spreadsheets.values.batchUpdate |
values |
| Empty cells | spreadsheets.values.clear / batchClear |
values |
| Remove or insert rows | deleteDimension / insertDimension |
spreadsheets.batchUpdate |
| Formatting, new tabs | other requests |
spreadsheets.batchUpdate |
Limits and errors
As of October 2026, the usage limits page lists 300 read and 300 write requests per minute per project, and 60 of each per minute per user per project. Over the limit you get HTTP 429. Google recommends retrying with exponential backoff: wait about 1, 2, 4, 8 seconds plus some random jitter, up to 32 or 64 seconds. Requests running longer than 180 seconds time out, and Google recommends keeping payloads to about 2 MB.
Standard use of the API costs nothing. The same page says charges for going over the quota limits are planned for later in 2026, so batch your writes now rather than later.
Other errors you'll hit:
403 The caller does not have permission: the sheet isn't shared with the service account, or the scope is read-only.400 Unable to parse range: the tab name is wrong. Quote names with spaces:'My Tasks'!A:D.- Values come back as strings: by default
values.getreturns formatted values. PassvalueRenderOption: 'UNFORMATTED_VALUE'to get raw numbers.
When a sheet is the wrong database
Sheets is great for small internal tools, forms and data that people also edit by hand. It's a poor fit once you need concurrent writers, transactions, uniqueness constraints or more than a few thousand rows a day. Rows can be edited, sorted or deleted by hand at any time, so never cache row numbers. If you're outgrowing it, a hosted Postgres with a free tier is the usual next step: browse database tools on DevHunt, or the free Airtable alternatives if you want to keep a spreadsheet-like UI.
FAQ
Can I use the Google Sheets API from JavaScript in the browser?
Yes, but not with a service account. In the browser, load the Google API client and Google Identity Services, sign the user in with OAuth, and call the API with their token, as in Google's JavaScript quickstart. For anything that writes to a shared sheet, put the code on a server or a serverless function.
How do I delete a row with the Google Sheets API?
Send a deleteDimension request to spreadsheets.batchUpdate with the tab's numeric sheetId, dimension: 'ROWS', and a zero-based startIndex and exclusive endIndex. To delete sheet row 5, use startIndex: 4, endIndex: 5. values.clear only empties the cells.
What's the difference between values.batchUpdate and spreadsheets.batchUpdate?
spreadsheets.values.batchUpdate writes cell values into several ranges. spreadsheets.batchUpdate changes the spreadsheet's structure and formatting: inserting and deleting rows, adding tabs, merging cells, conditional formatting.
Is the Google Sheets API free?
Yes, standard use has no extra cost as of October 2026. You're limited by per-minute quotas (300 requests per minute per project for reads and for writes). Google has said charges for exceeding the quota are planned later in 2026.
Can Google Sheets run JavaScript itself?
Yes, through Google Apps Script (Extensions > Apps Script in a sheet), which uses JavaScript syntax and its own SpreadsheetApp service. It's convenient for automations inside one sheet. The REST API in this guide is for code running outside Google, such as your own backend.