Price rises Jun 23 Get Starter for $10/mo before it becomes $11/mo Only soon left

Lock in $10 Learn more

Syncing Google Sheets to Notion with Workers

Updated:

Google Sheets is still where many teams do the messy, useful work: imports, formulas, finance models, lead lists, lightweight CRMs, and one-off operational trackers. Notion is where those same teams often want the data to live afterwards: in databases, pages, views, and agent-accessible workspaces.

You can connect the two with Zapier, Make, CSV exports, or a dedicated sync product. You can also write the sync yourself with Notion Workers. Workers are hosted Node/TypeScript programs that run on Notion's infrastructure. For this use case, they let you pull data from Google Sheets into a Notion managed database on a schedule.

This guide walks through the full version: setting up the Worker, defining the Notion database schema, authorizing Google Sheets with OAuth, writing the sync, testing it, and understanding the limitations before you depend on it.

What Are Notion Workers?

A Notion Worker is a small TypeScript project that exports one Worker instance. You register capabilities on that Worker and deploy it with the ntn CLI. Notion hosts the code and runs it for you.

Workers currently support three capability types:

  • Syncs pull external records into Notion managed databases on a schedule.
  • Agent tools are functions that Notion Custom Agents can call, for example to look up a customer or create an issue in another system.
  • Webhooks expose HTTP endpoints that external services can call, for example GitHub, Stripe, or Zendesk.

For a Google Sheets to Notion pipeline, the sync capability is the main piece. Tools and webhooks are useful in other workflows, but they are not required for a scheduled spreadsheet import.

If you have used the regular Notion API, the difference is hosting and database management. A normal integration runs on your own server. A Worker runs inside Notion. A sync Worker can also declare a managed database in code, and Notion creates and migrates that database when you deploy.

What We Are Building

We will sync a Google Sheet named Pipeline into a Notion database named Sales Pipeline (from Sheets).

The sheet should have this header row:

Row ID, Name, Email, Company, Deal Value, Stage

The Row ID column matters. A sync needs a stable key so it can update the same Notion page on the next run instead of creating duplicates. If you use the row number as the key, inserting or deleting a row in the middle of the sheet can make the sync update the wrong Notion records. A UUID, CRM ID, order ID, or some other stable external identifier is better.

For this tutorial we will use a replace-mode sync. Each run reads the sheet and sends the full dataset to Notion. When a row disappears from the sheet, it disappears from the Notion database after the next completed sync cycle.

Step 1: Scaffold the Worker

You need Node.js 22 or higher and npm 10 or higher. You also need a Google Cloud project with the Google Sheets API enabled.

Warning: make sure Workers are available in your workspace first.

Workers are available on Notion Business and Enterprise plans, including Business trials. If your workspace is on the Free plan, you can still access Notion's developer platform, but Workers will not be available. Open Notion's developer token page and check the Personal access tokens section. If Workers is grayed out and says "Workers are not available in this workspace", ntn login may still succeed, but ntn workers deploy will fail with unauthorized. Fix this before spending time on the Google Cloud setup.

Install the Notion CLI and create a new Worker project:

curl -fsSL https://ntn.dev | bash
ntn workers new

Choose a folder name when prompted, then open the generated project. The important file is src/index.ts. The scaffolded project also includes TypeScript config, dependencies, and a workers.json file after the first deploy.

A minimal Worker looks like this:

import { Worker } from "@notionhq/workers";

const worker = new Worker();
export default worker;

Everything else in this guide goes into that same src/index.ts file.

Step 2: Define the database schema

Every sync writes to a database handle declared with worker.database(). Workers currently support managed databases for sync output, which means Notion creates the database for you and keeps the properties declared in code under Worker control.

Add the schema imports and define the database:

import { Worker } from "@notionhq/workers";
import * as Builder from "@notionhq/workers/builder";
import * as Schema from "@notionhq/workers/schema";

const worker = new Worker();
export default worker;

const STAGE_OPTIONS = [
  { name: "Lead" },
  { name: "Qualified", color: "blue" as const },
  { name: "Proposal", color: "yellow" as const },
  { name: "Won", color: "green" as const },
  { name: "Lost", color: "red" as const },
];

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.email(),
      Company: Schema.richText(),
      "Deal Value": Schema.number("dollar"),
      Stage: Schema.select(STAGE_OPTIONS),
    },
  },
});

