This workflow follows the Airtable → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "12 - Airtable \u2192 Google Sheets Sync (Bidirectional)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "*/30 * * * *"
}
]
}
},
"id": "node-schedule",
"name": "Schedule - Every 30 Minutes",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
260,
400
]
},
{
"parameters": {
"operation": "list",
"baseId": {
"__rl": true,
"value": "YOUR_AIRTABLE_BASE_ID",
"mode": "id"
},
"tableId": {
"__rl": true,
"value": "YOUR_TABLE_ID",
"mode": "id"
},
"returnAll": true,
"options": {
"fields": [
"Name",
"Status",
"Amount",
"Date",
"Notes",
"Last Modified",
"Record ID"
],
"sort": [
{
"field": "Last Modified",
"direction": "desc"
}
]
}
},
"id": "node-airtable-read",
"name": "Airtable - Read Records",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.1,
"position": [
480,
280
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "read",
"documentId": {
"__rl": true,
"value": "YOUR_GSHEETS_SPREADSHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Data",
"mode": "name"
},
"options": {
"headerRow": 1
}
},
"id": "node-gsheets-read",
"name": "Google Sheets - Read Rows",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
480,
520
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "const airtableItems = $('Airtable - Read Records').all();\nconst sheetsItems = $('Google Sheets - Read Rows').all();\n\n// Build lookup maps keyed by Record ID\nconst airtableMap = {};\nfor (const item of airtableItems) {\n const id = item.json.id || item.json['Record ID'] || item.json.fields?.['Record ID'];\n if (id) airtableMap[id] = item.json.fields || item.json;\n}\n\nconst sheetsMap = {};\nfor (const item of sheetsItems) {\n const id = item.json['Record ID'] || item.json['Airtable ID'];\n if (id) sheetsMap[id] = { ...item.json, _rowIndex: item.json._rowIndex };\n}\n\n// Determine changes\nconst toAddToSheets = []; // In Airtable but not Sheets\nconst toUpdateInSheets = []; // In both but Airtable is newer\nconst toAddToAirtable = []; // In Sheets but not Airtable (new rows added directly to Sheets)\n\nfor (const [id, atRecord] of Object.entries(airtableMap)) {\n if (!sheetsMap[id]) {\n toAddToSheets.push({ airtableId: id, ...atRecord });\n } else {\n const sheetRecord = sheetsMap[id];\n const atModified = new Date(atRecord['Last Modified'] || atRecord.lastModified || 0);\n const sheetModified = new Date(sheetRecord['Last Modified'] || 0);\n if (atModified > sheetModified) {\n toUpdateInSheets.push({ airtableId: id, _rowIndex: sheetRecord._rowIndex, ...atRecord });\n }\n }\n}\n\nfor (const [id, sheetRecord] of Object.entries(sheetsMap)) {\n if (!airtableMap[id] && sheetRecord['Sync To Airtable'] === 'YES') {\n toAddToAirtable.push(sheetRecord);\n }\n}\n\nreturn [{\n json: {\n toAddToSheets,\n toUpdateInSheets,\n toAddToAirtable,\n summary: {\n airtableRecords: airtableItems.length,\n sheetsRows: sheetsItems.length,\n newToSheets: toAddToSheets.length,\n updatesToSheets: toUpdateInSheets.length,\n newToAirtable: toAddToAirtable.length\n }\n }\n}];"
},
"id": "node-diff",
"name": "Code - Calculate Sync Diff",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
400
]
},
{
"parameters": {
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.summary.newToSheets + $json.summary.updatesToSheets + $json.summary.newToAirtable }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
]
}
},
"id": "node-if-changes",
"name": "IF - Any Changes to Sync?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
400
]
},
{
"parameters": {
"operation": "appendOrUpdate",
"documentId": {
"__rl": true,
"value": "YOUR_GSHEETS_SPREADSHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Data",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Record ID": "={{ $json.airtableId }}",
"Name": "={{ $json.Name }}",
"Status": "={{ $json.Status }}",
"Amount": "={{ $json.Amount }}",
"Date": "={{ $json.Date }}",
"Notes": "={{ $json.Notes }}",
"Last Modified": "={{ $json['Last Modified'] }}"
},
"matchingColumns": [
"Record ID"
]
},
"options": {}
},
"id": "node-sheets-upsert",
"name": "Google Sheets - Upsert Records",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
1140,
280
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "upsert",
"baseId": {
"__rl": true,
"value": "YOUR_AIRTABLE_BASE_ID",
"mode": "id"
},
"tableId": {
"__rl": true,
"value": "YOUR_TABLE_ID",
"mode": "id"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Name": "={{ $json.Name }}",
"Status": "={{ $json.Status }}",
"Amount": "={{ $json.Amount }}",
"Date": "={{ $json.Date }}",
"Notes": "={{ $json.Notes }}"
}
},
"options": {}
},
"id": "node-airtable-upsert",
"name": "Airtable - Upsert Records",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2.1,
"position": [
1140,
520
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "append",
"documentId": {
"__rl": true,
"value": "YOUR_GSHEETS_SPREADSHEET_ID",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "Sync Log",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"Timestamp": "={{ new Date().toISOString() }}",
"Airtable Records": "={{ $('Code - Calculate Sync Diff').item.json.summary.airtableRecords }}",
"Sheets Rows": "={{ $('Code - Calculate Sync Diff').item.json.summary.sheetsRows }}",
"Added to Sheets": "={{ $('Code - Calculate Sync Diff').item.json.summary.newToSheets }}",
"Updated in Sheets": "={{ $('Code - Calculate Sync Diff').item.json.summary.updatesToSheets }}",
"Added to Airtable": "={{ $('Code - Calculate Sync Diff').item.json.summary.newToAirtable }}"
}
},
"options": {}
},
"id": "node-log-sync",
"name": "Google Sheets - Log Sync Run",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
1360,
400
],
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
}
}
],
"connections": {
"Schedule - Every 30 Minutes": {
"main": [
[
{
"node": "Airtable - Read Records",
"type": "main",
"index": 0
},
{
"node": "Google Sheets - Read Rows",
"type": "main",
"index": 0
}
]
]
},
"Airtable - Read Records": {
"main": [
[
{
"node": "Code - Calculate Sync Diff",
"type": "main",
"index": 0
}
]
]
},
"Google Sheets - Read Rows": {
"main": [
[
{
"node": "Code - Calculate Sync Diff",
"type": "main",
"index": 0
}
]
]
},
"Code - Calculate Sync Diff": {
"main": [
[
{
"node": "IF - Any Changes to Sync?",
"type": "main",
"index": 0
}
]
]
},
"IF - Any Changes to Sync?": {
"main": [
[
{
"node": "Google Sheets - Upsert Records",
"type": "main",
"index": 0
},
{
"node": "Airtable - Upsert Records",
"type": "main",
"index": 0
}
],
[
{
"node": "Google Sheets - Log Sync Run",
"type": "main",
"index": 0
}
]
]
},
"Google Sheets - Upsert Records": {
"main": [
[
{
"node": "Google Sheets - Log Sync Run",
"type": "main",
"index": 0
}
]
]
},
"Airtable - Upsert Records": {
"main": [
[
{
"node": "Google Sheets - Log Sync Run",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"tags": [
{
"name": "sync"
},
{
"name": "airtable"
},
{
"name": "sheets"
}
],
"meta": {
"description": "Every 30 minutes: reads both Airtable and Google Sheets, calculates a diff keyed by Record ID, pushes new/updated records from Airtable \u2192 Sheets and Sheets \u2192 Airtable, then logs each sync run.",
"prerequisites": [
"Airtable Personal Access Token with data.records:read/write",
"Google Sheets OAuth2",
"Airtable table must have a 'Record ID' field (formula: RECORD_ID())",
"Google Sheet 'Data' tab with matching column headers",
"Google Sheet 'Sync Log' tab for audit trail",
"Update YOUR_AIRTABLE_BASE_ID, YOUR_TABLE_ID, YOUR_GSHEETS_SPREADSHEET_ID"
],
"testingScenario": {
"happy_path": "Add record in Airtable \u2192 run workflow \u2192 verify row appears in Sheets within 30 min",
"edge_cases": [
"Conflict (both modified) \u2192 Airtable wins (newer Last Modified)",
"Deleted from Airtable \u2192 Sheets row remains (no delete sync to avoid accidents)",
"1000+ records \u2192 may hit n8n item limit, enable pagination",
"Sheets row without Record ID \u2192 skipped, not synced to Airtable"
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
airtableTokenApigoogleSheetsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
12 - Airtable → Google Sheets Sync (Bidirectional). Uses airtable, googleSheets. Scheduled trigger; 8 nodes.
Source: https://github.com/satmakuru222/TheAIStackk/blob/main/n8n-workflows/12-airtable-gsheets-bidirectional-sync.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Reads every workflow on your n8n instance every 30 minutes, extracts their schedule triggers, and keeps a matching recurring event on Google Calendar — one event per workflow, forever in sync.
This template is for sales teams, marketing operations (M-Ops), or freelancers who use Airtable as a "control panel" or staging area for new leads. If you're tired of manually copying and pasting appr
This workflow monitors customer health by combining payment behavior, complaint signals, and AI-driven feedback analysis. It runs on daily and weekly schedules to evaluate risk levels, escalate high-r
Workflow Description:
Code Postgres. Uses httpRequest, splitInBatches, postgres, hubspot. Scheduled trigger; 23 nodes.