---
title: "Google Search Console API Query Builder"
description: "Build GSC API requests with dimensions, filters, and regex. Learn the 5K row bug, searchAppearance limitations, and undocumented quirks."
canonical_url: "https://gscdump.com/learn-google-search-console/api/query-builder"
last_updated: "2026-04-30T06:36:30.928Z"
---

The GSC API lets you filter and group search performance data by dimensions like page, query, country, and device. Understanding how to build queries, and their undocumented bugs, is critical for reliable data extraction.

## 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: '2025-01-01',
      endDate: '2025-01-31',
      dimensions: ['query', 'page'],
      dimensionFilterGroups: [{
        filters: [{
          dimension: 'country',
          operator: 'equals',
          expression: 'usa'
        }]
      }],
      rowLimit: 25000,
      startRow: 0
    })
  }
)
```

```python [Python]
import requests
from datetime import date, timedelta

def query_gsc(site_url, access_token, dimensions=['query'], start_days_ago=7):
    """Fetch GSC data for last N days"""

    end_date = date.today() - timedelta(days=3)  # Account for 2-3 day lag
    start_date = end_date - timedelta(days=start_days_ago)

    url = f'https://searchconsole.googleapis.com/webmasters/v3/sites/{site_url}/searchAnalytics/query'

    payload = {
        'startDate': start_date.isoformat(),
        'endDate': end_date.isoformat(),
        'dimensions': dimensions,
        'rowLimit': 25000
    }

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

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

    return response.json()

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

</code-group>

**Key fields:**

- `startDate` (YYYY-MM-DD) - Start date for data collection.
- `endDate` (YYYY-MM-DD) - End date (inclusive).
- `dimensions` - Array of grouping keys (e.g., `query`, `page`, `date`).
- `dimensionFilterGroups` - Nested filters for narrowing results (AND/OR logic).
- `rowLimit` - Max rows per response (up to 25,000).
- `startRow` - Zero-based index for pagination.

## Dimensions Explained

### 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">
          2025
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sNEDb">
          01
        </span>
        
        <span class="sq0yK">
          -
        </span>
        
        <span class="sNEDb">
          27
        </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)
    </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>
      SERP feature type
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "VIDEO"
        </span>
      </code>
      
      , <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sZOz5">
          "RICH_RESULT"
        </span>
      </code>
    </td>
  </tr>
</tbody>
</table>

### Dimension Combinations

You can request up to **7 dimensions** in a single query, but some combinations have hidden costs:

**Safe combinations** (no data loss):

- `date` only
- `date` + `country`
- `date` + `device`
- `query` only
- `page` only

**Lossy combinations** (Google drops data):

- `page` + `query` - **~66% impression loss** on large sites (Google's [documented behavior](https://developers.google.com/search/blog/2021/11/understanding-gsc-data))
- `date` + `query` - Triggers 5K row bug (see below)
- Any combination with `searchAppearance` - Must be ONLY dimension

### The 5K Row Bug

When querying with `date` + `query` dimensions, the API returns **only 5,000 rows** despite setting `rowLimit: 25000`.

**Affected query:**

```typescript
{
  dimensions: ['date', 'query'],
  rowLimit: 25000  // Ignored! Returns 5,000 max
}
```

**Workaround:** Query by `query` alone, then make separate date-range queries per keyword:

```typescript
// Step 1: Get top queries (works fine)
const queries = await queryGSC({
  dimensions: ['query'],
  rowLimit: 25000
})

// Step 2: Loop queries, get daily breakdown
for (const q of queries.rows) {
  const dailyData = await queryGSC({
    dimensions: ['date'],
    dimensionFilterGroups: [{
      filters: [{
        dimension: 'query',
        operator: 'equals',
        expression: q.keys[0]
      }]
    }]
  })
}
```

**Source:** Developer forums and API users [consistently report](https://support.google.com/webmasters/thread/116904664/search-console-api-returns-only-5000-rows-instead-of-25000) this truncation issue when multiple dimensions are present. Google recommends BigQuery for datasets of this scale.

## Filters Deep Dive

### 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>

### Regex Filtering

Google [officially added RE2 regex support](https://developers.google.com/search/blog/2021/04/regex-filtering-performance-report) to Search Console in April 2021, and to the API in October 2021. Syntax follows [RE2 spec](https://github.com/google/re2/wiki/Syntax).

**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']
}
```

Regex is powerful but **not anchored by default**, use `^` (start) and `$` (end) explicitly.

### searchAppearance Filter Bug

The `searchAppearance` dimension has two critical bugs that remain **unresolved as of March 2026**:

**Bug 1: Must be ONLY dimension**

This fails:

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

`searchAppearance` cannot combine with `page`, `query`, `country`, or `device`. Must query alone, then filter other dimensions separately.

**Bug 2: notContains/notEquals return OPPOSITE results**

When filtering searchAppearance with `notContains` or `notEquals`, the API returns the **opposite** of what you requested (returning only the rows you tried to exclude).

```typescript
// Request: Exclude VIDEO results
{
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'searchAppearance',
      operator: 'notEquals',
      expression: 'VIDEO'
    }]
  }]
}
// Bug: Returns ONLY VIDEO results instead
```

**Workaround:** Manually filter results client-side. Google acknowledged this bug in early 2025 but [Search Engine Roundtable confirmed](https://www.seroundtable.com/google-search-console-api-search-appearance-bug-37234.html) it remains "under investigation" with no fix date.

## Advanced Filter Patterns

### 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
```

