google search consolegsc apijsonsearch analytics apiseo automation

How to Export Google Search Console Data as JSON (API Power-User Guide)

Export Google Search Console data as JSON via the Search Analytics API. Bypass the 1,000-row UI cap, paginate every row, and feed dashboards and AI.

Search Console Tools Team11 min read
Table of Contents

If you have ever clicked "Export" in the Search Console Performance report, opened the CSV, and realized it stopped at exactly 1,000 rows, you have hit the wall this guide is about. The browser UI is built for eyeballing trends, not for piping data into a dashboard, a spreadsheet pipeline, or an AI workflow that needs the full long tail of queries.

The good news is that the data you can see in the UI is also available as structured JSON through the Search Console API — specifically the Search Analytics query method. It is free, it returns clean JSON, and with pagination you can pull far more than the 1,000 rows the UI hands you. The trade-off is that you have to authenticate, build a request body, and loop through pages yourself.

This guide walks through exactly how to get Google Search Console data out as JSON: the limits you are actually fighting, the shape of the request body, a copy-paste example, a real sample response, how to authenticate, and a pagination loop that pulls every row. If you would rather skip the plumbing entirely, there is a free option at the end that does all of this for you.

UI export vs. the Search Console API

The Performance report's export button is genuinely capped. No matter how many queries your site ranks for, a UI export tops out at 1,000 rows per dimension. That is fine for a quick look but useless if you are trying to analyze the long tail, where most content opportunities actually live. We cover why this cap exists and how it bites in the 1,000-row limit breakdown.

The API, by contrast, is designed for bulk extraction. A single query request returns up to 25,000 rows, and with startRow pagination you can keep going well beyond that. Practically, you can pull tens of thousands of rows per property per day before you bump into quota.

| Capability | Browser UI export | Search Console API (searchanalytics.query) | |---|---|---| | Output format | CSV / Google Sheets | JSON | | Max rows | 1,000 per export | 25,000 per request, paginate for more | | Daily volume | Manual, one file at a time | Tens of thousands of rows/day per property (quota-bound) | | Filtering | UI filters only | dimensionFilterGroups with regex, contains, equals | | Dimensions | One view at a time | query, page, country, device, searchAppearance, date combined | | Automation | None | Scriptable, schedulable | | Cost | Free | Free | | Date range | Last 16 months | Last 16 months |

One thing the API does not change: the 16-month data retention window. The API cannot retrieve data older than Google keeps, so if you need long-term history you must export and store it yourself on a schedule. See the 16-month data limit guide for an archiving strategy.

The request body: what searchanalytics.query expects

Every export boils down to one POST request to:

POST https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/searchAnalytics/query

The {siteUrl} is your property exactly as it appears in Search Console, URL-encoded. For a domain property that is sc-domain:example.com; for a URL-prefix property it is the full https://example.com/.

The request body is a JSON object. Here are the fields that matter:

  • startDate / endDate — required, in YYYY-MM-DD format. The range is inclusive.
  • dimensions — an array of how you want rows grouped: any of query, page, country, device, searchAppearance, date. Combine them to get a row per unique combination.
  • dimensionFilterGroups — optional filters. Each group holds filters with a dimension, an operator (equals, contains, notContains, includingRegex, excludingRegex), and an expression.
  • rowLimit — up to 25000. Default is 1,000, so always set this explicitly.
  • startRow — the offset for pagination. Default 0.
  • typeweb (default), image, video, news, or discover.
  • dataStatefinal (default) or all to include fresh, not-yet-finalized rows.
  • aggregationType — usually leave as auto; affects how clicks/impressions are summed across pages and properties.

Here is a complete, copy-paste-ready request body that pulls the top queries and pages for May 2026, filtered to a section of the site, with regex excluding branded terms:

