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 type | Example | What it is |
|---|---|---|
| Resource attribute | campaign.id, campaign.name | A direct property of the resource in your FROM clause |
| Attributed resource | ad_group.name while FROM is ad_group_ad | A resource implicitly joined to the main one — its attributes are selectable without writing a JOIN |
| Metric | metrics.impressions, metrics.cost_micros | A measurement of performance |
| Segment | segments.date, segments.device | A 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_MATCHLIKE 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 YESTERDAYORDER 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 shape | Paginated — you iterate next_page_token yourself | A continuous stream, paginated automatically for you |
| Best for | Smaller result sets, or when you want explicit control over paging | Large result sets, less pagination code to maintain |
| Quota cost | 1 operation per call; further paginated requests on a valid token don't count again | 1 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_DAYS2. 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 DESCThe 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 DESCUseful 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 1000change_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_strategyA 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 DESC11. 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_MONTHThis 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_viewand similar resources happily return rows with zero clicks and zero impressions. If a report should only show active items, filter explicitly —metrics.cost_micros > 0ormetrics.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_eventrequires 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 level | Daily operations, production accounts | Daily operations, test accounts |
|---|---|---|
| Explorer | 2,880 | 15,000 |
| Basic | 15,000 | 15,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?▼
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()?▼
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?▼
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?▼
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?▼
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 consultationGoogle Ads Consulting
A one-off audit or ongoing strategy partnership.
Google Ads Management
Full account management, including API-driven monitoring.
Google Ads API Authentication Guide
OAuth2, developer tokens, and the 7-day refresh token trap.
Google Ads API Basic Access Guide
Getting your developer token approved, including the July 2026 fast-track.
Google API Access Guide for Agencies
One Cloud project, six APIs: Ads, GA4, Search Console, GTM, and more.
Offline Conversion Import for B2B
Feeding closed deals back into Google Ads, running on the same API.
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.
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.