This workflow follows the HTTP Request → Postgres 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": "Postgres to Google Sheets Sync",
"nodes": [
{
"parameters": {
"content": "## Postgres to Google Sheets Sync\n\nSchedule trigger fires (default daily 06:00 UTC). Workflow reads a configurable Postgres SELECT query (typically with a `> last_synced_at` WHERE clause), transforms each row into a Sheets row-shape, appends to a target spreadsheet, then updates the high-water-mark.\n\nIncremental sync. Idempotent. Retries-safe.\n\n**Production patterns wired:**\n- Idempotency on row primary key (the same row, fetched twice within the window, lands in Sheets once)\n- Rate limit on the Sheets append call\n- Error branch with Slack alert + structured log\n- Hard `MAX_ROWS_PER_RUN` cap (default 5000) to protect against runaway queries\n\nSee `README.md` for setup, env vars, and extension recipes.",
"height": 340,
"width": 400,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
-100
]
},
{
"parameters": {
"content": "### >> SET ME <<\n\n1. Add a Postgres credential pointing at the read-only replica (recommended) or production primary.\n2. Set `PG_SYNC_QUERY` env to a SELECT statement. Use `$1` placeholder for the high-water-mark. Example: `SELECT id, email, created_at, updated_at FROM users WHERE updated_at > $1 ORDER BY updated_at ASC LIMIT 5000`.\n3. Set `PG_SYNC_HWM_INITIAL` to an ISO-8601 timestamp for the first run (the high-water-mark seed). Example: `2026-01-01T00:00:00Z`.\n4. Set `GOOGLE_SHEETS_ID` to the target spreadsheet ID.\n5. Set `GOOGLE_SHEETS_RANGE` to `Sheet1!A1:Z` (or your tab + column range).\n6. Add a Google Sheets OAuth2 credential.\n7. Optional: `MAX_ROWS_PER_RUN=5000` (default), `SLACK_OPS_WEBHOOK` for alerts.\n8. Self-hosted n8n: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto`.",
"height": 360,
"width": 380,
"color": 5
},
"id": "note-setup",
"name": "Sticky Note - Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-200,
260
]
},
{
"parameters": {
"content": "## Production Patterns\n\nThree opt-in nodes wired, default-off so the import boots clean.\n\n- **Rate limit:** `RATE_LIMIT_ENABLED=1` (60 Sheets calls / 5 min)\n- **Idempotency:** `IDEMPOTENCY_ENABLED=1` (24-hour window on row primary key)\n- **Error branch:** always on. Sheets failure -> Slack alert + log. Postgres failure raises and stops the run (not silenced).\n- **Hard caps:** `MAX_ROWS_PER_RUN` (default 5000) inside the parser to protect against runaway queries.\n\nNo HMAC: this is a Schedule trigger, not a public webhook.\n\nFor clustered n8n, swap the in-memory dedup for Redis SET NX EX 86400. Snippet in the node's comments.",
"height": 340,
"width": 380,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
840,
-260
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"triggerAtHour": 6
}
]
}
},
"id": "pg-1-trigger",
"name": "Schedule (Daily 06:00 UTC)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
60
]
},
{
"parameters": {
"jsCode": "// Read the high-water-mark from workflow static data.\n// On the first run, fall back to PG_SYNC_HWM_INITIAL env var.\n// Output one item with the current HWM for the Postgres node to consume.\n\nconst data = $getWorkflowStaticData('global');\nlet hwm = data.lastSyncedAt;\nif (!hwm) {\n const seed = $env.PG_SYNC_HWM_INITIAL;\n if (!seed) throw new Error('PG_SYNC_HWM_INITIAL env var is required for the first run (ISO-8601 timestamp)');\n // Validate seed is a parseable ISO date\n const seedDate = new Date(seed);\n if (isNaN(seedDate.getTime())) throw new Error('PG_SYNC_HWM_INITIAL is not a valid ISO-8601 date: ' + seed);\n hwm = seedDate.toISOString();\n}\n\nreturn [{ json: { hwm, runStartedAt: new Date().toISOString() } }];"
},
"id": "pg-2-read-hwm",
"name": "Read HWM",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
60
]
},
{
"parameters": {
"operation": "executeQuery",
"query": "={{ $env.PG_SYNC_QUERY }}",
"options": {
"queryReplacement": "={{ $json.hwm }}"
}
},
"id": "pg-3-query",
"name": "Postgres Query",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [
640,
60
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// Per-key sliding-window rate limit on the Sheets API call, opt-in.\n// Default 60 ops / 5 min, scoped workflow-wide.\n\nif ($env.RATE_LIMIT_ENABLED !== '1') {\n return $input.all();\n}\n\nconst LIMIT = 60;\nconst WINDOW_MS = 5 * 60 * 1000;\nconst MAX_KEYS = 5000;\n\nconst data = $getWorkflowStaticData('global');\ndata.rateBuckets = data.rateBuckets || {};\nconst buckets = data.rateBuckets;\nconst now = Date.now();\n\nfor (const k of Object.keys(buckets)) {\n buckets[k] = (buckets[k] || []).filter(t => now - t < WINDOW_MS);\n if (buckets[k].length === 0) delete buckets[k];\n}\nif (Object.keys(buckets).length > MAX_KEYS) {\n const oldest = Object.entries(buckets).sort((a, b) => (a[1][0] || 0) - (b[1][0] || 0)).slice(0, 100);\n for (const [k] of oldest) delete buckets[k];\n}\n\nconst key = 'sheets-write';\nconst hits = buckets[key] || [];\nif (hits.length >= LIMIT) {\n throw new Error('RATE_LIMIT_EXCEEDED: ' + LIMIT + ' Sheets writes per ' + Math.round(WINDOW_MS / 60000) + ' minutes reached');\n}\nbuckets[key] = [...hits, now];\n\nreturn $input.all();"
},
"id": "pg-pp-1-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
60
]
},
{
"parameters": {
"jsCode": "// 24-hour idempotency window on row primary key, opt-in.\n// Drops rows already seen within the window. Reads `id` (or PG_SYNC_DEDUP_KEY env) from each row.\n\nconst crypto = require('crypto');\n\nif ($env.IDEMPOTENCY_ENABLED !== '1') {\n return $input.all();\n}\n\nconst WINDOW_MS = 24 * 60 * 60 * 1000;\nconst MAX_KEYS = 50000;\nconst dedupKeyField = $env.PG_SYNC_DEDUP_KEY || 'id';\n\nconst data = $getWorkflowStaticData('global');\ndata.seenRows = data.seenRows || {};\nconst seen = data.seenRows;\nconst now = Date.now();\n\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\nif (Object.keys(seen).length > MAX_KEYS) {\n const oldest = Object.entries(seen).sort((a, b) => a[1] - b[1]).slice(0, 5000);\n for (const [k] of oldest) delete seen[k];\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n const j = item.json || {};\n const pkRaw = j[dedupKeyField];\n if (pkRaw == null) { out.push(item); continue; } // no PK -> let it through, do not drop\n const pkStr = String(pkRaw);\n const hashed = crypto.createHash('sha256').update(pkStr, 'utf8').digest('hex').slice(0, 32);\n if (seen[hashed]) continue; // dedup hit\n seen[hashed] = now;\n out.push(item);\n}\nreturn out;\n\n// Redis variant for clustered n8n:\n// const result = await redis.set('pgsheets-idem:' + hashed, '1', 'EX', 86400, 'NX');\n// if (result === null) skip;"
},
"id": "pg-pp-2-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1040,
60
]
},
{
"parameters": {
"jsCode": "// Hard cap defense before we hand the rows to Sheets.\n// MAX_ROWS_PER_RUN env (default 5000). If exceeded, slice and warn.\n\nconst MAX_ROWS = parseInt($env.MAX_ROWS_PER_RUN || '5000', 10);\nconst items = $input.all();\nif (items.length === 0) {\n return [{ json: { skipped: true, reason: 'no-new-rows' } }];\n}\nif (items.length > MAX_ROWS) {\n // Take the first MAX_ROWS, the rest will be picked up next run via the HWM.\n return items.slice(0, MAX_ROWS);\n}\nreturn items;"
},
"id": "pg-4-cap",
"name": "Cap MAX_ROWS_PER_RUN",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1240,
60
]
},
{
"parameters": {
"jsCode": "// Project each Postgres row into a Sheets row-shape. Order matters for Sheets append.\n// PG_SYNC_COLUMN_ORDER env defines the column projection. Example: 'id,email,created_at,updated_at'.\n// If unset, falls back to the keys of the first row (ordering not guaranteed across rows).\n\nconst items = $input.all();\nif (items.length === 0 || (items[0].json && items[0].json.skipped)) {\n return items;\n}\n\nconst order = ($env.PG_SYNC_COLUMN_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);\nconst keys = order.length > 0 ? order : Object.keys(items[0].json || {});\n\nconst values = items.map(item => {\n const row = item.json || {};\n return keys.map(k => {\n const v = row[k];\n if (v == null) return '';\n if (v instanceof Date) return v.toISOString();\n if (typeof v === 'object') return JSON.stringify(v);\n return String(v);\n });\n});\n\nreturn [{ json: { values, columnOrder: keys, rowCount: values.length } }];"
},
"id": "pg-5-transform",
"name": "Transform Rows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1440,
60
]
},
{
"parameters": {
"method": "POST",
"url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $env.GOOGLE_SHEETS_ID }}/values/{{ encodeURIComponent($env.GOOGLE_SHEETS_RANGE || 'Sheet1!A1:Z') }}:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "googleApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ values: $json.values }) }}",
"options": {}
},
"id": "pg-6-sheets",
"name": "Append to Sheets",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1640,
60
],
"credentials": {
"googleApi": {
"name": "<your credential>"
}
},
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// Update the HWM only after a successful Sheets append.\n// New HWM = max(updated_at, created_at, ...) across the rows we just synced.\n// PG_SYNC_HWM_FIELD env names the timestamp field. Default 'updated_at'.\n\nconst hwmField = $env.PG_SYNC_HWM_FIELD || 'updated_at';\nconst pgRows = $('Cap MAX_ROWS_PER_RUN').all();\nif (pgRows.length === 0 || (pgRows[0].json && pgRows[0].json.skipped)) {\n return [{ json: { hwmUnchanged: true } }];\n}\n\nlet maxTs = null;\nfor (const item of pgRows) {\n const v = (item.json || {})[hwmField];\n if (v == null) continue;\n const d = new Date(v);\n if (isNaN(d.getTime())) continue;\n if (!maxTs || d > maxTs) maxTs = d;\n}\nif (!maxTs) {\n return [{ json: { hwmUnchanged: true, reason: 'no-' + hwmField + '-field-on-rows' } }];\n}\n\nconst data = $getWorkflowStaticData('global');\ndata.lastSyncedAt = maxTs.toISOString();\n\nreturn [{ json: { newHwm: data.lastSyncedAt, rowsSynced: pgRows.length } }];"
},
"id": "pg-7-update-hwm",
"name": "Update HWM",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1840,
60
]
},
{
"parameters": {
"jsCode": "// Fallback when Postgres OR Sheets fails. Build a structured error log\n// and post a Slack alert if SLACK_OPS_WEBHOOK is set.\n\nconst input = $input.first();\nconst raw = input.json || {};\nconst errorRaw = raw.error || raw;\nconst isHttpOrPgError = !!(errorRaw && (errorRaw.message || errorRaw.code));\nconst errorMessage = isHttpOrPgError ? (errorRaw.message || 'Sync error') : 'Unknown error: ' + JSON.stringify(errorRaw).slice(0, 200);\n\n// Identify which stage failed.\nlet stage = 'unknown';\ntry { if ($('Postgres Query').first()) stage = 'after-postgres'; } catch (e) {}\ntry { if ($('Append to Sheets').first()) stage = 'after-sheets'; } catch (e) {}\n\nreturn [{\n json: {\n syncError: { message: errorMessage, stage, raw: errorRaw },\n runStartedAt: ($('Read HWM').first() && $('Read HWM').first().json && $('Read HWM').first().json.runStartedAt) || null,\n },\n}];"
},
"id": "pg-err-fallback",
"name": "Error Fallback",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1240,
380
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_OPS_WEBHOOK }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: ':warning: Postgres-to-Sheets sync failed at ' + ($json.syncError.stage || '?') + ': ' + ($json.syncError.message || 'unknown error') }) }}",
"options": {}
},
"id": "pg-err-slack",
"name": "Slack Alert",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1440,
380
],
"onError": "continueRegularOutput"
}
],
"connections": {
"Schedule (Daily 06:00 UTC)": {
"main": [
[
{
"node": "Read HWM",
"type": "main",
"index": 0
}
]
]
},
"Read HWM": {
"main": [
[
{
"node": "Postgres Query",
"type": "main",
"index": 0
}
]
]
},
"Postgres Query": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Idempotency Check (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Check (opt-in)": {
"main": [
[
{
"node": "Cap MAX_ROWS_PER_RUN",
"type": "main",
"index": 0
}
]
]
},
"Cap MAX_ROWS_PER_RUN": {
"main": [
[
{
"node": "Transform Rows",
"type": "main",
"index": 0
}
]
]
},
"Transform Rows": {
"main": [
[
{
"node": "Append to Sheets",
"type": "main",
"index": 0
}
]
]
},
"Append to Sheets": {
"main": [
[
{
"node": "Update HWM",
"type": "main",
"index": 0
}
],
[
{
"node": "Error Fallback",
"type": "main",
"index": 0
}
]
]
},
"Error Fallback": {
"main": [
[
{
"node": "Slack Alert",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
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.
googleApipostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
Keep your Google Sheets effortlessly updated with the latest data from your Postgres database, saving hours of manual exports and ensuring real-time insights for decision-making. This workflow is ideal for data analysts, business owners, or teams relying on spreadsheets for reporting without deep technical expertise. It runs a scheduled Postgres query to fetch new or changed records, processes them for efficiency, and syncs directly to Google Sheets, with the key step being the idempotency check to prevent duplicates and maintain data integrity.
Use this workflow for routine daily data pulls, such as tracking sales metrics or inventory levels, where consistency matters more than instant updates. Avoid it for high-frequency needs like real-time dashboards, as the cron trigger suits batch processing; opt for webhooks instead in those cases. Common variations include adjusting the schedule for hourly runs or adding filters in the Postgres query to target specific tables.
About this workflow
Postgres to Google Sheets Sync. Uses stickyNote, scheduleTrigger, postgres, httpRequest. Scheduled trigger; 14 nodes.
Source: https://github.com/studiomeyer-io/n8n-workflows/blob/main/templates/12-postgres-to-sheets-sync/workflow.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.
Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.
공유회_알림톡_크론. Uses postgres, httpRequest, n8n-nodes-solapi. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.