Skip to content

GitHub GraphQL API: Queries, Connections and Cost

Lesson 2 of 10Intermediate → Advanced13 min readGitHub Engineering · GitHub APIVerified: GitHub GraphQL API v4 via gh 2.98.0, August 2026

GraphQL lets a client describe the connected data shape it needs.

That is the whole idea. Instead of one endpoint per resource, there is one endpoint, and the query says what to return — including data from related objects that REST would require separate requests for.

Suppose you want the last twenty open pull requests in a repository, with each author’s name and each one’s labels.

In REST: one request for the list, then — depending on what the list embeds — potentially one request per pull request for labels and one per author. That is the N+1 problem, and it turns a simple report into forty-one requests.

In GraphQL it is one request:

query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 20, states: OPEN) {
nodes {
number
title
author { login }
labels(first: 10) { nodes { name } }
}
}
}
}

You get exactly those fields, for exactly those objects, in one round trip.

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
name
stargazerCount
issues(states: OPEN) { totalCount }
}
}' -F owner=OWNER -F repo=REPO

What it doesSends a GraphQL query with variables through gh, which supplies the endpoint and authentication.

Why we run itgh api graphql is the fastest way to develop a query. Variables passed with -F are typed, which matters for anything that is not a string.

Expected resultA JSON object whose shape mirrors the query.

Output:

{
"data": {
"repository": {
"issues": { "totalCount": 143 },
"name": "REPO",
"stargazerCount": 21847
}
}
}

The response mirrors the query exactly. Nothing is returned that was not asked for — which is the practical difference from REST, where a repository object arrives with a hundred fields whether you want them or not.

With curl, the query is a JSON body:

Terminal window
curl -sS https://api.github.com/graphql \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"query { viewer { login } }"}'

GitHub’s schema uses the connection pattern for anything that is a list. It looks over-engineered at first and exists to make cursor pagination possible.

repository(owner: "OWNER", name: "REPO") {
pullRequests(first: 10) {
totalCount
pageInfo { hasNextPage endCursor }
edges {
cursor
node { number title }
}
}
}
  • nodes — the objects themselves. Use this when you only want the data.
  • edges — the connection between the parent and each node, carrying cursor and sometimes metadata about the relationship.
  • pageInfo — whether more pages exist, and the cursor to continue from.

Most queries want nodes. Reach for edges when you need the cursor for manual pagination, or when the edge itself carries a field you need.

Cursor-based rather than page-numbered. You request first: N, receive an endCursor, and pass it back as after for the next page.

query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issues(first: 100, after: $cursor, states: OPEN) {
pageInfo { hasNextPage endCursor }
nodes { number title }
}
}
}

Loop until hasNextPage is false. gh api --paginate does this automatically, provided the query declares an $endCursor variable and requests pageInfo:

Terminal window
gh api graphql --paginate -f query='
query($endCursor: String) {
viewer {
repositories(first: 100, after: $endCursor) {
pageInfo { hasNextPage endCursor }
nodes { nameWithOwner isPrivate }
}
}
}' --jq '.data.viewer.repositories.nodes[].nameWithOwner'

The variable must be named endCursor for gh to recognise it. That is a gh convention rather than a GraphQL one, and getting it wrong produces a single page with no error.

GraphQL is not limited by request count. Each query is assigned a cost based on how many nodes it could return, and you have a points budget per hour.

Ask what a query costs without spending much:

Terminal window
gh api graphql -f query='
query {
rateLimit { limit cost remaining resetAt }
viewer { login }
}'
{
"data": {
"rateLimit": {
"cost": 1,
"limit": 5000,
"remaining": 4999,
"resetAt": "2026-08-24T06:00:00Z"
},
"viewer": { "login": "octocat" }
}
}

Including rateLimit in a query during development tells you immediately whether it is affordable at scale. A query costing 1 can run five thousand times an hour; one costing 100 cannot.

This is why “GraphQL is more efficient” needs qualifying. One GraphQL query replacing forty REST requests is a real saving. One over-broad query costing a hundred points is not.

Writes are mutations, and they follow a consistent shape: an input object, and a selection of what to return afterwards.

Terminal window
gh api graphql -f query='
mutation($repositoryId: ID!, $title: String!, $body: String!) {
createIssue(input: {repositoryId: $repositoryId, title: $title, body: $body}) {
issue { number url }
}
}' -F repositoryId="$REPO_ID" -F title="Retry bug" -F body="Details here."

Note repositoryId — a node ID, not owner/repo. Mutations identify objects by their global node ID, which you fetch first:

Terminal window
REPO_ID=$(gh api graphql -f query='
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) { id }
}' -F owner=OWNER -F repo=REPO --jq '.data.repository.id')

