Measurement. Search Console API
Search Console API Missing Keywords: Why the Numbers Don’t Add Up
Quick answer
Two separate things go wrong. First, the Search Analytics API returns rows sorted by clicks, highest first, and stops at your row limit. A sort by impressions applied afterwards only reorders that top-by-clicks set, so a head term with big impressions and almost no clicks never shows up. The fix is one call per keyword per period with an exact-match query filter and a row limit of 1, then a check that the period impressions add up to one whole-period call. Second, query rows always sum short of the site total, because Google leaves anonymized queries out of every query-level table. Quote query splits as ratios and never expect them to add up to total clicks.
Written by Ram Kr Shukla, SEO and growth consultant
I rebuilt a keyword ranking report for a D2C beauty brand on Shopify and found the store’s biggest non-brand head term missing from it. The keyword hadn’t dropped. It just wasn’t in the data. Across the tracking period it had 22,589 impressions and 6 clicks, and it had vanished from a top-rows pull in two separate months. The grid built on that pull had 55 blank cells out of 132. Every one of those 22 keywords was ranking in every month.
That’s the first trap, and it catches anyone building reports, dashboards or rank trackers on Search Console data. The second is quieter: query-level numbers that never add up to the site total, however carefully you pull them. Both follow directly from how Google documents the API. Neither is a bug you can report.
Below you’ll find both, with the exact request bodies, a short Python script and the reconciliation check that would have caught the first one on day one. If your doubt sits further down the funnel, in whether GA4 records the sale at all, that’s what my GA4 conversion tracking work covers, and the Shopify GA4 funnel audit is the place to start. This post stays inside Search Console.
What the Search Analytics API Actually Promises
Before you blame a tool, read the method’s own reference. Google’s searchanalytics.query documentation is short, and more candid than most people expect. These are the lines that matter for reporting.
| Setting | What Google documents | Why it matters for reports |
|---|---|---|
| rowLimit | Valid range 1 to 25,000. Default 1,000. | Ask for 15 rows and you get the top 15. Nothing else. |
| startRow | Zero-based offset for paging. Past the end you get a successful response with zero rows. | You can page, but an empty page doesn’t prove you’ve seen every query. |
| Ordering | Sorted by click count, descending. Grouped by date: oldest date first. Ties: arbitrary order. | There’s no impressions sort on Google’s side. |
| Completeness | The API doesn’t guarantee all data rows, only top ones. | Missing rows are part of the design. |
| Daily cap | Up to 50,000 rows per day, per site, per search type. | Large sites lose long-tail rows even with paging. |
| dataState | Omitted or final: finalized data only. all: adds fresh data that can still change. | Mix the two across calls and your sums won’t match. |
| equals | Exact match. Case-sensitive for the page and query dimensions. | Your keyword has to match the stored query string exactly. |
Sources: Google’s searchanalytics.query reference, its data limits guide and the Search Central performance data deep dive.
The ordering line is the one that bites. Google’s wording is blunt: results are “sorted by click count, in descending order.” There’s no sort field in the request body at all. So if a tool offers a sort by impressions or by position, it’s sorting rows Google already chose for you, using clicks.
The daily cap comes from Google’s guide to getting all your data, which says the method exposes a maximum of 50,000 rows per day per search type, sorted by clicks. The Search Central team repeats the figure in its deep dive on performance data filtering and limits and adds that the Looker Studio connector shares it. Most small and mid-sized sites never reach it. The click sort applies to every request.
Trap One: Sorting by Impressions Only Reorders the Top by Clicks
Here’s how the head term disappeared. The reporting tool took a row limit and a sort field, and I asked it for the top 15 queries by impressions for each month. It passed the row limit to Google. Google returned the 15 queries with the most clicks. The tool then sorted those 15 by impressions and handed them back. It looked like a top 15 by impressions. It was a top 15 by clicks in a different order. The tool’s description even says the sort is applied after the fact, in one line that’s very easy to skip.
On a store where search is dominated by the brand name, the top rows by clicks are nearly all brand queries. Brand searches on that store clicked through at 11.08%. Non-brand ran at 1.13%. A non-brand term with 22,589 impressions and 6 clicks sits below every brand variant, every misspelling and every small query that happened to collect 7 clicks or more. It didn’t make the cut in two separate months.
Why the cut lands on the keywords you care about most
Head non-brand terms are exactly the high-impression, low-click queries an SEO report exists to show. They’re the terms where a store ranks on page one but below the top few spots, so it collects impressions and very few clicks. The click sort puts them under brand queries and under long-tail queries that picked up a handful of clicks. Then the row limit cuts them off.
And it falls hardest on the rows that matter most. In the rebuilt grid, the store’s 22 tracked non-brand keywords averaged position 7.3 over a rolling 28 days, weighted by impressions, and none sat below position 15. Across the whole tracking period, three head terms carried 72% of the tracked impressions and returned 108 clicks between them. That’s a page-one set with a click-through problem, the kind of gap I describe in the position 11 to 100 trap. The blank cells had been telling a different story: keywords that didn’t rank at all.
Every tool on the API inherits the same ceiling
Anything that reads the API inherits its ordering and its caps: connectors, MCP servers, spreadsheet add-ons, warehouse pipelines. Some document a client-side sort. Plenty don’t mention it.
The test takes two minutes. Pick a keyword you know ranks, with high impressions and few clicks. Pull it from a top-rows listing, then pull it again with an exact-match filter. If the first call misses it and the second finds it, every report built on that tool’s top-rows listing has the same hole. (If the keyword shows up but its impressions are split across two of your URLs, that’s a different problem. My guides to the Search Console reports most marketers never open and to spotting keyword cannibalization in Search Console cover it.)
Why Paging Through Everything Doesn’t Fix It
The obvious response is to set rowLimit to 25,000 and page with startRow until a page comes back empty. On a small site that works for discovery. It doesn’t make a ranking report reliable, for three reasons.
- The daily cap. Google exposes at most 50,000 rows per day per search type. Large sites hit it, and the long tail is gone before you page through anything.
- Anonymized queries. They never appear as rows, however far you page. More on that in trap two.
- Cost. Pulling every row for every month to keep 22 keywords means downloading thousands of rows to use a few dozen. Google’s usage limits page says queries grouped or filtered by query string are expensive, and a six-month range costs far more than a one-day range.
Paging is still the right tool for discovery, meaning finding which queries exist. Use a contains filter or a regex to shortlist the terms worth tracking. Then switch methods for the tracking itself. If you need every row on a very large property, Google’s bulk data export to BigQuery carries all the performance data Search Console has, except anonymized queries.
The Fix: One Exact-Match Call per Keyword per Period
For a known keyword list, stop asking Google for its top rows. Ask for each keyword by name: one request per keyword per period, with operator equals on the query dimension and a row limit of 1. The click sort stops mattering, because only one row can come back.
The request that misses the head term
Top rows: what most tools send
{
"startDate": "2026-05-01",
"endDate": "2026-05-31",
"dimensions": ["query"],
"type": "web",
"dimensionFilterGroups": [{
"filters": [
{"dimension": "country", "operator": "equals", "expression": "ind"}
]
}],
"rowLimit": 15
}Google returns the 15 queries with the most May clicks in that country. Sort them by impressions afterwards and you’ve reordered 15 brand-heavy rows.
The request that can’t miss it
Exact match, one row
{
"startDate": "2026-05-01",
"endDate": "2026-05-31",
"dimensions": ["query"],
"type": "web",
"dataState": "final",
"dimensionFilterGroups": [{
"groupType": "and",
"filters": [
{"dimension": "country", "operator": "equals", "expression": "ind"},
{"dimension": "query", "operator": "equals", "expression": "your head term"}
]
}],
"rowLimit": 1
}The response is one row with that query’s clicks, impressions, CTR and average position for May, wherever it sits in the click ranking. Or no rows at all, which means something specific (covered below).
Two details trip people up. The equals operator is case-sensitive for queries, so match the query string exactly as Search Console stores it; copy it from the Performance report rather than typing it. And keep dataState the same on every call in a report. Leave it out and you get finalized data only. Set it to all and you also get fresh data that can still change.
The whole-period call you reconcile against
Control total for the same keyword
{
"startDate": "2026-04-01",
"endDate": "2026-08-31",
"dimensions": ["query"],
"type": "web",
"dataState": "final",
"dimensionFilterGroups": [{
"groupType": "and",
"filters": [
{"dimension": "country", "operator": "equals", "expression": "ind"},
{"dimension": "query", "operator": "equals", "expression": "your head term"}
]
}],
"rowLimit": 1
}Same keyword, same filters, same dataState. One date range that spans every period in the grid. This is the number the period cells have to add up to.
How to Build a Search Console Rank Tracker That Doesn’t Drop Keywords
Seven steps. The first is the only one where a top-rows pull is safe.
The Reconciliation Check That Catches Missing Cells
Most rank trackers skip this step, and it’s the one that would’ve caught the blank grid on day one. A keyword’s period impressions must add up to its whole-period impressions. If five monthly cells sum to, say, 18,000 and the whole-period call says 22,589, a month is missing or short, and you know exactly which keyword to rerun.
On the rebuild, each of the 22 keywords’ six period cells summed exactly to its whole-period total. All 22 reconciled. Zero blank cells, against 55 in the version built on top rows.
When a keyword doesn’t reconcile, it’s usually one of these:
- One period’s call came back empty because of a typo or a case difference in the keyword.
- The periods overlap and double count a day, or leave a day uncovered.
- dataState differs between calls, so one includes fresh data and the other doesn’t.
- A filter is missing from one call. A country filter on the period calls but not the whole-period call is the classic.
Position is the exception. Average position doesn’t add, so don’t try to reconcile it. For a set-level number, weight each keyword’s position by its impressions: multiply, sum, then divide by total impressions. That’s how the 22 keywords came out at 7.3.
A Short Python Version
This uses Google’s official client library (google-api-python-client), with the service name, version and read-only scope from Google’s Python quickstart: searchconsole, v1 and webmasters.readonly. Add the service account’s email address as a user on the Search Console property first, or every call fails with a permission error.
track_keywords.py
from google.oauth2 import service_account
from googleapiclient.discovery import build
SITE = "sc-domain:example.com" # or "https://www.example.com/" for a URL-prefix property
SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
creds = service_account.Credentials.from_service_account_file("service-account.json", scopes=SCOPES)
gsc = build("searchconsole", "v1", credentials=creds)
PERIODS = [("2026-04-01", "2026-04-30"), ("2026-05-01", "2026-05-31"),
("2026-06-01", "2026-06-30"), ("2026-07-01", "2026-07-31"),
("2026-08-01", "2026-08-31")]
WHOLE = (PERIODS[0][0], PERIODS[-1][1])
def one_cell(keyword, start, end, country="ind"):
body = {
"startDate": start,
"endDate": end,
"dimensions": ["query"],
"type": "web",
"dataState": "final",
"dimensionFilterGroups": [{
"groupType": "and",
"filters": [
{"dimension": "country", "operator": "equals", "expression": country},
{"dimension": "query", "operator": "equals", "expression": keyword},
],
}],
"rowLimit": 1,
}
resp = gsc.searchanalytics().query(siteUrl=SITE, body=body).execute()
rows = resp.get("rows", [])
return rows[0] if rows else None # None means no itemized data, not position zero
def track(keyword):
cells = [one_cell(keyword, s, e) for s, e in PERIODS]
whole = one_cell(keyword, *WHOLE)
summed = sum(c["impressions"] for c in cells if c)
return {
"keyword": keyword,
"position": [round(c["position"], 1) if c else None for c in cells],
"impressions": [int(c["impressions"]) if c else 0 for c in cells],
"thin": [bool(c) and c["impressions"] < 100 for c in cells],
"reconciles": whole is not None and int(summed) == int(whole["impressions"]),
}
for kw in ["first tracked keyword", "second tracked keyword"]:
print(track(kw))That’s one call per keyword per period plus one whole-period call per keyword. For 22 keywords across 6 periods, 154 calls. Google’s per-site quota is 1,200 queries per minute, so a set that size is nowhere near it. For hundreds of keywords, spread the run out, because query-filtered calls count heavier against the load quota.
Trap Two: Query Rows Never Add Up to the Site Total
The second problem has nothing to do with sorting. Even with every row in hand, query-level clicks sum short of the property’s total clicks. The missing slice is what Google calls anonymized queries.
Google’s definition, from the Search Central deep dive: queries that aren’t issued by more than a few dozen users over a two to three month period. The query text is withheld to protect the person searching. Their clicks and impressions still count in the chart totals, unless you filter by query.
That last clause is the whole trap. Any breakdown built on the query dimension, including a brand and non-brand split, is a filter on query. The anonymized slice drops out of both halves, so the halves can’t add up to the total. Google’s troubleshooting page for the Performance report says as much: match and does-not-match totals may not add up to the unfiltered total, because of anonymized queries and data truncation.
On the D2C beauty store, over one 28-day window in one country, brand queries took 2,509 clicks and non-brand took 2,553. That’s 5,062. The property total for the same window and country, pulled by the country dimension so no query filter touches it, was 7,173 clicks. So 2,111 clicks, 29.4%, came with no query attached. On impressions the gap was wider: 248,539 itemized against 450,960, which leaves 44.9% of impressions unitemized.
That store isn’t unusual. Ahrefs’ study of 887,534 Search Console properties put anonymized queries at 46.77% of clicks. The impressions gap is often bigger than the clicks gap, because rare long-tail searches rack up impressions and seldom get clicked.
Quote the split as a ratio
Don’t present brand and non-brand clicks as two numbers that ought to total the site. Someone will add them up, find 2,111 clicks missing and decide the whole report is broken. Show the split as shares of itemized data, and show the unitemized share as its own line.
| Segment | Impressions | Clicks | CTR | Avg position |
|---|---|---|---|---|
| Non-brand | 225,897 | 2,553 | 1.13% | 7.14 |
| Brand | 22,642 | 2,509 | 11.08% | 4.97 |
One D2C beauty store on Shopify, one 28-day window, one country. Query-level data, so the anonymized slice is excluded from both rows.
Read it as ratios. Non-brand had about ten times the impressions of brand for roughly the same clicks, at about a tenth of the CTR and two positions lower. That’s the opportunity, and it holds whatever the size of the anonymized slice. The split is a ratio, not a sum.
One caveat worth saying out loud. The anonymized slice is mostly rare, long-tail searches, and on a store like this one long-tail searches are mostly non-brand. So the itemized ratio probably understates the non-brand share. For why that non-brand line is the one to grow, see branded vs non-branded search traffic.
The include and exclude trap
The same thing catches regex filters. Filter queries containing your brand, then queries not containing it, and the two won’t add up to the unfiltered total. Google’s own worked example has 175 clicks matching a filter, 275 not matching and 550 in the chart. The missing 100 are anonymized. If you’re building this into a dashboard, the Looker Studio SEO dashboard walkthrough uses the same split, and the same rule applies there.
Reading Blank and Thin Cells in a Rank Tracker
An exact-match call that returns zero rows doesn’t mean position zero. It doesn’t always mean the page stopped ranking either. It means Search Console has no itemized data for that query in that period: no impressions at all, or a query that fell under the anonymization threshold for that window. Label it no data. Don’t plot it as a drop.
Thin cells cause the opposite mistake. On the rebuild, 40 of 132 cells sat on fewer than 100 impressions. Eleven keywords showed a top-three position in the first month on double-digit impressions, and a change column anchored to that month read worse on 14 of 22 keywords. Most of that was sampling. The fix was to flag every cell under 100 impressions and keep it out of trend claims, then drop the change column.
Pick one window and apply it to every row. A rolling 28 days is steadier than a part month. Never take each keyword’s number from whichever date range flatters it: the client can open Search Console and check. What a client-ready version looks like is in what a real Shopify SEO report contains, and the wider point about headline numbers is in vanity metrics vs revenue.
A Pre-Flight Checklist for Any Search Console Report
Run these before a Search Console number goes in front of a client.
| Symptom | Likely cause | Check |
|---|---|---|
| A keyword you know ranks shows blank | Top-rows pull cut by the click sort | Exact-match call with rowLimit 1 |
| Sort by impressions shows no head terms | Client-side sort on a click-ranked set | Compare with an exact-match call |
| Brand plus non-brand is below total clicks | Anonymized queries dropped by the query filter | Report a ratio and show the unitemized share |
| Contains plus not-contains is below the total | Same cause | Same treatment |
| Period cells don’t sum to the period total | Missing cell, overlapping periods or mixed dataState | Rerun the keyword and fix the dates |
| Long tail thins out on a very large site | 50,000 rows per day cap | Daily pulls, tighter filters or bulk export |
Search Console API and Missing Data: FAQ
Why is a keyword missing from my Search Console API results?
Usually because the API returns rows sorted by clicks and stops at your row limit. A keyword with high impressions and few clicks falls below the cut. Request it directly with an exact-match query filter and a row limit of 1. If that still returns nothing, Search Console has no itemized data for it in that period.
Can you sort Search Console API results by impressions?
Not on Google’s side. The request body has no sort field, and Google’s documentation says results come back sorted by clicks, highest first, or by date when you group by date. A tool that offers an impressions sort is reordering rows Google already picked.
What is the Search Console API row limit?
rowLimit accepts 1 to 25,000 rows per request, and the default is 1,000. You page with startRow. Separately, Google exposes at most 50,000 rows per day per site per search type, so very large sites can’t page their way to the full long tail.
Why don’t my Search Console query rows add up to total clicks?
Because anonymized queries are left out of every query-level table and API response but still counted in the property total. Any filter on query drops them as well. Expect query rows to sum short of the total and report the gap as an unitemized share.
What are anonymized queries in Search Console?
Google describes them as queries that aren’t issued by more than a few dozen users over two to three months. The query text is withheld to protect the searcher. Their clicks and impressions count in chart totals but never appear as rows, and the API can’t recover them.
How do I track keyword rankings with the Search Console API?
Make one call per keyword per period with an exact-match query filter and a row limit of 1. Make one more call per keyword for the whole period and check the period impressions add up to it. Weight average position by impressions when you roll up a keyword set.
Does an empty exact-match result mean the keyword stopped ranking?
Not necessarily. It means Search Console has no itemized data for that query in that period. Either it had no impressions or it was anonymized for that window. Label the cell as no data and don’t plot it as a ranking drop.
Should brand and non-brand clicks add up to total clicks?
No. Both are query filters, so both lose the anonymized slice. In one store’s 28-day window, brand plus non-brand came to 5,062 clicks against a property total of 7,173. Quote the split as a ratio and show the unitemized share separately.
Related reading
Written by Ram Kr Shukla
SEO and growth consultant and Fractional Chief Digital Officer, with 20+ years in SEO and growth across 50+ brands. Google Analytics and Google Ads certified. Works daily in Search Console, GA4 and Looker Studio, and checks that the numbers reconcile before a client sees them. Need the tracking checked end to end? See GA4 conversion tracking.




