FFastero

Connect any database. Ask in plain English.

Try free
Back to blog

Blog article

How to Analyze Google Ads ROAS with SQL

Google Ads reports "conversion value" based on attribution models and pixel fires. Your bank account disagrees. Here's how to compute real ROAS by joining ad spend with actual Stripe charges, broken down by campaign, keyword, and cohort.

Fastero Dev TeamFastero Dev Team
2026-08-05
google-adsstripeROASSQLattributionpaid-acquisition
How to Analyze Google Ads ROAS with SQL

How to Analyze Google Ads ROAS with SQL

Google Ads will happily tell you your ROAS is 4.2x. Open Stripe, add up what you actually collected, divide by what you spent, and you get 1.8x. Both numbers are "correct" inside their own systems. Only one of them reflects money that hit your bank account.

The gap comes from how Google Ads calculates conversion value. It uses attribution models and pixel fires to estimate revenue. A user clicks your ad, triggers a purchase event three days later, and Google records the full order value as conversion revenue. But the pixel fires on the thank-you page regardless of whether the charge succeeds in Stripe. Failed payments, refunds, disputes, trial-to-paid dropoffs. None of that feeds back into Google Ads by default.

The fix: stop trusting Google's conversion value and compute ROAS from the source data yourself. Ad spend from Google Ads, actual revenue from Stripe, joined on UTM parameters.

The tables

Three tables. Google Ads campaign spend: google_ads.campaigns(campaign_id, campaign_name, cost, impressions, clicks, date). Users with UTM attribution captured at signup: users(id, email, utm_source, utm_medium, utm_campaign, created_at). Actual charges from Stripe: stripe.charges(customer_id, amount, created).

The users table bridges the two. A user signs up with utm_source=google and utm_campaign=brand_exact, then pays via Stripe. That UTM field is the join key Google Ads doesn't give you natively.

True ROAS: spend vs. actual revenue

Start with the aggregate number — total spend versus total collected revenue from users who came through Google Ads.

WITH google_users AS (
  SELECT id AS user_id, utm_campaign, created_at
  FROM users
  WHERE utm_source = 'google' AND utm_medium = 'cpc'
),
actual_revenue AS (
  SELECT
    gu.utm_campaign,
    SUM(sc.amount) / 100.0 AS revenue  -- Stripe stores cents
  FROM google_users gu
  JOIN stripe.charges sc ON sc.customer_id = gu.user_id
  WHERE sc.amount > 0
  GROUP BY gu.utm_campaign
),
ad_spend AS (
  SELECT
    campaign_name,
    SUM(cost) / 1e6 AS spend  -- Google Ads API stores micros
  FROM google_ads.campaigns
  WHERE date >= '2026-01-01'
  GROUP BY campaign_name
)
SELECT
  s.campaign_name,
  ROUND(s.spend, 2) AS spend,
  ROUND(COALESCE(r.revenue, 0), 2) AS revenue,
  CASE
    WHEN s.spend > 0
    THEN ROUND(COALESCE(r.revenue, 0) / s.spend, 2)
    ELSE 0
  END AS true_roas
FROM ad_spend s
LEFT JOIN actual_revenue r ON r.utm_campaign = s.campaign_name
ORDER BY spend DESC

Two unit traps. Stripe amounts are in cents, so divide by 100. Google Ads API costs are in micros (millionths of the currency unit), so divide by 1,000,000. I've seen dashboards showing 450x ROAS because somebody forgot the micros conversion. Nobody questioned it for two weeks.

The LEFT JOIN from spend to revenue is deliberate. Campaigns that spent money but generated zero revenue should show up as ROAS 0, not vanish from the report.

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 →

Campaign-level ROAS by month

The aggregate hides trends. A campaign that returned 3x in Q1 and 0.5x in Q2 averages to a decent-looking number while actively burning money.

WITH google_users AS (
  SELECT id AS user_id, utm_campaign, DATE_TRUNC('month', created_at) AS signup_month
  FROM users
  WHERE utm_source = 'google' AND utm_medium = 'cpc'
),
monthly_revenue AS (
  SELECT
    gu.utm_campaign,
    gu.signup_month,
    SUM(sc.amount) / 100.0 AS revenue
  FROM google_users gu
  JOIN stripe.charges sc ON sc.customer_id = gu.user_id
  WHERE sc.amount > 0
  GROUP BY gu.utm_campaign, gu.signup_month
),
monthly_spend AS (
  SELECT
    campaign_name,
    DATE_TRUNC('month', date) AS spend_month,
    SUM(cost) / 1e6 AS spend,
    SUM(clicks) AS clicks
  FROM google_ads.campaigns
  GROUP BY campaign_name, DATE_TRUNC('month', date)
)
SELECT
  s.campaign_name,
  s.spend_month,
  s.clicks,
  ROUND(s.spend, 2) AS spend,
  ROUND(COALESCE(r.revenue, 0), 2) AS revenue,
  ROUND(COALESCE(r.revenue, 0) / NULLIF(s.spend, 0), 2) AS roas,
  ROUND(s.spend / NULLIF(s.clicks, 0), 2) AS cpc
