How to Run SQL Across Multiple Databases Without a Warehouse
Last month I needed to answer a question that sounds simple: which Stripe customers are paying us but don't have an active deal in HubSpot?
The Stripe data lives in Stripe. The CRM data lives in HubSpot. The product usage data lives in Postgres. And the territory assignments live in a Google Sheet that the VP of Sales updates every quarter.
To answer the question properly, I need to join across all four. The "right" way to do this — according to every data engineering blog and vendor pitch deck — is a data warehouse. Snowflake or BigQuery or Redshift, plus Fivetran or Airbyte for ingestion, plus dbt for transformation. You're looking at $50k/year minimum and two to three months before the first query runs.
For a 5-person team, that's insane.
The warehouse tax
I'm not anti-warehouse. I've built warehouse stacks. They're the right call when you have 50+ sources, terabytes of history, and a dedicated data engineering team to babysit the pipelines.
But most teams don't have that. Most teams have 3-6 data sources and one person who knows SQL. The warehouse becomes a project that never gets prioritized — too expensive, too slow, too much overhead. So the data stays siloed, and people answer cross-source questions by exporting CSVs and running VLOOKUPs in Excel.
There's a better option: pull the data into DuckDB and join it there.
Why DuckDB works for this
DuckDB is an in-process analytical database. Think SQLite, but built for analytics workloads instead of transactional ones. It handles millions of rows on a single machine without breaking a sweat, and it speaks standard SQL — not some proprietary dialect.
The important bit for cross-source work: DuckDB doesn't care where the data came from. Once it's in a DuckDB table, it's just a table. Stripe charges, HubSpot contacts, Postgres users, Google Sheets rows — they all become SQL-queryable tables you can join freely.
Fastero's Cross-Source DuckDB Store does the plumbing for you. Connect your sources, configure a sync schedule, and the platform pulls the relevant tables into DuckDB automatically. Then you write SQL against it — standard joins, aggregations, window functions, whatever you need.
No Fivetran. No dbt. No Airflow DAGs.
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 →Cross-source join: Stripe charges + HubSpot contacts
Here's the question that started this whole thing. Which paying Stripe customers aren't tracked in HubSpot?
SELECT
s.customer_email,
s.amount / 100.0 AS charge_amount,
s.created AS charge_date,
h.lifecycle_stage,
h.hubspot_owner
FROM stripe.charges s
LEFT JOIN hubspot.contacts h
ON LOWER(s.customer_email) = LOWER(h.email)
WHERE s.status = 'succeeded'
AND s.created >= '2026-01-01'
AND h.email IS NULL
ORDER BY s.amount DESC;That LEFT JOIN with the IS NULL filter gives you every Stripe charge where the customer has no matching HubSpot contact. These are paying customers your CRM doesn't know about — revenue that nobody on the sales team can see or account for.
The LOWER() on both sides matters. I've seen email casing mismatches cause 15% of records to silently drop out of joins. Stripe normalizes to lowercase; HubSpot doesn't always.
For a deeper dive on reconciliation patterns, see our Stripe-CRM reconciliation guide.
Cross-source join: Postgres users + Google Sheets territory mapping
Your product's users table lives in Postgres. Your sales team's territory assignments live in a Google Sheet because that's what the VP of Sales is comfortable editing.
SELECT
u.email,
u.created_at,
u.plan_type,
t.territory,
t.rep_name,
t.region
FROM postgres.users u
LEFT JOIN google_sheets.territory_map t
ON LOWER(SPLIT_PART(u.email, '@', 2)) = LOWER(t.domain)
WHERE u.plan_type = 'free'
AND u.created_at >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY t.territory, u.created_at;This joins on email domain — extract the domain from the user's email, match it against the territory sheet's domain column. The result is every free-tier signup from the last 30 days, tagged with the sales territory and assigned rep. Your SDRs now know who to call without manually cross-referencing two systems.
The LEFT JOIN is deliberate. Users whose domain isn't in the territory sheet show up with NULLs for territory and rep — that's the signal that the sheet needs updating, not that the query is broken.
The three-way join: revenue attribution across systems
Here's where it gets genuinely powerful. Say you want to know: for each HubSpot deal that closed this quarter, what's the actual Stripe revenue collected, and what product plan is the customer on?
SELECT
h.deal_name,
h.close_date,
h.deal_amount,
COALESCE(SUM(s.amount), 0) / 100.0 AS stripe_collected,
h.deal_amount - COALESCE(SUM(s.amount), 0) / 100.0 AS gap,
MAX(u.plan_type) AS current_plan
FROM hubspot.deals h
LEFT JOIN stripe.charges s
ON LOWER(h.contact_email) = LOWER(s.customer_email)
AND s.status = 'succeeded'
AND s.created >= h.close_date
LEFT JOIN postgres.users u
ON LOWER(h.contact_email) = LOWER(u.email)
WHERE h.deal_stage = 'closedwon'
AND h.close_date >= DATE_TRUNC('quarter', CURRENT_DATE)
GROUP BY h.deal_name, h.close_date, h.deal_amount
ORDER BY gap DESC;Three sources. One query. The gap column shows you the difference between what HubSpot says you sold and what Stripe actually collected. When that gap is large, something went wrong — failed payment, incorrect deal amount, customer that never signed the contract.
The MAX(u.plan_type) is a hack, but it works when you're grouping by deal. If a customer has multiple user records (it happens), you get the most recent plan. For production reconciliation you'd want proper deduplication, but for a weekly review this gets you 90% of the way.
Scheduled sync, not one-time export
The difference between this approach and a one-off CSV export is that the DuckDB store stays current. Fastero syncs data from each connector on a schedule you set — hourly, daily, whatever makes sense for your use case. The tables in DuckDB reflect the latest state of each source.
So the queries above aren't point-in-time snapshots. They're live. Build a dashboard on top of them and it updates automatically.
Gotchas I've hit
Email matching is fragile. It works for 80% of cases, but some Stripe customers use billing@company.com while HubSpot has the individual contact's address. You'll need a fallback — domain matching, customer name fuzzy matching, or a lookup table that maps Stripe customer IDs to HubSpot contact IDs.
Timezone mismatches. Stripe timestamps are UTC. HubSpot close dates are in the portal's timezone. Postgres depends on how your app writes data. If you're comparing dates across sources, cast everything to a common timezone first or you'll get off-by-one errors on period boundaries.
Schema drift. Google Sheets columns get renamed. HubSpot custom properties get deleted. Your Postgres schema changes with every migration. The sync handles schema changes automatically, but your queries will break if a column disappears. Worth adding a check for that.
NULL handling. Different sources represent missing data differently. Stripe uses null. Google Sheets might use an empty string. HubSpot sometimes returns "None" as a string. Normalize these in your queries with NULLIF and COALESCE or your joins will silently miss matches.
When to upgrade to a warehouse
This approach has limits. If you're joining tens of millions of rows across 20+ sources, DuckDB on a single machine will start to feel it. If you need governed, version-controlled transformations that go through code review before touching production dashboards, you want dbt and a warehouse. If you have regulatory requirements for data lineage and audit trails, the warehouse stack earns its keep.
But for the RevOps manager at a Series A company who needs to know why CRM revenue doesn't match billing revenue — this is faster to set up, cheaper to run, and gives you the answer today instead of next quarter.
Skip the SQL entirely
Not a SQL person? Fastero's AI agent understands the schemas across all your synced sources. Ask it:
"Show me Stripe customers paying more than $500/month who aren't in HubSpot."
It writes the cross-source query, runs it against the DuckDB store, and returns the result. If you want a dashboard or a CSV join, it handles that too.
Try Fastero free — connect your databases, SaaS tools, and spreadsheets, then query across them with SQL or plain English. No credit card required.

