Skip to content

Diagnostic GitHub API

search returns at most 1,000 results whatever total_count says

The response says "total_count": 24831. You page through it at 100 per request, and on page 11 the API returns 422 Validation Failed with "Only the first 1000 search results are available". The count was true. The results were never there to fetch — total_count describes the match set, not the part of it you are allowed to page through.

Read-only token Python and Node.js Tests included
Server cabinets
Photo by Eric Stoynov on Unsplash
The short answer

Read total_count from the first page of any /search/* response. Anything above 1,000 is a query whose tail you cannot reach at any page size: the Search API serves at most the first 1,000 results per query, and asking past that boundary returns 422 rather than an empty page.

The repair is to partition the query — by created: date ranges, by repo:, by label — until every slice reports under 1,000, then union the slices yourself. Where you want an inventory rather than a search, the equivalent list endpoint has no such cap.

The problem in plain words

The dangerous version of this is not the 422. A crash is a gift; someone sees it and fixes it. The dangerous version is the code that pages until it gets a short page or an error, catches the error, logs a warning nobody reads, and reports 1,000 results as though that were the answer. The number is oddly round, and round numbers in a report are the thing to be suspicious of, but 1,000 issues is not obviously wrong to anyone reading a dashboard.

It is worse than plain truncation because total_count is sitting right there in the same response, correct and unreachable. Every consumer of that field — the progress bar, the "showing 1,000 of 24,831" label, the capacity plan — is being told the truth about a set it cannot enumerate. The gap between the two numbers is not an error state anywhere in the API; it is the normal, documented behaviour.

Query matches24,831total_count ishonestPages 1 to 10workthe first 1,000resultsPage 11 returns422only the first1000Error swallowedlogged, never readReport says1,000a round, wrongnumber
The count and the results disagree by design. Paging past the boundary is an error rather than an empty page, so retry logic makes it worse.

Why it happens

The cap is per query, not per token or per hour. Waiting does not help, a bigger page size does not help, and a second token does not help. One thousand results is what a single query yields, so the only lever is making the query narrower.

Paging past the boundary is an error, not an empty page. A client that expects pagination to end quietly with a short page instead receives 422 Validation Failed. Generic retry logic then treats a permanent, arithmetic condition as a transient failure and retries it, which spends the search bucket without ever getting further.

Search has its own small bucket. Search requests are not billed to core: authenticated search is limited per minute rather than per hour, so a partitioning strategy that fires dozens of narrow queries in a burst trades one limit for another. GET /rate_limit reports resources.search separately, and asking costs nothing.

Sorting decides which thousand you get. Since only 1,000 results are reachable, the sort and order parameters stop being cosmetic and become the definition of your dataset. "The 1,000 most recently updated" is a defensible sample; "the first 1,000 in whatever order the index felt like" is not, and results can also shift between pages as items are updated underneath the walk.

The fix, as a flow

The script reads total_count with a one item page and compares it against the cap rather than against the page count. It also reports the search bucket from /rate_limit, which is a separate allowance and free to ask about.

total_count at per_page=1one search requestNo matches at allthe query found nothingUnder 900 resultsreachable in full, page away900 to 1,000 resultsworks now, not for longAbove 1,000 resultsthe tail cannot be paged to
The 900 to 1,000 band exists so the note arrives before the outage: that query is correct today and loses results the week it grows.

How to fix it

Read total_count with a one-item page

GET /search/issues?q=...&per_page=1 returns the full total_count for the cost of one search request and one item. You do not need to fetch anything to learn whether the query is over the cap.

Compare it against 1,000, not against your page count

Above 1,000 means results exist that no amount of paging will return. Between about 900 and 1,000 is the state worth acting on before it breaks: a query that returns 950 today crosses the cap on its own as the repository grows, and nothing about that transition is announced.

Work out where the 422 starts

Pages entirely inside the first 1,000 results are fine; the request that reaches across the boundary is the one that fails. At per_page=100 that is page 11, at per_page=30 it is page 34. Knowing the number turns a mystery 422 into an expected one.

Partition the query until every slice is under the cap

created:2024-01-01..2024-03-31, then the next quarter; or one query per repo:; or one per label. Each slice is an independent query with its own 1,000-result budget. Union them client-side and de-duplicate on the item id, because slices on non-disjoint fields overlap.

Ask whether you wanted search at all

If the goal is "every issue in this repository", the list endpoint GET /repos/{owner}/{repo}/issues has no 1,000-result ceiling, is billed to the ordinary core quota rather than the small search bucket, and paginates conventionally. Search is for finding things; lists are for enumerating them.

How to check it worked

Re-run against each partitioned query. Every slice should report reachable.

GITHUB_TOKEN=... python3 github_search_cap_audit.py --query "repo:octocat/hello-world is:issue created:2024-01-01..2024-03-31"
# 1 quer(y/ies), 0 over the 1,000-result cap

The full code

One search request per query, no writes, a read-only token. It also reads GET /rate_limit first to show the search bucket, which is a separate and much smaller allowance than core and costs nothing to inspect. The classifier is pure arithmetic over total_count, including the near-cap state that exists so the note arrives before the outage rather than after it.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_search_cap_audit.py
"""Report search queries whose results cannot be paged through in full.

Read only. GET requests and nothing else: a token with read access is enough.
The repair is printed, never performed.

The cap is a property of the query, so this is one of the few checks here that
gives a complete answer: total_count above 1,000 means results exist that no
client, correct or otherwise, can reach.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_search_cap_audit")

API = "https://api.github.com"

CAP = 1000
NEAR = 900
MAX_PER_PAGE = 100


def last_reachable_page(per_page=MAX_PER_PAGE):
    """The highest page number that lies entirely inside the 1,000-result cap.

    Pure. The request that straddles the boundary is the one that returns 422,
    so this is the page after which a walk stops working: 10 at per_page=100,
    33 at per_page=30.
    """
    size = min(max(int(per_page or 30), 1), MAX_PER_PAGE)
    return CAP // size


def reach(total_count, per_page=MAX_PER_PAGE):
    """Classify one query against the cap. Pure. Returns (state, detail)."""
    total = int(total_count or 0)
    last = last_reachable_page(per_page)

    if total <= 0:
        return ("no-matches", "no results; the query matches nothing")

    if total > CAP:
        slices = -(-total // CAP)
        return ("capped",
                "total_count is %d and only the first %d are reachable, so %d "
                "match(es) cannot be paged to at any page size. Page %d at "
                "per_page=%d is the last that works; the next one returns 422. "
                "Partition into at least %d narrower queries."
                % (total, CAP, total - CAP, last, per_page, slices))

    if total >= NEAR:
        return ("near-cap",
                "total_count is %d, inside the 1,000-result cap but close to it. "
                "This query starts losing results silently as soon as it grows "
                "past %d; partition it now rather than after."
                % (total, CAP))

    return ("reachable",
            "total_count is %d, all reachable in %d request(s) at per_page=%d."
            % (total, -(-total // min(max(int(per_page), 1), MAX_PER_PAGE)), per_page))


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed or "
                         "revoked")
    if r.status_code == 403:
        raise SystemExit("403 from GitHub. Search has its own small per-minute "
                         "bucket; GET /rate_limit reports resources.search and "
                         "does not itself consume quota")
    if r.status_code == 422:
        raise SystemExit("422 from search: either the query is malformed or it "
                         "already reaches past the 1,000-result cap")
    r.raise_for_status()
    return r.json()


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--query", action="append", required=True,
                    help="a search query string. Repeatable, so a partitioned "
                         "query can be checked slice by slice.")
    ap.add_argument("--endpoint", default="issues",
                    choices=["issues", "repositories", "commits", "code", "users",
                             "labels", "topics"],
                    help="which /search/ endpoint to ask")
    ap.add_argument("--per-page", type=int, default=MAX_PER_PAGE,
                    help="the page size your client sends, used for the page "
                         "arithmetic")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": "github-search-cap-audit",
    })

    # Free to ask: /rate_limit is not billed against any bucket, and search is
    # not billed against core, so this is the only cheap way to see the bucket
    # these queries will actually spend.
    quota = get(session, "/rate_limit").get("resources", {}).get("search", {})
    log.info("search bucket: %s of %s remaining, resets at %s",
             quota.get("remaining", "?"), quota.get("limit", "?"),
             quota.get("reset", "?"))

    over = 0
    for q in args.query:
        # per_page=1 is enough: total_count is on every page, and the first item
        # costs less to transfer than a hundred you are not going to read.
        body = get(session, "/search/%s" % args.endpoint, q=q, per_page=1)
        state, detail = reach(body.get("total_count"), args.per_page)
        line = "%-10s %s  %s" % (state, q, detail)
        if state in ("capped", "near-cap"):
            over += 1
            log.warning(line)
            log.warning("  repair: split this query by created: date ranges, by "
                        "repo:, or by label until every slice reports under "
                        "1,000, then union the slices and de-duplicate on id. "
                        "For a full inventory use the matching list endpoint "
                        "instead, which has no such cap.")
        else:
            log.info(line)

    log.info("%d quer(y/ies), %d over or near the %d-result cap",
             len(args.query), over, CAP)
    return 1 if over else 0


if __name__ == "__main__":
    sys.exit(main())
github-search-cap-audit.mjs
/**
 * Report search queries whose results cannot be paged through in full.
 *
 * Read only. GET requests and nothing else: a token with read access is enough.
 * The repair is printed, never performed.
 */