{
  "startDate": "2026-05-01",
  "endDate": "2026-05-28",
  "dimensions": ["query", "page"],
  "type": "web",
  "dataState": "final",
  "dimensionFilterGroups": [
    {
      "groupType": "and",
      "filters": [
        {
          "dimension": "page",
          "operator": "contains",
          "expression": "/blog/"
        },
        {
          "dimension": "query",
          "operator": "excludingRegex",
          "expression": "(?i)brandname"
        }
      ]
    }
  ],
  "rowLimit": 25000,
  "startRow": 0
}

The includingRegex and excludingRegex operators use RE2 syntax, the same engine the UI uses. If you want to do precise pattern matching on queries — non-branded, question intent, multi-word — the patterns in our regex filters guide drop straight into the expression field.

A sample JSON response

When the request succeeds, you get back a rows array. Each row carries a keys array (one entry per dimension, in the order you requested) plus the four metrics:

{
  "rows": [
    {
      "keys": ["how to export search console data", "https://example.com/blog/export-guide"],
      "clicks": 184,
      "impressions": 5120,
      "ctr": 0.0359375,
      "position": 6.42
    },
    {
      "keys": ["search console api json", "https://example.com/blog/api-guide"],
      "clicks": 97,
      "impressions": 3340,
      "ctr": 0.029041916,
      "position": 8.91
    }
  ],
  "responseAggregationType": "byProperty"
}

A few things worth knowing about this payload:

  • ctr is a decimal fraction, not a percentage. Multiply by 100 for display.
  • position is the average position over the date range, not a fixed rank.
  • If there is no data for your query, you get an empty body (no rows key), not an error. Your code should handle that case.
  • The metrics are sums/averages across whatever dimension combination you grouped by. The numbers will not match a differently-grouped query — clicks attributed to a query + page pair are split more finely than clicks attributed to query alone.

For a deeper tour of how these metrics behave and where they diverge from the UI, see the Performance report explainer.

Authentication: OAuth2 or a service account

The API is free but not anonymous. You need a Google Cloud project with the Search Console API enabled, and then one of two credential types:

  • OAuth2 — for accessing properties owned by a human who logs in and consents. Best for tools and one-off scripts where a person clicks "allow."
  • Service account — for server-to-server automation with no human in the loop. Create the service account, then add its email address as a user on the Search Console property (Settings to Users and permissions). The service account does not automatically inherit your access; you must grant it explicitly.

The official googleapis (Node) and google-api-python-client (Python) libraries handle token exchange and refresh for you. Our full Search Console API guide walks through the Cloud Console setup screen by screen if this is your first time.

Here is a minimal Python snippet using a service account that runs the request body above:

from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
SITE_URL = "sc-domain:example.com"

creds = service_account.Credentials.from_service_account_file(
    "service-account.json", scopes=SCOPES
)
service = build("searchconsole", "v1", credentials=creds)

request_body = {
    "startDate": "2026-05-01",
    "endDate": "2026-05-28",
    "dimensions": ["query", "page"],
    "rowLimit": 25000,
    "startRow": 0,
}

response = service.searchanalytics().query(
    siteUrl=SITE_URL, body=request_body
).execute()

print(response.get("rows", []))

The Node.js equivalent uses the same auth flow through google.auth:

import { google } from "googleapis";

const auth = new google.auth.GoogleAuth({
  keyFile: "service-account.json",
  scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
});

const searchconsole = google.searchconsole({ version: "v1", auth });

const { data } = await searchconsole.searchanalytics.query({
  siteUrl: "sc-domain:example.com",
  requestBody: {
    startDate: "2026-05-01",
    endDate: "2026-05-28",
    dimensions: ["query", "page"],
    rowLimit: 25000,
    startRow: 0,
  },
});

console.log(data.rows ?? []);

Pagination: pulling every single row

A single request returns at most 25,000 rows. If your property has more unique query/page combinations than that, you paginate by incrementing startRow and re-requesting until a response comes back with fewer rows than your rowLimit (or no rows at all). That short page is the signal that you have reached the end.

Here is the canonical loop in Python:

