Problem: Your Data Lives in Google Sheets but Your Tools Don't Talk to It
You've got CRM leads in one place, form submissions in another, and a Google Sheet that's supposed to hold it all together — but syncing it is manual copy-paste work.
n8n can automate every part of this: read rows on a schedule, append new data from any trigger, update cells when upstream records change, and push Sheet data into any API.
You'll learn:
- How to connect n8n to Google Sheets with OAuth2
- How to read, append, and update rows using the Google Sheets node
- Three real workflow patterns: form-to-sheet, sheet-to-CRM, and scheduled sync
Time: 20 min | Difficulty: Intermediate
Why Google Sheets + n8n Works Well
Google Sheets is a practical data layer for small teams: human-readable, shareable, no schema migrations. n8n treats it as a first-class integration — the Google Sheets node handles auth, pagination, and cell mapping without custom code.
The catch: you need a properly configured OAuth2 credential the first time. That setup takes 5 minutes and you do it once.
Solution
Step 1: Create a Google Cloud OAuth2 Credential in n8n
You need a Google Cloud project with the Sheets API enabled before n8n can authenticate.
- Go to console.cloud.google.com → APIs & Services → Enable APIs → search "Google Sheets API" → Enable it.
- Also enable the Google Drive API (required for listing spreadsheets by name).
- Go to Credentials → Create Credentials → OAuth 2.0 Client ID.
- Set Application type to Web application.
- Under Authorized redirect URIs, add your n8n callback URL:
https://your-n8n-domain.com/rest/oauth2-credential/callback
For local n8n: http://localhost:5678/rest/oauth2-credential/callback
- Copy the Client ID and Client Secret.
In n8n:
- Go to Credentials → New → search Google Sheets OAuth2 API.
- Paste the Client ID and Client Secret.
- Click Sign in with Google and authorize the account that owns the Sheet.
Expected: Credential shows green "Connected" status.
If it fails:
redirect_uri_mismatch→ The URI in Google Cloud doesn't exactly match n8n's callback URL. Copy it character-for-character from n8n's credential screen.Access blocked: app not verified→ Click "Advanced" → "Go to [app] (unsafe)" during OAuth for personal/dev use. For production, submit the app for Google verification.
Step 2: Read Rows from a Sheet
Add a Google Sheets node to your workflow. Set the operation to Read Rows.
Resource: Spreadsheet
Operation: Read Rows
Document: [Select your spreadsheet by name]
Sheet: Sheet1
By default this returns all rows as JSON objects, using the first row as column headers.
Example output for a leads sheet:
[
{ "Name": "Alice Chen", "Email": "alice@example.com", "Status": "New" },
{ "Name": "Bob Torres", "Email": "bob@example.com", "Status": "Contacted" }
]
Each item in n8n's output maps to one row. Downstream nodes like IF, Set, or HTTP Request can reference any column by name: {{ $json.Email }}.
Tip — filter rows at the node level: Under Filters, add a column condition like Status = New to avoid pulling the whole sheet into memory for large datasets.
Step 3: Append New Rows
Use the Append Row operation to write data from any upstream node into your Sheet.
Resource: Spreadsheet
Operation: Append Row
Document: [Your spreadsheet]
Sheet: Sheet1
Under Values to Send, map the incoming data to your column headers:
Name → {{ $json.name }}
Email → {{ $json.email }}
Date → {{ $now.toISO() }}
Source → Typeform
n8n appends after the last populated row automatically. You don't need to track row numbers.
Common pattern — form submission to Sheet:
Webhook (Typeform / Tally / custom)
→ Set node (normalize field names)
→ Google Sheets (Append Row)
→ Slack (notify team)
The Webhook node receives the POST, Set maps it to your schema, Sheets writes the row, Slack sends a message. Total nodes: 4.
Step 4: Update an Existing Row
Updating requires knowing which row to target. n8n's Google Sheets node supports Update Row with a column-match lookup — you don't need the row index.
Resource: Spreadsheet
Operation: Update Row
Document: [Your spreadsheet]
Sheet: Sheet1
Lookup Column: Email
Lookup Value: {{ $json.email }}
Under Values to Update, set only the columns you want to change:
Status → Closed
ClosedDate → {{ $now.toISO() }}
n8n finds the first row where Email matches the lookup value and updates those cells. Everything else in the row stays untouched.
If it fails:
No rows updated→ The lookup value doesn't match exactly (check for trailing spaces or case differences). Add a Set node before this one to normalize the value with{{ $json.email.trim().toLowerCase() }}.- Column not found error → The column header in your Sheet has a leading/trailing space. Fix the header directly in Sheets.
Step 5: Trigger a Workflow When a Sheet Changes
For sheet-triggered workflows, use the Google Sheets Trigger node instead of a manual or webhook trigger.
Node: Google Sheets Trigger
Document: [Your spreadsheet]
Sheet: Sheet1
Event: Row Added
Poll Interval: Every 1 minute
n8n polls the Sheet on your chosen interval and fires the workflow for each new row since the last check. The trigger node outputs one item per new row.
Limitation: Google Sheets has no native push webhook. Polling every minute is the minimum interval on most n8n plans. For real-time triggers, use a Google Apps Script to POST to an n8n webhook instead — set it up in the Sheet's Extensions → Apps Script:
// Paste this in Apps Script → assign to onFormSubmit or onChange trigger
function sendToN8n(e) {
const payload = {
row: e.range.getValues()[0],
sheet: e.range.getSheet().getName(),
timestamp: new Date().toISOString()
};
UrlFetchApp.fetch("https://your-n8n.com/webhook/sheets-trigger", {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload)
});
}
This fires instantly on form submit or cell edit — no polling delay.
Step 6: Complete Workflow — Sheet to CRM Sync
Here's a production-ready workflow that reads new "Status = Qualified" rows from a Sheet every 15 minutes and creates contacts in HubSpot.
Schedule Trigger (every 15 min)
→ Google Sheets: Read Rows
Filter: Status = Qualified
→ IF: HubspotID is empty ← skip rows already synced
TRUE branch:
→ HTTP Request: POST to HubSpot Contacts API
URL: https://api.hubapi.com/crm/v3/objects/contacts
Auth: Header Auth (Bearer token)
Body: {
"properties": {
"email": "{{ $json.Email }}",
"firstname": "{{ $json.Name.split(' ')[0] }}",
"lastname": "{{ $json.Name.split(' ')[1] }}"
}
}
→ Google Sheets: Update Row
Lookup: Email = {{ $json.Email }}
Update: HubspotID = {{ $json.id }}
Status = Synced
The IF node prevents re-syncing rows by checking whether HubspotID is already populated. After each sync, the Update Row node writes the HubSpot contact ID back to the Sheet — so the next run skips it.
Verification
Run the full workflow manually first:
Click "Test Workflow" in n8n → check each node's output panel
Verify in your Sheet that the HubspotID column populates after the run. Check HubSpot Contacts to confirm the records created.
For the scheduled version, activate the workflow and wait one cycle:
You should see: New Sheet rows with Status = Synced and a populated HubspotID column within 15 minutes of the trigger firing.
What You Learned
- The Google Sheets node supports Read, Append, and Update in the same credential — one OAuth2 setup covers all three operations.
- Use column-match lookups for updates instead of row indexes — it's more resilient when rows get inserted or reordered.
- For real-time triggers, Google Apps Script + n8n Webhook beats the polling trigger by an order of magnitude in latency.
- The
IFnode with a "already synced" check is the simplest way to make a Sheet-to-CRM sync idempotent.
When not to use this approach: If your Sheet has 10,000+ rows and you're reading all of them on every poll, move the data to PostgreSQL and use n8n's Postgres node instead. Sheets starts slowing down API calls noticeably above ~5,000 rows.
Tested on n8n 1.40.0, Google Sheets API v4, Node.js 20, Ubuntu 24.04