Published Sep 16, 2025 ⦁ Updated Oct 11, 2026 ⦁ 9 min read
Google Sheets API JavaScript: Create, Update, Delete Rows

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: RAW stores the input as-is. USER_ENTERED parses it the way the Sheets UI would, so =SUM(A1:A3) becomes a formula and 2026-10-11 becomes a date. Use RAW for user-supplied text so nobody can inject formulas.
  • insertDataOption: INSERT_ROWS inserts 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.get returns formatted values. Pass valueRenderOption: '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.