HOW-TO · DATA SYNC

Get a Google Sheet into Postgres on a schedule.

By Chris Davidson, founder of yForest · Updated September 26, 2026

A manually maintained planning sheet is often the least reliable input to a Postgres-backed app, not because the data's wrong, but because getting it there means someone remembering to export and import it. Data Sync removes the remembering part.

Start 14-day free trial Download on theMac App Store

No credit card. 14 days. Cancel in one click.

macOS 15+ · Apple Silicon native · 14-day free trial · No credit card

Quick answer: Add Google Sheets and PostgreSQL as connections, open Pipelines, Build, New Sync, and map the sheet's columns to a Postgres table. Use Upsert with a real unique key if the sheet gets edited in place, then save it as a scheduled job. Pipelines tier.

The sheet that quietly became load-bearing

Most teams have one: a planning sheet, a manually maintained exceptions list, a headcount tracker, that started as a scratch document and slowly became something an actual application or report depends on. Nobody set out to build production infrastructure on a spreadsheet, but once a Postgres-backed tool needs that data, someone has to get it there reliably, not by copy-pasting it in when they remember.

Before you start

Steps

  1. Add a Google Sheets connection under Databases, pointed at the sheet, and confirm your Postgres connection works.
  2. Open Pipelines → Build and click New Sync.
  3. On the left, pick the Google Sheets connection and the sheet or range as the source.
  4. On the right, pick the Postgres connection and the target table.
  5. Drag fields across, or click AI Map, then pick a MODE.
  6. Click Dry Run to preview, then Save it as a scheduled job.

A worked example

An operations team keeps a manually updated exceptions sheet, accounts that should be excluded from an automated billing run, with columns account_id, reason, and added_by:

Target table: billing.exceptions
Columns: account_id, reason, added_by, added_at

Because entries get added and occasionally removed by hand, set MODE to Upsert and MATCH ON to account_id, assuming each account only appears once. QueryFlow handles the upsert into Postgres on that key, so a note added to an existing exception updates the row instead of creating a duplicate. Schedule it hourly, well ahead of whenever the billing run actually checks the table.

Why Upsert, not a plain Insert

A manually maintained sheet gets edited in place more often than a system-generated export does. Insert alone would pile up a new row every run even when nothing changed, or duplicate a corrected entry. Upsert with a real unique key avoids both, provided the key actually is unique, which is worth double-checking on a sheet humans edit directly.

Handling removed rows

Data Sync's Upsert mode adds and updates rows; it doesn't delete a Postgres row just because it disappeared from the sheet. If an account gets removed from the exceptions list and should stop being excluded, that removal needs a separate cleanup step, a periodic query that removes rows in Postgres no longer present in the sheet, since Upsert alone won't infer a deletion from an absence.

What this doesn't do

It doesn't watch the sheet for a live edit and sync that instant, it's a scheduled job. For a billing exceptions list, an hourly lag is usually fine; for something that genuinely needs to reflect a change within seconds, this isn't the right tool, and a purpose-built real-time integration is worth the extra setup instead.

Who should actually be allowed to edit the sheet

A sheet that feeds a billing exclusion list is exactly the kind of document where uncontrolled edit access becomes a real problem, not a hypothetical one, since anyone with edit rights can change what gets billed. Restricting the sheet's editors to the specific people responsible for it, and treating the Postgres table as downstream of that access control rather than a place to double-check who changed what, keeps the responsibility where it belongs.

Auditing what actually changed between runs

Because Upsert only shows a row count in the job history, not which specific rows changed, a sensitive table like this benefits from an added_at or updated_at column in the mapping, even if the sheet itself doesn't naturally track it well. A timestamp lets you query for what changed since the last run, rather than relying on the sheet's own edit history, which Google Sheets does track but isn't queryable from Postgres.

What happens if the sheet gets accidentally deleted

A deleted or moved Google Sheet breaks the connection at the next scheduled run, and depending on how quickly that's noticed, the billing process downstream could start running without any exceptions applied at all, silently, which is a worse failure mode than an obvious error. Pairing this job with a failure notification, so a broken connection surfaces immediately rather than being discovered when someone asks why an excluded account got billed, matters more here than on a lower-stakes sync.

Considering a lightweight approval step before it reaches Postgres

For a list with real financial consequences, some teams add a review column, a second person's initials confirming an addition before it's considered live, rather than trusting a single edit to the sheet. That's a process decision made in the sheet itself, not a QueryFlow feature, but it's worth building the source query to only pull rows marked as reviewed if that level of control matters for this particular list.

Why not run this off a live Google Sheets API call instead

A live, on-demand API call to Google Sheets every time the billing run executes would technically remove the lag a scheduled sync introduces, but it also means the billing process now has a hard runtime dependency on Google's API being reachable and fast at exactly the moment billing runs. Decoupling the two, syncing on a schedule into Postgres and letting billing read from the local table, trades a small freshness lag for not making billing's reliability depend on an external API's uptime.

A final word on treating the sheet as infrastructure

Once a spreadsheet feeds a billing process, it stops being a casual document even if it still looks like one. Naming conventions, access control, and a designated owner all matter more than they would for a sheet nobody downstream depends on, and it's worth treating those decisions with the same seriousness as any other piece of production infrastructure, even though the interface is still just a spreadsheet.

What this costs against Fivetran and Coefficient

Fivetran's pricing page (September 26, 2026) meters Google Sheets as a standard connector, 500,000 MAR free, then a $5 base charge per connection between 1 and 1,000,000 MAR, usage above that behind a quote. Coefficient targets a different use case, spreadsheet-to-spreadsheet and BI-tool refreshes, not a database target like Postgres, so it doesn't directly compete on this specific pair. Either way, a hand-maintained exceptions sheet with a few hundred rows costs nothing extra under QueryFlow's flat $199.99-a-year Pipelines price.

Check it worked

Run Dry Run and check the preview against what the sheet currently shows. After a real run, check the job's history for rows synced with no errors.

Troubleshooting

If you seeFix
No Postgres connections availableAdd a Postgres connection under Databases first.
Duplicate key values in sourceThe MATCH ON column isn't actually unique in the sheet; add another column to the key or clean up duplicates.
A blank row in the sheet breaks the runA stray blank row past your data range reads as a row of nulls. Tighten the source range.

Sources

Sync Google Sheets to BigQuery Postgres Mac client Every warehouse, one client Integrations Load a CSV into PostgreSQL. Excel to Postgres on Mac
QueryFlow Studio $9.99/mo · $99/yr
QueryFlow Pipelines $29.99/mo · $199.99/yr

Frequently asked

Do I need Apps Script for this?

No. Data Sync reads the sheet directly through QueryFlow's Google Sheets connection; there's no script to write, deploy, or debug inside the spreadsheet.

What happens if someone edits a row instead of just adding new ones?

Insert alone would add a duplicate row every run. Use Upsert with a MATCH ON column that uniquely identifies each row, so a correction updates the existing row instead.

How does Upsert write into Postgres?

An INSERT with an ON CONFLICT clause against the MATCH ON column you set, Postgres's native upsert mechanism.

Which tier includes this?

Pipelines. Studio covers querying both Google Sheets and Postgres, not building a sync between them.

Stop copy-pasting the exceptions list.

14-day free trial, no card. Map it once, sync it hourly.

Start 14-day free trial

No credit card. 14 days. Cancel in one click.