ads-system.store

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.

Read with GAQL, write with selectors

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

  1. Remove the WHERE clause except the date range. If rows appear, a condition is wrong.
  2. Check status filters. campaign.status != 'REMOVED' is usually what you meant; = 'ENABLED' silently drops paused entities you were counting on.
  3. Check the date format. yyyy-MM-dd, quoted, in the account time zone.
  4. 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.