def fetch_all_rows(service, site_url, body):
    all_rows = []
    page_size = 25000
    start_row = 0

    while True:
        body["rowLimit"] = page_size
        body["startRow"] = start_row

        response = service.searchanalytics().query(
            siteUrl=site_url, body=body
        ).execute()

        rows = response.get("rows", [])
        if not rows:
            break

        all_rows.extend(rows)

        # A short page means there is nothing left to fetch.
        if len(rows) < page_size:
            break

        start_row += page_size

    return all_rows

A few practical notes on doing this at scale:

  • Order is stable within a query. Google sorts by clicks descending by default, so paging through startRow gives you the full ranked list without duplicates or gaps.
  • Mind the quota. The API enforces per-project and per-property rate limits. If you fire requests in a tight loop you will eventually see 429 responses; add a small delay and exponential backoff between pages.
  • startRow has a practical ceiling. Very deep pagination on a single date range can get expensive and slow. A common pattern is to break a large pull into per-day requests (add date to your dimensions or loop one day at a time), which keeps each page small and your numbers granular.
  • Write JSON as you go. For large pulls, append each page to a file or database rather than holding everything in memory. Newline-delimited JSON (NDJSON) is convenient for streaming into BigQuery or DuckDB.

Once you have the full rows array, dumping it to a .json file is trivial — json.dump(all_rows, f) in Python or JSON.stringify(rows) in Node. From there it feeds dashboards, prompts, or any downstream tool that speaks JSON.

Let a tool do the wrangling for you

Everything above is correct and reproducible, but it is also a fair amount of moving parts: a Cloud project, credentials, scope juggling, a pagination loop, backoff, and somewhere to put the output. If you just want clean JSON (or a content brief built from it) without standing up infrastructure, Search Console Tools handles the entire flow for free. You connect your property with Google OAuth in a couple of clicks, and it does the authentication, the 25,000-row pagination, the quota backoff, and the JSON export for you — then turns the data into actionable content briefs on top.

If you are still deciding which approach fits your workflow, our roundup of the best Google Search Console tools for 2026 compares the options, including raw-API setups versus managed extractors.

Frequently Asked Questions

How many rows can I export from the Search Console API?

A single searchanalytics.query request returns up to 25,000 rows. By incrementing the startRow offset and requesting again, you can paginate well beyond that and pull your property's entire long tail. The real ceiling is the API's daily quota per project and property, not a fixed export cap like the UI's 1,000 rows.

Why does the Search Console UI only export 1,000 rows?

The Performance report's export button is deliberately capped at 1,000 rows per dimension to keep file sizes and the UI responsive. It is not a bug or a permissions issue — it is a fixed limit. The API is the supported way to get more, and you can read the full explanation in our 1,000-row limit guide.

Do I need OAuth2 or a service account to export JSON?

Either works. Use OAuth2 when a human logs in and consents to access their own properties, which suits desktop scripts and apps. Use a service account for unattended server automation, and remember to add the service account's email as a user on the Search Console property so it actually has access.

Is the Search Console API free?

Yes. The Search Console API has no usage fee; you only need a Google Cloud project with the API enabled. The constraint is rate quota, not money, so heavy pulls should include backoff to stay under the per-minute limits rather than worrying about a bill.

Can I export data older than 16 months?

No. The API exposes the same 16-month retention window as the UI and cannot return anything older. To keep longer history, you must export on a schedule and store the JSON yourself — our 16-month data limit guide outlines a simple archiving setup.

What date format does the API expect?

startDate and endDate must be YYYY-MM-DD strings, and the range is inclusive of both ends. Times and timezones are not part of the request; Search Console data is reported in Pacific Time on a daily granularity. If you need per-day rows, add date to your dimensions array.

2026 Standard

Run a Free AI Citation Audit

Are you in the AI Overview? Get a free report showing how often ChatGPT, Claude, and Gemini cite your brand, plus the 3 blockers preventing your discovery in 2026.

No spam. 1-click unsubscribe. Join 1,200+ SEO teams managing the GEO pivot.

Put These Tips Into Action

Connect your Google Search Console and let our AI find your biggest opportunities.

Get Started Free