The goal
Marketing wants one view of what each paid campaign costs and what it brings: sessions from GA4, spend and conversion value from Google Ads, and cost per session and return on ad spend worked out per campaign per day.
| You will build | Lens feature |
|---|---|
| A Google Ads table: cost and conversion value per campaign per day | Google Ads source |
| A GA4 table: sessions per campaign per day | BigQuery source on the GA4 export |
| One table with both, matched by date and campaign | Join |
| Cost per session and ROAS | Ratio metrics |
| A board and a client report | Boards, Reports |
Why the campaign column needs the same name
A join matches rows on the date plus key dimensions that have the same name in both tables. On the Campaign Performance stream, Google Ads stores the campaign name in a dimension called name. A GA4 Data API table stores it as sessionCampaignName, and GA4 dimension names cannot be renamed, so those two tables cannot be joined on campaign.
The GA4 BigQuery export solves this: a Custom SQL query can name its columns anything. This walkthrough names the GA4 campaign column name so it matches Google Ads.
A Google connection with access to the Ads account, and a Google service account that can read the BigQuery dataset holding your GA4 export.
Step 1: Google Ads table
- Configure > Data Tables > + New Table > Google Ads.
- Pick the connection and customer account.
- Choose the Campaign Performance stream. Its fields include the dimension
nameand the metricscostandconversions_value. - Name it "Google Ads Spend", set a 90-day backfill and a daily schedule, and save.
Step 2: GA4 sessions per campaign
- + New Table > Google BigQuery, mode Custom SQL.
- Use a query like this one, with your own project and dataset:
SELECT
PARSE_DATE('%Y%m%d', event_date) AS date,
collected_traffic_source.manual_campaign_name AS name,
COUNT(DISTINCT CONCAT(user_pseudo_id, CAST(
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS STRING))) AS sessions
FROM `your-project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', @start_date) AND FORMAT_DATE('%Y%m%d', @end_date)
AND collected_traffic_source.manual_campaign_name IS NOT NULL
GROUP BY date, name
- Set Date Column to
date, marknameas a dimension andsessionsas a metric. - Name it "GA4 Campaign Sessions", set the same 90-day backfill, check Estimated scan per sync, and save.
Each table now has one row per campaign per day, which is what a join on campaign needs. Both are stored in your property's time zone, so their days line up.
Step 3: Join them
- + New Table, under Composite Tables choose Joined. Name it "Campaign Sessions and Cost".
- Base Table: "GA4 Campaign Sessions".
- + Add Join Table: "Google Ads Spend".
- Join Keys:
name. Date is always matched; do not add it. - Save. The join builds right away, and rebuilds every time either source syncs.
Every GA4 row is kept. Campaigns with sessions but no Google Ads spend, such as email or social campaigns, show empty cost.
Step 4: Add the metrics
On the joined table, add:
| Metric | Type | Numerator | Denominator | Format |
|---|---|---|---|---|
cost_per_session | Ratio | cost | sessions | Currency |
roas | Ratio | conversions_value | cost | Number, 2 decimals |
Ratios are worked out after grouping, so a campaign's ROAS over 30 days is its total value divided by its total cost, not an average of daily ROAS.
Step 5: Build the board
Boards > + New Board, "Paid Campaign Performance", then add:
| Tile | Table | Metric | Group By | Options |
|---|---|---|---|---|
| KPI | Campaign Sessions and Cost | cost | Compare To: previous period | |
| KPI | Campaign Sessions and Cost | roas | Compare To: previous period | |
| Line | Campaign Sessions and Cost | cost_per_session | Compare overlay: previous period | |
| Table | Campaign Sessions and Cost | cost, then sessions, cost_per_session, roas | name | Sort by cost, descending; show total row |
Click Create Board. Rename the name column to "Campaign" in the joined table's Column Labels panel so tiles read well.
Step 6: Share it
Reports > + New Report, tick the board, add the client's logo URL and a password, and click Save Report. Send the link from the Reports list.
Check the numbers
- Total
coston the board for last month should match Google Ads for the same dates in the account's time zone. - Total
sessionswill be lower than GA4's own campaign report if many sessions have no campaign; this query only counts sessions with a manual campaign name. - If cost shows empty for a campaign you know spent money, compare the campaign name in both tables: the match is exact and case-sensitive.