Close enough to hurt
The Google Ads Query Language borrows SQL's shape: SELECT, FROM, WHERE, ORDER BY. The familiarity is deliberate and mostly helpful, right up until you write a query from SQL instincts and hit one of the places GAQL quietly diverges. The divergences below are all silent in the worst sense: the query succeeds and returns something plausible. Nothing errors. You find out when the export is five times longer than expected, or worse, when nobody notices that it is.
1. Segments multiply your rows
The one that costs the most time. A campaign query with a date segment does not return one row per campaign. It returns one row per campaign per day:
SELECT campaign.id, campaign.name, segments.date, metrics.clicks
FROM campaign
WHERE segments.date BETWEEN '2026-03-01' AND '2026-04-30'
AND metrics.clicks > 0
That is up to 61 rows per campaign across the two months, and a 40-campaign account turns into a couple of thousand rows before you have done anything. On a historical export spanning a year or two, the same behaviour stops being an annoyance and becomes a genuine volume problem: the file is dozens of times larger than the thing you were trying to count, and every downstream join now has to know that.
In SQL you would reach for GROUP BY to collapse the rows back down. GAQL has no GROUP BY. The grammar is SELECT, FROM, WHERE, ORDER BY, LIMIT and PARAMETERS, and that is the whole list. There is no HAVING either, for the same reason. Whatever aggregation you wanted, you do it in Python, either by summing into a dictionary keyed on the entity ID or, if all you need is identity, by deduplicating as the rows stream in. For a campaign ID key, that means a seen set:
seen = set()
for batch in ga.search_stream(customer_id=CUSTOMER_ID, query=query):
for row in batch:
cid = row.campaign.id
if cid in seen:
continue
seen.add(cid)
rows.append((cid, row.campaign.name))
The rule underneath, worth internalising: a segment in the SELECT clause changes the grain of the result set, and with one exception you cannot keep segments out of the SELECT. The core date segments, segments.date and its week, month, quarter and year siblings, are the only segments allowed in the WHERE clause without also being selected, which is how you ask for range totals. Every other segment must appear in the SELECT before you can filter on it. So the moment a filter touches segments.device or segments.conversion_action_name, the segment is in your SELECT, and your row count has changed with it. A WHERE clause in GAQL is not guaranteed to be grain-neutral, and that sentence has no SQL equivalent.
The second-order bug: metrics on segmented rows are per-segment, so the clicks on each row are one day's clicks. Deduplicating for identity, as above, is fine. Deduplicating when you also want the totals keeps one day's clicks and silently discards the other sixty. If you need identity and totals, sum first, dedupe never.
2. On view resources, WHERE fields want to be in SELECT
A filter that works perfectly well against campaign can fail against a view resource, because the view demands the filtered field also be selected. In my scripts this has bitten on keyword_view, search_term_view, ad_group_ad and geographic_view:
-- Fails
SELECT keyword_view.resource_name, metrics.clicks
FROM keyword_view
WHERE campaign.status = 'ENABLED' AND ad_group.status = 'ENABLED'
-- Works
SELECT keyword_view.resource_name, metrics.clicks,
campaign.status, ad_group.status
FROM keyword_view
WHERE campaign.status = 'ENABLED' AND ad_group.status = 'ENABLED'
geographic_view is the cleanest example I have hit: it required campaign.status explicitly in the SELECT even though the field only ever appeared in the filter. What makes this one expensive is where your attention goes when it fails. The query worked against campaign five minutes ago, so the natural suspects are the view itself, the field path, the quoting of the enum value, anything except the SELECT list, which you have no SQL-trained reason to look at. The practical takeaway needs no theory: if a query works against campaign and the same filter fails against a view, add the filter fields to the SELECT before debugging anything else. The extra columns cost nothing and you can drop them on the Python side.
3. A compatibility matrix, not a schema
In SQL, if a column exists on a table, you can select it. GAQL fields exist and are still refused in combination. segments.geo_target_country is not valid against user_location_view or geographic_view: those views carry their own location fields, such as user_location_view.country_criterion_id, and reject the geo segments outright. The error text is at least specific here:
Cannot select or filter on the following segments: 'segments.geo_target_country'
(could not support requested resources: 'USER_LOCATION_VIEW'), since segment is
incompatible with the resource in the FROM clause or other selected segmenting resources.
Note the second clause. Incompatibility can come from another segment you have already selected, not just from the FROM resource. Two segments that each work alone can fail together, which makes "add one more column" a riskier edit than it looks.
Also seen in the wild: segments.conversion_action_name and segments.conversion_action_category cannot be selected alongside metrics.cost_micros on the campaign resource. That one is principled. Cost is not attributable to a single conversion action, so the API refuses the query rather than returning a misleading number, which is more honesty than most reporting tools manage. The compatibility rules live in the "selectable with" lists in the field reference, and the interactive query builder applies them as you click; checking there before writing the query is cheaper than decoding the error after.
4. Two small ones from the same mental bucket
Both belong to the family of "the API does not return what you would put in a report".
- Money is in micros: 1,000,000 micros to £1, on every cost, budget and bid field. Divide before anyone sees the number, because a £2,500 spend reading as 2,500,000,000 has a way of surviving into dashboards.
- Geo targeting comes back as resource names like
geoTargetConstants/1006886, not place names. There is no join to reach for, so I resolve them with a second, batched query against thegeo_target_constantresource, an IN clause in groups of 500, selectingcanonical_name, and cache the lookup table for the rest of the run. The IDs are stable, so the cache survives across runs too.
The mutate side of the API has its own version of this post, on the Python client's wrapper layer: proto-plus, not protobuf. And if the reason you are querying at all is keyword data, the Keyword Planner tooling post covers that pipeline end to end.