Skip to content

gh api: The Full GitHub API from the Command Line

Lesson 8 of 10Intermediate → Advanced12 min readGitHub Engineering · GitHub CLIVerified: gh 2.98.0 on Ubuntu 24.04; API version 2022-11-28, August 2026

The porcelain commands cover common operations. gh api covers everything else — every REST endpoint, the GraphQL API, and anything added since your version of gh shipped.

It is the most important command in the CLI, because it means you are never blocked by the CLI’s coverage. And it handles the parts of API access that are tedious to get right: authentication, pagination, base URL, and the API version header.

Terminal window
gh api repos/OWNER/REPO

That is a GET to https://api.github.com/repos/OWNER/REPO, authenticated with your gh credentials, returning JSON. No token handling, no base URL, no headers.

Compare with the equivalent curl:

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/OWNER/REPO

gh api supplies all three headers and the token. That convenience is the whole point — and it is also why the REST API lesson covers what those headers are, because you will need them when you write a client that is not gh.

gh api substitutes placeholders from the current repository context:

Terminal window
gh api repos/{owner}/{repo}/pulls
gh api repos/{owner}/{repo}/issues?state=open

Inside a checkout, {owner} and {repo} resolve from the Git remotes. This is convenient interactively and a hazard in scripts — a script relying on it does different things depending on the working directory. In automation, name the repository explicitly or set GH_REPO:

Terminal window
export GH_REPO=OWNER/REPO
gh api repos/{owner}/{repo}/pulls
Terminal window
gh api --method POST repos/OWNER/REPO/issues \
-f title="Retry logic drops the final attempt" \
-f body="Full report attached." \
-f "labels[]=bug"
gh api --method PATCH repos/OWNER/REPO/issues/42 -f state=closed
gh api --method DELETE repos/OWNER/REPO/issues/comments/COMMENT_ID

The two parameter flags differ in a way that matters:

FlagBehaviour
-f, --raw-fieldAlways a string
-F, --fieldTypedtrue, false, numbers and null are converted; @file reads from a file, @- from stdin

This catches people out because the shorter flag is the string one. Sending -f draft=true submits the string "true", which some endpoints reject and others silently treat as truthy. When a boolean or number is required, use -F.

Terminal window
gh api --method POST repos/OWNER/REPO/pulls \
-f title="Add retry handling" -f head=my-branch -f base=main \
-F draft=true

For a complex body, send JSON directly:

Terminal window
gh api --method POST repos/OWNER/REPO/rulesets --input ruleset.json
cat ruleset.json | gh api --method POST repos/OWNER/REPO/rulesets --input -

The REST API returns results in pages. Forgetting this is the most common cause of automation that works in testing and silently misses data in production — you get the first thirty results and no indication there were four hundred.

Terminal window
gh api repos/OWNER/REPO/issues --paginate

What it doesFetches every page of results and emits them as a single stream.

Why we run itWithout --paginate you get one page. With it, gh follows the Link headers until there are no more, so the result is complete.

Expected resultConcatenated results from all pages.

One subtlety: --paginate on an endpoint returning arrays produces multiple JSON arrays, not one. For jq processing that needs to see everything at once, add --slurp, which wraps all pages in one outer array — each page stays an element:

Terminal window
gh api repos/OWNER/REPO/issues --paginate --slurp --jq 'add | length'
gh api repos/OWNER/REPO/issues --paginate --jq '.[].number'

The second form works without --slurp because .[] is applied per page. The first needs it, and needs the addadd concatenates the page arrays into one list, and length then counts items. --slurp --jq 'length' on its own counts pages, which is the kind of answer that looks plausible and is wrong.

Terminal window
gh api repos/OWNER/REPO --jq '.stargazers_count'
gh api repos/OWNER/REPO/pulls --jq '.[] | {number, title, user: .user.login}'
gh api repos/OWNER/REPO/pulls --template '{{range .}}{{.number}}: {{.title}}{{"\n"}}{{end}}'

--jq uses jq syntax and requires no jq binary — it is built in. --template uses Go templates, which suit fixed-format text output.

Terminal window
gh api repos/OWNER/REPO --include
gh api repos/OWNER/REPO --verbose
gh api rate_limit --jq '.resources.core'

--include prints status and headers, which is how you read rate-limit state:

x-ratelimit-limit: 5000
x-ratelimit-remaining: 4983
x-ratelimit-reset: 1787443200

Checking rate_limit costs nothing against your quota and is worth doing at the start of any script that will make many calls. See the REST API lesson for the limits themselves.

--cache is worth knowing for development: it caches responses locally for a duration, so iterating on a --jq filter does not re-request each time.

Terminal window
gh api repos/OWNER/REPO/issues --cache 5m --jq '.[].title'

