Google Search Console API limits are more than a page-size setting. A reliable export must handle pagination, quota failures and Search Analytics data limitations separately. Finishing a pagination loop does not prove that every search query has been returned. Preserve the request scope and compare totals using the same aggregation.
Separate four different limits
| Boundary | What it changes | What it does not prove |
|---|---|---|
| Request quota | Whether another API call is accepted | How many search queries exist |
| rowLimit and startRow | The size and offset of one returned page | That hidden or internally limited rows become available |
| Query/page dimensions | The detail and aggregation of the result | That query rows add up to an unfiltered property total |
| Data freshness | Which period can be compared consistently | That today is complete |
Google documents usage limits separately from the Search Analytics query contract. The query method allows rowLimit up to 25,000 and does not guarantee all data rows. Its output can omit information that is visible only at a less detailed aggregation. A larger rowLimit cannot recover queries that the service does not expose.
Export the returned pages without hiding a stop condition
The Python implementation uses a finite page budget, rejects repeated pages and retains response metadata. Install requests, obtain an authorized OAuth access token through Google’s documented flow, and set GSC_ACCESS_TOKEN. This is Google Search Console access, not a SearchCans SERP request. Run the saved script with a verified property and two dates; the URL-prefix or sc-domain property must match your authorization.
import json
import os
from urllib.parse import quote
def export_rows(call, start_date, end_date, row_limit=25000, max_pages=4):
if not 1 <= row_limit <= 25000 or max_pages < 1:
raise ValueError("Invalid pagination budget")
rows, calls, metadata, seen_pages = [], [], [], set()
for page in range(max_pages):
request = {"startDate": start_date, "endDate": end_date,
"dimensions": ["query", "page"], "type": "web",
"dataState": "final", "rowLimit": row_limit,
"startRow": page * row_limit}
data = call(request)
if not isinstance(data, dict) or data.get("error"):
raise ValueError("API error or invalid response")
batch = data.get("rows", [])
if not isinstance(batch, list):
raise ValueError("rows must be a list")
fingerprint = json.dumps(batch, sort_keys=True)
if batch and fingerprint in seen_pages:
raise ValueError("Repeated page; stop instead of looping")
seen_pages.add(fingerprint)
rows.extend(batch)
calls.append({"startRow": request["startRow"], "rows": len(batch)})
metadata.append(data.get("metadata", {}))
if len(batch) < row_limit:
return {"rows": rows, "calls": calls, "metadata": metadata,
"pagination_finished": True, "all_search_data": False}
return {"rows": rows, "calls": calls, "metadata": metadata,
"pagination_finished": False, "all_search_data": False}
def authenticated_call(site):
import requests
token = os.environ["GSC_ACCESS_TOKEN"]
url = ("https://www.googleapis.com/webmasters/v3/sites/"
+ quote(site, safe="") + "/searchAnalytics/query")
def call(payload):
response = requests.post(url, json=payload,
headers={"Authorization": "Bearer " + token}, timeout=30)
response.raise_for_status()
return response.json()
return call
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("site")
parser.add_argument("start_date")
parser.add_argument("end_date")
args = parser.parse_args()
result = export_rows(authenticated_call(args.site), args.start_date, args.end_date)
print(json.dumps(result, ensure_ascii=False, indent=2))
pagination_finished means that the returned page was shorter than the requested limit. all_search_data deliberately remains false. If the page budget is reached, save that condition with the output rather than presenting a capped export as complete. Keep the date range, type, dimensions, property and aggregation with each run.
Test the exporter before using its numbers
- Use a synthetic rowLimit of two and response pages of two, two and one rows. Verify startRow values 0, 2 and 4 and five retained rows.
- Repeat the same full page to verify that the exporter stops with an error instead of looping.
- Supply an API error, a non-list rows value and an exhausted page budget. None should become a successful complete export.
Reconcile totals at the right level
Query an unfiltered property total independently from a query-by-page table. Report their scopes beside the values. Do not turn missing query rows into zero search demand, or add page-level impressions and call the sum a property total. For current data, inspect the requested dataState and returned completeness metadata; a date not advancing is a freshness issue before it is a traffic conclusion.
A Search Console export describes your own property’s reported performance. A SERP API or webpage collection workflow answers a different question about observed search results or source content. Use the grounding-source guide when the consumer needs evidence for an answer rather than an analytics export.