A few details are worth calling out:

  • type: "managed" is currently the supported database type for sync output.
  • initialTitle is used when Notion creates the database. Changing it later does not rename an existing database.
  • primaryKeyProperty must point to a property in schema.properties. The sync uses it to match records across runs.
  • Schema changes are applied on deploy. Removing or changing properties can drop data, so review schema edits carefully before deploying to a database people depend on.

The current schema helpers cover more than just text and numbers. Workers support title, rich text, URL, email, phone number, checkbox, file, number, date, select, multi-select, status, people, place, and relations between databases declared in the same Worker. For this sheet, email, number, and select are enough.

Step 3: Set up Google OAuth

Google Sheets uses OAuth when you want the Worker to read a user's private spreadsheets. The Workers SDK gives you worker.oauth() for this: you configure Google's authorization and token endpoints, deploy, authorize through the CLI, and then call accessToken() inside your sync.

Add the OAuth capability to src/index.ts:

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",
  },
});

The order matters. You need to deploy once before the Worker can be registered with Notion and before the OAuth redirect URL is available.

ntn workers deploy
ntn workers oauth show-redirect-url

Create an OAuth 2.0 client in the Google Cloud Console. Use "Web application" as the application type, and add the redirect URL from ntn workers oauth show-redirect-url as an authorized redirect URI.

Then store your Google OAuth credentials and spreadsheet ID as Worker secrets:

ntn workers env set GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_SECRET=your-client-secret
ntn workers env set SPREADSHEET_ID=your-spreadsheet-id

Redeploy so the Worker picks up the OAuth credentials, then start the authorization flow:

ntn workers deploy
ntn workers oauth start googleAuth

The CLI opens a browser window where you grant the Worker read-only access to Google Sheets. The Worker runtime stores the token securely and refreshes access tokens for you.

For local development, you can pull remote secrets into a local .env file:

ntn workers env pull

Treat that file as sensitive. Worker templates ignore .env and .env.* by default, but it is still worth checking before you pull secrets into a project.

Step 4: Write the sync logic

Now we can read rows from Google Sheets and return sync changes for Notion. This example reads in batches of 100 rows. Each upsert uses the sheet's Row ID as the sync key and writes matching values into the managed Notion database.

Add this below the OAuth and database declarations:

const SHEET_NAME = "Pipeline";
const FIRST_DATA_ROW = 2;
const LAST_COLUMN = "F";
const BATCH_SIZE = 100;

const sheetsApi = worker.pacer("googleSheetsApi", {
  allowedRequests: 50,
  intervalMs: 60_000,
});

const STAGE_NAMES = new Set(STAGE_OPTIONS.map((option) => option.name));

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`${name} is not configured`);
  }
  return value;
}

function parseDealValue(value: string | undefined): number {
  const normalized = (value ?? "").replace(/[$,]/g, "").trim();
  if (!normalized) {
    return 0;
  }

  const parsed = Number.parseFloat(normalized);
  return Number.isFinite(parsed) ? parsed : 0;
}

function normalizeStage(value: string | undefined): string {
  const stage = (value ?? "").trim();
  return STAGE_NAMES.has(stage) ? stage : "Lead";
}

function toDeal(row: string[], sheetRowNumber: number) {
  const rowId = row[0]?.trim() || `sheet-row-${sheetRowNumber}`;

  return {
    rowId,
    name: row[1]?.trim() || `Untitled row ${sheetRowNumber}`,
    email: row[2]?.trim() || "",
    company: row[3]?.trim() || "",
    dealValue: parseDealValue(row[4]),
    stage: normalizeStage(row[5]),
  };
}

worker.sync("dealsSync", {
  database: deals,
  mode: "replace",
  schedule: "30m",
  execute: async (state?: { nextRow?: number }) => {
    const spreadsheetId = requireEnv("SPREADSHEET_ID");
    const token = await googleAuth.accessToken();
    const startRow = state?.nextRow ?? FIRST_DATA_ROW;
    const endRow = startRow + BATCH_SIZE - 1;
    const range = `${SHEET_NAME}!A${startRow}:${LAST_COLUMN}${endRow}`;

    await sheetsApi.wait();

    const response = await fetch(
      `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}?majorDimension=ROWS`,
      {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      },
    );

    if (!response.ok) {
      const body = await response.text();
      throw new Error(`Google Sheets API error ${response.status}: ${body}`);
    }

    const body = (await response.json()) as { values?: string[][] };
    const rows = body.values ?? [];

    return {
      changes: rows.map((row, index) => {
        const deal = toDeal(row, startRow + index);

        return {
          type: "upsert" as const,
          key: deal.rowId,
          properties: {
            Name: Builder.title(deal.name),
            "Row ID": Builder.richText(deal.rowId),
            Email: Builder.email(deal.email),
            Company: Builder.richText(deal.company),
            "Deal Value": Builder.number(deal.dealValue),
            Stage: Builder.select(deal.stage),
          },
        };
      }),
      hasMore: rows.length === BATCH_SIZE,
      nextState:
        rows.length === BATCH_SIZE
          ? { nextRow: startRow + BATCH_SIZE }
          : undefined,
    };
  },
});

