7 min read
Search Console API limits: what to plan for before you sync
Sixteen months of retention, a two to three day lag, 25,000-row pages and a daily row ceiling. How each Search Console API limit should shape your sync.
If you pull Search Console data into your own database, the Search Console API limits end up shaping the whole design: how far back you can go, which days you can trust, how many rows you can page through, and why your totals never quite reconcile. None of them are hard to work around. They are easy to miss until a number looks wrong.
These notes cover the Search Analytics endpoint, searchAnalytics.query, which is where clicks, impressions, CTR and position come from. I've kept to behaviour Google documents, or that you can check on your own property in a few minutes.
What does a basic request look like?
One POST per property, with a date range, the dimensions to group by, and two paging fields:
type Row = { keys: string[]; clicks: number; impressions: number; ctr: number; position: number };
const res = await fetch(
`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
{
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
startDate: "2026-05-01",
endDate: "2026-05-01",
dimensions: ["date", "page", "query"],
type: "web",
rowLimit: 25000,
startRow: 0,
}),
},
);
const { rows = [] } = (await res.json()) as { rows?: Row[] };
// rows[0].keys is [date, page, query], in the order you asked for them
siteUrl is the property exactly as Search Console knows it: sc-domain:example.com for a domain property, or the full prefix such as https://example.com/ for a URL-prefix property. The read-only OAuth scope is https://www.googleapis.com/auth/webmasters.readonly.
Dimensions include date, page, query, country, device and searchAppearance. For connecting search to revenue, the first three are the ones that matter.
How far back can you go?
Sixteen months. Search Console doesn't keep performance data longer than that, in the interface or the API. Every day after a site goes live is a day of history that will eventually fall off the end.
So backfill everything the day you connect, and store it yourself from then on. If you only ever read a rolling window from the API, you'll never compare this spring with the spring two years ago.
The simplest backfill is one day per request, oldest first, with all three dimensions. Day-sized requests keep each response small enough to page through, and they make retries boring: if one day fails, you redo that day.
Which recent days can you trust?
Data shows up with a lag of roughly two to three days, and the newest days keep filling in after they first appear. By default the API returns only finalized data. You can ask for fresher rows with "dataState": "all", but those numbers can still change.
Either way, treat the last few days as provisional. A daily job that pulls only yesterday stores a partial day and never corrects it. The safer pattern is to re-pull a trailing window on every run, say the last five days, and overwrite what you stored. Upserts keyed on date, page and query make that cheap.
The same applies to charts. A line that runs up to the most recent day will show a drop that isn't real. Stop the chart at the last finalized date, or mark the tail as incomplete.
How does pagination work?
rowLimit takes up to 25,000 rows per request. The default is 1,000, so set it explicitly. startRow is a zero-based offset. To get everything a request will return, keep adding 25,000 to startRow until a page comes back short or empty:
async function pullDay(day: string): Promise<Row[]> {
const all: Row[] = [];
for (let startRow = 0; ; startRow += 25000) {
const rows = await query({ startDate: day, endDate: day, dimensions: ["page", "query"], rowLimit: 25000, startRow });
all.push(...rows);
if (rows.length < 25000) break;
}
return all;
}
Paging gets you everything the API exposes. That is not quite the same as everything that exists.
Is there a cap on rows per day?
For large sites, yes, and it's easy to misread. Google's 2022 Search Central post on performance data describes a ceiling on how many rows the API exposes for each day of data, per search type, for a property. The figure given there is around 50,000. Rows past that point don't come back however you page. They are the lowest-traffic combinations, the long tail of query and page pairs.
Most small SaaS sites never get close. If yours does, you'll notice the row count for a day stopping at roughly the same place, day after day. The practical response is to keep the top queries per page at full detail and account for the rest through page totals, which you pull with the page dimension alone.
Request quotas are a separate thing from the row ceiling. Google's usage limits documentation singles out requests that group or filter by both page and query as the expensive kind, especially across long date ranges. Day-sized requests with a short pause between them keep a 16-month backfill comfortably inside them.
Why don't query × page rows add up to page totals?
Pull clicks for one page with only the page dimension. Then pull the same page grouped by page and query, and add up the rows. The second number is smaller. A few causes stack on top of each other:
- Anonymized queries. Google leaves out queries searched by very few people. Their clicks count in the page total but never appear as a query row. On long-tail content this can be a large share. Search Console hidden queries shows how to measure it on your own site.
- The row ceiling, on large sites, drops the smallest combinations.
- Aggregation, which can differ between the two requests. That's the next section.
Scaling the visible rows up to match the total hides the problem rather than fixing it. Store page totals and query rows separately, and keep the difference as its own "hidden" line.
Should you aggregate by page or by property?
aggregationType decides how a search counts when more than one of your URLs appears on the same results page.
| Aggregation | Two of your URLs show for one search | Position reported |
|---|---|---|
byProperty | One impression for the property | Your topmost result |
byPage | One impression for each URL | Each URL's own position |
The default, auto, aggregates by property unless you group or filter by page, in which case it aggregates by page. You can't aggregate by property while grouping or filtering by page. The response tells you which one it used in responseAggregationType.
The consequence is that a site-wide total from a property-level request won't equal the sum of your page rows. Summed page impressions can come out higher, because one search was counted once per URL. Neither number is wrong. They answer different questions, so don't compare one against the other.
For revenue per page, use page aggregation, since the question is how each URL performs. For "how visible is the site for this query", property aggregation is closer to what you mean.
A sync that respects all of this
Put together, a sync that stays honest looks something like this:
- On connect, backfill 16 months, one day per request, oldest first.
- On every daily run, re-pull the last five days and overwrite.
- For each day, request page totals and page × query rows as two separate calls.
- Page through each call until a short page comes back.
- Store the gap between the two as hidden clicks and impressions per page.
- Record which aggregation type produced each number.
Joinsight's Search Console integration follows this pattern: 16 months backfilled on connect, with hidden queries kept in their own bucket. If the point of all this data is deciding which pages to improve, striking distance keywords covers what to do with the queries sitting at positions 8 to 20.