Syncing Google Sheets to Notion with Workers
Updated:
Google Sheets is unmatched when it comes to raw computation. GOOGLEFINANCE, IMPORTXML, pivot tables, Apps Script — it's a powerhouse for crunching numbers. Notion is unmatched for structured collaboration: databases, views, relations, and now Custom Agents. The problem has always been getting data from one into the other. Historically, that meant Zapier, Make, CSV exports, or plain old copy-paste.
In early 2026, Notion released Workers — a way to run your own TypeScript code on Notion's infrastructure. Among other things, Workers can sync external data into Notion databases on a schedule. This guide walks through building a Google Sheets to Notion sync from scratch, what it costs, and when a dedicated tool might be a better fit.
What Are Notion Workers?
Workers launched as an "extreme pre-release alpha" alongside Custom Agents. They're currently available on Notion Business and Enterprise plans. A Worker is a small TypeScript program that Notion hosts and runs for you. You write the code, deploy it with the ntn CLI, and Notion handles execution.
Workers have two capability types:
- Tools — functions that Custom Agents can call on demand. Think of them as giving your agent a new verb: "send an SMS," "look up the weather," "query an internal API."
- Syncs — scheduled jobs that pull data from an external source into a Notion database. Think of them as a cron job with a database attached.
To use Workers, a workspace admin has to opt in.
If you've used the Notion API before, you might be wondering how Workers differ from regular integrations. Integrations are self-hosted — you run them on your own server or infrastructure. Workers are hosted by Notion and can be called by Custom Agents. Workers also get a managed database primitive that integrations don't have.
Understanding the Sync Primitive
worker.sync() defines a scheduled job that pulls data from an external source into a Notion managed database. You define the database schema with worker.database(), and Notion creates the database for you on first deploy. The primaryKeyProperty works like a primary key in SQL — it's how the sync knows which rows to update vs. insert.
The execute function returns an object with changes, hasMore, and optionally nextState. Each change is an upsert (insert or update) or delete operation, identified by a unique key. If you have more data than fits in one batch, return hasMore: true with a nextState value and the sync will call execute again.
There are two sync modes:
- Replace (
mode: "replace") — each cycle returns the full dataset. After the final page (hasMore: false), any rows that weren't included get deleted from Notion. Best for small datasets under 1,000 records. - Incremental (
mode: "incremental") — only sends what changed since the last run. Records not mentioned are left alone. Deletions must be explicit. Best for large datasets or APIs with change tracking.
For a Google Sheet with a few hundred rows, replace mode works well. The Worker reads the entire sheet on each sync and the managed database mirrors it.
The default sync schedule is every 30 minutes. You can configure it anywhere from "1m" (every minute) to "7d" (once a week), or use "continuous" (runs back-to-back) and "manual" (CLI trigger only).
For production use, the Notion docs recommend a "backfill + delta" pattern: a manual replace sync for full catch-up, paired with a frequent incremental sync for staying current. For the tutorial below, plain replace mode is all we need.
One important thing: the sync direction is one-way only. External to Notion. There is no reverse sync. If you edit a record in the Notion database, those changes don't flow back to Google Sheets. This will come up again later.
Step 1: Scaffold the Worker
Before you start, make sure you have Node.js installed, along with a Google Cloud project that has the Sheets API enabled. You'll also need a Google Sheet with structured data. For this tutorial, we'll use a sample sales pipeline with columns: Name, Email, Company, Deal Value, and Stage.
Install the ntn CLI globally, log in, and scaffold a new Worker:
npm i -g ntn
ntn login
ntn workers new
Follow the prompts to name your Worker. Once done, cd into the project directory. The generated structure is minimal: src/index.ts, package.json, and tsconfig.json.
Step 2: Define the database schema
Open src/index.ts and define the managed database that will mirror your Google Sheet:
import { Worker } from "@notionhq/workers";
import { Schema } from "@notionhq/workers/schema";
import { Builder } from "@notionhq/workers/builder";
const worker = new Worker();
const deals = worker.database("deals", {
type: "managed",
initialTitle: "Sales Pipeline (from Sheets)",
primaryKeyProperty: "Row ID",
schema: {
properties: {
Name: Schema.title(),
"Row ID": Schema.richText(),
Email: Schema.richText(),
Company: Schema.richText(),
"Deal Value": Schema.number(),
Stage: Schema.richText(),
},
},
});
type: "managed" means Notion creates and owns this database. You define the schema, and on first deploy, the database shows up in your workspace. primaryKeyProperty is how the sync deduplicates rows — if two records share the same key, the newer one wins.
Step 3: Set up Google OAuth
This is the friction-heavy part. You need OAuth credentials from Google Cloud Console to authenticate with the Sheets API.
Create OAuth 2.0 credentials in your Google Cloud Console (select "Web application" as the application type). Then store your credentials as Worker secrets:
ntn workers env set GOOGLE_CLIENT_ID=your-client-id
ntn workers env set GOOGLE_CLIENT_SECRET=your-client-secret
Add the OAuth configuration to your Worker code:
const googleAuth = worker.oauth("googleAuth", {
name: "google-sheets",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
scope: "https://www.googleapis.com/auth/spreadsheets.readonly",
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
authorizationParams: {
access_type: "offline",
prompt: "consent",
},
});
You need to deploy the Worker before starting the OAuth flow, because the deployed Worker needs the client secret to exchange the authorization code for tokens. Deploy, then grab the redirect URL:
ntn workers deploy
ntn workers oauth show-redirect-url
Copy the redirect URL and add it to your Google Cloud OAuth credentials as an authorized redirect URI. Then start the OAuth flow:
ntn workers oauth start googleAuth
This opens a browser window where you grant the Worker read-only access to your Google Sheets. All in all, it's about 10 steps across two platforms. If you've dealt with Google OAuth before, you know the drill. If you haven't, there's plenty of material on the Internets about it — budget about 30 minutes.
Step 4: Write the sync logic
Store your spreadsheet ID as a Worker secret:
ntn workers env set SPREADSHEET_ID=your-spreadsheet-id
Now write the sync that reads from the Sheets API and maps rows to Notion properties:
const SPREADSHEET_ID = process.env.SPREADSHEET_ID ?? "";
const RANGE = "Sheet1!A2:E"; // skip the header row
const sheetsApi = worker.pacer("sheets", {
allowedRequests: 50,
intervalMs: 60_000,
});
worker.sync("dealsSync", {
database: deals,
mode: "replace",
schedule: "30m",
execute: async () => {
const token = await googleAuth.accessToken();
await sheetsApi.wait();
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/${RANGE}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) {
throw new Error(`Sheets API error: ${res.status}`);
}
const data = await res.json();
const rows = data.values ?? [];
return {
changes: rows.map((row: string[], i: number) => ({
type: "upsert" as const,
key: `row-${i}`,
properties: {
Name: Builder.title(row[0] ?? ""),
"Row ID": Builder.richText(`row-${i}`),
Email: Builder.richText(row[1] ?? ""),
Company: Builder.richText(row[2] ?? ""),
"Deal Value": Builder.number(parseFloat(row[3]) || 0),
Stage: Builder.richText(row[4] ?? ""),
},
})),
hasMore: false,
};
},
});
export default worker;
A few things to keep in mind:
mode: "replace"means if a row is deleted from the Sheet, it gets deleted from Notion on the next sync cycle.- The
keymust be stable. Using the row index (row-0,row-1, ...) works for append-only sheets, but breaks if rows are reordered or deleted from the middle. If your sheet changes frequently, consider adding a UUID column and using that as the key instead. - The Sheets API returns everything as strings. You need
parseFloat()for numbers, and you'd need date parsing if your sheet has date columns. worker.pacer()ensures you don't hit the Sheets API rate limit. If multiple syncs share the same pacer, the budget gets split between them automatically.
Step 5: Deploy and test
ntn workers deploy
You can preview the sync output without writing to Notion:
ntn workers sync trigger dealsSync --preview
If the preview looks right, trigger the real sync:
ntn workers sync trigger dealsSync
Check the sync status:
ntn workers sync status
This enters a live-updating watch mode that polls every 5 seconds. You should see the status go from INITIALIZING to HEALTHY once the sync completes. The managed database will appear in your Notion workspace, populated with rows from your Google Sheet.
From here, you can point a Custom Agent at this database and it'll be able to query your spreadsheet data directly within Notion.
Debugging
When things go wrong (and during alpha, they will — Notion's own words are "expect things to go wrong"), here's how to dig in.
Check the sync status first:
ntn workers sync status dealsSync
The status labels tell you what's happening:
- HEALTHY — last run succeeded.
- INITIALIZING — deployed but hasn't completed a run yet.
- WARNING — 1-2 consecutive failures.
- ERROR — 3 or more consecutive failures.
- DISABLED — paused via
ntn workers capabilities disable.
To see logs for a specific run:
ntn workers runs list
ntn workers runs logs <runId>
To quickly get logs for the most recent run:
ntn workers runs list --plain | head -n1 | cut -f1 | xargs -I{} ntn workers runs logs {}
Common issues:
- OAuth token refresh — if your Google token expires and the refresh fails, the sync will start erroring out. Re-run
ntn workers oauth start googleAuthto re-authorize. - Batch size — returning too many changes in one execution will fail. If your sheet has thousands of rows, paginate using
hasMoreandnextState. Start with batches of around 100. - Schema mismatches — if you rename or add columns in your Google Sheet but don't update the Worker code, the sync will either write empty values or miss the new columns entirely. Update the code and redeploy.
- Rate limiting — the
worker.pacer()should prevent hitting the Sheets API rate limit, but if multiple syncs share the same Google Cloud project, you might need to lower theallowedRequestsvalue.
You can pause a sync while you investigate:
ntn workers capabilities disable dealsSync
# fix the issue, redeploy
ntn workers capabilities enable dealsSync
Keep in mind that deploying does not reset the sync state. If you need a fresh start:
ntn workers sync state reset dealsSync
What This Will Cost You
As of this writing, Workers and Custom Agents are free while in alpha on Business and Enterprise plans. The free period runs through May 3, 2026.
After that, Notion plans to introduce credit-based pricing at 1,000 credits for $10. The exact credit cost per sync execution hasn't been published yet. A sync running every 30 minutes means 48 executions per day, roughly 1,440 per month — and that's just one sync. Multiple syncs multiply this linearly.
Beyond credits, there are costs that don't show up on a bill:
- Developer time to build and maintain the Worker. The OAuth setup alone can eat an afternoon.
- Debugging time when the alpha breaks. And it will — Notion says as much.
- OAuth credential management. Tokens expire, refresh tokens get revoked, Google Cloud Console settings drift.
- Schema drift. Every time someone adds or renames a column in the Sheet, you need to update the Worker code and redeploy.
A dedicated sync tool runs on flat-rate pricing with no credits, no deployment pipeline, and no code to maintain. Depending on how many syncs you run and how often, the total cost of ownership for a Worker can add up quickly — even before credit charges kick in.
What Workers Can't Do (Yet)
One-way only. worker.sync() goes from external source to Notion. There is no reverse sync primitive. If you update a record in the Notion database, those changes don't flow back to Google Sheets. For workflows where both sides need to stay in sync — say, enriching data in Sheets and reflecting it in Notion — Workers alone won't cut it.
Limited property types. The Workers schema supports basic types: title, rich text, number, checkbox, select, date, and URL. There's no native handling of relations, rollups, formulas, people, or files & media. The Sheets API returns everything as strings, so you're on the hook for all type coercion.
Schema drift. Add a column to your Google Sheet? The Worker doesn't know about it. Rename a column? The sync writes empty values. Change a column's data type? You need to update the Worker code and redeploy. A dedicated tool can detect schema changes and adapt or alert you.
Alpha instability. Notion explicitly warns to "expect things to go wrong." Breaking changes to the @notionhq/workers package, the ntn CLI, and the hosting platform are expected. There's no SLA, no uptime guarantee, and support is limited to the Workers GitHub repo. If your team depends on data freshness, this matters.
Limited observability. You get logs via ntn workers runs logs, but there's no dashboard, no alerting, and no retry visibility. If a sync fails at 3 AM, you won't know until you check manually.
When to Use Workers vs. a Dedicated Sync Tool
Use Workers when:
- You need custom business logic in the sync — transformations, filtering, API mashups
- You're already building Custom Agent tools and want syncs to live alongside them
- The data source is unusual (an internal API, a niche SaaS, something no off-the-shelf tool supports)
- You have a developer on staff who will maintain the Worker long-term
Use a dedicated tool when:
- You need bidirectional sync (Notion ↔ Google Sheets)
- Non-technical team members need to set up or modify the sync
- You need production reliability — monitoring, alerting, automatic retries
- Schema changes happen often and you don't want to redeploy code each time
- You want flat-rate pricing instead of credit metering
Some teams will use both: Workers for bespoke agent tools and a dedicated sync for the Sheets ↔ Notion data pipeline. That's a perfectly valid setup — Workers for custom logic, managed tools for commodity integrations.
Workers is genuinely exciting as a platform for extending Notion, and the sync primitive is a solid option for developers who need custom integrations with unusual data sources. But for the common case of keeping Sheets and Notion in sync, the alpha status, one-way limitation, and upcoming credit costs make a dedicated tool the more practical choice for most teams today.
Notion Backups syncs Notion databases to Google Sheets and back, starting at $10/month. No code, no OAuth setup, no credit costs. If the tutorial above felt like a lot of work for a straightforward sync, that's the point.
Back up your Notion workspaces today
Get started