Google Sheets Export: Append, Upsert & Dedupe avatar

Google Sheets Export: Append, Upsert & Dedupe

Pricing

from $0.20 / 1,000 row writtens

Go to Apify Store
Google Sheets Export: Append, Upsert & Dedupe

Google Sheets Export: Append, Upsert & Dedupe

Export Apify datasets or JSON to Google Sheets. Append, append only new rows, upsert by key, or replace with backup. Read tabs back into datasets.

Pricing

from $0.20 / 1,000 row writtens

Rating

0.0

(0)

Developer

Kirby Bookhart-Tsai

Kirby Bookhart-Tsai

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

19 days ago

Last modified

Categories

Share

Send any Apify dataset, or JSON you paste, to a Google Sheets tab, and keep that tab up to date run after run without duplicates. Run it after a scraper, on a schedule, from the API or from an AI agent.

  • Append, Append new only (skip rows already in the sheet), Upsert (update matching rows, add the rest), Replace (with automatic backup) and Read (load a tab into a dataset).
  • Reliable in pipelines. Rows go exactly below your last row, even if the tab has blank rows. Google rate limits and outages are retried with backoff. Google's 10-million-cell limit is checked before anything is written. Errors explain what to fix.
  • Respects your sheet. New data columns are added at the right end. Upsert only touches the columns in your data, so notes and formulas you add in other columns stay.
  • Clean columns. Nested data is flattened into readable columns (seller.name, stats.rating), while fields that vary wildly between items (like maps keyed by IDs) stay as one JSON column instead of thousands of columns.
  • Safe values. By default values are stored exactly as they are, so scraped text that starts with = can't run as a formula in your sheet.
  • Connect in 3 minutes without Google Cloud, or use a Google service account. No interactive sign-in during runs, so it works in schedules, integrations and AI agents.
  • Try it free. Without a Google connection the Actor shows a preview of the columns and rows it would write. Dry run connects and shows what would be appended, updated and skipped, without changing anything.

Not affiliated with or endorsed by Google. Google Sheets is a trademark of Google LLC.

Quick start

  1. Connect Google using Option A (easiest) or Option B.
  2. Choose what to export: pick a dataset (for example, the results of a scraper run) or paste JSON.
  3. Choose where: paste the spreadsheet link and a tab name (created if missing), then pick a mode.
  4. Run. The run summary shows how many rows were appended, updated and skipped, with a link to the sheet.

How to connect

Option A: connector script (no Google Cloud needed)

You add a small script to your spreadsheet. The Actor sends rows to that script, and the script writes them with your own Google account. Your Google password and sign-in tokens never leave Google. The script can only touch the spreadsheet it's installed in, and only accepts requests that carry your secret.

  1. Open the spreadsheet and click Extensions > Apps Script.
  2. Delete any code in the editor and paste the connector code below.
  3. In the first lines, replace CHANGE-ME-to-a-long-random-password with your own long random text (16+ characters). This is your connector secret. Click the save icon.
  4. In the left sidebar, click + next to Services, choose Google Sheets API and click Add. (This switches on the API the connector uses; no Google Cloud setup needed.)
  5. Click Deploy > New deployment. Next to "Select type" click the gear icon and choose Web app. Set Execute as: Me and Who has access: Anyone. Click Deploy.
  6. Google asks you to authorize the script. Choose your account. You'll see "Google hasn't verified this app": that's normal for a script you wrote yourself in your own account. Click Advanced > Go to … (unsafe) > Allow. The permissions it asks for are explained in the FAQ.
  7. Copy the Web app URL (it ends in /exec). In the Actor input, paste it as Connector URL and your secret as Connector secret.

The secret and URL are stored encrypted by Apify as secret inputs. Anyone without the secret gets an error and can't touch your data. If you ever change the secret, click Deploy > Manage deployments > Edit (pencil) > Version: New version > Deploy; the URL stays the same.

Some company Google accounts don't allow "Who has access: Anyone". If you can't pick it, use Option B.

Option B: service account

A service account is a robot Google account you create in Google Cloud and share the spreadsheet with. Teams like it because it doesn't depend on any person's account.

  1. Go to Google Cloud Console and create a project (or pick one).
  2. Open Google Sheets API and click Enable.
  3. Go to IAM & Admin > Service Accounts > Create service account. Give it a name and click Done (no roles are needed).
  4. Open the new account, go to Keys > Add key > Create new key > JSON. A .json file downloads.
  5. In your spreadsheet click Share, paste the service account's email (it ends in .iam.gserviceaccount.com), choose Editor, and share.
  6. In the Actor input choose Google service account, paste the whole JSON file into Service account key (JSON), and paste the spreadsheet link.

Keep the key file private: anyone with it can edit spreadsheets shared with that service account. Apify stores it encrypted.

Modes

