gh api --paginate --slurp --jq 'length' is the command everyone reaches for to count issues in a repository. It does not count issues. It counts pages.
I found this while writing pagination recipes for a toolkit, and then found the same wrong example in a lesson I had published — with 1,797 search impressions a month on it. So this is partly a correction and partly the mental model that stops it happening again.
What --slurp actually does
From gh api --help (gh 2.100.0):
Each page is a separate JSON array or object. Pass
--slurpto wrap all pages of JSON arrays or objects into an outer JSON array.
Read it twice. Without --slurp, --paginate emits one JSON array per page and applies your --jq filter to each page in turn. With --slurp, you get one array whose elements are the pages — an array of arrays. length on that is the number of pages.
You can see the shape without a token. Simulate two pages from the public API and wrap them the way --slurp does (jq -s slurps its inputs into one array, which is exactly the --slurp semantics):
curl -s 'https://api.github.com/repos/cli/cli/pulls?per_page=5&page=1' > p1.json
curl -s 'https://api.github.com/repos/cli/cli/pulls?per_page=5&page=2' > p2.json
jq -s 'length' p1.json p2.json
# 2 ← pages
jq -s 'add | length' p1.json p2.json
# 10 ← pull requests
add concatenates the page arrays into one list. Then length counts items. Every whole-collection filter after --slurp starts with add.
The three flags, and how they combine
| Flag | What it does | Needs |
|---|---|---|
--paginate |
Follows Link: rel="next" (REST) or pageInfo.hasNextPage (GraphQL with $endCursor) |
— |
--jq EXPR |
Runs the filter per page, unless --slurp is present |
— |
--slurp |
Wraps all pages in one outer array before --jq runs |
--paginate |
The rule: anything that counts, sorts, or deduplicates across pages needs --slurp and then add.
# Wrong — one number per page
gh api --paginate repos/OWNER/REPO/issues --jq 'length'
# Wrong — number of pages
gh api --paginate --slurp repos/OWNER/REPO/issues --jq 'length'
# Right
gh api --paginate --slurp 'repos/OWNER/REPO/issues?per_page=100' --jq 'add | length'
Two more things in that last line. per_page=100 is the maximum for almost every list endpoint; the default of 30 makes --paginate do three times the requests. And the path is quoted because & would otherwise background the command.
The second trap on the same endpoint
/issues returns pull requests too. Every PR is an issue in GitHub's data model, with a pull_request key set. So the count above is still not "open issues":
gh api --paginate --slurp 'repos/OWNER/REPO/issues?per_page=100' \
--jq 'add | map(select(.pull_request == null)) | length'
Endpoints that wrap their list
Some endpoints return an object — { total_count, workflow_runs: [...] } — not a bare array. add on those merges objects, which is not what you want. Reach into the list first:
gh api --paginate --slurp 'repos/OWNER/REPO/actions/runs?per_page=100&status=failure' \
--jq 'map(.workflow_runs) | add | length'
Same for search/* (map(.items) | add), artifacts, and anything else with a total_count.
A recipe you can keep
Open pull requests older than two weeks, oldest first, drafts excluded — run against a repository you have access to:
gh api --paginate --slurp 'repos/OWNER/REPO/pulls?per_page=100&state=open' \
--jq 'add
| map(select(.draft | not))
| map({number, title, author: .user.login, created_at})
| sort_by(.created_at)
| .[]
| "\(.number)\t\(.created_at[:10])\t\(.author)\t\(.title)"'
gh api --jq prints strings raw, so the output goes straight into cut, sort, or a spreadsheet.
Where the rest of these live
I put ten of these patterns — issues counted by label, failed runs this week, repositories in an org missing a CODEOWNERS file, GraphQL cursor pagination, idempotent label writes — in a free file: gh api pagination recipes. Every jq filter in it was run against the live API before publication, which is how the counting bug got caught.
The full lesson on gh api — placeholders, -f versus -F, GraphQL, response headers, the safety habits — is here: gh api: the full GitHub API from the command line. It is corrected now.
Top comments (0)