---
title: "Google Search Console API Query Builder"
description: "Build GSC API requests with dimensions, filters, regex, pagination, and search appearance data."
canonical_url: "https://gscdump.com/learn-google-search-console/api/query-builder"
last_updated: "2026-07-20"
---

The GSC API lets you filter and group search performance data by dimensions such as page, query, country, and device. Most mistakes come from how those dimensions and filters affect completeness and API load.

## Implementation Examples

<code-group>

```typescript [TypeScript]
const response = await fetch(
  `https://searchconsole.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      startDate: '2026-06-01',
      endDate: '2026-06-30',
      dimensions: ['query', 'page'],
      dimensionFilterGroups: [{
        filters: [{
          dimension: 'country',
          operator: 'equals',
          expression: 'usa'
        }]
      }],
      rowLimit: 25000,
      startRow: 0
    })
  }
)

if (!response.ok) {
  throw new Error(`Search Console API error: ${response.status} ${await response.text()}`)
}

const data = await response.json()
```

```python [Python]
import requests
from urllib.parse import quote

def query_gsc(
    site_url,
    access_token,
    *,
    start_date,
    end_date,
    dimensions=None,
    dimension_filter_groups=None,
    row_limit=25000,
    start_row=0,
):
    """Fetch one page of Search Analytics data."""

    encoded_site_url = quote(site_url, safe='')
    url = f'https://searchconsole.googleapis.com/webmasters/v3/sites/{encoded_site_url}/searchAnalytics/query'

    payload = {
        'startDate': start_date,
        'endDate': end_date,
        'rowLimit': row_limit,
        'startRow': start_row,
    }

    if dimensions is not None:
        payload['dimensions'] = dimensions

    if dimension_filter_groups:
        payload['dimensionFilterGroups'] = dimension_filter_groups

    headers = {
        'Authorization': f'Bearer {access_token}',
        'Content-Type': 'application/json'
    }

    response = requests.post(url, json=payload, headers=headers, timeout=30)
    response.raise_for_status()

    return response.json()