That two-step requirement — look up the ID, then mutate — is the main reason simple writes are often easier in REST, where the URL identifies the object.

GraphQL returns 200 OK for most failures, with the problem in the body:

{
"data": { "repository": null },
"errors": [
{
"type": "NOT_FOUND",
"message": "Could not resolve to a Repository with the name 'OWNER/nope'."
}
]
}

A client that checks only the HTTP status will treat that as success and then fail confusingly on a null field. Always check for an errors key, regardless of status.

Partial success is also possible: data populated for the parts that worked, errors describing the parts that did not. That is a feature, and it means “did this succeed?” is not a yes-or-no question.

Neither is generally better. Choose per task.

REST fits when:

  • One endpoint maps to what you want — fetch a repository, create an Issue
  • You are writing a shell script and want readable curl or gh api calls
  • The operation is a simple write, avoiding the node-ID lookup
  • The feature has no GraphQL equivalent

GraphQL fits when:

  • You need related objects together and REST would be N+1
  • You want a few fields from large objects
  • You are traversing relationships — pull requests, their reviews, and the reviewers
  • The feature is GraphQL-only, as Discussions largely is

Coverage differs in both directions. Some features exist only in GraphQL, others only in REST. Check before committing to one for a whole project.

Repeating the same field selection across a query is how GraphQL queries become unmaintainable. Fragments extract it:

fragment PullRequestSummary on PullRequest {
number
title
createdAt
author { login }
labels(first: 10) { nodes { name } }
}
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
open: pullRequests(first: 20, states: OPEN) {
nodes { ...PullRequestSummary }
}
merged: pullRequests(first: 20, states: MERGED) {
nodes { ...PullRequestSummary mergedAt }
}
}
}

Two things are happening. The fragment defines a reusable selection, and open: and merged: are aliases — the same field queried twice with different arguments, distinguished in the response. Aliases are what let one request answer several related questions, which is the main efficiency argument for GraphQL.

The response mirrors the aliases:

{
"data": {
"repository": {
"merged": { "nodes": [] },
"open": { "nodes": [] }
}
}
}

The schema is queryable, which means you never have to guess what a type offers:

Terminal window
gh api graphql -f query='
query {
__type(name: "PullRequest") {
fields { name description }
}
}' --jq '.data.__type.fields[] | .name' | head -40
Terminal window
gh api graphql -f query='
query {
__type(name: "MergeableState") {
enumValues { name }
}
}' --jq '.data.__type.enumValues[].name'

Introspection is more reliable than documentation for two reasons: it reflects the schema actually deployed, and it covers types the documentation summarises. When a field name does not work, introspecting the type settles it in one call.

Query cost is computed from the maximum number of nodes a query could return, not the number it actually does. That distinction is what makes some innocent-looking queries expensive.

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!) {
rateLimit { cost remaining }
repository(owner: $owner, name: $repo) {
issues(first: 100) {
nodes {
number
comments(first: 100) { nodes { body } }
}
}
}
}' -F owner=OWNER -F repo=REPO --jq '.data.rateLimit'

That requests up to 100 issues each with up to 100 comments — ten thousand potential nodes — and is charged accordingly, even against a repository with three issues.

The fix is to request small inner pages and fetch more only where needed:

issues(first: 50) {
nodes {
number
comments(first: 3) { totalCount nodes { body } }
}
}

totalCount tells you which items have more, so you can follow up on the few that matter rather than paying for the possibility across all of them.

Including rateLimit { cost } while developing is the habit worth forming. It turns cost from something you discover in production into a number you see immediately.

The common ones, with the node-ID lookup they require:

Terminal window
# Get IDs first
IDS=$(gh api graphql -f query='
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
id
pullRequest(number: $number) { id }
}
}' -F owner=OWNER -F repo=REPO -F number=128)
PR_ID=$(jq -r '.data.repository.pullRequest.id' <<<"$IDS")
# Mark ready for review — REST has no equivalent
gh api graphql -f query='
mutation($id: ID!) {
markPullRequestReadyForReview(input: {pullRequestId: $id}) {
pullRequest { number isDraft }
}
}' -F id="$PR_ID"
# Enable auto-merge
gh api graphql -f query='
mutation($id: ID!) {
enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: SQUASH}) {
pullRequest { number }
}
}' -F id="$PR_ID"
# Add a comment
gh api graphql -f query='
mutation($id: ID!, $body: String!) {
addComment(input: {subjectId: $id, body: $body}) {
commentEdge { node { url } }
}
}' -F id="$PR_ID" -F body="Queued for release."

Marking ready for review and enabling auto-merge are both GraphQL-only, which is the clearest practical reason to know both APIs rather than committing to one.

Being fair to REST, since GraphQL is often presented as the modern option:

