FFastero

Connect any database. Ask in plain English.

Try free
Back to blog

Blog article

How to Merge Google Sheets with Postgres Data

Your finance team tracks targets in Google Sheets. Your database has the actuals. Here's how to join them with cross-source SQL — no CSV exports, no warehouse, no weekly copy-paste ritual.

Fastero Dev TeamFastero Dev Team
2026-08-04
Google-Sheetspostgrescross-sourceSQLDuckDBdata-engineering
How to Merge Google Sheets with Postgres Data

How to Merge Google Sheets with Postgres Data

Here's a scene that plays out every quarter. Finance maintains budget targets in a Google Sheet. Engineering has the actuals in Postgres. The CEO wants a variance report. Someone volunteers to "just pull the numbers together," which means exporting CSVs from both sides, pasting them into a third spreadsheet, and vlookup-ing until the columns line up. By the time it's done, the numbers are already stale.

Nobody enjoys this. The finance team doesn't want to learn SQL. The engineers don't want to maintain an export script. And the CEO doesn't care how it gets done — they just want the report to exist and be current.

The fix is straightforward: treat Google Sheets as a queryable data source, join it with Postgres using SQL, and stop moving data around by hand. Fastero does this through its cross-source DuckDB store.

Connecting Google Sheets

The Google Sheets connector authenticates via OAuth. Pick a spreadsheet, select which tabs you want, and each tab becomes a table you can query with SQL. A spreadsheet called "2026 Planning" with tabs for "Sales Targets" and "Category Map" gives you google_sheets.sales_targets and google_sheets.category_map.

Column types get auto-detected from the data. Numbers stay numbers, dates become dates. More on where this breaks in the type mismatches section below.

Your Postgres connection works the same way — connect with credentials or an SSH tunnel, and your tables are available as postgres.schema.table or just postgres.table for the public schema.

Both sources feed into Fastero's DuckDB store. That's where cross-source joins happen.

Example 1: Sales rep attainment

Sales ops tracks quarterly targets in a Google Sheet with columns rep_name, region, and quarterly_target. Your Postgres database has an orders table with the actual revenue.

SELECT
  t.rep_name,
  t.region,
  t.quarterly_target,
  COALESCE(SUM(o.amount_cents) / 100.0, 0) AS actual_revenue,
  ROUND(
    COALESCE(SUM(o.amount_cents) / 100.0, 0)
    / NULLIF(t.quarterly_target, 0) * 100, 1
  ) AS attainment_pct
FROM google_sheets.sales_targets t
LEFT JOIN postgres.orders o
  ON LOWER(TRIM(t.rep_name)) = LOWER(TRIM(o.rep_name))
  AND o.created_at >= '2026-07-01'
  AND o.status = 'completed'
GROUP BY t.rep_name, t.region, t.quarterly_target
ORDER BY attainment_pct DESC

Notice the LOWER(TRIM(...)) on both sides of the join. This matters. In Postgres, rep_name is probably clean — inserted by application code. In Google Sheets, someone typed " Sarah Chen " with a trailing space, and now your join silently drops her rows. Always normalize string joins when one side comes from a spreadsheet.

Fastero

Connect your database. Ask questions. Get dashboards.

Postgres, BigQuery, Snowflake, and 10+ sources — live-connected, AI-powered, no dashboard builder learning curve.

Try free →

Example 2: Product category reporting

Product teams love maintaining category mappings in Sheets. It's easier to update than a database table, and non-engineers can edit it without a deploy. The Sheet has sku, category, and subcategory. Your Postgres order_items table has the transactions.

SELECT
  cm.category,
  cm.subcategory,
  COUNT(DISTINCT oi.order_id) AS order_count,
  SUM(oi.quantity) AS units_sold,
  SUM(oi.unit_price * oi.quantity) / 100.0 AS revenue
FROM postgres.order_items oi
JOIN google_sheets.category_map cm
  ON oi.sku = cm.sku
WHERE oi.created_at >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY cm.category, cm.subcategory
ORDER BY revenue DESC

Short and clean. The Sheet is the lookup table, Postgres has the facts. If product adds a new subcategory in the Sheet, the next sync picks it up and the dashboard updates. No migration, no PR, no deploy.

Example 3: Customer tier overrides

Automated scoring systems are great until account managers disagree with them. A common pattern: Postgres has a customer_scores table computed nightly by a job, but the CS team maintains override tiers in a Google Sheet for accounts where the automated score is wrong.

SELECT
  cs.customer_id,
  cs.company_name,
  cs.auto_tier,
  cs.health_score,
  COALESCE(ovr.override_tier, cs.auto_tier) AS effective_tier,
  ovr.override_reason
FROM postgres.customer_scores cs
LEFT JOIN google_sheets.tier_overrides ovr
  ON CAST(ovr.customer_id AS INTEGER) = cs.customer_id