# Usage
data = query_gsc(
    site_url='sc-domain:example.com',
    access_token='ya29.a0Ae...',
    start_date='2026-06-01',
    end_date='2026-06-28',
    dimensions=['page', 'query'],
)
```

</code-group>

The [Search Analytics query schema](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body) defines these core fields:

- `startDate` (YYYY-MM-DD): Inclusive start date in Pacific Time.
- `endDate` (YYYY-MM-DD): Inclusive end date in Pacific Time.
- `dimensions`: Grouping keys such as `query`, `page`, or `date`. Omitting this field returns one aggregate row.
- `dimensionFilterGroups`: Filters for narrowing results. Every group must match, and the only supported `groupType` is `and`.
- `type`: Search result type, such as `web`, `image`, `video`, `news`, `discover`, or `googleNews`. The older `searchType` field is deprecated.
- `dataState`: Use `final` for finalized data, `all` for fresh data, or `hourly_all` for hourly data.
- `rowLimit`: Maximum rows per response (1–25,000; default 1,000).
- `startRow`: Zero-based index for pagination.

## Dimensions

### Available Dimensions

<table>
<thead>
  <tr>
    <th>
      Dimension
    </th>
    
    <th>
      Description
    </th>
    
    <th>
      Example Values
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          date
        </span>
      </code>
    </td>
    
    <td>
      Daily breakdown
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sNEDb">
          2026
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sNEDb">
          07
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sNEDb">
          17
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          hour
        </span>
      </code>
    </td>
    
    <td>
      Hourly breakdown with <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sNEDb">
          dataState
        </span>
        
        <span class="sU-n2">
          :
        </span>
        
        <span class="sZOz5">
          "hourly_all"
        </span>
      </code>
      
      ; <a href="https://developers.google.com/search/blog/2025/04/san-hourly-data" rel="nofollow">
        available for up to 10 days
      </a>
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sNEDb">
          2026
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sNEDb">
          07
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sU-n2">
          17
        </span>
        
        <span class="sNEDb">
          T14
        </span>
        
        <span class="sU-n2">
          :
        </span>
        
        <span class="sNEDb">
          00
        </span>
        
        <span class="sU-n2">
          :
        </span>
        
        <span class="sNEDb">
          00
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sNEDb">
          07
        </span>
        
        <span class="sU-n2">
          :
        </span>
        
        <span class="sNEDb">
          00
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          query
        </span>
      </code>
    </td>
    
    <td>
      Search keywords
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "gsc api query"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          page
        </span>
      </code>
    </td>
    
    <td>
      Landing page URL
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "https://example.com/article"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          country
        </span>
      </code>
    </td>
    
    <td>
      User country (ISO 3166-1 alpha-3)
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "usa"
        </span>
      </code>
      
      , <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "gbr"
        </span>
      </code>
      
      , <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "jpn"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          device
        </span>
      </code>
    </td>
    
    <td>
      Device type
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "DESKTOP"
        </span>
      </code>
      
      , <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "MOBILE"
        </span>
      </code>
      
      , <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "TABLET"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          searchAppearance
        </span>
      </code>
    </td>
    
    <td>
      Search result feature reported for the property
    </td>
    
    <td>
      Values returned by a discovery query
    </td>
  </tr>
</tbody>
</table>

### Dimension Combinations

The [API reference](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body) does not set a numeric limit on the number of distinct dimensions in a request, although a dimension cannot be repeated. Google may drop rows from requests that group or filter by `page` or `query`; [using both also puts the most load on the API](https://developers.google.com/webmaster-tools/limits#search_analytics).

When you need accurate property-level totals, omit `page` and `query`. Run separate dimensioned queries for diagnostic detail.

`searchAppearance` follows a special two-step process: group by it as the only dimension to discover the values available for the property, then filter by one discovered value in a second request that uses the other dimensions you need.

Aggregation is a separate choice from dimensions. Leave `aggregationType` as `auto` when grouping or filtering by `page`; the API rejects `byProperty` for those requests. Choose `byPage` or `byProperty` explicitly only when the [request meets the aggregation rules](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body).

## Filters

### Filter Operators

<table>
<thead>
  <tr>
    <th>
      Operator
    </th>
    
    <th>
      Behavior
    </th>
    
    <th>
      Use Case
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          equals
        </span>
      </code>
    </td>
    
    <td>
      Exact match
    </td>
    
    <td>
      <code className="language-sql shiki shiki-themes vesper" language="sql" style="">
        <span class="sU-n2">
          country
        </span>
        
        <span class="sq0yK">
          =
        </span>
        
        <span class="sZOz5">
          "usa"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          notEquals
        </span>
      </code>
    </td>
    
    <td>
      Exclude exact match
    </td>
    
    <td>
      <code className="language-sql shiki shiki-themes vesper" language="sql" style="">
        <span class="sU-n2">
          device
        </span>
        
        <span class="sq0yK">
          !=
        </span>
        
        <span class="sZOz5">
          "TABLET"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          contains
        </span>
      </code>
    </td>
    
    <td>
      Substring match
    </td>
    
    <td>
      <code className="language-sql shiki shiki-themes vesper" language="sql" style="">
        <span class="sNEDb">
          page
        </span>
        
        <span class="sU-n2">
          contains
        </span>
        
        <span class="sZOz5">
          "/blog/"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          notContains
        </span>
      </code>
    </td>
    
    <td>
      Exclude substring
    </td>
    
    <td>
      <code className="language-sql shiki shiki-themes vesper" language="sql" style="">
        <span class="sU-n2">
          query
        </span>
        
        <span class="sNEDb">
          not
        </span>
        
        <span class="sU-n2">
          contains
        </span>
        
        <span class="sZOz5">
          "brand"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          includingRegex
        </span>
      </code>
    </td>
    
    <td>
      RE2 regex match
    </td>
    
    <td>
      <code className="language-sql shiki shiki-themes vesper" language="sql" style="">
        <span class="sNEDb">
          page
        </span>
        
        <span class="sU-n2">
          matches
        </span>
        
        <span class="sZOz5">
          "\/2025\/"
        </span>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          excludingRegex
        </span>
      </code>
    </td>
    
    <td>
      Exclude regex match
    </td>
    
    <td>
      <code className="language-sql shiki shiki-themes vesper" language="sql" style="">
        <span class="sU-n2">
          query excludes
        </span>
        
        <span class="sZOz5">
          "^brand"
        </span>
      </code>
    </td>
  </tr>
</tbody>
</table>

The [query reference](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body) defines the matching behavior: `contains` and `notContains` are case-insensitive, while `equals` and `notEquals` are case-sensitive for page and query values.

### Regex Filtering

Search Analytics regex filters use [RE2 syntax](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body). The [RE2 specification](https://github.com/google/re2/wiki/Syntax) lists the supported constructs.

**Example: Match blog posts from 2025:**

```typescript
{
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'page',
      operator: 'includingRegex',
      expression: '\\/blog\\/2025\\/[0-9]{2}\\/'
    }]
  }],
  dimensions: ['page']
}
```

**Example: Exclude branded queries:**

```python
{
    'dimensionFilterGroups': [{
        'filters': [{
            'dimension': 'query',
            'operator': 'excludingRegex',
            'expression': '^(brand|company|product)'
        }]
    }],
    'dimensions': ['query']
}
```

Use the RE2 `(?i)` flag when the filter should ignore case. Add `^` (start) and `$` (end) when you need a whole-value match. The query schema caps [filter expressions at 4,096 characters](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body).

### Querying Search Appearance

Google documents a two-step process for search appearance data.

First, discover the appearance values present for the property:

```typescript
{
  dimensions: ['searchAppearance']
}
```

When grouping, `searchAppearance` must be the only dimension. A request such as this is invalid:

```typescript
{
  dimensions: ['searchAppearance', 'page']
}
```

Then filter by one of the values returned by the first request while grouping by other dimensions:

```typescript
{
  dimensions: ['page', 'device'],
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'searchAppearance',
      operator: 'equals',
      expression: discoveredAppearanceValue
    }]
  }]
}
```

Run the second request separately for each appearance value you need. See Google's [performance data guide](https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data#getting_search_appearance_data).

Do not hard-code a permanent list of appearance values. Google adds and retires them; for example, the [query reference](https://developers.google.com/webmaster-tools/v1/searchanalytics/query) says it will deprecate API support for FAQ rich results in August 2026. Discover the values for each property, and make downstream code tolerate an empty discovery response or an unfamiliar value.

## Combining Filters

### Multiple Filters (AND Logic)

Filters in the same group are ANDed:

```typescript
{
  dimensionFilterGroups: [{
    filters: [
      { dimension: 'country', operator: 'equals', expression: 'usa' },
      { dimension: 'device', operator: 'equals', expression: 'MOBILE' }
    ]
  }]
}
// Returns: USA AND Mobile traffic only
```

### OR Logic

The [request schema](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body) contains a group type, but the API currently supports only `and`. Multiple groups do not provide OR behavior because every group must match.

For values from the same text dimension, an RE2 alternation can cover some OR cases:

```typescript
{
  dimensionFilterGroups: [{
    groupType: 'and',
    filters: [{
      dimension: 'query',
      operator: 'includingRegex',
      expression: '^(brand|company)$'
    }]
  }]
}
```

For OR conditions across different dimensions, make separate requests and combine the results in your application.

## Missing Detail

Google's [performance data guide](https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data#why_do_i_lose_data_when_asking_for_more_detail) says the system may drop data when a request groups by page or query so that it can calculate results within reasonable resource limits. Google does not publish a fixed percentage, and the difference varies by property and request.

For the most complete aggregate counts:

1. Request property totals without grouping or filtering by `page` or `query`.
2. Query top pages and top queries separately for detail.
3. To investigate one page, filter by that page and group by query, accepting that dimensioned query rows can still be incomplete.

Anonymized queries also remain absent from query rows even though their metrics can contribute to an unfiltered aggregate total.

## Sorting and Limits

### No Sort Parameter

The API **does not support custom sorting**. [Results are sorted](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#response) by **clicks descending**, except when `date` is a grouping dimension; date-grouped results are sorted by date ascending. Ties can appear in an arbitrary order.

You cannot sort by impressions, CTR, or position in the request. Sort client-side after fetching the rows.

### Pagination

The [maximum response size is 25,000 rows](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#request-body). For larger datasets, use `startRow`:

```typescript
const pages = [
  { rowLimit: 25000, startRow: 0 },
  { rowLimit: 25000, startRow: 25000 },
  { rowLimit: 25000, startRow: 50000 },
]
```

[Search Analytics exposes at most 50,000 rows per day per search type](https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data#data-limits), regardless of how many requests you make. For a one-day query, the request at `startRow: 50000` is the empty sentinel that ends the loop; a multi-day query can contain more rows because the limit applies to each data day. A smaller `rowLimit` requires more requests to page through the available rows.

## Anonymized Queries

Search Console [omits some queries to protect user privacy](https://support.google.com/webmasters/answer/17011259?hl=en). Google does not publish the threshold.

Their clicks and impressions contribute to unfiltered totals, but the query strings and their rows are absent from results grouped by query.

Applying a query filter [removes anonymized query contributions from the total](https://support.google.com/webmasters/answer/17011259?hl=en) because Search Console cannot test the hidden strings against that filter.

You cannot retrieve anonymized queries via API. They exist only in aggregate metrics.

## Querying Synchronized Data with gscdump

gscdump's hosted MCP server exposes structured analytics tools rather than an arbitrary SQL tool. For example, the `get-keywords` tool accepts these arguments:

```json
{
  "siteUrl": "sc-domain:example.com",
  "startDate": "2026-06-01",
  "endDate": "2026-06-30",
  "limit": 100
}
```

The tool returns keywords ordered by clicks, with aggregated clicks, impressions, CTR, and position. Its `limit` is capped at 1,000 rows per call. After a Pro sync has stored Google's rows, these MCP analytics calls read the retained copy rather than spending Search Analytics API quota. Composed reports are a separate, unmetered beta path that can query Search Analytics live when stored coverage is unavailable. Both paths remain subject to Google's upstream anonymization and row-availability limits.

## Common Query Patterns

### Top Keywords by Clicks

```typescript
{
  startDate: '2026-06-01',
  endDate: '2026-06-30',
  dimensions: ['query'],
  rowLimit: 100
}
```

### Pages Losing Traffic (Month-over-Month)

```python
# Get current month
current = query_gsc(
    site_url,
    access_token,
    dimensions=['page'],
    start_date='2026-06-01',
    end_date='2026-06-30',
)