gh api graphql reaches the GraphQL endpoint, which is a genuinely different API rather than a different syntax for the same one.

Terminal window
gh api graphql -f query='
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 5, states: OPEN) {
nodes { number title author { login } }
}
}
}' -F owner=OWNER -F repo=REPO

Variables are supplied with -F and referenced with $name in the query. The query itself goes in -f query=.

GraphQL’s advantage is fetching related data in one request. The REST equivalent of the query above — pull requests plus each author — is one call for the list and one per author. The trade-offs are covered in GraphQL API.

Pagination in GraphQL is cursor-based and --paginate understands it, provided the query requests pageInfo and accepts an $endCursor variable:

Terminal window
gh api graphql --paginate -f query='
query($endCursor: String) {
viewer {
repositories(first: 100, after: $endCursor) {
nodes { nameWithOwner }
pageInfo { hasNextPage endCursor }
}
}
}'
Terminal window
# Verify, then act
gh api "repos/$OWNER/$REPO" --jq '.full_name'
[ "$CONFIRM_DELETE" = "yes" ] || { echo "refusing without CONFIRM_DELETE=yes" >&2; exit 1; }
gh api --method DELETE "repos/$OWNER/$REPO"

Use porcelain when it exists — it is clearer, validated and stable.

Use gh api when the operation has no command, when you need a field the porcelain does not expose, when you need GraphQL, or when a feature is newer than your CLI.

Use a proper HTTP client when you are building an application rather than a script. gh api depends on a binary being installed at a compatible version and on shell quoting — neither is a good foundation for a service. That is where the API cluster takes over.

When a call does not do what you expect, three flags answer nearly every question.

Terminal window
gh api repos/OWNER/REPO --include | head -20

--include prints the status line and headers before the body — which is how you read rate-limit state, check for a deprecation warning, or confirm a redirect happened.

Terminal window
GH_DEBUG=api gh api repos/OWNER/REPO 2>&1 | head -30

GH_DEBUG=api prints the full request and response, including the headers gh added on your behalf. This is the fastest way to learn what a porcelain command actually calls — run GH_DEBUG=api gh pr list and read the endpoint, then reproduce it in whatever language you are writing.

Terminal window
gh api repos/OWNER/REPO --verbose

--verbose is the same idea built into the command rather than an environment variable.

Some information exists only in headers, and --include plus a filter is how you get at it.

Terminal window
# Rate limit state without spending a request on /rate_limit
gh api repos/OWNER/REPO --include --silent 2>/dev/null | grep -i '^x-ratelimit'
# Is this endpoint deprecated?
gh api some/endpoint --include --silent 2>/dev/null | grep -iE '^(deprecation|sunset|warning)'

The Deprecation and Sunset headers are worth checking occasionally on anything you depend on. GitHub announces removals through them well before the endpoint disappears, and a script that reads them is a script that gets warning rather than a surprise outage.

Link carries pagination, which --paginate consumes for you:

Terminal window
gh api "repos/OWNER/REPO/issues?per_page=1" --include --silent 2>/dev/null | grep -i '^link'

For anything polled, ETag is the single biggest rate-limit saving available — a 304 Not Modified does not count against your quota.

