GAQL in 2026: Google Ads API Queries That Return Honest Numbers
You pulled a month of spend through the Google Ads API, built the report, and the total doesn’t match the interface. Off by 4%, by 11%, sometimes by half. Everyone starts debugging the code, the credentials, the warehouse. In practice, Google Ads API queries look like ordinary SQL and behave nothing like it, and the bug is almost always one line: a stray segment in SELECT, a date filter covering the wrong window, or campaign-level metrics stacked on top of keyword-level rows.
GAQL (Google Ads Query Language) looks like SQL and behaves nothing like it. This is a working guide to how the language actually treats your data: which entities can be requested together, why rows multiply, where campaigns without impressions disappear, and how to reconcile an export against the UI in ten minutes. If you write reporting against the API — or sign off on reporting someone else wrote — you’ll finish this able to read a query and spot where it lies.
GAQL is not SQL, and that is the whole problem
The syntax is familiar: SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT .... The resemblance ends there, and every difference is a potential error in your numbers.
- No JOIN. Exactly one resource goes in
FROM. Related entities are available not because you joined them, but because Google decided in advance which attributes are visible from that resource. You can selectcampaign.namefromad_group; you cannot stitch arbitrary objects together. - No aggregate functions. There is no
SUM(), noCOUNT(), noGROUP BY. The server does the aggregation: the grain is set by the resource inFROMplus the segments you select. Summing happens on your side. - No subqueries, no computed expressions. You cannot write
metrics.cost_micros / 1000000 AS cost. Division, currency handling and derived metrics are your job. - Commas in SELECT have a cost. Every segment you add changes the number of rows returned — and therefore the meaning of every metric in those rows.
The practical consequence: GAQL is a retrieval language, not an analytics language, and Google Ads API queries have to be designed around that. Analysis starts once the rows land in your warehouse. For how to build that warehouse without duplicating rows on every incremental load, see our walkthrough of the GA4 BigQuery export for paid traffic — the table design and backfill logic carry over directly.
Anatomy of a query: four kinds of fields
Every field in GAQL falls into one of four groups, and the group decides how the row behaves.
| Group | Prefix | What it is | Changes row count |
|---|---|---|---|
| Resource | campaign., ad_group. |
The object itself: name, status, type | Yes — it sets the grain |
| Attributed resources | campaign.name from ad_group |
Parent fields visible from the current resource | No |
| Segments | segments. |
Breakdowns: date, device, network, click type | Yes — they multiply rows |
| Metrics | metrics. |
Impressions, clicks, cost, conversions | No, but they are recomputed per breakdown |
A minimal, honest campaign spend query looks like this:
SELECT
campaign.id,
campaign.name,
campaign.status,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM campaign
WHERE segments.date BETWEEN '2026-10-01' AND '2026-10-20'
AND campaign.status != 'REMOVED'
ORDER BY metrics.cost_micros DESC
Note that segments.date appears in WHERE but not in SELECT. That is the difference between “spend per campaign for the period” and “spend per campaign per day.” The first returns one row per campaign; the second returns twenty.
Six mistakes that make the numbers disagree with the UI
1. A segment in SELECT multiplied your rows
The most common failure by a wide margin. Someone added segments.device “for completeness,” and now each campaign returns up to four rows — MOBILE, DESKTOP, TABLET, CONNECTED_TV. Take the first row as “the campaign” and spend collapses. Average metrics.ctr across those rows and the result is meaningless.
The rule: a segment in SELECT is another dimension of your table. The segments that most often break reconciliation are segments.date, segments.device, segments.ad_network_type, segments.conversion_action and segments.click_type. Which breakdowns actually change a decision is covered in our piece on report segments in Google Ads; the logic is identical in the API, the UI just hides it behind a button.
2. Campaigns with no impressions vanished
If your SELECT contains even one metric, the API returns only rows that had statistics in the period. Paused campaigns, ad groups with no delivery, keywords that never triggered — none of them appear. You cannot build an “all campaigns in the account” report this way.
The fix is two queries. One for structure, with no metrics at all (SELECT campaign.id, campaign.name, campaign.status FROM campaign), one for statistics. Join them on campaign.id in your own code and fill the gaps with zeros. Otherwise every report silently hides whatever stopped spending — which is usually exactly what you needed to see.
3. Dates: account time zone and immature conversions
Every date in GAQL is in the advertising account’s time zone, not your server’s and not UTC. If the account runs on America/New_York and your job fires on Central European time, the day boundary shifts and your daily export systematically clips or double-counts evening traffic.
The second layer is conversions. Constants like DURING LAST_30_DAYS end yesterday — today is not included. And metrics.conversions is attributed to the date of the click, not the date of the conversion: yesterday’s row will keep filling in for weeks as people come back and convert. A “yesterday” export written once and never revisited understates conversions by design. It is the same mechanic behind platform-versus-analytics gaps, unpacked in our article on the Google Ads and GA4 conversion discrepancy.
Working practice: rewrite the last 14–30 days on every run instead of appending only the new day. Pick the window from your measured conversion lag, not from habit.
4. Conversions counted twice
metrics.conversions counts only the actions marked primary for optimisation. metrics.all_conversions counts everything tracked, secondary actions included. They are different numbers living in different UI columns, and half of all “discrepancies” are simply a comparison of one against the other.
A separate trap is segments.conversion_action. It splits rows by conversion type; combine it with another segment and sum the result, and part of your conversions gets counted more than once. If you need a per-action view, run it as its own query and keep it away from the spend report.
5. Metrics from different grains stacked together
Spend from campaign, spend from keyword_view and spend from search_term_view are not required to match. Search terms do not cover Performance Max or Demand Gen traffic; keywords do not cover dynamic formats; some impressions are never attributed to a keyword at all. Stacking those exports into one “spend” table guarantees a wrong total.
And separately: averages do not add up. metrics.ctr, metrics.average_cpc and metrics.cost_per_conversion must be recomputed from their raw components (clicks ÷ impressions, cost ÷ clicks), never averaged across rows.
6. Micros and currency
Every monetary field arrives in micros: metrics.cost_micros, campaign_budget.amount_micros, all bids. Divide by 1,000,000. Currency is whatever the account is set to, and the API does not convert: a multi-account rollup without normalisation shows a number that does not exist anywhere. customer.currency_code and customer.time_zone give you both, and neither can be changed after the account is created — a constraint we covered in the piece on Google Ads account limits.
The resources that cover 90% of reporting
| Resource | What it returns | Catch |
|---|---|---|
customer |
Account: currency, time zone, status | Query it first, before anything else |
campaign |
Campaigns and their statistics | The default grain for most reports |
campaign_budget |
Budgets, including shared ones | Amounts in micros |
ad_group / ad_group_ad |
Ad groups, ads, approval status | Policy review status lives here |
ad_group_criterion |
Keywords, audiences, placements, negatives | Several criterion types share one resource |
keyword_view |
Keyword-level statistics | Only where keywords exist |
search_term_view |
Actual search queries | Partial coverage; aggregates with a delay |
asset_group / asset_group_asset |
Performance Max structure | PMax reporting spans several resources |
geographic_view / user_location_view |
Physical presence vs. location of interest | Two different resources — don’t mix them |
change_event |
Account change history | Limited look-back; requires a date filter |
click_view |
Individual clicks with GCLID | One day per query, short retention — not for backfills |
change_event deserves special mention: it is the same change history analysts use to diagnose performance drops, only machine-readable. Pipe it into your own database and you get an alert that says “this setting changed an hour before CPA moved” instead of a manual archaeology session. The diagnostic routine itself is in our guide to Google Ads change history.
Filters that save quota and sanity
GAQL supports =, !=, >, <, IN, NOT IN, LIKE, CONTAINS ANY, REGEXP_MATCH, BETWEEN and the DURING constants (TODAY, YESTERDAY, LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH).
- Filter on the server, not in your code. Pulling everything and discarding most of it burns quota and wall-clock time for nothing.
- View resources require a date. Many reporting resources simply return nothing without a
segments.dateconstraint. - Exclude removed entities.
campaign.status != 'REMOVED'and its equivalents keep years of dead objects out of the report. - Use
searchStreamfor volume. The streaming method returns results without pagination and is materially faster at scale; plainsearchis easier for small, targeted lookups. - Remember
PARAMETERS.include_draftspulls drafts and experiments into the result;omit_unselected_resource_namesstrips service fields and shrinks the payload.
If a query returns hundreds of thousands of rows, the cause is almost certainly a segment you didn’t need — not an account that grew too large. Remove segments one at a time and watch where the row count drops by an order of magnitude.
Validating Google Ads API queries before production
- The query builder and validator in the Google Ads API documentation. They assemble a query interactively and flag incompatible fields immediately — and compatibility in GAQL is far from obvious, since not every segment is allowed with every resource.
- Metadata from the API itself. Querying
google_ads_fieldreturns, for each field, the resources and segments it is compatible with and whether it is selectable, filterable and sortable. More reliable than guessing from docs. - A single-day reconciliation. Before you backfill a year, export one day for one campaign and compare four numbers against the UI: impressions, clicks, cost, conversions. If they match, scale up.
The ten-minute reconciliation routine
- Pick one account, one day, one campaign with steady delivery.
- In the UI, set that date and read the four metrics with no segments applied.
- Run the query with zero segments in
SELECTand only a date filter. - Compare impressions and clicks. A mismatch here is a date or time-zone problem.
- Compare cost. A mismatch with matching clicks is micros or currency.
- Compare conversions. A mismatch points to
conversionsvsall_conversions, or to conversion lag. - Only now add segments — one at a time, checking the sum against the unsegmented total at each step.
Three Google Ads API queries worth keeping on hand
Three templates that cover recurring work. Swap in your own dates and thresholds.
Search terms that spent money without converting
SELECT
campaign.name,
ad_group.name,
search_term_view.search_term,
segments.keyword.info.match_type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM search_term_view
WHERE segments.date DURING LAST_30_DAYS
AND metrics.clicks > 5
AND metrics.conversions = 0
ORDER BY metrics.cost_micros DESC
LIMIT 500
This is the backbone of systematic negative keyword work: instead of scrolling a report daily, you get a short candidate list sorted by wasted spend. The click threshold is not optional — without it the result fills with single-click terms that say nothing statistically. The selection method itself is in our guide to negative keywords and search terms.
Campaigns constrained by budget
SELECT
campaign.id,
campaign.name,
campaign_budget.amount_micros,
campaign.primary_status,
campaign.primary_status_reasons,
metrics.impressions,
metrics.cost_micros
FROM campaign
WHERE segments.date DURING YESTERDAY
AND campaign.status = 'ENABLED'
ORDER BY metrics.cost_micros DESC
primary_status and primary_status_reasons are an underused pair. They explain why a campaign is technically enabled but not delivering: budget constrained, no eligible ads, payment problems, disapproved assets. That is a ready-made daily “what is broken today” alert.
Everything that changed in the account
SELECT
change_event.change_date_time,
change_event.user_email,
change_event.change_resource_type,
change_event.resource_change_operation,
change_event.changed_fields,
campaign.name
FROM change_event
WHERE change_event.change_date_time DURING LAST_14_DAYS
ORDER BY change_event.change_date_time DESC
LIMIT 1000
Note that this resource filters on its own timestamp field rather than segments.date, and requires an explicit LIMIT. The user_email field answers the first question in any performance investigation: who made the change. In accounts with several contractors, this is the first thing worth automating.
And about looping over a manager account
There is no “query the whole MCC” shortcut: every query runs in the context of one client account. The pattern is always the same — fetch the list of child accounts, then iterate, handling errors per account. One suspended or closed account should never take down the whole nightly export: log the failure and keep going. And normalise currencies before aggregating, or your group-level rollup reports a number that exists nowhere.
API versions: why a working query stops working
GAQL is bound to a Google Ads API version, and versions ship — and sunset — on a schedule. Fields get renamed, resources merge, segments disappear alongside retired campaign types. A query written a year ago may quietly return something different, or fail on a field that no longer exists.
Minimum hygiene: pin the API version explicitly in code, keep queries in a repository rather than in people’s heads, and read the release notes on every upgrade. The release and sunset calendar is in our article on Google Ads API versions. If you haven’t integrated yet, start with getting Google Ads API access — developer token, Cloud Console project and access tiers are their own project before you write a single line of GAQL.
When GAQL is overkill
More often than people admit. If the requirement is “email me campaign spend every Monday,” the interface does that with no code at all. Where the boundary actually sits is laid out in Google Ads API vs scripts.
The API earns its keep when you need history beyond what the UI keeps, several accounts in one warehouse, derived metrics of your own, or alerting on custom logic. For everything else, look at custom columns and the report editor first — they cover more ground than most teams assume.
One closing thought: better reporting does not make advertising profitable. It only surfaces the truth sooner. The economics are decided by structure, bidding and traffic quality. If you are working on that side of the problem, the Google Ads guide and the agency account setups from PPC Rebels cover the practical layer that API documentation deliberately leaves out.
Two companion pieces published alongside this one: Google Ads changes in October 2026, where several updates change the very metrics you export, and Google Ads support and your account rep, for when the problem genuinely is not on your side.
FAQ
How is GAQL different from SQL?
No JOIN, no aggregate functions, no subqueries or computed expressions. Exactly one resource goes in FROM, and the grain is set by that resource plus the segments you select. Summing and derived metrics happen in your own code.
Why doesn’t API spend match the interface?
Three usual causes: a segment in SELECT multiplied the rows; the period was computed in the wrong time zone; cost was not divided by 1,000,000 because it arrives in micros. Check in that order.
Where did campaigns with no impressions go?
If SELECT contains any metric, rows without statistics are not returned. Query structure separately with no metrics and join it to the stats on your side.
What is the difference between metrics.conversions and metrics.all_conversions?
The first counts only actions marked primary for optimisation; the second counts everything tracked, including secondary actions. They are separate columns in the UI, so compare like with like.
Why do yesterday’s conversions keep rising?
Conversions are attributed to the click date. Someone clicked yesterday and bought a week later, so yesterday’s number changes. Rewrite the last two to four weeks on every export instead of appending a single new day.
Can one query cover every account under a manager account?
No. A query runs in the context of one client account. Fetch the list of child accounts first, then loop. Normalise currencies if the accounts differ.
How do I know which fields are compatible?
Use the API’s own metadata: a query against google_ads_field returns compatible resources and segments per field, plus selectable, filterable and sortable flags. That beats trial and error.
search or searchStream?
searchStream for large exports — it streams results without pagination and is faster at volume. Plain search is simpler for small, targeted lookups where a paged response is convenient.
Can GAQL change anything in the account?
No. GAQL is read-only. Changes go through the mutate operations of the relevant API services.
Why does my click_view query fail?
That resource requires a single-day filter and has a short retention window. It is not meant for backfills: collect GCLIDs daily and store them yourself.
Do I need GAQL if I already have built-in reporting and scripts?
For most teams, no. The API pays off when you need long history, many accounts in one warehouse, custom derived metrics, or alerting on your own rules. Single-account routine reporting is usually well served by the interface.
How often will queries need rewriting?
Pin a specific API version and read the release notes on every upgrade: fields get renamed, resources merge, and segments retire with the campaign types they described. Keep queries version-controlled rather than scattered across scripts.