# Get previous month
previous = query_gsc(
    site_url,
    access_token,
    dimensions=['page'],
    start_date='2026-05-01',
    end_date='2026-05-31',
)

# Compare the union so pages that disappeared from the current result are retained
current_clicks = {row['keys'][0]: row['clicks'] for row in current.get('rows', [])}
previous_clicks = {row['keys'][0]: row['clicks'] for row in previous.get('rows', [])}

for page in current_clicks.keys() | previous_clicks.keys():
    diff = current_clicks.get(page, 0) - previous_clicks.get(page, 0)
    if diff < -100:
        print(f"Declining: {page} ({diff} clicks)")
```

### Mobile vs Desktop Performance

```typescript
const mobileRequest = {
  dimensions: ['page'],
  dimensionFilterGroups: [{
    filters: [{ dimension: 'device', operator: 'equals', expression: 'MOBILE' }]
  }]
}

const desktopRequest = {
  dimensions: ['page'],
  dimensionFilterGroups: [{
    filters: [{ dimension: 'device', operator: 'equals', expression: 'DESKTOP' }]
  }]
}
```

### Striking Distance Keywords (Position 4-15)

The GSC API cannot filter by position. Fetch the rows first, then filter them in your application:

```python
data = query_gsc(
    site_url,
    access_token,
    dimensions=['query'],
    start_date='2026-06-01',
    end_date='2026-06-30',
)