Terminal window
# First request: capture the ETag
etag=$(gh api repos/OWNER/REPO --include --silent 2>/dev/null \
| grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
# Later: ask only for changes
gh api repos/OWNER/REPO -H "If-None-Match: $etag" --include --silent 2>/dev/null | head -1

Output when nothing changed:

HTTP/2.0 304 Not Modified

A dashboard refreshing every minute across fifty repositories is 72,000 requests a day without this and almost none with it. It is also, of course, an argument for webhooks — but conditional requests are the right fix when you genuinely must poll.

Iterating on a --jq filter re-requests every time, which is slow and wasteful:

Terminal window
gh api "repos/OWNER/REPO/issues?per_page=100" --cache 10m --jq '.[].title'
gh api "repos/OWNER/REPO/issues?per_page=100" --cache 10m --jq '.[] | select(.comments > 5) | .number'

The second call reads from the local cache. --cache accepts durations like 30s, 10m or 1h, and it applies only to GET requests — which is the correct behaviour, since caching a POST would be actively dangerous.

Remember to drop it when the script goes to production, or you will eventually debug a script confidently acting on ten-minute-old state.

--jq produces JSON or raw values. --template produces formatted text, which suits reports meant for a human:

Terminal window
gh api repos/OWNER/REPO/pulls \
--template '{{range .}}{{printf "#%v" .number | autocolor "green"}} {{.title}}
{{"by" | color "gray"}} {{.user.login}}
{{end}}'

Available helpers include autocolor, color, timeago, truncate, tablerow and tablerender. timeago is particularly useful — it turns an ISO timestamp into “3 days ago” without any date arithmetic:

Terminal window
gh api repos/OWNER/REPO/issues \
--template '{{range .}}{{tablerow (printf "#%v" .number) (truncate 50 .title) (timeago .created_at)}}{{end}}{{tablerender}}'

Run gh help formatting for the current list — it is authoritative for your installed version, and the set of helpers grows.

Forgetting --paginate. Silently incomplete results.

--paginate without --slurp for whole-array jq. Multiple arrays, not one.

-f where -F is needed. Booleans and numbers submitted as strings.

Relying on {owner}/{repo} in scripts. Behaviour depends on the working directory.

Destructive calls without verifying first. No guard rails.

Hardcoding an API version header. gh api supplies the current one; see the REST API lesson before pinning it yourself.

gh api passes headers through, which unlocks the alternative representations REST offers:

Terminal window
# A file's raw contents, no base64 decoding
gh api "repos/OWNER/REPO/contents/README.md?ref=main" \
-H "Accept: application/vnd.github.raw"
# A pull request as a unified diff
gh api "repos/OWNER/REPO/pulls/128" -H "Accept: application/vnd.github.diff"
# Stargazers with the time each star was added
gh api "repos/OWNER/REPO/stargazers" \
-H "Accept: application/vnd.github.star+json" --paginate \
--jq '.[] | [.starred_at, .user.login] | @tsv'

The raw form is one step where the default JSON representation needs decoding, and it does not hit the size threshold above which the JSON form omits content entirely.

Terminal window
# Create a label
gh api --method POST "repos/OWNER/REPO/labels" \
-f name=needs-repro -f color=FBCA04 -f description="Cannot reproduce as written"
# Update a file, with the concurrency guard
sha=$(gh api "repos/OWNER/REPO/contents/config.yml?ref=main" --jq '.sha')
gh api --method PUT "repos/OWNER/REPO/contents/config.yml" \
-f message="Bump the retry limit" \
-f content="$(base64 -w0 < config.new.yml)" \
-f sha="$sha" -f branch=main
# Complex bodies from a file
gh api --method POST "repos/OWNER/REPO/rulesets" --input ruleset.json
gh api --method POST "repos/OWNER/REPO/rulesets" --input - < ruleset.json

Building a body with jq rather than string concatenation avoids an entire class of quoting bug:

Terminal window
jq -n --arg title "$TITLE" --arg body "$BODY" --argjson draft true \
'{title: $title, body: $body, draft: $draft, head: "my-branch", base: "main"}' \
| gh api --method POST "repos/OWNER/REPO/pulls" --input -

jq -n constructs JSON from scratch, --arg passes strings safely regardless of their content, and --argjson passes typed values. A title containing a quote, a newline or a backslash is handled correctly, which shell interpolation does not manage.

Terminal window
# All pages, streamed
gh api "repos/OWNER/REPO/issues?per_page=100" --paginate --jq '.[].number'
# All pages wrapped in one outer array (one element per page); add flattens it
gh api "repos/OWNER/REPO/issues?per_page=100" --paginate --slurp --jq 'add | length'
# Cap the pages fetched
gh api "repos/OWNER/REPO/issues?per_page=100&page=1" --jq 'length'

The --slurp distinction is worth restating because it produces silently wrong answers. Without it, --paginate emits one JSON array per page. --jq '.[].number' works, because the filter applies per page. --jq 'length' returns the length of each page, printed once per page — which looks like a list of hundreds and is not the count you wanted. With --slurp, jq sees an array of pages, so whole-collection filters start with add (arrays) or map(.items) | add (endpoints that wrap their list in an object with total_count).

For GraphQL, --paginate follows cursors provided the query declares $endCursor and requests pageInfo. The variable name is a gh convention; anything else produces one page with no error.

Three that come up constantly.

Check existence without failing the script:

Terminal window
if gh api "repos/$OWNER/$REPO" --silent >/dev/null 2>&1; then
echo "exists and is visible"
else
echo "missing or not permitted" # 404 is ambiguous by design
fi

Extract one value with a fallback:

Terminal window
branch=$(gh api "repos/$OWNER/$REPO" --jq '.default_branch // "main"')

Iterate safely over results:

Terminal window
gh api "repos/$OWNER/$REPO/issues?state=open&per_page=100" --paginate \
--jq '.[] | select(has("pull_request") | not) | [.number, .title] | @tsv' \
| while IFS=$'\t' read -r number title; do
printf '#%s %s\n' "$number" "$title"
done

@tsv escapes embedded tabs and newlines, so a title containing either cannot corrupt the parse — the single most important detail when reading structured data into shell variables.

  1. Fetch a public repository with gh api repos/cli/cli --jq '.stargazers_count'.
  2. Run the same with --include and read the rate-limit headers.
  3. List Issues with and without --paginate, and compare the counts.
  4. Add --slurp and use --jq 'add | length' to count them in one go.
  5. Run the GraphQL query above and compare the shape of the response with the REST equivalent.
  6. Check gh api rate_limit --jq '.resources.core' before and after.

Step 3 is the important one — seeing the count differ is what makes pagination memorable before it costs you a production incident.

Ten worked --paginate / --slurp / --jq patterns — open PRs by age, issues counted by label, failed runs this week, repositories missing a CODEOWNERS file, GraphQL cursors — are in the free gh api pagination recipes file. Every filter in it was run against the live API before publication.

gh api respects the same environment as the rest of the CLI, and a few change its behaviour directly:

VariableEffect
GH_TOKENAuthentication, overriding stored credentials
GH_REPODefault repository for {owner}/{repo} placeholders
GH_HOSTTarget host, for Enterprise Server
GH_DEBUG=apiPrint full requests and responses
NO_COLORDisable colour, useful when piping

For Enterprise Server the API path differs — /api/v3 rather than the hostname root — and gh handles that translation when GH_HOST or --hostname is set, which is a good reason to use them rather than constructing URLs by hand.

Terminal window
GH_HOST=github.company.com gh api repos/OWNER/REPO
gh api --hostname github.company.com repos/OWNER/REPO

Both reach the same API. The trade-off is worth stating plainly.

gh apicurl
AuthenticationAutomaticYou supply the header
Base URLImplicitYou write it
API version headerSuppliedYou supply it
Pagination--paginateYou follow Link yourself
JSON filtering--jq built inPipe to jq
PortabilityRequires gh installedAvailable almost everywhere
ExplicitnessHides what is sentEverything is visible

gh api is better for scripting on a machine where gh is present. curl is better for documentation, for environments where installing gh is not an option, and for learning — because writing the headers yourself is how you find out what they are.

The GH_DEBUG=api output bridges them: it shows exactly what gh sent, which is the fastest way to translate a working gh api call into a curl call or into code.

gh api is a shell tool. The signals it has been outgrown are the same as for shell generally:

  • You are constructing JSON bodies by string concatenation
  • You need to branch on specific error codes across many call sites
  • You want retries with different policies per operation
  • The script needs tests
  • It runs unattended and matters

At that point, an HTTP client in a real language is simpler, not more complex. The REST API lesson covers the headers gh api was supplying, and Python + GitHub API builds the small client that replaces it.

  • gh api supplies authentication, base URL, Accept and API version headers automatically.
  • -F is typed and -f is string — the opposite of most people’s assumption.
  • --paginate is required for complete results; --slurp wraps the pages in one outer array — add to flatten.
  • --jq is built in and needs no jq binary; --template handles fixed-format text.
  • GraphQL is reached with gh api graphql, with variables passed as -F.
  • gh api has no guard rails; verify with a GET before anything destructive.

gh api supplies authentication, the base URL, the Accept header and the API version, which is why a one-line gh api call replaces a five-line curl. It is the escape hatch that means CLI coverage never blocks you.

Four things to remember: --paginate is required for complete results and --slurp wraps the pages in one outer array (add to flatten); -F is typed and -f is a raw string, which is the opposite of most expectations; {owner}/{repo} inference is convenient interactively and unpredictable in scripts; and there are no guard rails, so verify with a GET before anything destructive.

GH_DEBUG=api prints the actual request, which is the fastest way to learn which endpoint a porcelain command uses — and therefore how to reproduce it in a language that is not shell.

Check your understanding

4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

You run `gh api repos/OWNER/REPO/issues --paginate --slurp --jq 'length'` on a repository with 250 open issues. What does it print?
Show answer

The number of pages fetched — `--slurp` wraps all pages in one outer array, one element per page. `length` counts those elements. To count items, flatten first: `--jq 'add | length'`.

What is the difference between `-f` and `-F` when passing a parameter to `gh api`?
Show answer

`-f` sends a raw string; `-F` is typed, so numbers and booleans are sent as such — It is the opposite of most people's guess. `-f state=true` sends the string "true"; `-F state=true` sends a boolean. Wrong choice, wrong type, confusing 422s.

Why is relying on `{owner}`/`{repo}` placeholders discouraged in scripts?
Show answer

Their value depends on the working directory's repository, so the script behaves differently depending on where it runs — Placeholder inference is convenient at an interactive prompt and unpredictable in automation. Scripts should name the repository explicitly.

Which statement about `gh api` and safety is correct?
Show answer

It has no guard rails; verify with a `GET` before anything destructive — `gh api` sends whatever you asked for. The lesson's habit is to fetch the resource first and confirm it is the one you mean before a `DELETE` or a `PATCH`.

Professional ToolkitTen more gh api pagination patterns, plus the PR triage and release-notes scripts, are in the Professional Toolkit.