Measurement
The Search Console API: What the Interface Hides, and How to Get the Rest
Every SEO has had the conversation where a client asks how many keywords a page ranks for, and the honest answer is that Search Console will not tell you. Not because the data does not exist, but because the Performance report shows a thousand rows and stops, without saying that it stopped.
The API removes some of that ceiling and — more valuably — documents where the remaining ceilings are. That second part is what makes it worth an afternoon: not the extra rows, but knowing which of your numbers are complete, which are sampled, and which are still being collected.
The Search Console API’s Search Analytics method returns your Google Search performance data as JSON, grouped by dimensions you choose. It returns up to 25,000 rows per request against a documented daily ceiling, exposes fresh data the interface withholds, and states plainly where it drops rows rather than presenting a truncated result as a complete one.
Key takeaways
- The row ceiling is 25,000 per request, not 1,000.
rowLimithas a valid range of 1–25,000 with a default of 1,000, andstartRowpages through the rest. - There is a hard daily ceiling too. The Search Analytics method exposes a maximum of 50K rows of data per day per search type, sorted by clicks. Paging does not get you past it.
- Grouping by page and query costs you rows. Google states that when you group by page and/or query, “our system may drop some data in order to be able to calculate results in a reasonable time” — so detail and accuracy are a trade, not a free upgrade.
dataState: "all"returns data the UI does not show. Fresh, still-incomplete data, with ametadataobject naming the first incomplete date or hour so you know where to stop trusting it.type: "web"excludes Discover and Google News. Google’s parameter documentation is explicit that the default web type does not include Discover or Google News results — they are separate queries.
What the interface is not telling you
Three specific limitations, each with an API answer.
The 1,000-row table. The Performance report’s table stops at 1,000 rows and gives no indication that it is a truncation rather than the total. On any site with real query breadth, every “top queries” export is a sample of the top thousand by clicks. The chart totals above it are accurate; the table under it is not the whole story.
No fresh data. The interface shows finalised data. Google notes that performance data is typically available after 2-3 days, which means the most recent two days of a launch or an incident are simply absent when you most want them.
Silent sampling. When you ask for pages and queries together, rows get dropped. The interface does not say so. The API documents the behaviour, which at least lets you decide when to accept it.
There is one thing neither surface will fix, and it is worth stating before anyone builds a pipeline expecting otherwise. Google says the API “is bounded by internal limitations of Search Console and does not guarantee to return all data rows but rather top ones.” The API gets you more data and better-documented data. It does not get you all of it.
The minimum viable query
One POST, with an OAuth token carrying either the webmasters.readonly or webmasters scope:
POST https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/searchAnalytics/query
siteUrl is URL-encoded and takes one of two forms, which is the first thing that trips people up. For a URL-prefix property it is the full URL — http://www.example.com/. For a Domain property it is sc-domain:example.com. Getting this wrong produces a permissions error rather than a helpful one.
The body, at its simplest:
{
"startDate": "2026-09-01",
"endDate": "2026-09-01",
"dimensions": ["query", "page"],
"rowLimit": 25000,
"startRow": 0
}
Both dates are required, inclusive, and in PT — Google specifies YYYY-MM-DD “in PT time (UTC - 7:00/8:00)”. If your reporting runs on UTC or on local time, that offset is a real source of “the numbers do not match” between Search Console and everything else.
The response is rows of keys plus four metrics — clicks, impressions, ctr (0 to 1.0) and position:
{
"rows": [
{
"keys": ["crawl budget", "https://example.com/crawl-budget/"],
"clicks": 12.0,
"impressions": 480.0,
"ctr": 0.025,
"position": 8.4
}
],
"responseAggregationType": "byProperty"
}
Rows come back sorted by clicks descending — except when you group by date, in which case they are sorted by date ascending. And when date is one of your dimensions, Google omits days with no data entirely rather than returning zeros, so a naive daily chart built from the response will have gaps that look like drops.
Paging, and the ceiling paging cannot pass
Page by re-issuing the same query with startRow advanced, until you get a response with zero rows. Google’s own pseudocode advances in steps of 25,000:
MAX_ROWS = 25000
start_row, all_rows = 0, []
while True:
body = {
"startDate": day, "endDate": day,
"dimensions": ["query", "page"],
"rowLimit": MAX_ROWS, "startRow": start_row,
}
rows = service.searchanalytics().query(siteUrl=site, body=body).execute().get("rows", [])
if not rows:
break
all_rows.extend(rows)
start_row += MAX_ROWS
Now the part that matters more than the loop. Google’s data-limits note states that the Search Analytics method exposes a maximum of 50K rows of data per day per search type, sorted by clicks. So two pages of 25,000 is the end of the road for a given day and type. If your site has more than 50,000 query-page combinations in a day, the remainder is not retrievable, and it is the lowest-click tail that goes.
This is why Google’s recommended pattern is a daily query for one day of data, run every day and accumulated in your own store. It is not primarily about quota. It is that the 50K ceiling is per day, so a year assembled one day at a time can hold far more than a year requested in one call — and the historical detail you did not capture at the time cannot be reconstructed later.
Choosing between detail and accurate counts
This is the distinction that makes the API worth understanding rather than just calling, and it is the one most pipelines get wrong.
Google documents two query styles, and is explicit about the trade:
For accurate counts, omit the page and query dimensions entirely:
{
"startDate": "2026-09-01",
"endDate": "2026-09-01",
"dimensions": ["country", "device"],
"type": "web",
"aggregationType": "byPage"
}
For greater detail, add them — “at the expense of losing some data”:
{
"startDate": "2026-09-01",
"endDate": "2026-09-01",
"dimensions": ["page", "query", "country", "device"],
"type": "web"
}
The reason is computational rather than commercial. Google’s explanation: “When you group by page and/or query, our system may drop some data in order to be able to calculate results in a reasonable time using a reasonable amount of computing resources.”
The practical consequence for reporting: your totals and your breakdowns should come from different queries. Pull totals with a dimension-light query and treat those as the true numbers. Pull query- and page-level detail separately and treat it as a rich sample. Summing a query-grouped export and presenting the result as total clicks will under-report, and the gap widens with site size — which is exactly the sort of discrepancy that destroys trust in a dashboard. Which numbers deserve to be in front of a stakeholder at all is the subject of SEO KPIs.
aggregationType is the other half of this. byPage aggregates by canonical URI, byProperty aggregates everything for the property, and auto lets Google decide. Two constraints: if you group or filter by page you cannot aggregate by property, and Google notes it will never silently change your aggregation type — an invalid request returns an error rather than a different answer.
The parameters worth knowing
type — and the Discover trap
Six values: web (default), image, video, news, googleNews and discover. The documentation is precise about web: it filters to the combined “All” tab and “does not include Discover or Google News results.”
So a pipeline that only ever queries the default type is missing Discover entirely. If Discover is a meaningful surface for the site, it needs its own query — and its own table, since it has no query dimension at all. Why that surface behaves so differently, and why its numbers should never share a chart with search, is in Google Discover.
dataState — the fresh-data switch
Omit it, or set final, and you get finalised data only, matching the interface. Set all and you get fresh data — including rows still being collected. Set hourly_all for an hourly breakdown, used with the hour dimension.
Fresh data comes with a safety rail worth wiring into any alerting you build. The response may include a metadata object naming first_incomplete_date or first_incomplete_hour, and Google’s warning is unambiguous: “All values after the first_incomplete_date may still change noticeably.” Those timestamps are in America/Los_Angeles.
Use fresh data for incident response — a deploy, a migration, a suspected drop — where a directional read today beats a precise read on Thursday. Do not use it for anything anyone will hold you to, and never let an automated alert fire on rows past first_incomplete_date.
Filters, including regex
dimensionFilterGroups applies filters to any dimension, “even if you are not grouping by that dimension”. Operators are contains, equals (the default), notContains, notEquals, includingRegex and excludingRegex, with regex in RE2 syntax and expressions capped at 4,096 characters.
Two behaviours to note. equals and notEquals are case-sensitive for the page and query dimensions, while contains and notContains are not. And within a filter group, only and is currently supported — the documentation lists or as “not yet supported”, so multi-condition logic belongs in your own code rather than in the request.
Regex filters are the feature that makes the API worth the setup for anyone doing real analysis: question-shaped queries, brand versus non-brand, one URL pattern across a large catalogue — all a single filter rather than an export and a spreadsheet formula.
searchAppearance — the two-step query
This one is genuinely unintuitive and costs people an afternoon if they do not read the guide. Google states that “search appearance is not available as a column along with any other dimensions.” You cannot request it alongside page or query.
Instead, it is two queries. First, group by searchAppearance alone to discover which appearance types your site has:
{ "startDate": "2026-08-01", "endDate": "2026-08-31",
"type": "web", "dimensions": ["searchAppearance"] }
Then, for each type you care about, run a second query filtering to it and adding the dimensions you actually wanted:
{ "startDate": "2026-08-01", "endDate": "2026-08-31",
"type": "web", "dimensions": ["page", "device"],
"dimensionFilterGroups": [{ "filters": [{
"dimension": "searchAppearance",
"operator": "equals",
"expression": "AMP_BLUE_LINK" }]}]}
Once per appearance type. This is how you answer “which of our pages earn rich results, and what do they earn from them” — a question the interface makes tedious and the API makes routine, once you know it takes two round trips. Whether those rich results are worth pursuing is a separate argument, made in do rich snippets help SEO.
Quota, and the queries that burn it
Two quota systems run in parallel, and the one that bites is not the one people plan for.
QPS quota is generous: 1,200 queries per minute per site, 1,200 per minute per user, and 30,000,000 per day per project. Google’s worked example — “User A can make up to 1,200 QPM combined to her 3 websites” — makes the shape clear. You are unlikely to hit this.
Load quota is the real constraint. Load “represents the internal resources consumed by a query”, measured in 10-minute chunks (short-term) and 1-day chunks (long-term). Both surface as the same generic “quota exceeded” error, which is why the diagnosis is a documented procedure rather than an obvious one: if you exceed quota while running a single query in a 10-minute window, you are hitting the long-term limit rather than the short-term one.
Google names exactly what makes a query expensive:
- Grouping or filtering by page or query string. Doing both at once is, in Google’s words, “the most expensive”.
- Long date ranges. “Queries with a six month range are much more expensive than a query with a one day range.”
- Re-querying the same data — Google specifically calls out “querying all data for last month over and over”.
Which is the technical argument for the daily-increment pattern, on top of the 50K ceiling: one cheap query per day, stored once, never re-requested. A dashboard that re-pulls six months of page-and-query data on every refresh is the canonical way to exhaust load quota, and it will do it while returning less data than the incremental approach would have accumulated.
Current usage is visible in the quota tab of your Google API Console project. Separate limits govern URL inspection — 2,000 queries per day and 600 per minute per site — which is the constraint anyone building a bulk index-status checker meets first.
What to build with it
Three things justify the setup, in rough order of return.
A daily incremental store. One query per day per type, appended to a table you own. Within a few months you have query-level history at a depth the interface cannot show and a retention window that does not expire, and it is the only way to accumulate past the 50K daily ceiling.
Reports the interface cannot produce. Regex-filtered segments — question queries, brand versus non-brand, a URL pattern across ten thousand pages — as standing views rather than manual exports. This is also where the API and a no-code dashboard diverge: Looker Studio is the faster path to a shareable report, and the API is the path to a report whose numbers you can defend.
Incident checks on fresh data. After a migration or a deploy, dataState: "all" gives a same-day read where the interface gives you nothing for two to three days. Wire it to the first_incomplete_date metadata so the alert knows which rows are still moving. If the question is whether pages are being crawled rather than how they are performing, the API will not answer it — that is log file analysis territory.
And the honest counter-case: if you want a shareable report and nothing more, use the interface or a connector. The API earns its keep when you need rows the UI truncates, history it forgets, segments it cannot express, or numbers you can state the error bars on. Short of that it is engineering for its own sake — and the argument for treating SEO work as engineering, where it genuinely is, is in SEO for engineers.
Where this fits
The Search Console API is the measurement layer’s foundation: Looker Studio sits above it for presentation, SEO KPIs decides what belongs in the report, and log file analysis answers the crawl-side questions performance data cannot. For surfaces the API reports separately or not at all, see Google Discover and how to track AI Overviews. If you would rather query this data conversationally than build a pipeline, the best MCP servers for SEO covers that route.
If you want the measurement layer built so the totals and the breakdowns come from the right queries and the sampling is documented rather than discovered, that is part of GEO and technical SEO consulting.
FAQ
How many rows can the Search Console API return?
Up to 25,000 per request — rowLimit has a valid range of 1–25,000 and defaults to 1,000 — and you page further with startRow. But there is a second, harder ceiling: the Search Analytics method exposes a maximum of 50K rows per day per search type, sorted by clicks. Paging cannot exceed that, which is why Google recommends querying one day at a time and accumulating the results yourself.
Why don’t my API numbers match the Search Console interface?
Usually one of four reasons. The interface’s table caps at 1,000 rows while the chart totals do not, so a summed table never matches a total. Grouping by page and/or query causes Google to drop rows — it says so explicitly. Dates are in PT, so a UTC-based pipeline is comparing shifted days. And dataState: "all" includes fresh, still-changing data that the interface excludes. Match on aggregation type and date handling before assuming a bug.
Does the Search Console API give me all my keywords?
No, and Google says so: the API “is bounded by internal limitations of Search Console and does not guarantee to return all data rows but rather top ones.” It returns substantially more than the interface, and it tells you where the limits are, but anonymised and long-tail queries are withheld regardless of how you ask. Treat query-level data as a deep sample rather than a census.
How do I avoid the Search Console API quota error?
Reduce load rather than request rate. Google names the expensive patterns: grouping or filtering by page or query string — both together being “the most expensive” — long date ranges, and re-querying data you already have. The recommended pattern is one query per day for one day of data, stored locally and never re-requested. If a single query in a ten-minute window still exceeds quota, you are hitting the long-term daily limit, not the short-term one.
How do I get Discover data from the Search Console API?
Set type to discover in its own query. The default web type explicitly does not include Discover or Google News results, so a pipeline that never sets type has no Discover data at all. Note that Discover has no query dimension and its metrics are defined differently from Search, so it belongs in a separate table rather than merged into search reporting.
How fresh is Search Console API data?
Finalised data is typically available after 2-3 days. Setting dataState to all returns fresh data ahead of that, along with a metadata object naming first_incomplete_date or first_incomplete_hour — after which, Google warns, values “may still change noticeably.” Use fresh data for incident response, not for anything that gets reported as final.
What’s the difference between byPage and byProperty aggregation?
byPage aggregates metrics by canonical URI; byProperty aggregates them across the whole property. They produce genuinely different impressions, clicks and position figures, which is why comparing a page-aggregated export against a property-aggregated total is a category error rather than a discrepancy. If you group or filter by page you cannot aggregate by property, and auto lets Google pick — the response’s responseAggregationType tells you what it chose.