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.
The simplest form
Section titled “The simplest form”gh api repos/OWNER/REPOThat 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:
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/REPOgh 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.
Placeholders
Section titled “Placeholders”gh api substitutes placeholders from the current repository context:
gh api repos/{owner}/{repo}/pullsgh api repos/{owner}/{repo}/issues?state=openInside 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:
export GH_REPO=OWNER/REPOgh api repos/{owner}/{repo}/pullsMethods and parameters
Section titled “Methods and parameters”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_IDThe two parameter flags differ in a way that matters:
| Flag | Behaviour |
|---|---|
-f, --raw-field | Always a string |
-F, --field | Typed — true, 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.
gh api --method POST repos/OWNER/REPO/pulls \ -f title="Add retry handling" -f head=my-branch -f base=main \ -F draft=trueFor a complex body, send JSON directly:
gh api --method POST repos/OWNER/REPO/rulesets --input ruleset.jsoncat ruleset.json | gh api --method POST repos/OWNER/REPO/rulesets --input -Pagination
Section titled “Pagination”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.
gh api repos/OWNER/REPO/issues --paginateWhat 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:
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 add — add 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.
Filtering and formatting
Section titled “Filtering and formatting”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.
Inspecting the response
Section titled “Inspecting the response”gh api repos/OWNER/REPO --includegh api repos/OWNER/REPO --verbosegh api rate_limit --jq '.resources.core'--include prints status and headers, which is how you read rate-limit state:
x-ratelimit-limit: 5000x-ratelimit-remaining: 4983x-ratelimit-reset: 1787443200Checking 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.
gh api repos/OWNER/REPO/issues --cache 5m --jq '.[].title'GraphQL
Section titled “GraphQL”gh api graphql reaches the GraphQL endpoint, which is a genuinely different API rather than a
different syntax for the same one.
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=REPOVariables 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:
gh api graphql --paginate -f query=' query($endCursor: String) { viewer { repositories(first: 100, after: $endCursor) { nodes { nameWithOwner } pageInfo { hasNextPage endCursor } } } }'Safety
Section titled “Safety”# Verify, then actgh 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"When to use gh api rather than porcelain
Section titled “When to use gh api rather than porcelain”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.
Debugging a request
Section titled “Debugging a request”When a call does not do what you expect, three flags answer nearly every question.
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.
GH_DEBUG=api gh api repos/OWNER/REPO 2>&1 | head -30GH_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.
gh api repos/OWNER/REPO --verbose--verbose is the same idea built into the command rather than an environment variable.
Working with response headers
Section titled “Working with response headers”Some information exists only in headers, and --include plus a filter is how you get at it.
# Rate limit state without spending a request on /rate_limitgh 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:
gh api "repos/OWNER/REPO/issues?per_page=1" --include --silent 2>/dev/null | grep -i '^link'Conditional requests
Section titled “Conditional requests”For anything polled, ETag is the single biggest rate-limit saving available — a 304 Not Modified
does not count against your quota.
# First request: capture the ETagetag=$(gh api repos/OWNER/REPO --include --silent 2>/dev/null \ | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
# Later: ask only for changesgh api repos/OWNER/REPO -H "If-None-Match: $etag" --include --silent 2>/dev/null | head -1Output when nothing changed:
HTTP/2.0 304 Not ModifiedA 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.
Caching during development
Section titled “Caching during development”Iterating on a --jq filter re-requests every time, which is slow and wasteful:
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.
Templates for readable output
Section titled “Templates for readable output”--jq produces JSON or raw values. --template produces formatted text, which suits reports meant
for a human:
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:
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.
Common mistakes
Section titled “Common mistakes”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.
Reading files and other media types
Section titled “Reading files and other media types”gh api passes headers through, which unlocks the alternative representations REST offers:
# A file's raw contents, no base64 decodinggh api "repos/OWNER/REPO/contents/README.md?ref=main" \ -H "Accept: application/vnd.github.raw"
# A pull request as a unified diffgh api "repos/OWNER/REPO/pulls/128" -H "Accept: application/vnd.github.diff"
# Stargazers with the time each star was addedgh 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.
Writing through gh api
Section titled “Writing through gh api”# Create a labelgh 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 guardsha=$(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 filegh api --method POST "repos/OWNER/REPO/rulesets" --input ruleset.jsongh api --method POST "repos/OWNER/REPO/rulesets" --input - < ruleset.jsonBuilding a body with jq rather than string concatenation avoids an entire class of quoting bug:
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.
How --paginate, --slurp and --jq combine
Section titled “How --paginate, --slurp and --jq combine”# All pages, streamedgh api "repos/OWNER/REPO/issues?per_page=100" --paginate --jq '.[].number'
# All pages wrapped in one outer array (one element per page); add flattens itgh api "repos/OWNER/REPO/issues?per_page=100" --paginate --slurp --jq 'add | length'
# Cap the pages fetchedgh 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.
Scripting patterns
Section titled “Scripting patterns”Three that come up constantly.
Check existence without failing the script:
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 designfiExtract one value with a fallback:
branch=$(gh api "repos/$OWNER/$REPO" --jq '.default_branch // "main"')Iterate safely over results:
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.
Exercise
Section titled “Exercise”- Fetch a public repository with
gh api repos/cli/cli --jq '.stargazers_count'. - Run the same with
--includeand read the rate-limit headers. - List Issues with and without
--paginate, and compare the counts. - Add
--slurpand use--jq 'add | length'to count them in one go. - Run the GraphQL query above and compare the shape of the response with the REST equivalent.
- 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.
Environment variables
Section titled “Environment variables”gh api respects the same environment as the rest of the CLI, and a few change its behaviour
directly:
| Variable | Effect |
|---|---|
GH_TOKEN | Authentication, overriding stored credentials |
GH_REPO | Default repository for {owner}/{repo} placeholders |
GH_HOST | Target host, for Enterprise Server |
GH_DEBUG=api | Print full requests and responses |
NO_COLOR | Disable 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.
GH_HOST=github.company.com gh api repos/OWNER/REPOgh api --hostname github.company.com repos/OWNER/REPOComparing gh api with curl
Section titled “Comparing gh api with curl”Both reach the same API. The trade-off is worth stating plainly.
gh api | curl | |
|---|---|---|
| Authentication | Automatic | You supply the header |
| Base URL | Implicit | You write it |
| API version header | Supplied | You supply it |
| Pagination | --paginate | You follow Link yourself |
| JSON filtering | --jq built in | Pipe to jq |
| Portability | Requires gh installed | Available almost everywhere |
| Explicitness | Hides what is sent | Everything 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.
When to stop using gh api
Section titled “When to stop using gh api”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.
What you learned
Section titled “What you learned”gh apisupplies authentication, base URL, Accept and API version headers automatically.-Fis typed and-fis string — the opposite of most people’s assumption.--paginateis required for complete results;--slurpwraps the pages in one outer array —addto flatten.--jqis built in and needs no jq binary;--templatehandles fixed-format text.- GraphQL is reached with
gh api graphql, with variables passed as-F. gh apihas no guard rails; verify with aGETbefore anything destructive.
The short version
Section titled “The short version”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.
Related lessons
Section titled “Related lessons”Check your understanding
4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.