### Multiple Filter Groups (OR Logic)

Separate groups are ORed:

```typescript
{
  dimensionFilterGroups: [
    {
      filters: [
        { dimension: 'country', operator: 'equals', expression: 'usa' }
      ]
    },
    {
      filters: [
        { dimension: 'country', operator: 'equals', expression: 'gbr' }
      ]
    }
  ]
}
// Returns: USA OR UK traffic
```

### Combining AND + OR

```python
{
    'dimensionFilterGroups': [
        {
            'filters': [
                {'dimension': 'country', 'operator': 'equals', 'expression': 'usa'},
                {'dimension': 'device', 'operator': 'equals', 'expression': 'MOBILE'}
            ]
        },
        {
            'filters': [
                {'dimension': 'country', 'operator': 'equals', 'expression': 'gbr'},
                {'dimension': 'device', 'operator': 'equals', 'expression': 'DESKTOP'}
            ]
        }
    ]
}
# Returns: (USA AND Mobile) OR (UK AND Desktop)
```

## Data Loss Warning

Google's [deep dive blog](https://developers.google.com/search/blog/2021/11/understanding-gsc-data) states:

> "When you group by page and/or query, the system may drop some data to reduce cardinality."

Translation: Large sites lose **~66% of impression data** when querying `page` + `query` together.

**Why?** Google pre-aggregates data to reduce storage. When you cross-reference page × query, the result set explodes (millions of combinations). Google drops low-traffic combinations.

**Impact example:**

```typescript
// Query 1: Pages alone
{ dimensions: ['page'] }
// Returns: 10M total impressions

// Query 2: Pages + Queries
{ dimensions: ['page', 'query'] }
// Returns: 3.4M total impressions (66% data loss)
```

**Workaround:** Query dimensions separately when precision matters:

1. Get top pages: `dimensions: ['page']`
2. Get top queries: `dimensions: ['query']`
3. For specific page, get queries: `dimensionFilterGroups: [{ filters: [{ dimension: 'page', ... }] }]`

This avoids cross-dimensional data loss.

## Sorting and Limits

### No Sort Parameter

The API **does not support custom sorting**. Results are always sorted by **clicks DESC**.

You cannot sort by impressions, CTR, or position. Must sort client-side after fetching.