const API = 'https://api.github.com';

const CAP = 1000;
const NEAR = 900;
const MAX_PER_PAGE = 100;

/**
 * The highest page number that lies entirely inside the 1,000-result cap. Pure.
 * The request that straddles the boundary is the one that returns 422.
 */
export function lastReachablePage(perPage = MAX_PER_PAGE) {
  const size = Math.min(Math.max(Number(perPage) || 30, 1), MAX_PER_PAGE);
  return Math.floor(CAP / size);
}

/** Classify one query against the cap. Pure. Returns [state, detail]. */
export function reach(totalCount, perPage = MAX_PER_PAGE) {
  const total = Number(totalCount) || 0;
  const size = Math.min(Math.max(Number(perPage) || 30, 1), MAX_PER_PAGE);
  const last = lastReachablePage(perPage);

  if (total <= 0) return ['no-matches', 'no results; the query matches nothing'];

  if (total > CAP) {
    const slices = Math.ceil(total / CAP);
    return ['capped',
      `total_count is ${total} and only the first ${CAP} are reachable, so ` +
      `${total - CAP} match(es) cannot be paged to at any page size. Page ` +
      `${last} at per_page=${perPage} is the last that works; the next one ` +
      `returns 422. Partition into at least ${slices} narrower queries.`];
  }

  if (total >= NEAR) {
    return ['near-cap',
      `total_count is ${total}, inside the 1,000-result cap but close to it. ` +
      `This query starts losing results silently as soon as it grows past ` +
      `${CAP}; partition it now rather than after.`];
  }

  return ['reachable',
    `total_count is ${total}, all reachable in ${Math.ceil(total / size)} ` +
    `request(s) at per_page=${perPage}.`];
}