A single known resource. gh api repos/OWNER/REPO against a GraphQL query with variables — the REST call is shorter, more readable and cheaper to write.

A simple write. REST identifies the object by URL; GraphQL needs a node-ID lookup first, turning one call into two.

Shell scripting. GraphQL queries in shell strings need careful quoting and are hard to read in a diff.

Endpoints with no GraphQL equivalent. Much repository administration is REST-only.

When your team does not know GraphQL. A REST call any colleague can read beats an elegant query only you can maintain. This is a real consideration and it is usually left out.

Checking only the HTTP status. Errors arrive with 200.

Large nested connections. Rejected on cost or intolerably slow.

Naming the cursor variable something other than endCursor. gh --paginate silently returns one page.

Forgetting mutations need node IDs. owner/repo is not accepted.

Assuming GraphQL is always cheaper. Cost is computed from potential node count.

Expecting unauthenticated access. GraphQL always requires a token.

Using edges when nodes would do. More verbose for no benefit.

Four that cover most of what people actually reach for.

A review dashboard in one request — the query that best demonstrates why GraphQL exists:

query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 30, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
isDraft
author { login }
reviewDecision
reviews(last: 5) { nodes { author { login } state } }
commits(last: 1) {
nodes { commit { statusCheckRollup { state } } }
}
}
}
}
}

In REST that is one call for the list, one per pull request for reviews, and one per head commit for checks — ninety-one requests against one.

Issues with their labels and comment counts:

query($owner: String!, $repo: String!, $endCursor: String) {
repository(owner: $owner, name: $repo) {
issues(first: 50, after: $endCursor, states: OPEN) {
pageInfo { hasNextPage endCursor }
nodes {
number title createdAt
labels(first: 10) { nodes { name } }
comments { totalCount }
}
}
}
}

Note comments { totalCount } without requesting nodes — the count costs almost nothing while fetching the comments themselves would be expensive.

Everything you can see across an organisation:

query($org: String!, $endCursor: String) {
organization(login: $org) {
repositories(first: 100, after: $endCursor, orderBy: {field: PUSHED_AT, direction: DESC}) {
pageInfo { hasNextPage endCursor }
nodes {
nameWithOwner
isArchived
pushedAt
defaultBranchRef { name }
issues(states: OPEN) { totalCount }
pullRequests(states: OPEN) { totalCount }
}
}
}
}

Three totalCount fields per repository, in one request per hundred repositories. The REST equivalent is three calls per repository.

Your own review queue:

query {
viewer {
pullRequests(first: 20, states: OPEN) {
nodes { number title repository { nameWithOwner } }
}
}
}

viewer is the authenticated user, which avoids passing your own login as a variable.

A client must check errors regardless of status, and the error shapes are worth recognising.

{
"data": { "repository": null },
"errors": [
{
"type": "NOT_FOUND",
"path": ["repository"],
"locations": [{ "line": 3, "column": 5 }],
"message": "Could not resolve to a Repository with the name 'owner/nope'."
}
]
}

path locates the failure within your query, which matters for partial failures — a query requesting three repositories where one does not exist returns data for two and an error naming the third.

Rate-limit exhaustion is different again:

{
"errors": [
{ "type": "RATE_LIMITED", "message": "API rate limit exceeded" }
]
}

That one is retryable after the reset; NOT_FOUND and validation errors are not. A client that retries everything with an errors key will loop on a typo.

Terminal window
gh api graphql -f query='...' --jq '
if (.errors | length) > 0
then (.errors[] | "\(.type // "ERROR"): \(.message)"), halt_error(1)
else .data end'

That pattern — fail loudly on errors, emit data otherwise — is the minimum for any script using GraphQL, and it is easy to forget precisely because the HTTP status looked fine.

The two APIs have separate budgets. REST is counted in requests; GraphQL in points.

Terminal window
gh api rate_limit --jq '{core: .resources.core, graphql: .resources.graphql}'
{
"core": { "limit": 5000, "remaining": 4812, "reset": 1787443200 },
"graphql": { "limit": 5000, "remaining": 4998, "reset": 1787443200 }
}

Spending one does not spend the other, which is occasionally useful: a job close to its REST limit can often complete through GraphQL. That is a workaround rather than a strategy, but knowing the budgets are independent explains behaviour that otherwise looks arbitrary.

  1. Run the repository query above and compare its response with the REST equivalent.
  2. Add rateLimit { cost remaining } and note the cost.
  3. Increase first: on a nested connection and watch the cost rise.
  4. Paginate all your repositories with gh api graphql --paginate and the $endCursor convention.
  5. Rename that variable to $cursor and confirm you now get only one page.
  6. Query a repository that does not exist and inspect the errors array with a 200 status.