FROM monthly_spend s
LEFT JOIN monthly_revenue r
  ON r.utm_campaign = s.campaign_name
  AND r.signup_month = s.spend_month
ORDER BY s.spend_month DESC, s.spend DESC

Now you can see the trajectory. I include CPC alongside ROAS because a campaign might look fine on ROAS while its cost-per-click has doubled, meaning it's converting harder to compensate. That usually doesn't last.

Keyword-level profitability

Campaign-level tells you which buckets work. Keyword-level tells you which search terms generate paying customers. You'll need a keyword cost table, google_ads.keywords(campaign_id, keyword, cost, clicks, date), from the search terms report.

WITH google_users AS (
  SELECT id AS user_id, utm_campaign, utm_term, created_at
  FROM users
  WHERE utm_source = 'google' AND utm_medium = 'cpc'
    AND utm_term IS NOT NULL
),
keyword_revenue AS (
  SELECT
    gu.utm_term AS keyword,
    COUNT(DISTINCT gu.user_id) AS paying_users,
    SUM(sc.amount) / 100.0 AS revenue
  FROM google_users gu
  JOIN stripe.charges sc ON sc.customer_id = gu.user_id
  WHERE sc.amount > 0
  GROUP BY gu.utm_term
),
keyword_spend AS (
  SELECT
    keyword,
    SUM(cost) / 1e6 AS spend,
    SUM(clicks) AS clicks
  FROM google_ads.keywords
  WHERE date >= '2026-01-01'
  GROUP BY keyword
)
SELECT
  ks.keyword,
  ks.clicks,
  ROUND(ks.spend, 2) AS spend,
  COALESCE(kr.paying_users, 0) AS paying_users,
  ROUND(COALESCE(kr.revenue, 0), 2) AS revenue,
  ROUND(COALESCE(kr.revenue, 0) / NULLIF(ks.spend, 0), 2) AS roas,
  ROUND(ks.spend / NULLIF(COALESCE(kr.paying_users, 0), 0), 2) AS cost_per_paying_user
FROM keyword_spend ks
LEFT JOIN keyword_revenue kr ON kr.keyword = ks.keyword
WHERE ks.spend > 10  -- filter out noise
ORDER BY ks.spend DESC

This requires utm_term={keyword} in your tracking template. If you're not passing it, you're flying blind at the keyword level. Fix that before anything else.

cost_per_paying_user is often more useful than ROAS for keywords. A keyword with $2,000 spend, 3 paying users, and $5,000 revenue looks great at 2.5x ROAS. But $667 per paying customer on an $800 ACV means $133 of margin per customer. That's tight.

The double-counting problem

Here's a gotcha that biases your numbers. A user clicks Ad A on Monday, doesn't convert, clicks Ad B on Thursday, and signs up. Google Ads attributes a conversion to both campaigns (depending on your attribution model). If you report Google's conversion numbers, you've double-counted the revenue.

The SQL approach above avoids this naturally because users.utm_campaign captures the last click only. One user, one UTM, one revenue attribution. Campaign B gets all the credit even if campaign A did the awareness work. For budget allocation decisions, that's the right trade-off. You want to know which campaigns close, not which campaigns "assist."

Time-lagged ROAS: cohorted view

A click today might not convert for two weeks. Measure ROAS within the same month the spend happened and you'll systematically undercount revenue from longer-cycle campaigns. The fix is cohort-based ROAS: group users by signup month, then measure revenue over fixed windows after signup.

WITH google_users AS (
  SELECT
    id AS user_id,
    utm_campaign,
    created_at AS signup_date,
    DATE_TRUNC('month', created_at) AS cohort_month
  FROM users
  WHERE utm_source = 'google' AND utm_medium = 'cpc'
),
cohort_revenue AS (
  SELECT
    gu.utm_campaign,
    gu.cohort_month,
    COUNT(DISTINCT gu.user_id) AS signups,
    SUM(CASE WHEN sc.created < gu.signup_date + INTERVAL '30 days'
             THEN sc.amount ELSE 0 END) / 100.0 AS rev_30d,
    SUM(CASE WHEN sc.created < gu.signup_date + INTERVAL '60 days'
             THEN sc.amount ELSE 0 END) / 100.0 AS rev_60d,
    SUM(CASE WHEN sc.created < gu.signup_date + INTERVAL '90 days'
             THEN sc.amount ELSE 0 END) / 100.0 AS rev_90d
  FROM google_users gu
  LEFT JOIN stripe.charges sc
    ON sc.customer_id = gu.user_id AND sc.amount > 0
  GROUP BY gu.utm_campaign, gu.cohort_month
),
monthly_spend AS (
  SELECT
    campaign_name,
    DATE_TRUNC('month', date) AS spend_month,
    SUM(cost) / 1e6 AS spend
  FROM google_ads.campaigns
  GROUP BY campaign_name, DATE_TRUNC('month', date)
)
SELECT
  cr.utm_campaign,
  cr.cohort_month,
  cr.signups,
  ROUND(ms.spend, 2) AS spend,
  ROUND(cr.rev_30d, 2) AS rev_30d,
  ROUND(cr.rev_60d, 2) AS rev_60d,
  ROUND(cr.rev_90d, 2) AS rev_90d,
  ROUND(cr.rev_30d / NULLIF(ms.spend, 0), 2) AS roas_30d,
  ROUND(cr.rev_60d / NULLIF(ms.spend, 0), 2) AS roas_60d,
  ROUND(cr.rev_90d / NULLIF(ms.spend, 0), 2) AS roas_90d