If your sheet name contains spaces, wrap it in single quotes in the A1 range, for example 'Sales Pipeline'!A2:F101.

There are a few important choices in this code:

  • Stable keys. The sync key and the Row ID property both use the same stable ID from the sheet. The generated sheet-row-2 fallback is only there to keep the tutorial from crashing; in real use, fill the Row ID column.
  • Replace mode. The Worker sends the full dataset over one or more batches. After the final batch returns hasMore: false, Notion deletes records that were not seen in that completed cycle.
  • Batching. The runtime calls execute again when hasMore is true, passing nextState back in as the next state. A batch size around 100 is a good starting point.
  • Rate limiting. worker.pacer() spaces requests to Google Sheets so the Worker does not burn through the upstream quota immediately. If multiple capabilities share the same pacer, the runtime divides the budget across them.
  • Type coercion. Google Sheets returns cell values as strings. You still have to parse numbers, normalize select values, and handle blank cells yourself.

Step 5: Deploy and test

Deploy the Worker:

ntn workers deploy

Preview the sync output before writing anything to Notion:

ntn workers sync trigger dealsSync --preview

If the preview shows the expected upserts, run the sync for real:

ntn workers sync trigger dealsSync

Then check the live sync status dashboard:

ntn workers sync status

Once the first run completes, the managed database should appear in Notion with one page per Google Sheets row. From there, the schedule keeps it updated every 30 minutes.

You can also use the Worker from Notion Custom Agents indirectly: point an agent at the managed database, and it can use the synced spreadsheet data as regular Notion database content. You do not need to write an agent tool just to make the synced data visible to an agent.

How Workers Syncs Behave

The core sync contract is small: your execute function returns changes, hasMore, and optionally nextState.

  • changes is a batch of upsert or delete operations.
  • hasMore tells Notion whether to call execute again in the same sync cycle.
  • nextState is the cursor, page number, timestamp, or other serializable value the next execute call needs.

There are two modes:

  • Replace is the default. A completed cycle represents the full upstream dataset. Anything not seen by the time the final page returns hasMore: false gets deleted from Notion.
  • Incremental sends only changed records. Records not mentioned are left alone, and deletions must be returned explicitly with type: "delete".

For a small or medium Google Sheet, replace mode is usually simpler and safer. Google Sheets does not give you a clean "changed rows since cursor" API for arbitrary spreadsheets, so an incremental sync would require your own change-tracking column, Apps Script, or another upstream system of record.

For larger systems, Notion recommends a backfill + delta pattern: a manual replace sync that can repopulate everything, plus a scheduled incremental sync that fetches recent changes. Both syncs can write to the same managed database if they share the database handle and key space.

Schedules can be interval strings such as "30m", "1h", or "1d". The current Workers docs also document "manual" for CLI-triggered syncs and "continuous" in the SDK reference. If the exact minimum interval matters, check the current SDK and Syncs docs before deploying; the safe default is still "30m".

Deploying does not reset sync state. If your sync stores a cursor in nextState, it resumes from that cursor after deploy. That is what you want most of the time, but after a schema change or a bug fix you may need to reset state manually.

Debugging

Start with a preview. It tells you whether your execute function is returning the rows you think it is returning, without writing to the managed database.

ntn workers sync trigger dealsSync --preview

If the preview is empty, the problem is usually the Sheets request: wrong spreadsheet ID, wrong tab name, wrong range, missing OAuth permission, or a Google account that cannot access the sheet.

Use the status dashboard for sync health:

ntn workers sync status

Use run logs when you need the actual exception:

ntn workers runs list
ntn workers runs logs <runId>

If one of these commands is missing, update the CLI first:

ntn update

Inspect state before resetting it:

ntn workers sync state get dealsSync

Reset state when you need the next run to start from scratch:

