10+ years · 3x UK Search Awards

Blog

Google Ads API Reporting with GAQL: Anatomy + Copy-Paste Queries [2026]

Blog |Automation|2026-08-29|11 min

Automation · 2026-08-29 · 11 min

In short

GAQL looks like SQL — SELECT, FROM, WHERE, ORDER BY, LIMIT — but it isn't SQL, and the difference bites the first time you write a query that mixes an incompatible metric and segment. There's no JOIN, no subquery, and the resource in your FROM clause decides which metrics and segments are even legal to ask for. Get the combination wrong and the request fails validation.

12

copy-paste report queries below

5

clauses you'll actually use (SELECT/FROM/WHERE/ORDER BY/LIMIT)

1

operation charged per search() or searchStream() call, regardless of row count

20,000

max items allowed in a single IN clause

Quick answer

GAQL (Google Ads Query Language) is the query syntax you send to GoogleAdsService.search() or searchStream() to pull reporting data out of the API. A query has up to five clauses — SELECT, FROM, WHERE, ORDER BY, LIMIT — and every field you select is one of four types: a resource attribute (campaign.id), an attributed resource field pulled in through an implicit join (ad_group.name while your FROM is ad_group_ad), a metric (metrics.clicks), or a segment (segments.date). Not every metric can be paired with every segment or every resource — Google's own Query Validator exists specifically because that compatibility isn't obvious from the field names alone.

Every morning, a script pulls GAQL reports against nine client accounts before I'm at my desk — spend, search terms, conversions, asset performance. None of that runs without the auth chain working first, which I covered in the authentication guide. If your google-ads.yaml and OAuth flow aren't wired up yet, start there — this post picks up exactly where that one ends, with a working connection and no idea yet what to actually ask it.

This is the part nobody hands you as a cheat sheet: the anatomy of a GAQL query, when to use search() versus searchStream(), twelve report queries pulled straight from my own account-monitoring scripts, and the traps that produce a query that runs fine but returns the wrong thing — or doesn't validate at all.


Anatomy of a GAQL query

Per Google's own query language overview, GAQL supports five clauses: SELECT, FROM, WHERE, ORDER BY, and LIMIT (plus an optional PARAMETERS clause for edge cases). Only SELECT and FROM are required — a query against GoogleAdsFieldService, used to inspect field metadata, even drops FROM entirely and keeps just SELECT and WHERE.

Every field you put in SELECT falls into one of four categories, and the category determines what you're allowed to combine it with:

Field typeExampleWhat it is
Resource attributecampaign.id, campaign.nameA direct property of the resource in your FROM clause
Attributed resourcead_group.name while FROM is ad_group_adA resource implicitly joined to the main one — its attributes are selectable without writing a JOIN
Metricmetrics.impressions, metrics.cost_microsA measurement of performance
Segmentsegments.date, segments.deviceA dimension to group by — add it alongside metrics and those metrics split by segment (more rows)

One easy mistake: a resource attribute on the FROM resource itself comes back "for free" once you select anything from it, but an attribute of an attributed resource — ad_group.name when your FROM is campaign, for instance — has to be selected explicitly, or it simply isn't in the response.

WHERE supports a longer operator list than plain SQL gives you. Per the GAQL grammar reference:

= != > >= < <= IN NOT IN
LIKE NOT LIKE
CONTAINS ANY  CONTAINS ALL  CONTAINS NONE
IS NULL  IS NOT NULL
DURING  BETWEEN
REGEXP_MATCH  NOT REGEXP_MATCH

LIKE only works on string fields, not arrays — use CONTAINS ANY/ALL/NONE for array fields instead. REGEXP_MATCH runs on RE2 syntax, and DURING pairs with a fixed set of date-range literals rather than arbitrary date math:

LAST_7_DAYS   LAST_14_DAYS   LAST_30_DAYS   LAST_BUSINESS_WEEK
LAST_MONTH    LAST_WEEK_MON_SUN   LAST_WEEK_SUN_SAT
THIS_MONTH    THIS_WEEK_MON_TODAY   THIS_WEEK_SUN_TODAY
TODAY   YESTERDAY

ORDER BY takes a field name with an optional ASC or DESC, and LIMIT takes a positive integer. Both are optional, but every report query below uses at least one — Google's docs recommend selecting only the fields you actually need, and the field reference is worth a bookmark before you write anything nontrivial.


search() vs. searchStream() — which one to call