**Source:** [AnalyticsEdge documentation on GSC API limitations](https://www.analyticsedge.com/google-search-console/gsc-api-limitations/)

### Pagination

Max 25,000 rows per request. For larger datasets, use `startRow`:

```typescript
// Page 1
{ rowLimit: 25000, startRow: 0 }

// Page 2
{ rowLimit: 25000, startRow: 25000 }

// Page 3
{ rowLimit: 25000, startRow: 50000 }
```

**Daily limit:** 50,000 rows per property. Two requests max before hitting quota.

## Anonymized Queries

GSC hides queries with **<few dozen users over 2-3 months** (exact threshold undocumented).

These "anonymized queries" are:

- **Included in totals** (clicks/impressions aggregated)
- **Excluded from query dimension** (missing from results)

Example:

```typescript
{ dimensions: ['query'] }
// Returns: 500 queries, 10K clicks

// But totals show:
// 12K clicks (2K from anonymized queries)
```

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

## gscdump Query Builder

gscdump stores GSC data in SQLite (D1) and exposes Drizzle-style query syntax:

```typescript
// Native GSC API (complex)
await fetch('https://searchconsole.googleapis.com/...', {
  body: JSON.stringify({
    startDate: '2025-01-01',
    endDate: '2025-01-31',
    dimensions: ['query'],
    dimensionFilterGroups: [{
      filters: [{
        dimension: 'query',
        operator: 'includingRegex',
        expression: '^mcp'
      }]
    }]
  })
})
```

```sql
-- gscdump MCP (simple)
SELECT query, SUM(clicks) as clicks
FROM gsc_keywords
WHERE date BETWEEN '2025-01-01' AND '2025-01-31'
  AND query LIKE 'mcp%'
GROUP BY query
ORDER BY clicks DESC
```

gscdump removes:

- 25k row limit (query unlimited historical data)
- 5K row bug (dimensions work correctly)
- searchAppearance bugs (data stored correctly)
- Data loss (no pre-aggregation)
- Rate limits (query your own DB)

## Common Query Patterns

### Top Keywords by Clicks

```typescript
{
  startDate: '2025-01-01',
  endDate: '2025-01-31',
  dimensions: ['query'],
  rowLimit: 100
}
```

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

```python
# Get current month
current = query_gsc(dimensions=['page'], start_date='2025-01-01', end_date='2025-01-31')

# Get previous month
previous = query_gsc(dimensions=['page'], start_date='2024-12-01', end_date='2024-12-31')

# Compare client-side
for page in current['rows']:
    prev_clicks = find_page_clicks(previous, page['keys'][0])
    diff = page['clicks'] - prev_clicks
    if diff < -100:
        print(f"Declining: {page['keys'][0]} ({diff} clicks)")
```

### Mobile vs Desktop Performance

```typescript
// Mobile
{
  dimensions: ['page'],
  dimensionFilterGroups: [{
    filters: [{ dimension: 'device', operator: 'equals', expression: 'MOBILE' }]
  }]
}

// Desktop (separate query)
{
  dimensions: ['page'],
  dimensionFilterGroups: [{
    filters: [{ dimension: 'device', operator: 'equals', expression: 'DESKTOP' }]
  }]
}
```

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

GSC API doesn't filter by position directly. Fetch all, filter client-side:

```python
data = query_gsc(dimensions=['query'])

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
// Brand queries
{
  dimensions: ['query'],
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'query',
      operator: 'includingRegex',
      expression: '(brand|company|product)'
    }]
  }]
}

// Non-brand (query separately, subtract)
{
  dimensions: ['query'],
  dimensionFilterGroups: [{
    filters: [{
      dimension: 'query',
      operator: 'excludingRegex',
      expression: '(brand|company|product)'
    }]
  }]
}
```

## Best Practices

**1. Account for 2-3 day lag**

Don't query today's date. Always subtract 3 days:

```typescript
const endDate = new Date()
endDate.setDate(endDate.getDate() - 3)
```

**2. Avoid lossy combinations**

Never query `page` + `query` for accurate totals. Query separately.

**3. Use regex for complex filters**

`contains` is slow on large datasets. Regex is optimized:

```typescript
// Slow
{ operator: 'contains', expression: '/blog/' }

// Fast
{ operator: 'includingRegex', expression: '\\/blog\\/' }
```

**4. Paginate large results**

Don't assume <25k rows. Always implement pagination:

```python
all_rows = []
start_row = 0

while True:
    data = query_gsc(row_limit=25000, start_row=start_row)
    rows = data.get('rows', [])

    if not rows:
        break

    all_rows.extend(rows)
    start_row += 25000

    if len(rows) < 25000:  # Last page
        break
```

**5. Cache aggressively**

GSC data updates once daily. Cache responses for 24 hours:

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

if (cached) return cached

const data = await queryGSC(...)
await cache.set(cacheKey, data, { ttl: 86400 })  // 24h
```

## Limitations Summary

<table>
<thead>
  <tr>
    <th>
      Issue
    </th>
    
    <th>
      Impact
    </th>
    
    <th>
      Workaround
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      5K row bug with <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          date
        </span>
      </code>
      
      +<code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          query
        </span>
      </code>
    </td>
    
    <td>
      Returns 5k instead of 25k
    </td>
    
    <td>
      Query dimensions separately
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          searchAppearance
        </span>
      </code>
      
       bugs
    </td>
    
    <td>
      <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          notContains
        </span>
      </code>
      
       returns opposite
    </td>
    
    <td>
      Filter client-side
    </td>
  </tr>
  
  <tr>
    <td>
      Data loss with <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          page
        </span>
      </code>
      
      +<code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          query
        </span>
      </code>
    </td>
    
    <td>
      66% impressions missing
    </td>
    
    <td>
      Query dimensions separately
    </td>
  </tr>
  
  <tr>
    <td>
      No sort parameter
    </td>
    
    <td>
      Always sorted by clicks
    </td>
    
    <td>
      Sort client-side
    </td>
  </tr>
  
  <tr>
    <td>
      25k row limit
    </td>
    
    <td>
      Large sites need pagination
    </td>
    
    <td>
      Use <code className="language-ts shiki shiki-themes vesper" language="ts" style="">
        <span class="sU-n2">
          startRow
        </span>
      </code>
      
       + loop
    </td>
  </tr>
  
  <tr>
    <td>
      Anonymized queries
    </td>
    
    <td>
      Missing from results
    </td>
    
    <td>
      Accept data gap
    </td>
  </tr>
</tbody>
</table>

## Next Steps

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

## Why gscdump Exists

The GSC API's bugs, limits, and data loss make reliable querying difficult. gscdump syncs your full dataset daily, stores it without limits, and fixes API quirks:

- No 5K row bug (query any dimension combination)
- No 25K row limit (query millions of rows)
- No data loss (raw data stored before aggregation)
- No rate limits (query your own database)

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