ntn workers sync state reset dealsSync

Pause and resume the sync while you investigate:

ntn workers sync pause dealsSync
# fix the issue, redeploy, then resume
ntn workers sync resume dealsSync

Common issues:

  • Rows are duplicated. The key is changing between runs. Use a real Row ID column instead of row numbers, names, or emails that may change.
  • Rows are not deleted. In replace mode, stale rows are deleted only after a cycle reaches hasMore: false. If the sync errors halfway through, Notion does not delete unseen rows. That is intentional.
  • Select values disappear or normalize incorrectly. The value from the sheet does not match one of the configured Schema.select() options. Normalize it before calling Builder.select().
  • OAuth starts failing. Re-run ntn workers oauth start googleAuth. If you are testing with a Google OAuth app in testing mode, also check your test users and Google Cloud consent screen settings.
  • The Worker cannot read Notion pages through context.notion. Syncs and webhooks need a Notion token in NOTION_API_TOKEN if they call the Notion API directly. Custom Agent tools get agent-scoped authentication automatically, but syncs do not.

What This Will Cost You

The current Workers docs linked in this guide do not publish a stable per-sync price in the quickstart, syncs guide, or SDK reference. Before treating a Worker as cheaper than a hosted sync product, check Notion's current pricing and plan requirements.

Even if the runtime cost is acceptable, the maintenance cost is real:

  • Google Cloud setup. You need a Google Cloud project, OAuth client, consent screen, redirect URI, and Sheets API access.
  • OAuth upkeep. Tokens can be revoked, app settings can drift, and Google consent screens can surprise you later.
  • Schema drift. If someone adds, removes, or renames columns in the sheet, the Worker does not magically understand the new shape. You update the code and redeploy.
  • Operational ownership. Someone has to watch failed runs, read logs, reset state when needed, and decide when a schema migration is safe.
  • One-off logic. Every special rule - date parsing, status normalization, row filtering, duplicate handling - lives in your code.

For a developer-owned workflow, that tradeoff may be fine. For a team that just wants Google Sheets and Notion to stay in sync, the hidden cost is usually the ongoing maintenance rather than the first afternoon of coding.

What Workers Can't Do

They do not give you bidirectional sync. A Worker sync pulls external data into Notion. Editing the managed Notion database does not write changes back to Google Sheets. If both sides need to be editable sources of truth, this Worker is not enough.

They currently create managed databases. Syncs write to databases declared by worker.database(). The docs say support for syncing to existing databases is coming soon, but this tutorial assumes the current managed-database model.

The Worker owns the declared schema. Properties defined in code are not regular user-editable Notion properties. Users can add extra properties in Notion, but the schema controlled by the Worker is migrated on deploy.

Google Sheets is not a change feed. Replace mode works well for small and medium sheets. Near-real-time incremental sync requires your own way to know what changed.

There is no no-code admin layer. Changing columns, mappings, filters, schedules, or credentials is a developer task. Non-technical teammates will not be able to adjust the sync from a Notion settings screen.

Observability is CLI-first. You have status, run lists, logs, and state commands. That is useful for developers, but it is not the same as a customer-facing dashboard with alerts, retries, and clear remediation steps.

When to Use Workers vs. a Dedicated Sync Tool

Use Workers when:

  • You need custom business logic in the sync: filtering, normalization, enrichment, or calls to multiple APIs.
  • The upstream source is unusual and no off-the-shelf sync tool supports it well.
  • You are already building Custom Agent tools or webhooks and want the integration code in one Worker project.
  • A developer owns the workflow and is comfortable maintaining OAuth, schema migrations, and run logs.

Use a dedicated sync tool when:

  • You need bidirectional sync between Notion and Google Sheets.
  • Non-technical users need to create or modify syncs.
  • You want monitoring, retries, alerts, and a support path that does not start with reading CLI logs.
  • Your sheet schema changes often and you do not want every mapping change to become a deploy.
  • You are syncing ordinary Notion databases rather than building a bespoke integration around an unusual API.

Workers are a good developer platform. They are especially interesting when the integration needs code anyway. But for the common case - keeping a Notion database and a Google Sheet in sync - a dedicated product is usually the more practical option.

Notion Backups includes Google Sheets Sync on Starter plans and above. It supports one-way Notion to Google Sheets syncing and two-way syncing, without writing a Worker, managing Google OAuth, or maintaining a deploy pipeline.

Back up your Notion workspaces today

Get started