Both methods run the same GAQL and return the same GoogleAdsRow objects, per Google's reporting overview. The difference is entirely in how the response arrives.

search()searchStream()
Response shapePaginated — you iterate next_page_token yourselfA continuous stream, paginated automatically for you
Best forSmaller result sets, or when you want explicit control over pagingLarge result sets, less pagination code to maintain
Quota cost1 operation per call; further paginated requests on a valid token don't count again1 operation total, no matter how many rows or batches come back

In my own stack, every module in ppc_ops — the nightly script that walks nine client accounts — calls search(), not searchStream(). The accounts are in the thousands-of-rows range, not millions, so explicit pagination control costs nothing and keeps the code simpler to debug when a report comes back wrong. searchStream() earns its keep once you're scanning an entire MCC at once and the row count climbs into the tens of thousands — a single call still costs one operation either way, which is the detail worth remembering when you're budgeting against a daily quota.


12 copy-paste GAQL reports

These are anonymized, working queries — most pulled directly out of scripts that run against live client accounts every morning. A few are flagged below as standard patterns rather than lifted from a specific file. Swap the date ranges and status filters for your own.

1. Campaign spend, last 30 days

SELECT campaign.name, campaign.status, campaign.id,
       segments.date, metrics.impressions, metrics.clicks,
       metrics.cost_micros, metrics.conversions, metrics.conversions_value
FROM campaign
WHERE campaign.status = 'ENABLED'
  AND segments.date DURING LAST_30_DAYS

2. Search terms report for a period

SELECT search_term_view.search_term, search_term_view.status,
       campaign.name, campaign.advertising_channel_type, ad_group.name,
       metrics.impressions, metrics.clicks, metrics.cost_micros,
       metrics.conversions, metrics.conversions_value
FROM search_term_view
WHERE segments.date BETWEEN '2026-01-01' AND '2026-01-31'

3. Conversions by day, for pacing and trend checks

SELECT campaign.id, segments.date,
       metrics.cost_micros, metrics.impressions, metrics.clicks,
       metrics.conversions, metrics.conversions_value
FROM campaign
WHERE segments.date BETWEEN '2026-01-01' AND '2026-03-31'

One 90-day pull, sliced into 7-day/30-day/prior-period windows locally afterward, instead of four separate calls — the API charges per call, not per row.

4. Keyword performance with Quality Score

SELECT
    campaign.name,
    ad_group.name,
    ad_group_criterion.keyword.text,
    ad_group_criterion.keyword.match_type,
    ad_group_criterion.quality_info.quality_score,
    ad_group_criterion.quality_info.creative_quality_score,
    ad_group_criterion.quality_info.post_click_quality_score,
    ad_group_criterion.quality_info.search_predicted_ctr,
    metrics.cost_micros,
    metrics.clicks,
    metrics.impressions,
    metrics.conversions,
    metrics.conversions_value,
    metrics.ctr,
    metrics.search_impression_share
FROM keyword_view
WHERE campaign.status = 'ENABLED'
  AND ad_group_criterion.status = 'ENABLED'
  AND segments.date BETWEEN '2026-01-01' AND '2026-01-31'
  AND metrics.cost_micros > 0
ORDER BY metrics.cost_micros DESC

The metrics.cost_micros > 0 filter is deliberate — it drops zero-spend keywords out of a Quality Score report, where they're noise rather than signal.

5. RSA asset performance and ad strength

SELECT ad_group_ad.ad.id,
       ad_group_ad.ad.type,
       ad_group_ad.ad.responsive_search_ad.headlines,
       ad_group_ad.ad.responsive_search_ad.descriptions,
       ad_group_ad.ad_strength,
       ad_group_ad.status,
       ad_group_ad.policy_summary.approval_status,
       ad_group_ad.policy_summary.policy_topic_entries,
       ad_group.id, ad_group.name, ad_group.status,
       campaign.id, campaign.name, campaign.status,
       campaign.advertising_channel_type,
       metrics.impressions, metrics.clicks, metrics.cost_micros,
       metrics.conversions
FROM ad_group_ad
WHERE ad_group_ad.status != 'REMOVED'
  AND campaign.status = 'ENABLED'
  AND segments.date BETWEEN '2026-01-01' AND '2026-01-31'

6. PMax asset group ad strength

SELECT asset_group.id, asset_group.name, asset_group.status,
       asset_group.ad_strength, campaign.name, campaign.status
FROM asset_group
WHERE asset_group.status != 'REMOVED' AND campaign.status = 'ENABLED'

