GAQL in Google Ads Scripts: The Rules That Actually Bite
GAQL queries one resource at a time with no joins, returns money in micros, needs an explicit date range for metrics, and omits rows with no activity rather than returning zeroes. AdsApp.search() runs it and is read-only.
GAQL is the query language of the Google Ads API, and AdsApp.search() exposes it inside scripts. It reads like SQL and behaves nothing like it, which is where most of the wasted time comes from.
The shape is fixed:
SELECT campaign.name, metrics.cost_micros
FROM campaign
WHERE segments.date DURING LAST_7_DAYS
AND campaign.status != 'REMOVED'
ORDER BY metrics.cost_micros DESC
The five rules that explain most errors
One resource in FROM, always. There are no joins. The resource you choose determines which fields are available and at what grain. If you need campaign data and keyword data together, you run two queries and join them in JavaScript.
Attributes, segments and metrics are three different things. Attributes describe the object (campaign.name). Segments slice the rows (segments.date, segments.device). Metrics are the numbers. Adding a segment silently multiplies your row count — adding segments.date to a campaign query turns thirty campaigns into thirty times the number of days.
Money is in micros. metrics.cost_micros is the currency unit times one million. Divide by 1,000,000 or every number you report will be wrong by six orders of magnitude, which at least is obvious. metrics.average_cpc is also in micros.
A date range is effectively required whenever metrics are involved. Either segments.date DURING LAST_30_DAYS or segments.date BETWEEN '2026-08-01' AND '2026-08-31'. Without one you get either an error or a default window you did not choose.
Rows with zero impressions do not exist. The API returns rows that had activity in the window. A keyword that got no impressions is absent, not zero. Any script that counts “how many keywords are inactive” by looking for zeroes will report none.
Reading the result
AdsApp.search() returns an iterator of nested objects, and the field names arrive in camelCase even though the query used snake_case:
const rows = AdsApp.search(
'SELECT campaign.name, metrics.cost_micros ' +
'FROM campaign ' +
'WHERE segments.date DURING YESTERDAY');
while (rows.hasNext()) {
const r = rows.next();
const name = r.campaign.name;
const cost = Number(r.metrics.costMicros) / 1000000;
}
Numeric fields come back as strings often enough that wrapping every one in Number() is cheaper than debugging a concatenation later. '12' + '5' is '125', and a report full of impossible totals is how you find out.
Resources worth memorising
| Resource | Grain | Use it for |
|---|---|---|
campaign |
One row per campaign | Anything account-wide. Covers every channel type including Performance Max. |
search_term_view |
One row per search term | Waste analysis, n-grams, negative discovery. |
keyword_view |
One row per keyword | Keyword performance and final URLs. |
ad_group_ad |
One row per ad | Ad performance, approval status, final URLs. |
asset_group |
One row per asset group | The only readable structure inside Performance Max. |
customer |
One row | Account-level totals, currency code, time zone. |
Where GAQL beats selectors outright
Reports are not subject to the entity limits that cap selectors at 50,000 results. In any account big enough for that to matter, the query layer is not merely faster, it is the only layer that returns everything.
It is also the only layer that covers every campaign type in a single pass. FROM campaign returns Search, Display, Shopping, Video, Demand Gen and Performance Max together, which is why every monitoring script on this site reads that way.
AdsApp.search() is read-only. To change something you still need the object, which means fetching it through a selector by ID or name after the query has told you which ones to fetch. That combination — wide read, narrow write — is the pattern behind almost every well-behaved script.
Debugging a query that returns nothing
- Remove the WHERE clause except the date range. If rows appear, a condition is wrong.
- Check status filters.
campaign.status != 'REMOVED'is usually what you meant;= 'ENABLED'silently drops paused entities you were counting on. - Check the date format.
yyyy-MM-dd, quoted, in the account time zone. - Remember that zero-impression rows are absent, so an empty result may be a correct answer.
See also: search, report and selectors compared.
Questions
- Can GAQL join two resources?
- No. Each query reads one resource. To combine campaign and keyword data you run two queries and join the results in JavaScript.
- Why is cost_micros so large?
- Money fields are expressed in millionths of the account currency unit. Divide metrics.cost_micros by 1,000,000 to get the currency value.
- Why does my query return no rows for keywords I know exist?
- Rows with no activity in the date range are not returned at all. An absent row means no impressions, not a zero.
- Can AdsApp.search() change anything?
- No. It is read-only. Writes go through selectors, so the usual pattern is a wide GAQL read followed by a narrow selector fetch of the entities you decided to change.