GitHub CLI Cheatsheet

The api Command

Use this GitHub CLI reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

The REST Path Form

gh api <endpoint> sends an authenticated request and prints the JSON. The endpoint is a REST path with no host and no /api/v3 prefix, and a leading slash is optional.

gh api repos/cli/cli
gh api /repos/cli/cli/releases
gh api user
gh api users/monalisa
gh api repos/cli/cli/issues/123
gh api orgs/my-org/members
gh api repos/cli/cli/commits/main
gh api rate_limit

{owner}, {repo}, and {branch} are filled in from the current directory's repository, or from GH_REPO.

gh api repos/{owner}/{repo}/releases
gh api repos/{owner}/{repo}/branches/{branch}/protection
gh api repos/{owner}/{repo}/issues --jq '.[].title'
GH_REPO=cli/cli gh api repos/{owner}/{repo}/labels

In PowerShell, quote anything containing {...} so the shell does not eat the braces.

Methods and Fields

The method is GET by default, and switches to POST automatically the moment you add a field.

FlagPurpose
-X, --methodForce GET, POST, PATCH, PUT, or DELETE
-f, --raw-fieldkey=value, always sent as a literal string
-F, --fieldkey=value with type conversion and @file reading
--input <file>Send a prebuilt body from a file, - for stdin
-H, --headerAdd a request header, key:value
--hostnameTarget another GitHub host
-i, --includePrint the status line and response headers too
--verbosePrint the full request and response
--silentSuppress the response body
--cache <duration>Cache the response, e.g. --cache 1h, --cache 600s
-p, --previewOpt into named API previews via the Accept header

-f versus -F is the distinction that bites. -f always sends a string. -F converts true, false, null, and bare integers to real JSON types, expands {owner}/{repo}/{branch}, and treats a leading @ as a filename.

gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
gh api -X GET repos/{owner}/{repo}/commits -f per_page=100 -f sha=main
gh api -F draft=true ...        # JSON true
gh api -f draft=true ...        # the string "true"
gh api -F body=@message.md ...  # body read from a file
gh api -F body=@- ...           # body read from stdin
gh api ... --input payload.json
gh api -F 'files[notes.txt][content]=@notes.txt'   # nested key
gh api -F 'labels[]=bug' -F 'labels[]=p1'          # array

Adding fields to a request you want to stay a GET needs an explicit -X GET, which moves them into the query string. Forget it and you accidentally POST.

Headers, Raw Content, Caching

gh api -H "Accept: application/vnd.github+json" repos/{owner}/{repo}
gh api -H "X-GitHub-Api-Version: 2022-11-28" repos/{owner}/{repo}
gh api -H "Accept: application/vnd.github.raw" \
  repos/{owner}/{repo}/contents/README.md
gh api -H "Accept: application/vnd.github.diff" \
  repos/{owner}/{repo}/pulls/123
gh api -H "Accept: application/vnd.github.patch" \
  repos/{owner}/{repo}/commits/9a3f21c
gh api -i repos/{owner}/{repo}          # see rate-limit headers
gh api --cache 1h repos/{owner}/{repo}/labels

The Accept header is how you get something other than JSON. .raw returns file bytes instead of a base64 blob, .diff and .patch return the textual diff of a PR or commit.

Pagination

gh api --paginate repos/{owner}/{repo}/issues
gh api --paginate -X GET search/issues -f q='repo:cli/cli is:open'
gh api --paginate repos/{owner}/{repo}/stargazers --jq '.[].login' | wc -l
gh api --paginate --slurp repos/{owner}/{repo}/issues > all.json
gh api --paginate repos/{owner}/{repo}/issues \
  --jq '.[] | {n: .number, t: .title}'

Without --slurp, each page prints as its own JSON array, so the output is a stream of arrays and not one valid document. --slurp wraps every page into a single outer array, which is what jq and any JSON parser expect. Combining --paginate with a per_page of 100 halves the request count:

gh api --paginate -X GET repos/{owner}/{repo}/issues -f per_page=100

GraphQL

The GraphQL API is the graphql endpoint, not a method. There is no -X GRAPHQL, that returns an HTML error page.

gh api graphql -f query='{ viewer { login } }'
gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
  query($owner: String!, $name: String!) {
    repository(owner: $owner, name: $name) {
      releases(last: 3) { nodes { tagName } }
    }
  }
'

Every field other than query and operationName becomes a GraphQL variable, so -F owner={owner} supplies $owner. Use -F when a variable must be a number or a boolean, since -f would send it as a string and the API would reject it.

Paginating GraphQL needs the query to cooperate: accept $endCursor: String and select pageInfo { hasNextPage endCursor }.

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

Reach for GraphQL when REST would need many round trips, or for data REST does not expose, such as discussions, projects v2, and reviewDecision.

Filtering the Response

--jq runs a jq expression in process, with no jq binary required. --template formats with a Go template.

gh api repos/{owner}/{repo} --jq .stargazers_count
gh api repos/{owner}/{repo}/issues --jq '.[].title'
gh api repos/{owner}/{repo}/issues \
  --jq '.[] | select(.comments > 5) | "\(.number) \(.title)"'
gh api repos/{owner}/{repo}/labels --jq 'map(.name) | join(", ")'
gh api repos/{owner}/{repo}/releases \
  --template '{{range .}}{{.tag_name}}{{"\t"}}{{.name}}{{"\n"}}{{end}}'
gh api user --template '{{.login}} joined {{timeago .created_at}}'

Note that --jq and --json are different things. gh api has no --json flag because it already returns raw JSON, while gh pr/gh issue/gh repo need --json to choose fields first. A second consequence: REST returns snake_case keys (tag_name, created_at), while the --json commands return camelCase (tagName, createdAt). Templates written for one break on the other.

Rate Limits and Debugging

gh api rate_limit
gh api rate_limit --jq '.rate'
gh api rate_limit --jq '.resources.graphql'
gh api rate_limit --jq '.rate | "\(.remaining)/\(.limit)"'
gh api -i user 2>&1 | grep -i x-ratelimit

rate_limit itself does not count against your quota. Authenticated REST gets 5,000 requests per hour, GraphQL is metered in points, and search has its own much lower bucket, which is why --paginate over search/issues throttles first.

GH_DEBUG=api gh api repos/{owner}/{repo}          # log the HTTP traffic
gh api --verbose repos/{owner}/{repo}             # full request and response
gh api repos/{owner}/{repo}/nope                  # 404 body plus exit code 1

A failed request exits non-zero and prints the API's error JSON, which usually names the missing scope or the exact invalid field.