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.
The problem it solves
Section titled “The problem it solves”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.
Running a query
Section titled “Running a query”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=REPOWhat 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:
curl -sS https://api.github.com/graphql \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query":"query { viewer { login } }"}'Connections, nodes and edges
Section titled “Connections, nodes and edges”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, carryingcursorand 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.
Pagination
Section titled “Pagination”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:
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.
Rate limiting by cost
Section titled “Rate limiting by cost”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:
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.
Mutations
Section titled “Mutations”Writes are mutations, and they follow a consistent shape: an input object, and a selection of what to return afterwards.
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:
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.
Errors
Section titled “Errors”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.
Choosing between REST and GraphQL
Section titled “Choosing between REST and GraphQL”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
curlorgh apicalls - 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.
Fragments and reuse
Section titled “Fragments and reuse”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": [] } } }}Introspecting the schema
Section titled “Introspecting the schema”The schema is queryable, which means you never have to guess what a type offers:
gh api graphql -f query=' query { __type(name: "PullRequest") { fields { name description } } }' --jq '.data.__type.fields[] | .name' | head -40gh 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.
Cost, concretely
Section titled “Cost, concretely”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.
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.
Mutations worth knowing
Section titled “Mutations worth knowing”The common ones, with the node-ID lookup they require:
# Get IDs firstIDS=$(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 equivalentgh api graphql -f query=' mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { number isDraft } } }' -F id="$PR_ID"
# Enable auto-mergegh api graphql -f query=' mutation($id: ID!) { enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: SQUASH}) { pullRequest { number } } }' -F id="$PR_ID"
# Add a commentgh 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.
When GraphQL is the wrong choice
Section titled “When GraphQL is the wrong choice”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.
Common mistakes
Section titled “Common mistakes”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.
Worked queries
Section titled “Worked queries”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.
Errors in practice
Section titled “Errors in practice”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.
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.
Rate limit accounting
Section titled “Rate limit accounting”The two APIs have separate budgets. REST is counted in requests; GraphQL in points.
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.
Exercise
Section titled “Exercise”- Run the repository query above and compare its response with the REST equivalent.
- Add
rateLimit { cost remaining }and note the cost. - Increase
first:on a nested connection and watch the cost rise. - Paginate all your repositories with
gh api graphql --paginateand the$endCursorconvention. - Rename that variable to
$cursorand confirm you now get only one page. - Query a repository that does not exist and inspect the
errorsarray 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.
Aliases and multiple objects
Section titled “Aliases and multiple objects”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:
repos=(api web worker)fields=""for i in "${!repos[@]}"; do fields+="r$i: repository(owner: \"acme\", name: \"${repos[$i]}\") { nameWithOwner issues(states: OPEN) { totalCount } } "donegh 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.
Schema evolution
Section titled “Schema evolution”GitHub’s GraphQL schema changes, and it does so under a deprecation policy: fields are marked deprecated before removal, with a replacement named.
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.
Practical guidance
Section titled “Practical guidance”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.
gh api graphql -f query="$(cat queries/review-dashboard.graphql)" -F owner=acme -F repo=apiCheck 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.
What you learned
Section titled “What you learned”- One endpoint; the query describes the response shape, so nothing is over-fetched.
- Connections carry
nodes,edgesandpageInfo; pagination is cursor-based. gh api --paginaterequires the cursor variable to be namedendCursor.- Rate limiting is by query cost, not request count, and
rateLimitcan 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.
The short version
Section titled “The short version”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.
Downloading the schema
Section titled “Downloading the schema”The full schema is available as a file, which is what tooling needs — editor completion, query validation, and code generation.
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.
# Keep queries in files, validate them as a build stepls 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"doneThat 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.