Sync a Notion database to Google Sheets with the API, including the sharing step that causes most 404s and the nested property values that arrive as objects.
Notion exports any database view to CSV in two clicks, which handles one-off requests. For anything that needs to stay current you go through the Notion API, and two things reliably break the first attempt: the integration has no access to your databases until you explicitly share each one with it, and every property comes back as a typed object rather than a value. This guide covers both, plus pagination and write-back.
The two-minute option: CSV export
Open the database as a full page, use the ••• menu, and choose Export with format CSV. Import into Sheets via File → Import. As with most tools, the export follows the current view, so its filters, sorts and hidden properties all apply. Sub-pages and page content are not included: you get the database properties, not what is written inside each page.
Notion to Google Sheets for free (Apps Script + API)
Create an integration at notion.so/my-integrations, choose your workspace, and copy the internal integration token (it begins ntn_ or secret_ on older integrations).
Now the step people miss. Creating the integration grants it access to nothing at all. Open the database you want to read, click •••, find Connections, and add your integration. Skip this and every request returns object_not_found as a 404, which looks exactly like a wrong database ID and sends people re-checking the ID for an hour.
Get the database ID from the URL of the database opened as a full page: it is the 32-character hex string before ?v=. The value after ?v= is the view ID and will not work.
function importNotionDatabase() { const TOKEN = "ntn_XXXXXXXXXXXXXXXX"; const DATABASE_ID = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; const headers = { Authorization: "Bearer " + TOKEN, "Notion-Version": "2022-06-28", // required; omitting it returns 400 "Content-Type": "application/json", }; const pages = []; let cursor = null; do { const body = { page_size: 100 }; // 100 is the maximum if (cursor) body.start_cursor = cursor; const res = JSON.parse(UrlFetchApp.fetch( "https://api.notion.com/v1/databases/" + DATABASE_ID + "/query", { method: "post", headers: headers, payload: JSON.stringify(body), muteHttpExceptions: true } ).getContentText()); if (res.object === "error") throw new Error(res.code + ": " + res.message); pages.push(...res.results); cursor = res.has_more ? res.next_cursor : null; if (cursor) Utilities.sleep(350); // ~3 requests/second average limit } while (cursor); if (!pages.length) return; const columns = Object.keys(pages[0].properties); const rows = pages.map((p) => columns.map((c) => readProperty(p.properties[c])) ); const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName("Notion") || ss.insertSheet("Notion"); sheet.clearContents(); sheet.getRange(1, 1, 1, columns.length).setValues([columns]); sheet.getRange(2, 1, rows.length, columns.length).setValues(rows);} // Every property is a typed object. Each type unwraps differently, which is// why writing the property straight into a cell yields [object Object].function readProperty(prop) { if (!prop) return ""; switch (prop.type) { case "title": return (prop.title[0] || {}).plain_text || ""; case "rich_text": return prop.rich_text.map((t) => t.plain_text).join(""); case "number": return prop.number; case "select": return prop.select ? prop.select.name : ""; case "status": return prop.status ? prop.status.name : ""; case "multi_select": return prop.multi_select.map((s) => s.name).join(", "); case "date": return prop.date ? prop.date.start : ""; case "people": return prop.people.map((u) => u.name).join(", "); case "checkbox": return prop.checkbox; case "url": return prop.url || ""; case "email": return prop.email || ""; case "relation": return prop.relation.length; // count; IDs are opaque case "formula": return prop.formula[prop.formula.type]; case "rollup": return prop.rollup[prop.rollup.type]; default: return ""; }}Run once to authorise, then add a time-driven trigger. Note the Notion-Version header is mandatory, not optional: requests without it are rejected outright. And relation properties return arrays of page IDs rather than titles, so displaying the related page's name means a second lookup per ID, which is why the example returns a count instead.
Limits: the API averages around three requests per second, Apps Script stops at six minutes, and page content (the body of each Notion page) requires separate block-level calls entirely.
The Brooked method
Brooked handles the cursor pagination and unwraps each property type into a plain column, including resolving relations to the titles of the pages they point at rather than leaving opaque IDs. Several databases can import into one spreadsheet on a single schedule.
Step 1: Install Brooked
Install Brooked from the Google Workspace Marketplace. Free tier includes 100 imports per month, no credit card required.
Step 2: Connect Notion and share the database
Choose Notion in Brooked and paste your integration token. Then share each database with the integration through its ••• → Connections menu. Brooked can only list databases that have been shared, so if the list looks empty this sharing step is almost always why.
Step 3: Schedule the refresh
Set the import to re-run hourly, daily or weekly. Since it re-reads the database rather than reacting to individual page events, edits made while an automation was failing still get picked up on the next run.
Method comparison
| Method | Cost | Setup | Refresh | Two-way | Best for |
|---|---|---|---|---|---|
| Notion CSV export | Free | 2 min | Manual, per view | No | A one-off snapshot |
| Apps Script + Notion API | Free | 25 to 40 min | Time-driven trigger | DIY | Custom property mapping, no budget |
| Zapier / Make / n8n | Free tier limited · paid for volume | 10 to 20 min | On page created or updated | DIY (second workflow) | Reacting to individual page changes |
| Brooked | Free tier · Pro $29/user/mo | ~3 min | Scheduled (hourly, daily, weekly) | Yes | Scheduled reporting across databases |
Common use cases
- Roadmap reporting: roll a Notion project database into charts leadership reads in Sheets
- Content calendars: report on editorial pipelines without giving every stakeholder workspace access
- CRM-lite tracking: pull a Notion pipeline into a sheet to join against billing or product data
- Historical snapshots: schedule a daily import so you keep history Notion itself does not retain
- Cross-database reporting: combine several Notion databases into one table
Troubleshooting common issues
Related Articles
- How to Connect Monday.com to Google Sheets
- How to Connect Clockify to Google Sheets
- How to Connect ClickUp to Google Sheets
Frequently asked questions
Why does the Notion API return object_not_found for a database I can see?
This is the most common Notion API problem and it is not a permissions bug in the usual sense. An integration starts with access to nothing: you have to explicitly share each database with it. Open the database as a full page, use the ••• menu, find Connections, and add your integration. Until you do, the API behaves as though the database does not exist, returning a 404 rather than a 403, which is why it reads like a wrong ID.
Where do I find my Notion database ID?
Open the database as a full page and look at the URL. The database ID is the 32-character hexadecimal string after your workspace name and before the ?v= view parameter. Note that the part after ?v= is the view ID, not the database ID, and using it produces a 404. Hyphenated and unhyphenated forms both work.
Can I connect Notion to Google Sheets for free?
Yes. Notion exports any database view to CSV from the ••• menu, which is free and takes two minutes for a one-off. For a refreshing sync, the Apps Script method in this guide uses a free internal integration token and a free time-driven trigger. Brooked's free tier covers 100 imports a month if you would rather not maintain the script.
Why are my Notion columns full of [object Object]?
Notion returns every property as a typed object rather than a plain value, and the shape differs per type. A title is an array of rich text objects you read plain_text from, a select is an object with a name, a multi-select is an array of those, and a date is an object with start and end. Each property type needs unwrapping individually, which the code example in this guide does.
Why does my import stop after 100 rows?
100 is the maximum page size on the Notion API. The query response includes has_more and next_cursor; pass that cursor as start_cursor on the following request and repeat until has_more is false. Code that reads a single response silently truncates every database larger than 100 pages.
Can I write from Google Sheets back into Notion?
Yes for editable properties, and Brooked supports write-back on that basis. Formula and rollup properties are computed by Notion and reject writes, as do created and last-edited time and user properties. Update the underlying property the formula reads instead.
Connect Notion to Google Sheets today.
Free tier: 100 imports per month, no credit card required. Or see the full Notion integration details.
Install Brooked free →