ORDER BY cs.health_score ASC

The COALESCE is doing the real work — if an override exists, use it; otherwise fall back to the automated tier. The CAST on ovr.customer_id is there because Sheets stored it as a string. Which brings us to the next problem.

Type mismatches: Sheets stores everything as strings

Google Sheets doesn't have a type system. What looks like the number 1042 in a cell might arrive as the string "1042" or even "1,042" if someone formatted it with a thousands separator. Dates are worse — "7/15/2026", "2026-07-15", and "July 15, 2026" can all appear in the same column.

Fastero's auto-detection handles the obvious cases. A column of clean integers gets typed as integers. A column of ISO dates becomes a date. But mixed-format columns or columns with even a single non-conforming value fall back to strings.

When that happens, cast explicitly in your query:

SELECT
  b.department,
  CAST(b.q3_budget AS DECIMAL) AS budget,
  SUM(a.amount_cents) / 100.0 AS actual_spend,
  SUM(a.amount_cents) / 100.0 - CAST(b.q3_budget AS DECIMAL) AS variance
FROM google_sheets.department_budgets b
LEFT JOIN postgres.accounting_entries a
  ON b.department = a.department
  AND a.posted_date >= '2026-07-01'
  AND a.posted_date < '2026-10-01'
GROUP BY b.department, b.q3_budget
ORDER BY variance DESC

A few things to watch for:

  • Numeric strings with commas: "1,250" won't cast to a number. Clean it first with REPLACE(b.q3_budget, ',', '') before the cast.
  • Date formats: If auto-detection missed a date column, use STRPTIME(ovr.effective_date, '%m/%d/%Y') to parse it explicitly. DuckDB's STRPTIME is your friend here.
  • Empty cells: Sheets returns empty cells as empty strings, not NULLs. A CAST('' AS INTEGER) will fail. Filter or NULLIF them first: CAST(NULLIF(b.q3_budget, '') AS DECIMAL).
  • Boolean columns: Sheets sometimes sends "TRUE" / "FALSE" as strings. DuckDB casts these correctly, but "Yes" / "No" needs a CASE expression.

Data freshness

Two different sync patterns are at play here. Your Postgres connection queries live — when a dashboard widget refreshes, it runs the query against your current Postgres data. No lag.

Google Sheets data syncs on a schedule you configure. Hourly is the default. You can set it to every 15 minutes or trigger a manual sync when you know the Sheet just changed.

For the cross-source DuckDB store, Postgres data gets pulled at query time or on a scheduled sync (depending on the table size and your configuration). Sheets data uses its own sync schedule. The practical effect: your dashboard shows Postgres data that's seconds old and Sheets data that's at most an hour old. For budget targets that change quarterly, that's more than enough. For a Sheet that someone updates throughout the day, set the sync to 15 minutes or trigger it manually after edits.

The CEO's variance report

Back to the original problem. Finance has department_budgets in Google Sheets. Postgres has accounting_entries. The CEO wants a variance report that updates itself.

SELECT
  b.department,
  CAST(NULLIF(b.q3_budget, '') AS DECIMAL) AS budget,
  COALESCE(SUM(a.amount_cents) / 100.0, 0) AS actual_spend,
  COALESCE(SUM(a.amount_cents) / 100.0, 0)
    - CAST(NULLIF(b.q3_budget, '') AS DECIMAL) AS variance,
  ROUND(
    COALESCE(SUM(a.amount_cents) / 100.0, 0)
    / NULLIF(CAST(NULLIF(b.q3_budget, '') AS DECIMAL), 0) * 100, 1
  ) AS pct_of_budget
FROM google_sheets.department_budgets b
LEFT JOIN postgres.accounting_entries a
  ON LOWER(b.department) = LOWER(a.department)
  AND a.posted_date >= '2026-07-01'
  AND a.posted_date < '2026-10-01'
GROUP BY b.department, b.q3_budget
HAVING CAST(NULLIF(b.q3_budget, '') AS DECIMAL) IS NOT NULL
ORDER BY variance DESC

Pin that to a dashboard widget. Set the Sheets sync to daily — budgets don't change hourly. Set the dashboard to refresh every morning at 8 AM. Done.

Finance updates the budget numbers in their Sheet whenever they want. The dashboard reflects the change on the next sync. No exports, no emails, no "can someone pull the latest numbers?" messages in Slack. If the CEO wants to drill down, Fastero's AI agent can answer follow-ups in plain English, joining the Sheets data with Postgres on the fly.


Try Fastero free — connect Google Sheets and Postgres, write cross-source SQL, and build dashboards that pull from both. No credit card required.

Ready to try it yourself?

Connect your database, ask questions in plain English, and get live dashboards — in under 2 minutes. No credit card required.