function args(name) {
  const out = [];
  process.argv.forEach((a, i) => { if (a === `--${name}`) out.push(process.argv[i + 1]); });
  return out;
}

async function get(token, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': 'github-search-cap-audit',
    },
  });
  if (res.status === 401) {
    throw new Error('401 from GitHub: GITHUB_TOKEN is missing, malformed or revoked');
  }
  if (res.status === 403) {
    throw new Error('403 from GitHub. Search has its own small per-minute bucket; ' +
                    'GET /rate_limit reports resources.search and does not itself ' +
                    'consume quota');
  }
  if (res.status === 422) {
    throw new Error('422 from search: the query is malformed, or it already ' +
                    'reaches past the 1,000-result cap');
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  const queries = args('query');
  if (!token || queries.length === 0) {
    console.error('set GITHUB_TOKEN and pass --query "..." at least once');
    process.exitCode = 2;
    return;
  }
  const endpoint = args('endpoint')[0] ?? 'issues';
  const perPage = Number(args('per-page')[0] ?? MAX_PER_PAGE) || MAX_PER_PAGE;

  // Free to ask: /rate_limit is not billed against any bucket, and search is not
  // billed against core.
  const quota = (await get(token, '/rate_limit')).resources?.search ?? {};
  console.log(`search bucket: ${quota.remaining ?? '?'} of ${quota.limit ?? '?'} ` +
              `remaining, resets at ${quota.reset ?? '?'}`);

  let over = 0;
  for (const q of queries) {
    const body = await get(token, `/search/${endpoint}`, { q, per_page: 1 });
    const [state, detail] = reach(body.total_count, perPage);
    const line = `${state.padEnd(10)} ${q}  ${detail}`;
    if (state === 'capped' || state === 'near-cap') {
      over += 1;
      console.warn(line);
      console.warn('  repair: split this query by created: date ranges, by repo:, ' +
                   'or by label until every slice reports under 1,000, then union ' +
                   'the slices and de-duplicate on id. For a full inventory use ' +
                   'the matching list endpoint instead, which has no such cap.');
    } else {
      console.log(line);
    }
  }

  console.log(`${queries.length} quer(y/ies), ${over} over or near the ${CAP}-result cap`);
  process.exitCode = over ? 1 : 0;
}

