In short: Shopping and PMax reports show the query but hide the product. Add one URL parameter — {lpurl}?utm_content={product_id} — and within a week GA4 hands you the retired query × product report by joining sessionManualAdContent with sessionGoogleAdsQuery. On a real account that exposed 85 % of spend running through zero-converting pairs and a 0.25 % slice carrying the whole account at 46.8× ROAS.
Add one URL parameter to your Shopping and Performance Max campaigns and, within a week, GA4 hands you the report Google quietly retired: every paid click paired as query × product — with sessions, conversions and revenue attached. That single pairing is where the money hides — which products pull garbage traffic, which titles miss real demand, which queries burn budget and never sell. The build is that one parameter; the rest of this article is what you do with what comes back.
If you run Shopping or Performance Max campaigns, you know the search terms report. What you may not have noticed — because Google never points it out — is what’s missing from it: the product.
You see that someone searched “cordless vacuum under 200”. You see clicks and cost. You don’t see which of your 5,000 products that query triggered, landed on, or sold.
Years ago, the AdWords API would give you this pairing. Today it won’t. And for an e-commerce account that’s not a cosmetic gap — query-to-product is where the real optimization lives: whether your titles match real demand, which products attract garbage traffic, why a product gets clicks but never converts.
Before you ask: no, there is no setting for this. No report, no API field, no script that brings it back from Google’s side. You have to rebuild it yourself — and the rebuild is almost embarrassingly simple.
The fix: one parameter, two GA4 dimensions
Add a tracking template
On your Shopping and PMax campaigns: {lpurl}?utm_content={product_id}. The ValueTrack {product_id} variable sends the Merchant Center product ID with every click.
GA4 stores the product
The UTM lands in the Session manual ad content dimension (sessionManualAdContent).
GA4 already knows the query
Auto-tagging (gclid) fills sessionGoogleAdsQuery on the same session.
Join the two dimensions
Every paid click becomes a query × product pair — with sessions, conversion rate and revenue attached.
Auto-tagging and the manual UTM don’t fight each other: gclid keeps handling source, medium and campaign; your UTM only carries the product ID.
Know what you’re getting (and what you’re not)
- Clicked queries only. This dataset starts at the click. Queries where your ad showed but nobody clicked never reach GA4 — impression-level analysis stays in Google’s standard report (without products).
- ~20 % of clicks won’t pair up: consent-rejected sessions, PMax surfaces with no query at all (Display, YouTube, Gmail), clicks that never fired analytics.
- Session-scoped. One session = one product ID — the clicked one, even if the user then browses ten others.
Pulling the report out of GA4
In the UI: Explore → free form. Dimensions: Session manual ad content + Session Google Ads query. Metrics: sessions, conversions, purchase revenue. Filter: session source/medium = google / cpc.
Via the Data API — for anything serious, this feeds a dashboard or a BigQuery join:
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
RunReportRequest, DateRange, Dimension, Metric, FilterExpression, Filter
)
request = RunReportRequest(
property=f"properties/{GA4_PROPERTY_ID}",
dimensions=[
Dimension(name="sessionManualAdContent"), # product ID
Dimension(name="sessionGoogleAdsQuery"), # search term
],
metrics=[
Metric(name="sessions"),
Metric(name="conversions"),
Metric(name="purchaseRevenue"),
],
date_ranges=[DateRange(start_date="30daysAgo", end_date="today")],
dimension_filter=FilterExpression(filter=Filter(
field_name="sessionSourceMedium",
string_filter=Filter.StringFilter(value="google / cpc"),
)),
)
Join sessionManualAdContent against your product feed (id → title, price, category) and the report is complete: query × product × title × economics.
Verified, not theorized
We deployed this on a live account — a Czech electronics retailer with 22 enabled Shopping campaigns — and checked GA4 seven days later:
GA4, 7 days after deployment
- Rows of google/cpc traffic 9,753
- Carried the product ID (utm_content) 96 %
- Carried the search query (sessionGoogleAdsQuery) 81 %
- Carried both — a working query × product report 78 %
Now the fun part: what the pairs tell you
1 · The title gap
Put the queries next to the title and description they triggered. The mismatch jumps out:
The feed roadmap writes itself. And this is where AI feed enrichment stops being a leap of faith and becomes a measurable loop: rewrite the title around the demand you just proved, then watch CTR and conversion rate per query move.
2 · Who else is on “your” query — and with what product
One thing you can’t do: add negative keywords per product. Google gives you no such lever in Shopping or PMax. So when a query × product pair underperforms, the question isn’t “how do I exclude it” — it’s “why does it underperform”.
Take the query and scrape the live results page for it. Concretely: DataForSEO, endpoint serp/google/organic/live/advanced — one POST with the query text and the location_code of your market, and you get back the whole results page as structured JSON: paid ads, shopping blocks with merchant names and prices, organic below. At ~$0.0035 per query, checking 200 queries costs about $0.70.
A typical finding: your mid-range sleeping bag collects clicks on a generic query — and the same query shows three budget brands in the same category at half your price. Your product isn’t bad; it’s outgunned on price in that specific auction.
Now you have real options: reprice; push the differentiator into the title (“down fill, −15 °C comfort”); move the product into a campaign with bidding that matches its margin reality; or accept the query as upper-funnel and judge it on assisted metrics instead of last click.
3 · Structure and bidding decisions
Products attracting high-intent queries deserve their own asset groups and budgets. Products collecting only generic traffic belong in catch-all groups with conservative targets. This report is the evidence base for shopping segmentation — not gut feeling.
4 · A health check for PMax
PMax tells you almost nothing about search. This report is the closest thing you’ll get to an audit of what PMax actually buys for you on the search surface — per product.
Watch me walk one real export, step by step
Everything above is the why. Here is the what it actually looks like — every step, what you download, what you stare at, and the real number that comes back.
The data is a second account: a different mid-size Czech e-shop (not the electronics retailer from the GA4 box above — I’m using it because its catalogue is broad enough that every pattern shows up at full scale). I pulled its raw Shopping search-terms report through the Google Ads API into a local SQLite table and ran the aggregations below. One caveat first: this raw report gives you the ad group / product group the query was served under, not the individual product. That’s precisely the gap the UTM trick closes — but even at the product-group level, the intermediate outputs are loud enough to make the point.
Source for every number in this section: Google Ads Shopping search-terms report, one mid-size Czech e-shop account, ~22.6M lines, data pulled April 2026.
Step 1 · Pull the raw report and measure the pile
What you do: export the Shopping search-terms report via the Google Ads API (search_term_view) into a local table — SQLite or BigQuery, anything you can run GROUP BY on. Why this first: before you join anything, you need to feel how big and how noisy the raw pile is. That single fact resets every expectation that follows.
Run a plain COUNT(*) and a couple of SUMs and this is what lands:
The raw pile — one COUNT, three SUMs
- Report lines (query × product group) 22,640,716
- Distinct search terms 5,370,131
- Product groups they were served under 10,393
- Spend / revenue (blended ROAS 6.8×) 2.57M / 17.4M CZK
What you’ve got: 22.6 million lines, 5.4 million unique queries. No human reads that. The number’s only job is to tell you the next move — collapse this pile by the dimension that pays the bills — and to warn you that any per-row manual triage is hopeless.
Step 2 · Throw away 96 % of it before you analyse anything
What you do: count the lines with zero clicks, then filter them out. Why: the Shopping search-terms report logs every query your ad showed for, most of which nobody clicked. Those impression-only lines can’t cost you and can’t convert — they’re noise that makes the table look scary.
Situation: 22.6M rows, you don’t know where to start. Action:
WHERE clicks > 0. Result: the table collapses to 738,444 lines — a workable size.
The impression flood
- Lines with zero clicks (pure impressions) 21,902,272 — 96.7 %
- Lines that ever cost a koruna 738,444 — 3.3 %
What you’ve got: 96.7 % of the scary number was never anything but noise. You now work a 738K-line table, not a 22.6M one — and every aggregation below runs on the part that actually spends money.
Step 3 · Ask the one question that reframes the account
What you do: on the clicked lines, split spend into “converted at least once” vs “never converted,” and total the cost of each. Why: this is the number that turns “the account is fine, ROAS is 6.8” into “most of the budget does nothing.” It’s the headline intermediate output — compute it before any optimisation idea.
The zero-conversion drag
- Report lines that converted zero times 99.75 %
- Share of total spend those lines ate 85.5 %
- Spend with no conversion behind it 2.20M CZK (~€88k)
- Of clicked lines only, share with zero conversions 92.3 %
What you’ve got: 85 % of the budget flowed through query × product-group combinations that never once converted. That isn’t a rounding error you optimise later — it’s the main event. And you could only see it because you collapsed the query down to what it was sold against.
Step 4 · Check whether the waste is a few villains or the whole crowd
What you do: sort the clicked lines by cost, take the top 1 % and top 10 %, and see how much of total spend they hold. Why: this decides your tactic. If a handful of terms burn the budget, you pause them and you’re done. If the waste is spread thin, pausing terms is pointless — you need structural fixes.
Where the wasted spend actually sits
- Top 1 % of clicked lines by cost 10.5 % of spend
- Top 10 % of clicked lines by cost 29.8 % of spend
What you’ve got: the drag is long-tail, not a few offenders — the top 1 % of costly lines hold barely a tenth of spend. So pausing 20 bad terms changes nothing. This is the data-backed reason to fix structure (which products sit in which campaign at which target) instead of chasing individual queries — and a reminder that Google won’t even let you add a negative per product anyway.
Step 5 · Find out why the long tail leaks: one query, many products
What you do: for each search term, count how many distinct product groups it was served under. Why: it explains the waste mechanically. Shopping matches a query against your whole feed’s signals, not against one product’s relevance — so a single query leaks across unrelated corners of your catalogue, and you pay for every miss.
Situation: “anti-bark device” keeps burning budget. Action:
COUNT(DISTINCT ad_group)for that term. Result: it was served under 139 different ad / product groups for 976 CZK and ~0 conversions. “lego technic” touched 300.
Query spray across the catalogue
- Distinct terms served under more than one product group 46.7 %
- Most product groups a single query reached 6,661
- "anti-bark device" → groups / cost / conversions 139 / 976 CZK / ~0
What you’ve got: nearly half your queries are smeared across multiple product groups, and the worst offenders reach thousands. That’s the engine of the zero-conversion drag from Step 3 — and the exact thing you can finally see once every click carries its product ID.
Step 6 · Eyeball the worst mismatches — they’re absurd
What you do: pull the highest-cost lines that never converted and read the query next to the product group it was sold against. Why: the aggregate numbers convince your spreadsheet; these three rows convince your boss. They’re the human-readable face of Step 5.
| Search query | Served under product group | Clicks | Cost | Conv. |
|---|---|---|---|---|
| dog training collar | Handbags | 97 | 270 CZK | 0 |
| anti-bark device | Baby products | 76 | 239 CZK | 0 |
| lego technic | Lighting | 70 | 169 CZK | 0 |
A dog-training-collar query paid for under your Handbags group. An anti-bark device sold against Baby products. The product group has nothing to do with the query — Google matched on broad feed signals, collected the click, and charged you. With query × product you see this in one glance; with Google’s standard report you never will. (Illustrative example — categories translated and anonymised.)
Step 7 · Now the payoff: the 0.25 % that pays for the whole account
What you do: invert Step 3 — isolate only the lines that did convert and total their cost and revenue. Why: this is the reason the whole exercise matters. Once you can separate the winners from the drag, you protect the winners and starve the rest.
The slice that earns its keep
- Lines that converted (share of all lines) 57,209 — 0.25 %
- What they cost 372k CZK (~€14.9k)
- What they returned 17.4M CZK (~€697k)
- ROAS on that slice 46.8×
What you’ve got: a quarter of one percent of the lines run at 46.8× ROAS and effectively carry the account; the other 99.75 % drag the blended figure down to 6.8×. Finding that slice, protecting its budget, and structuring everything else away from it is the entire job — and none of it is possible until every line carries the product it was sold against. That’s what the one-parameter UTM at the top of this article buys you.
The template + the three traps
{lpurl}?utm_content={product_id}- Don’t put a custom parameter in utm_campaign. Google Ads sanitizes custom parameter values — your campaign names get rewritten and GA4 historical continuity breaks. Send only
utm_content; auto-tagging handles the rest. - Apply only to ENABLED Shopping + PMax campaigns. Search doesn’t need it; paused campaigns just pollute your change history.
- API gotcha: deploying programmatically? Recent Google Ads API versions dropped
client.get_type(“FieldMask”)— importfield_mask_pb2.FieldMaskinstead.
FAQ
Does this work for Performance Max?
Yes, for the search/shopping surface. Display, YouTube and Gmail clicks carry the product ID but no query — expect those rows to have an empty query dimension.
Will the UTM break my GA4 attribution?
No. Auto-tagging (gclid) keeps handling source/medium/campaign; you only add ad content. What would break things is a custom parameter inside utm_campaign — don’t do that.
Why only 78 % coverage?
Consent mode, queryless PMax surfaces and analytics blockers eat the rest. 78 % is plenty for every use case above — you’re reading patterns, not auditing cents.
Can I see queries my ad showed for but nobody clicked?
No. This dataset starts at the click. Impression-level analysis stays in the standard search terms report — without products.
Does the pattern work outside Google — Bing, Sklik?
Yes, it transfers: any platform with a URL template, a product macro and an analytics dimension to catch it. The specific macros differ per platform.
How long until I have usable data?
Depends on volume. Our account had a workable report in 7 days; smaller accounts should collect 30.