ModeWhat happensNeeds key columns
Append (default)Adds every item as a new row below the last non-empty row.No
Append new onlyAdds only items whose key isn't in the tab yet; also drops duplicates within the input. Ideal for daily scrapes.Yes
UpsertUpdates rows whose key matches (only in your data's columns) and appends the rest. If the input repeats a key, the last item wins.Yes
ReplaceSaves the tab's current contents to the run's key-value store (BACKUP-001, …), clears the tab, and writes fresh data with a header row.No
ReadLoads the tab into the run's dataset. Row 1 is used as field names.No

Key columns identify a row: usually url or id. Use several for a combined key, like sku + country. Values are compared as text, so 123 and "123" match.

Use it after a scraper (integration)

  1. Open your scraper (Actor or task) and go to the Integrations tab.
  2. Add an integration, choose Google Sheets Export, and fill in the connection, spreadsheet, tab and mode.
  3. Make sure the input contains "datasetId": "{{resource.defaultDatasetId}}". Each time the scraper succeeds, its results go straight to your sheet.

Tip: with Append new only and url as the key column, a daily scraper builds one clean list in your sheet with no duplicates.

Input examples

Append a dataset with the connector:

{
"datasetId": "YOUR_DATASET_ID",
"sheetName": "Leads",
"mode": "append",
"connection": "appsScript",
"appsScriptUrl": "https://script.google.com/macros/s/AKfy.../exec",
"appsScriptSecret": "your-connector-secret"
}

Keep a price list current by product URL, using a service account and only three columns:

{
"datasetId": "YOUR_DATASET_ID",
"spreadsheet": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID/edit",
"sheetName": "Prices",
"mode": "upsert",
"keyColumns": ["url"],
"fields": ["url", "title", "price"],
"connection": "serviceAccount",
"serviceAccountKey": "{ \"type\": \"service_account\", ... }"
}

Read a tab of URLs into a dataset, for example to feed a scraper:

{
"mode": "read",
"sheetName": "URLs to scrape",
"connection": "appsScript",
"appsScriptUrl": "https://script.google.com/macros/s/AKfy.../exec",
"appsScriptSecret": "your-connector-secret"
}

Output

Each export adds one summary item to the run's dataset (also saved as OUTPUT in the key-value store):

{
"spreadsheetUrl": "https://docs.google.com/spreadsheets/d/.../edit",
"tab": "Leads",
"mode": "append-new",
"itemsRead": 900,
"rowsAppended": 25,
"rowsUpdated": 0,
"rowsSkippedExisting": 875,
"duplicatesInInput": 0,
"columnsAdded": [],
"stoppedAtChargeLimit": false,
"warnings": []
}

In Read mode, the dataset contains the rows and the summary is in OUTPUT. In Replace mode, the old contents are in BACKUP-001 (and more parts for big tabs).

Pricing

You pay only for what the Actor does. Platform usage is included.

EventPrice
Run (only when at least one row is written or read)$0.005
Row written (appended or updated)$0.20 per 1,000 rows
Row read (Read mode)$0.10 per 1,000 rows
Actor start (Apify's standard fee, every run)$0.00005

Previews, dry runs, skipped rows and runs that find nothing new cost only the $0.00005 start fee.

Examples:

  • A daily export of 500 new rows: $0.005 + $0.10 = $0.105 per run, about $3.15 a month.
  • A weekly upsert of 2,000 rows: $0.405 per run.

Set Maximum cost per run in the run options to cap spending. The Actor writes only what fits and says so in the summary. Replace mode refuses to start if the limit can't cover every row, so a tab is never left half-written.

Limits

  • Google's limits:
    • 10 million cells per spreadsheet, across all tabs; empty cells count too. The Actor checks this before writing and tells you how big the result would be.
    • 50,000 characters per cell. Longer values are shortened and counted in the summary.
    • About 60 write requests per minute per user. The Actor batches up to 5,000 rows per request and paces itself.
  • Up to 1,000 columns per export. Use Columns to include for very wide data.
  • Don't run two exports into the same tab at the same time; they could interleave rows.
  • With the connector, Google's daily Apps Script quota applies: 20,000 requests a day for personal accounts, more for Workspace. Each request carries up to 5,000 rows, so the spreadsheet's cell limit usually comes first.

FAQ

Why does the connector need "See, edit, create and delete all your Google Sheets spreadsheets" and "Connect to an external service"? The connector writes through Google's official Sheets API, and Google only offers that permission for all your spreadsheets. The code itself can only reach the spreadsheet it's installed in: it builds every request from its own spreadsheet ID and allows only a short list of read and write calls. "External service" refers to Google's Sheets API at sheets.googleapis.com. You can read the whole script below; it's under 90 lines.

The run failed with "The connector rejected the secret". The Connector secret must match SECRET in the script exactly. If you edited the script, deploy a new version (Deploy > Manage deployments > Edit > Version: New version).

"Google asked for a sign-in, so the connector is not public". Set Who has access to Anyone in the deployment and deploy a new version. If your company blocks that, use a service account.

"The service account … can't edit this spreadsheet". Share the spreadsheet with the service account's email as an Editor. The email is shown in the error and the run log.

Why are some nested fields one JSON column? Fields whose keys differ a lot between items (for example, objects keyed by IDs or names) would create thousands of columns, so they stay as JSON. To get a specific value as its own column, list it in Columns to include with a dot path, like pricing.events.start.price.

Dates and numbers look like text. By default values are stored exactly as in the data; numbers stay numbers, but a date like 2026-10-02 stays text. Choose Let Google interpret them to have Google parse dates and percentages like typed input. Don't use that option for untrusted scraped text, since text starting with = would become a formula.

How do I undo a Replace? Open the run's Storage > Key-value store, download BACKUP-001, and copy the rows back.

Is my data stored? The Actor reads your dataset, writes to your sheet, and keeps only the run summary (and Replace backups) in that run's own storage, under your Apify account's retention settings.

Connector code

Paste this into Extensions > Apps Script in your spreadsheet (see Option A):

/**
* Google Sheets Export connector (version 1)
*
* Lets the "Google Sheets Export" Apify Actor read and write THIS spreadsheet only, using your own
* Google account. Your Google password and tokens never leave Google.
*
* Setup (about 3 minutes):
* 1. Replace the SECRET below with your own long random text (at least 16 characters).
* 2. At the left, click + next to "Services", choose "Google Sheets API" and click Add.
* 3. Click Deploy > New deployment > Select type: Web app.
* Execute as: Me. Who has access: Anyone. Click Deploy and allow access.
* 4. Copy the Web app URL. In the Actor input, paste it as "Connector URL" and your SECRET as
* "Connector secret".
*
* If you change the SECRET later: Deploy > Manage deployments > Edit > Version: New version.
*/
const SECRET = 'CHANGE-ME-to-a-long-random-password';
const VERSION = 1;
const SHEETS_API = 'https://sheets.googleapis.com/v4/spreadsheets/';
const PLACEHOLDER = 'CHANGE-ME-to-a-long-random-password';
function doPost(e) {
var req;
try {
req = JSON.parse(e.postData.contents);
} catch (err) {
return reply_({ ok: false, error: 'bad-request', message: 'Body is not JSON' });
}
if (SECRET === PLACEHOLDER || SECRET.length < 16) return reply_({ ok: false, error: 'secret-not-set' });
if (!req || typeof req.token !== 'string' || !safeEquals_(req.token, SECRET)) return reply_({ ok: false, error: 'bad-token' });
var ss = SpreadsheetApp.getActiveSpreadsheet();
var id = ss.getId();
if (req.action === 'ping') return reply_({ ok: true, version: VERSION, spreadsheetId: id, spreadsheetUrl: ss.getUrl() });
if (req.action !== 'proxy') return reply_({ ok: false, error: 'bad-request', message: 'Unknown action' });
var method = String(req.method || 'GET').toUpperCase();
var path = String(req.path || '');
if (method !== 'GET' && method !== 'POST') return reply_({ ok: false, error: 'bad-request', message: 'Method not allowed' });
if (!isAllowedPath_(path)) return reply_({ ok: false, error: 'bad-request', message: 'Path not allowed' });
var options = {
method: method.toLowerCase(),
headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
muteHttpExceptions: true,
};
if (method === 'POST') {
options.contentType = 'application/json';
options.payload = JSON.stringify(req.body || {});
}
var res = UrlFetchApp.fetch(SHEETS_API + id + path, options);
return reply_({ ok: true, status: res.getResponseCode(), body: res.getContentText() });
}
function doGet() {
return reply_({ ok: true, message: 'The connector is deployed. Paste this URL into the Actor input.' });
}
/** Only these Sheets API calls on this spreadsheet are forwarded. */
function isAllowedPath_(p) {
if (p.indexOf('..') !== -1 || p.indexOf('#') !== -1 || p.indexOf('\\') !== -1) return false;
var query = '(\\?[^/?#]*)?$';
return new RegExp('^' + query).test(p) // spreadsheet info
|| new RegExp('^:batchUpdate' + query).test(p)
|| new RegExp('^/values:(batchUpdate|batchGet|batchClear)' + query).test(p)
|| new RegExp('^/values/[^/?#]+(:clear|:append)?' + query).test(p);
}
function safeEquals_(a, b) {
if (a.length !== b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
function reply_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(ContentService.MimeType.JSON);
}
/** Never called. Makes Apps Script ask for full Sheets access, which the Sheets API calls above need. */
function requestScopes_() {
SpreadsheetApp.openById('unused');
}

Feedback

Found a bug or need a feature? Open an issue on the Issues tab and include a link to the run.