GitHub’s GraphQL API, from the comfort of your terminal
The GitHub CLI is well known for simplifying everyday Git and GitHub workflows, but it also has a lesser-known capability: it can execute queries and mutations against GitHub’s GraphQL API. This means you can pull relational data, run complex operations, and automate tasks without leaving your terminal or managing tokens manually.
Why GraphQL at GitHub?
GraphQL is a query language that lets clients request exactly the shape and depth of data they need in a single request, as opposed to REST’s fixed endpoints and nested calls. Since GitHub adopted it in 2016, the GraphQL endpoint at api.github.com/graphql has become a powerful way to retrieve linked objects — like issues with their labels, assignees, and comments — in one round trip. Some data and operations are only available through GraphQL (discussions, projects, certain enterprise settings), while others are REST-only (actions workflows, runners, logs), and many core resources like repos and users work with either.
A major difference between the two APIs is how rate limits are calculated. REST is capped by request count (typically 5,000 per hour for authenticated users), while GraphQL is limited by a point system, usually 5,000 points per hour. Each query costs at least one point, with the cost scaling based on the number of nodes and connections you request. You can check your current status by including the rateLimit field in any query.
In practice, GraphQL tends to be more efficient for relational data: one query costing 5-10 points might replace several REST calls, and you avoid over-fetching unused fields. That said, a badly designed query that pulls large nested datasets can burn through points faster than REST would. For bulk lists of a single type, like all repository names in an org, REST is often simpler. For a discrete set of related objects, GraphQL usually wins.
- Queries are read-only — the equivalent of REST GETs.
- Mutations modify server-side data, akin to POST, PATCH, PUT, and DELETE.
The gh api graphql workflow
While GitHub’s web-based GraphQL Explorer is fine for experimentation, the CLI removes several barriers. Authentication is handled through gh auth login, so there’s no need to juggle personal access tokens. Syntax is simpler than hand-written curl commands, output can be filtered with built-in JQ support, and the --paginate flag automates cursor-based pagination.
The basic structure for a query looks like this:
gh api graphql -H X-Github-Next-Global-ID:1 -f query='
query {
viewer {
login
name
bio
}
}
'
That returns your username, profile name, and bio. The -f flag sets the query form variable. Example output:
{
"data": {
"viewer": {
"login": "joshjohanning",
"name": "Josh Johanning",
"bio": "DevOps Architect | GitHub"
}
}
}
Querying a repository
For a more practical example, fetching a repository’s details involves passing variables with -F, which the query references as $variable:
gh api graphql -H X-Github-Next-Global-ID:1 -f query='
query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
name
description
id
stargazerCount
forkCount
issues(states:OPEN) {
totalCount
}
}
}
' -F owner=octocat -F repo=Hello-World
The response includes metadata like the repo’s GraphQL id, description, and URL:
{
"data": {
"repository": {
"name": "Hello-World",
"description": "My first repository on GitHub!",
"id": "R_kgDOABPHjQ",
"stargazerCount": 2894,
"forkCount": 2843,
"issues": {
"totalCount": 1055
}
}
}
}
💡 Tip: The -H X-Github-Next-Global-ID:1 parameter sets an HTTP header that instructs GitHub’s GraphQL API to use the new global node ID format rather than the legacy format. While your query will function without this header, including it prevents deprecation warnings when referencing node IDs (such as when passing repository.ID in subsequent operations). GitHub recommends adopting this format for all new integrations to ensure long-term compatibility.
|
Creating data with mutations
Mutations use the same invocation pattern. Here’s how to open a new issue, with the repositoryId taken from the output of the query above:
gh api graphql -H X-Github-Next-Global-ID:1 -f query='
mutation($repositoryId:ID!, $title:String!, $body:String) {
createIssue(input:{repositoryId:$repositoryId, title:$title, body:$body}) {
issue {
url
number
title
body
state
}
}
}
' -F repositoryId="R_kgDOABPHjQ" -F title="Creating issue with GraphQL" -F body="Issue body created via GraphQL\!"
Example output:
{
"data": {
"createIssue": {
"issue": {
"url": "https://github.com/octocat/Hello-World/issues/3706",
"number": 3706,
"title": "Creating issue with GraphQL",
"body": "Issue body created via GraphQL!",
"state": "OPEN"
}
}
}
}
Filtering output with JQ
When results feed into automation, you rarely want the full JSON envelope. The CLI’s --jq flag applies JQ expressions to the response, letting you extract just the fields you need. For example, to return only the array of issues from a repo query:
gh api graphql -H X-Github-Next-Global-ID:1 -f query='
query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
issues(first:3, states:OPEN) {
nodes {
number
title
url
}
}
}
}
' -F owner=octocat -F repo=Hello-World --jq '.data.repository.issues.nodes[]'
Example output:
{
"number": 26,
"title": "test issue",
"url": "https://github.com/octocat/Hello-World/issues/26"
}
{
"number": 27,
"title": "just for test",
"url": "https://github.com/octocat/Hello-World/issues/27"
}
{
"number": 28,
"title": "Test",
"url": "https://github.com/octocat/Hello-World/issues/28"
}
Narrowing further to just the URLs is equally straightforward:
gh api graphql -H X-Github-Next-Global-ID:1 -f query='
query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
issues(first:3, states:OPEN) {
nodes {
number
title
url
}
}
}
}
' -F owner=octocat -F repo=Hello-World --jq '.data.repository.issues.nodes[].url'
Example output:
https://github.com/octocat/Hello-World/issues/26
https://github.com/octocat/Hello-World/issues/27
https://github.com/octocat/Hello-World/issues/28
Handling pagination without the paperwork
GraphQL responses cap at 100 items per page. Pagination relies on cursor fields — hasNextPage and endCursor — to signal where the next batch starts. While you could manage that loop yourself, the CLI’s --paginate flag does it automatically, collecting pages behind the scenes as long as your query includes the pageInfo object. Here’s a query that pulls issues across all pages:
gh api graphql --paginate -H X-Github-Next-Global-ID:1 -f query='
query($owner:String!, $repo:String!, $endCursor:String) {
repository(owner:$owner, name:$repo) {
issues(first:100, after:$endCursor, states:OPEN, orderBy:{field:CREATED_AT, direction:DESC}) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
createdAt
}
}
}
}
' -F owner=octocat -F repo=Hello-World
Example output:
{
"data": {
"repository": {
"issues": {
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yOnYyOpK5MjAyNC0xMi0zMFQxNDo0ODo0NC0wNjowMM6kunD3"
},
"nodes": [
{
"number": 3708,
"title": "Creating issue with GraphQL once more",
"createdAt": "2025-04-02T18:15:11Z",
"author": {
"login": "joshjohanning"
}
},
{
"number": 3707,
"title": "Creating issue with GraphQL again",
"createdAt": "2025-04-02T18:15:02Z",
"author": {
"login": "joshjohanning"
}
},
{
"number": 3706,
"title": "Creating issue with GraphQL",
"createdAt": "2025-04-02T18:14:37Z",
"author": {
"login": "joshjohanning"
}
},
… and so on
]
}
}
}
}
For modest datasets this is seamless, but keep in mind the API’s point-based rate limits; very large fetches may still warrant manual delays between requests.
💡 Important limitation: The --paginate flag can only handle pagination for a single connection at a time. For example, when listing repository issues as shown above, it can paginate through all issues, but cannot simultaneously paginate through each issue’s comments. For nested pagination, you’ll need to implement custom logic. |
Chaining calls for multi-step automation
Real-world scripts often need the output of one GraphQL operation to feed the next. A common pattern is capturing an ID, then using it in a follow-up query. In shell, that looks like:
ISSUE_ID=$(gh api graphql -H X-Github-Next-Global-ID:1 -f query='
query($owner: String!, $repo: String!, $issue_number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issue_number) {
id
}
}
}
' -F owner=joshjohanning -F repo=graphql-fun -F issue_number=1 --jq '.data.repository.issue.id')
gh api graphql -H GraphQL-Features:sub_issues -H X-Github-Next-Global-ID:1 -f query='
query($issueId: ID!) {
node(id: $issueId) {
... on Issue {
subIssuesSummary {
total
completed
percentCompleted
}
}
}
}' -F issueId="$ISSUE_ID"
The sequence works like this:
- The first query fetches an issue’s ID using the repo name and issue number.
- A
--jqexpression extracts just the ID into a shell variable. - The second query passes that ID to get a list of sub-issues.
Example output:
{
"data": {
"node": {
"subIssuesSummary": {
"total": 3,
"completed": 1,
"percentCompleted": 33
}
}
}
}
That combination of extraction and reuse is what makes the CLI a genuine scripting tool, not just an interactive query console. For teams already using gh for everyday GitHub tasks, the GraphQL endpoint removes yet another reason to switch contexts or reach for separate API tooling.