Steps 3, 5 and 6 each demonstrate a failure mode that is silent in production, which is why they are worth causing deliberately.

One query can fetch several unrelated objects, which is a genuine capability REST has no equivalent for:

query {
api: repository(owner: "acme", name: "api") {
issues(states: OPEN) { totalCount }
}
web: repository(owner: "acme", name: "web") {
issues(states: OPEN) { totalCount }
}
viewer { login }
rateLimit { cost remaining }
}

Three repositories and your own identity in one request. Building that list dynamically means generating the query text, which is where a small helper earns its place:

Terminal window
repos=(api web worker)
fields=""
for i in "${!repos[@]}"; do
fields+="r$i: repository(owner: \"acme\", name: \"${repos[$i]}\") { nameWithOwner issues(states: OPEN) { totalCount } } "
done
gh api graphql -f query="query { $fields }" --jq '.data | to_entries[] | [.value.nameWithOwner, .value.issues.totalCount] | @tsv'

Aliases must be valid GraphQL names, so r0, r1 rather than the repository names themselves — hyphens are not permitted in aliases, which is the mistake this pattern avoids.

Be aware of cost: each aliased repository adds to the query’s complexity, so a hundred repositories in one query will be expensive or rejected. Batches of twenty or so are a reasonable compromise.

GitHub’s GraphQL schema changes, and it does so under a deprecation policy: fields are marked deprecated before removal, with a replacement named.

Terminal window
gh api graphql -f query='
query {
__type(name: "PullRequest") {
fields(includeDeprecated: true) {
name
isDeprecated
deprecationReason
}
}
}' --jq '.data.__type.fields[] | select(.isDeprecated) | {name, deprecationReason}'

Running that against the types you use is a cheap way to find out you are relying on something with a removal date, before it stops working.

Deprecated fields continue working during the notice period, so this is a scheduled-maintenance signal rather than an emergency — provided anyone looks.

Four habits that make GraphQL work well in practice.

Develop with rateLimit { cost } included and remove it once the query is settled. Discovering cost in production is expensive in both senses.

Introspect rather than guess. A wrong field name is a full round trip to find out; introspection answers it directly and reflects the schema actually deployed.

Keep queries in files, not in shell strings. They are easier to read, easier to diff, and easier to test.

Terminal window
gh api graphql -f query="$(cat queries/review-dashboard.graphql)" -F owner=acme -F repo=api

Check errors on every response. HTTP 200 with a null field and an error nobody read is the most common GraphQL bug, and it is entirely preventable at the client layer.

  • One endpoint; the query describes the response shape, so nothing is over-fetched.
  • Connections carry nodes, edges and pageInfo; pagination is cursor-based.
  • gh api --paginate requires the cursor variable to be named endCursor.
  • Rate limiting is by query cost, not request count, and rateLimit can be queried inline.
  • Mutations identify objects by node ID, which usually means a lookup first.
  • Errors come back with HTTP 200, and partial success is possible.
  • Coverage differs in both directions; check before committing to one API.

GraphQL is worth reaching for when you need related data in one request and REST would be N+1 — pull requests with their reviews and authors, Issues with their labels and comment counts, repositories with their open counts.

It is not worth it for a single known resource, for a simple write that needs a node-ID lookup first, or in a shell script where the query becomes an unreadable string.

Two things to get right regardless: check errors on every response, because failures arrive with HTTP 200; and include rateLimit { cost } while developing, because cost is computed from the nodes a query could return rather than the ones it does.

The full schema is available as a file, which is what tooling needs — editor completion, query validation, and code generation.

Terminal window
gh api graphql -f query='
query {
__schema {
types { name kind description }
}
}' --jq '.data.__schema.types | length'

For real tooling, the published SDL is easier than assembling it from introspection. Most GraphQL tooling accepts either an SDL file or an introspection endpoint it can query itself, given a token.

What this unlocks is worth knowing about even if you write queries by hand:

Editor completion. A schema-aware editor autocompletes field names and flags invalid ones as you type, which removes the write-run-fix loop entirely.

Validation in CI. Queries kept in files can be validated against the schema before merging, so a field that no longer exists fails a check rather than a production job.

Terminal window
# Keep queries in files, validate them as a build step
ls queries/*.graphql | while read -r q; do
gh api graphql -f query="$(cat "$q")" -F owner=OWNER -F repo=REPO --silent >/dev/null \
&& echo "ok $q" || echo "FAIL $q"
done

That crude version actually runs the queries, which costs rate limit but catches everything — including permission problems a schema check would miss. For a handful of queries it is sufficient, and it is the kind of check that pays for itself the first time a schema deprecation lands.

Professional ToolkitThe fine-grained token permissions matrix, App-vs-PAT guide and gh api recipes are in the Professional Toolkit.