7. Conversion action setup audit

SELECT
    conversion_action.id,
    conversion_action.name,
    conversion_action.type,
    conversion_action.status,
    conversion_action.category,
    conversion_action.primary_for_goal,
    conversion_action.counting_type,
    conversion_action.attribution_model_settings.attribution_model,
    conversion_action.value_settings.default_value,
    conversion_action.value_settings.always_use_default_value
FROM conversion_action
WHERE conversion_action.status = 'ENABLED'
ORDER BY conversion_action.primary_for_goal DESC

Useful as a first check on any new account — primary goal, counting type, and attribution model, in one call, before touching Smart Bidding.

8. Change event log — an audit trail of who changed what

SELECT change_event.change_date_time,
       change_event.change_resource_type,
       change_event.change_resource_name,
       change_event.client_type,
       change_event.user_email,
       change_event.changed_fields,
       change_event.resource_change_operation,
       change_event.old_resource,
       change_event.new_resource,
       campaign.name,
       ad_group.name
FROM change_event
WHERE change_event.change_date_time >= '2026-08-01 00:00:00'
  AND change_event.change_date_time <= '2026-08-29 23:59:59'
ORDER BY change_event.change_date_time DESC
LIMIT 1000

change_event is a resource where both the date filter and LIMIT are non-negotiable, and both come with hard caps — the date range can't reach back more than 30 days, and LIMIT can't exceed 10,000. See the trap below.

9. Effective target on portfolio (shared) bidding strategies

SELECT bidding_strategy.id,
       bidding_strategy.name,
       bidding_strategy.type,
       bidding_strategy.status,
       bidding_strategy.campaign_count,
       bidding_strategy.maximize_conversion_value.target_roas,
       bidding_strategy.target_roas.target_roas,
       bidding_strategy.target_cpa.target_cpa_micros,
       bidding_strategy.maximize_conversions.target_cpa_micros
FROM bidding_strategy

A portfolio strategy is where the target most often "hides" — the field on the campaign itself comes back empty, so this is the resource that actually has it.

10. Shopping and product performance

SELECT segments.product_title, metrics.impressions, metrics.clicks,
       metrics.cost_micros, metrics.conversions, metrics.conversions_value
FROM shopping_performance_view
WHERE segments.date DURING LAST_30_DAYS
ORDER BY metrics.cost_micros DESC

11. Account-level assets (sitelinks, callouts)

SELECT customer_asset.field_type, customer_asset.status,
       asset.id, asset.type
FROM customer_asset
WHERE customer_asset.status != 'REMOVED'

Check this before flagging "campaign has no sitelinks" — an account-level asset shows up on any campaign without its own, and skipping this query produces a false positive.

12. Budget pacing (standard pattern)

SELECT campaign.id, campaign.name,
       campaign_budget.amount_micros,
       campaign_budget.delivery_method,
       metrics.cost_micros
FROM campaign
WHERE campaign.status = 'ENABLED'
  AND segments.date DURING THIS_MONTH

This one is a standard pattern rather than a line lifted from a script — build your own pacing math on top of amount_micros versus month-to-date cost_micros.


Traps: metrics/segments, zero-impression rows, micros

What actually trips people up in production

  • Metric/segment compatibility isn't universal. Not every segment can be paired with every metric, and the mismatch fails at validation, not silently. Run new query shapes through the Query Validator before they hit production.
  • A rejected request still counts against your quota. Google's quota documentation doesn't carve out an exception for validation failures — a malformed query that never returns a row still spends one operation against your daily limit, so check new query shapes against the Query Validator locally instead of trial-and-error against a live account.
  • Zero-impression rows are normal, not a bug. keyword_view and similar resources happily return rows with zero clicks and zero impressions. If a report should only show active items, filter explicitly — metrics.cost_micros > 0 or metrics.impressions > 0.
  • Every money field is in micros. cost_micros, cpc_bid_micros, target_cpa_micros — divide by 1,000,000 for the actual currency amount. My own reporting code does this everywhere: cost = cost_micros / 1_000_000.
  • change_event requires both a date filter and a LIMIT — and both are capped. The date range can't reach back more than 30 days, and LIMIT can't exceed 10,000. Leave out either clause, or push past either cap, and the query fails validation outright — it's not optional the way it is for most other resources. See Google's change_event documentation.
  • An IN clause tops out at 20,000 items, per the quota documentation. Batch larger ID lists into multiple calls.
  • The gRPC response is capped at 64 MB, also per the quota documentation. For a large pull, select fewer fields or switch to searchStream() rather than assuming one giant query will just work.
  • Configuration resources have no metrics. Account Budget, Ad, Billing Setup, and similar resources can't be combined with metrics.* fields — they describe setup, not performance. The Query Validator splits resources into the two groups if you want to check before writing the query.