// Only run when invoked directly, so the test file can import the pure functions
// without main() running, failing on the missing token and failing the suite.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The near-cap state is the one that earns its keep. A query returning 950 results is working perfectly today and will start losing results with no error and no deploy, so a classifier that only knows "fine" and "broken" reports this note one growth spurt too late. The page arithmetic is pinned too, because "page 11 fails at per_page=100" is the sentence that turns an unexplained 422 into an expected one.

test_github_search_cap_audit.py
from github_search_cap_audit import last_reachable_page, reach


def test_a_small_query_is_fully_reachable():
    state, detail = reach(240, 100)
    assert state == "reachable"
    assert "3 request(s)" in detail


def test_a_query_over_the_cap_names_what_is_unreachable():
    state, detail = reach(24831, 100)
    assert state == "capped"
    assert "23831 match(es)" in detail
    assert "at least 25 narrower queries" in detail


def test_just_under_the_cap_is_a_warning_not_a_pass():
    # 950 works today and silently loses results the moment it passes 1,000.
    state, detail = reach(950, 100)
    assert state == "near-cap"
    assert "950" in detail


def test_no_matches_is_not_confused_with_a_capped_query():
    assert reach(0, 100)[0] == "no-matches"
    assert reach(None, 100)[0] == "no-matches"


def test_the_last_working_page_depends_on_the_page_size():
    assert last_reachable_page(100) == 10
    assert last_reachable_page(30) == 33
    assert last_reachable_page(1) == 1000


def test_page_size_above_the_maximum_is_clamped_before_the_arithmetic():
    assert last_reachable_page(500) == 10
github-search-cap-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { lastReachablePage, reach } from './github-search-cap-audit.mjs';

test('a small query is fully reachable', () => {
  const [state, detail] = reach(240, 100);
  assert.equal(state, 'reachable');
  assert.match(detail, /3 request\(s\)/);
});

test('a query over the cap names what is unreachable', () => {
  const [state, detail] = reach(24831, 100);
  assert.equal(state, 'capped');
  assert.match(detail, /23831 match\(es\)/);
  assert.match(detail, /at least 25 narrower queries/);
});

test('just under the cap is a warning, not a pass', () => {
  const [state, detail] = reach(950, 100);
  assert.equal(state, 'near-cap');
  assert.match(detail, /950/);
});

test('no matches is not confused with a capped query', () => {
  assert.equal(reach(0, 100)[0], 'no-matches');
  assert.equal(reach(null, 100)[0], 'no-matches');
});

test('the last working page depends on the page size', () => {
  assert.equal(lastReachablePage(100), 10);
  assert.equal(lastReachablePage(30), 33);
  assert.equal(lastReachablePage(1), 1000);
});

test('page size above the maximum is clamped before the arithmetic', () => {
  assert.equal(lastReachablePage(500), 10);
});

FAQ

Why does total_count report more results than I can fetch?

Because it describes the match set and the pagination describes what is served. The Search API returns at most the first 1,000 results for a query; total_count is the honest size of the match, which makes the two numbers correct and incompatible at the same time.

Can a larger per_page get me past 1,000?

No. The cap counts results, not pages. At per_page=100 you get ten usable pages, at per_page=30 you get thirty-three, and in both cases the eleventh hundred does not exist as far as the API is concerned.

Is the cap different in GraphQL?

No. The same 1,000-result ceiling applies to search there, so migrating the query to GraphQL changes the cost model and the response shape but not this limit.

What is the right way to partition a query?

Any qualifier that splits the match set into disjoint slices. created: date ranges are the most reliable, because every item has exactly one creation date; repo: and label slices work too but can overlap, so de-duplicate on the item id when you union them.

Should I be using search for this at all?

Often not. If you want every issue or every pull request in a repository, the corresponding list endpoint has no 1,000-result cap, paginates conventionally with the Link header, and is billed to the ordinary hourly quota rather than to search's much smaller per-minute bucket.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.