striking_distance = [
    row for row in data.get('rows', [])
    if 4 <= row['position'] <= 15 and row['impressions'] > 100
]

# Sort by impressions (opportunity size)
striking_distance.sort(key=lambda x: x['impressions'], reverse=True)
```

### Brand vs Non-Brand Traffic

```typescript
const brandRequest = {
  dimensions: ['query'],
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'query',
      operator: 'includingRegex',
      expression: '(?i)(brand|company|product)'
    }]
  }]
}

const nonBrandRequest = {
  dimensions: ['query'],
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'query',
      operator: 'excludingRegex',
      expression: '(?i)(brand|company|product)'
    }]
  }]
}
```

Do not add the brand and non-brand query rows and expect them to equal an unfiltered property total. Query filters exclude anonymized query contributions.

## Practical Defaults

### Allow 2–3 Days for Final Data

[Finalized data is typically available after 2–3 days](https://developers.google.com/webmaster-tools/v1/how-tos/all-your-data). For finalized-only reporting, find the newest available date with a request grouped by `date`. If you request `dataState: 'all'`, use the [response metadata](https://developers.google.com/webmaster-tools/v1/searchanalytics/query#response) to identify incomplete dates.

A simple finalized-data default is:

```typescript
function getPacificDateDaysAgo(days: number): string {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/Los_Angeles',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).formatToParts(new Date())

  const value = Object.fromEntries(parts.map(part => [part.type, part.value]))
  const date = new Date(Date.UTC(
    Number(value.year),
    Number(value.month) - 1,
    Number(value.day) - days,
  ))

  return date.toISOString().slice(0, 10)
}

const endDate = getPacificDateDaysAgo(3)
```

### Paginate Large Results

Page until a request returns no rows; some date ranges can reach the API's available-row limit:

```python
all_rows = []
start_row = 0

while True:
    data = query_gsc(
        site_url,
        access_token,
        start_date='2026-06-01',
        end_date='2026-06-30',
        dimensions=['query'],
        row_limit=25000,
        start_row=start_row,
    )
    rows = data.get('rows', [])

    if not rows:
        break

    all_rows.extend(rows)
    start_row += 25000
```

### Cache Finalized Date Ranges

Avoid repeatedly fetching finalized historical dates. Use a shorter cache lifetime for recent requests that include incomplete data:

```typescript
const cacheKey = `gsc:${siteUrl}:${hash(query)}`
const cached = await cache.get(cacheKey)
const data = cached ?? await queryGSC(query)

if (!cached) {
  await cache.set(cacheKey, data, { ttl: 86400 })  // 24h
}
```

## Related Guides

- [Rate Limits](/learn-google-search-console/api/rate-limits): Understand API quotas and 429 errors
- [Authentication](/learn-google-search-console/api/authentication): Set up OAuth for API access
- [MCP Server](/learn-google-search-console/ai-agents/mcp-server): Query GSC data with AI

Try gscdump free: [gscdump.com](https://gscdump.com)