FROM cohort_revenue cr
JOIN monthly_spend ms
  ON ms.campaign_name = cr.utm_campaign
  AND ms.spend_month = cr.cohort_month
WHERE cr.cohort_month <= CURRENT_DATE - INTERVAL '90 days'
ORDER BY cr.cohort_month DESC, ms.spend DESC

The WHERE cohort_month <= CURRENT_DATE - INTERVAL '90 days' filter matters. Without it, recent cohorts show artificially low 90-day ROAS because they haven't had 90 days yet. Same principle as trailing cohorts in retention analysis. Incomplete windows produce misleading zeros.

The pattern this reveals: some campaigns have a 30-day ROAS of 0.8x but a 90-day ROAS of 2.5x. Those campaigns attract users who take longer to convert but stick around. Killing them based on 30-day numbers is a mistake I've watched teams make repeatedly.

Budget allocation: where to shift spend

With per-campaign true ROAS in hand, rank campaigns and decide where to shift budget.

WITH campaign_performance AS (
  -- Use the campaign-level ROAS query from above
  SELECT
    s.campaign_name,
    SUM(s.cost) / 1e6 AS total_spend,
    SUM(s.clicks) AS total_clicks,
    COALESCE(SUM(r.revenue), 0) AS total_revenue
  FROM (
    SELECT campaign_name, cost, clicks
    FROM google_ads.campaigns
    WHERE date >= CURRENT_DATE - INTERVAL '90 days'
  ) s
  LEFT JOIN (
    SELECT gu.utm_campaign, SUM(sc.amount) / 100.0 AS revenue
    FROM users gu
    JOIN stripe.charges sc ON sc.customer_id = gu.id
    WHERE gu.utm_source = 'google' AND gu.utm_medium = 'cpc'
      AND gu.created_at >= CURRENT_DATE - INTERVAL '90 days'
      AND sc.amount > 0
    GROUP BY gu.utm_campaign
  ) r ON r.utm_campaign = s.campaign_name
  GROUP BY s.campaign_name
)
SELECT
  campaign_name,
  ROUND(total_spend, 2) AS spend_90d,
  total_clicks,
  ROUND(total_revenue, 2) AS revenue_90d,
  ROUND(total_revenue / NULLIF(total_spend, 0), 2) AS roas,
  CASE
    WHEN total_revenue / NULLIF(total_spend, 0) >= 3.0 THEN 'SCALE'
    WHEN total_revenue / NULLIF(total_spend, 0) >= 1.0 THEN 'MAINTAIN'
    WHEN total_revenue / NULLIF(total_spend, 0) >= 0.5 THEN 'OPTIMIZE'
    ELSE 'CUT'
  END AS action
FROM campaign_performance
ORDER BY roas DESC

The thresholds (3x for SCALE, 1x for MAINTAIN, 0.5x for OPTIMIZE, below that CUT) aren't universal. Adjust them for your margins. A SaaS product with 85% gross margins can tolerate a lower ROAS threshold than an e-commerce business running at 30% margins.

One caveat: this query won't tell you whether a SCALE campaign can actually absorb more budget. Doubling spend on a branded search campaign that already captures 90% impression share just raises your CPCs. The ranking tells you where to look; the decision to scale still requires checking impression share in the Google Ads UI.

Putting it together

Five queries, five angles: aggregate true ROAS, campaign-level monthly trends, keyword-level profitability, cohorted time-lag view, and the allocation ranking to act on it.

Fastero connects to both Google Ads and Stripe natively, so you can run these queries against your live data without building an ETL pipeline first. Same approach works for Meta Ads too. Set up a dashboard with the campaign-level query, schedule it to refresh daily, and add a Slack alert when any campaign's ROAS drops below your floor.


Try Fastero free — connect Google Ads and Stripe, query your real ROAS in SQL, and stop trusting attribution models with your budget. 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.