The Query Validator — check before you deploy

Google publishes a Query Validator / Query Builder that checks segment, metric, and resource compatibility before you ever send a request. Resources split into two groups — those with metrics and those without — and the tool catches an invalid segment-metric combination before it becomes a runtime error in a script running unattended overnight. Worth running any new query shape through it once, especially before it goes into a scheduled job.


Limits and quotas

Per Google's quota documentation, a search or searchStream request counts as exactly one operation against your daily operation quota — regardless of how many rows or streaming batches come back. Paginated follow-up requests on a valid page token don't count again. Exceed the quota and you get RESOURCE_EXHAUSTED.

Access levelDaily operations, production accountsDaily operations, test accounts
Explorer2,88015,000
Basic15,00015,000

My own nightly reporting across all nine client accounts runs entirely on Explorer access — every query above works fine at that level. In my experience, stepping up to Basic Access mainly unlocks planning tools like Keyword Planner fields, not the reporting resources themselves; I wrote up the full application process, including the July 2026 fast-track, in the Basic Access guide.


Frequently asked questions

What is GAQL, and is it the same as SQL?
GAQL (Google Ads Query Language) borrows SQL's SELECT/FROM/WHERE/ORDER BY/LIMIT shape but runs on its own grammar. There's no JOIN and no subquery — instead, certain resources are "attributed" to your main FROM resource and get pulled in implicitly, so you can select ad_group.name while querying ad_group_ad without writing a join yourself. It also has a longer WHERE operator set than plain SQL, including CONTAINS ANY/ALL/NONE and DURING for fixed date-range literals.
When should I use search() instead of searchStream()?
Use search() when the result set is small to moderate or you want explicit control over pagination — every module in my own account-monitoring stack uses it, since the accounts involved run in the thousands of rows, not millions. Use searchStream() for larger pulls where you'd rather not hand-roll page-token iteration. Both count as a single operation against your daily quota per call, no matter how many rows come back.
Why does my GAQL report return rows with zero impressions and zero cost?
That's expected behavior, not a bug — resources like keyword_view return every matching row, including ones with no activity in the period. If you only want rows that actually spent or served, add an explicit filter such as metrics.cost_micros > 0 or metrics.impressions > 0 to your WHERE clause.
What does the "_micros" suffix on fields like cost_micros mean?
Every monetary field in the Google Ads API — cost_micros, cpc_bid_micros, target_cpa_micros, and similar — is expressed in micros, a fixed-point unit equal to one-millionth of the account's currency. Divide by 1,000,000 to get the actual amount. It's easy to miss the first time and report a spend number that's off by six orders of magnitude.
Do I need Basic Access to run GAQL reports, or does Explorer access cover it?
In my experience, Explorer access covers the reporting resources behind most GAQL queries — campaign, ad_group, keyword_view, search_term_view, and the rest used in this post — at 2,880 operations per day on production accounts. Basic Access raises that to 15,000 operations per day and mainly unlocks planning tools like Keyword Planner fields, not reporting itself. See the Basic Access guide for the full application process.

Want reports like these running against your own accounts?

I build and run this exact GAQL reporting layer for client accounts — spend, search terms, Quality Score, asset performance, budget pacing — on top of a properly configured Google Ads API connection.

Schedule a free consultation
Last updated: August 29, 2026

Free video audit

Get a Personalised Video Audit of Your Google Ads Account

I'll record a 15-minute walkthrough of your campaigns showing exactly where you're losing money and what to fix first.

Book a free consultation

Free personalised video audit

Want a video walkthrough of your Google Ads account?

I'll personally record a 15-minute video walking through your campaigns, showing you where you're losing money and giving you 3 specific things to fix immediately. No sales pitch — just value.

What you get:

  • 15-min personalised video analysis
  • 3 specific quick wins to implement
  • Budget & bidding recommendations

Requirements:

  • Ad spend: €1,500+/month (or £1,500+)
  • Active account for 3+ months
  • eCommerce or Lead Gen business

Limited to 5 audits per month. Response within 48 hours.