DEV Community

Your Search Console API rows do not add up to the total: measure the gap in Python

You export every query for a property through the Search Console API, sum the clicks, and the number is lower than the total in the Performance report. Nothing is broken. But if you build reports on that sum without knowing how much is missing, you will overstate how well you understand your own traffic.

This tutorial builds a small script that measures the gap: it fetches the property total, fetches every row for a dimension with pagination, and reports how much of the total is visible. You will also see what the gap does and does not tell you.

Why the rows do not sum to the total

Google's own troubleshooting page for the Performance report lists the causes. Two matter here:

  • Anonymized queries. Rare queries are omitted from the query table but included in the chart totals unless a query filter is applied.
  • Row limits and truncation. The UI table shows at most 1,000 rows, and when you filter by page or query the match and does-not-match totals may not add up to the unfiltered total because of anonymized queries and data truncation.

The API has the same shape. A request with no dimensions gives you one aggregate row. A request with dimensions: ["query"] gives you the rows Google returns for that grouping, and anything withheld is simply absent. You cannot recover the missing query text with a different filter.

So the useful question is not "why do they differ" but "by how much, on this property, this month".

What the API lets you ask for

From the Search Analytics query reference:

  • rowLimit accepts 1 to 25,000, with a default of 1,000. That default silently truncates your export if you forget to set it.
  • startRow is a zero-based offset for paging. If it exceeds the number of results, you get a successful response with zero rows.
  • dataState set to final (or omitted) returns finalized data only. all includes fresh data, which can still change.
  • aggregationType can be byProperty or byPage. Totals can differ between the two, so keep it the same across the requests you compare.

Two more details from Google's help pages. Daily data is labeled in Pacific Time, and collected data is usually available in 2 to 3 days. That is why the script ends its window a few days back and asks for final data.

The script

The full code is in one file, gsc_gap.py, with a test file next to it. The core is three functions.

PAGE_SIZE = 25_000 # documented maximum for rowLimit

def fetch_total(service, site, start, end, search_type="web"):
    """One request with no dimensions returns the aggregate for the window."""
    body = {
        "startDate": start,
        "endDate": end,
        "type": search_type,
        "aggregationType": "byProperty",
        "dataState": "final",
    }
    resp = service.searchanalytics().query(siteUrl=site, body=body).execute()
    rows = resp.get("rows", [])
    if not rows:
        return {"clicks": 0.0, "impressions": 0.0}
    return {"clicks": rows[0]["clicks"], "impressions": rows[0]["impressions"]}

def fetch_all_rows(service, site, start, end, dimensions, search_type="web"):
    """Page through results with startRow until a short page comes back."""
    all_rows = []
    start_row = 0
    while True:
        body = {
            "startDate": start,
            "endDate": end,
            "dimensions": dimensions,
            "type": search_type,
            "dataState": "final",
            "rowLimit": PAGE_SIZE,
            "startRow": start_row,
        }
        resp = service.searchanalytics().query(siteUrl=site, body=body).execute()
        rows = resp.get("rows", [])
        all_rows.extend(rows)
        if len(rows) < PAGE_SIZE: # short or empty page: nothing left
            break
        start_row += len(rows)
    return all_rows

def coverage(total, rows):
    """Share of the total that is visible in the returned rows."""
    clicks = sum(r["clicks"] for r in rows)
    impressions = sum(r["impressions"] for r in rows)
    return {
        "row_count": len(rows),
        "click_coverage": clicks / total["clicks"] if total["clicks"] else None,
        "impression_coverage": (impressions / total["impressions"] if total["impressions"] else None),
        "hidden_clicks": total["clicks"] - clicks,
        "hidden_impressions": total["impressions"] - impressions,
    }

Design choices worth copying: Both requests share the same window, search type, data state and aggregation. Change one of them and the comparison is meaningless. The loop stops on a short page. If a result ends exactly on a page boundary, the next call returns zero rows, so the loop makes one extra call and then exits. Click and impression coverage are reported separately. Anonymized queries tend to be rare, so impressions and clicks can be affected differently.

Authentication and running it

Install the client libraries and create OAuth credentials with the read-only scope https://www.googleapis.com/auth/webmasters.readonly. The account you authorize must already have access to the property in Search Console. Save the authorized user token as token.json, then run:

pip install google-api-python-client google-auth google-auth-oauthlib
python gsc_gap.py sc-domain:example.com --dimension query --days 28

Use sc-domain:example.com for a Domain property or the full URL with trailing slash for a URL-prefix property.

The output looks like this (illustrative format with placeholder numbers, not a real property):

Window: <start> to <end> (final data, web search type) Total clicks: <n> impressions: <n> Rows returned for 'query': <n> Click coverage: <pct> Impression coverage: <pct>

How the code was tested

I wrote five tests that run against a fake service object with synthetic rows, so they need no credentials:

pip install pytest
pytest -q test_gsc_gap.py

They check that pagination collects all 60,000 synthetic rows with startRow values of 0, 25,000 and 50,000, that an exact 25,000 row result needs one extra empty call, that the coverage math is correct, that an empty window returns None instead of dividing by zero, and that the default window is 28 days ending 3 days back. All five passed.

What this does not prove: I did not run the script against a live Search Console property, so I cannot tell you what coverage figure to expect. Treat the tests as proof that the logic is correct, not that the API behaves as the fake does.

Common errors

  • 403 or "user does not have sufficient permission". The authorized account is not a user on that property, or the siteUrl string does not match the property as Search Console lists it exactly.
  • Only 1,000 rows come back. rowLimit was not set. The default is 1,000.
  • Coverage above 100 percent. The two requests used different aggregation types, search types or date windows.
  • Numbers shift when you rerun. You used dataState: all, which includes fresh data that is still changing. Use final for reporting.

Limitations and trade-offs

  • The gap is a measurement, not an explanation. It combines anonymized queries and truncation, and the API gives you no way to split the two.
  • Coverage depends on the dimension. A query export and a page export of the same window can show different coverage, so run the script once per dimension you report on.
  • A low query coverage does not mean the traffic is worthless. It usually means a long tail of rare queries that no export will show you.
  • For very large properties, Google also offers a bulk export to BigQuery. That is a separate setup and I have not covered it here.

What to do with the number

Report coverage next to every query-level chart you share. A line such as "query rows cover X percent of clicks in this window" stops stakeholders from treating the visible rows as the whole picture, and it gives you a baseline to watch over time.

Sources

  • Search Analytics: query (Search Console API reference)
  • Performance report: Troubleshooting data discrepancies
  • Search Console API: Query your search traffic data (pagination guidance)
  • Why Search Console Query Rows Do Not Match Totals: A 28-Day Benchmark (aident.ai), a third-party example of the same total-versus-rows comparison

About the author

I am an SEO specialist working on technical SEO, GEO, AEO, entities and international SEO, with 5+ years of experience across 32+ websites, 6 markets and 12+ industries. I write about the parts of SEO that can be checked with a script.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.