PyGithub
PyGithub wraps the GitHub REST API in Python objects. You authenticate once, call g.get_repo('owner/name'), and get a Repository object with methods and attributes that mirror the API: repo.get_issues(), repo.create_pull(), repo.get_contents(), issue.add_to_labels(). Collections come back as PaginatedList objects you iterate normally while the library fetches pages behind you, and it handles conditional requests, retries on secondary rate limits, and a small default delay between calls so you do not get throttled by accident. It covers most of the REST surface including Actions, Apps, checks, and organization administration, and it works against GitHub Enterprise by changing base_url. It has been around since 2012, which shows in both directions: extremely broad coverage, and a codebase whose README openly asks for new maintainers.
Still the most complete hand-written GitHub client for Python and the fastest way to write a synchronous automation script, with GitHub App authentication that actually works. Weigh the open maintainer search and the LGPL license before committing a long-lived product to it; new projects that need async, GraphQL, or day-one endpoint coverage are better served by githubkit.
Use it if
- You are automating GitHub from Python (release scripts, bots, issue triage, repo audits) and want typed objects instead of assembling URLs and parsing JSON dicts by hand
- You need paginated endpoints handled for you: iterating repo.get_issues() walks every page without you tracking cursors or Link headers
- You are building a GitHub App and want the JWT signing, installation token exchange, and per-installation clients handled by Auth.AppAuth and GithubIntegration rather than by your own crypto code
- You are talking to GitHub Enterprise Server, where passing base_url gets you the same object model against a self-hosted instance
- You want conservative default behavior around rate limits, since the client waits 0.25 seconds between reads and 1 second between writes and retries on secondary limits without configuration
- You need async: PyGithub is synchronous and blocking, so a bot handling many repos concurrently either runs threads or picks gidgethub or githubkit, both of which are async native
- You need GraphQL: the v4 API is where new GitHub features land first, and PyGithub exposes only a bare requester.graphql_query escape hatch that returns raw dicts with no object model, so a GraphQL-heavy tool should start with githubkit
- You are chasing a brand-new endpoint: coverage is hand-written class by class, so recently shipped API surface can be missing for months; ghapi and githubkit generate their clients from GitHub's OpenAPI spec and get new endpoints on release day
- You depend on totalCount being accurate: GitHub moved several endpoints to cursor pagination with no last-page link, and PaginatedList.totalCount reports 1 for a repository whose open issue list actually runs to hundreds, so any count you show a user must come from the search API instead
- Your legal team screens licenses: PyGithub is LGPL-3.0, which is unusual for a Python library and does get flagged in organizations that only allow permissive licenses
- You want confidence in the maintenance path: the README states the project is actively seeking maintainers to triage and cut releases, and 281 issues (406 counting PRs) are open
Setup reality
pip install PyGithub pulls five dependencies including pynacl and pyjwt[crypto], both of which need cryptography, so on a platform without wheels you are compiling Rust and C. Python 3.9 or newer. The authentication API is the first trap: the old Github('token') and Github(login, password) positional forms still exist in the signature but are deprecated, and everything current goes through the Auth module as Github(auth=Auth.Token(...)). The second trap is that the client throttles itself by default, seconds_between_requests at 0.25 and seconds_between_writes at 1.0, which is kind to GitHub and slow when you are walking ten thousand objects; set them to None only if you are handling rate limits yourself. Attribute access is lazy in a way that surprises people: fetching a Repository object may trigger extra HTTP calls when you touch attributes that were not in the completed response, which turns an innocent loop into hundreds of requests. Pass lazy=True to get_repo when you only need to call methods on it, and call g.close() when you are done so the connection pool is released.
Patterns
Create a client with a personal access tokenauthenticate-with-token
from github import Auth, Github
auth = Auth.Token("ghp_...")
g = Github(auth=auth) # github.com
# g = Github(base_url="https://ghe.example.com/api/v3", auth=auth) # Enterprise
for repo in g.get_user().get_repos():
print(repo.full_name)
g.close()Github('token') as a positional argument still works but is deprecated; use auth=Auth.Token(...). Call close() or use the client as a context manager, otherwise the urllib3 pool stays open and long-running processes leak connections.
Authenticate as a GitHub App installationgithub-app-auth
from github import Auth, Github, GithubIntegration
app_auth = Auth.AppAuth(app_id=123456, private_key=open("key.pem").read())
gi = GithubIntegration(auth=app_auth)
for installation in gi.get_installations():
g = gi.get_github_for_installation(installation.id)
print([r.full_name for r in g.get_repos()])
# or go straight to one installation
inst_auth = app_auth.get_installation_auth(installation_id=987654)
g = Github(auth=inst_auth)AppAuth signs the JWT for you and the installation auth refreshes the one-hour installation token automatically, so hold onto the Github object rather than rebuilding it per request. A raw AppAuth client can only call App-level endpoints; repository calls need the installation auth.
Walk a paginated collectionpaginate-results
g = Github(auth=auth, per_page=100)
repo = g.get_repo("PyGithub/PyGithub")
issues = repo.get_issues(state="open")
for issue in issues: # pages fetched transparently
print(issue.number, issue.title)
issues[0] # index into the list
list(issues[:20]) # slice, stops after one page
issues.reversed # iterate from the last page backwardsSet per_page=100 on the client or you make four times as many requests. Do not trust issues.totalCount: GitHub now serves several endpoints with cursor pagination and no last-page link, and PyGithub reports 1 in that case even when the collection has hundreds of entries. Use g.search_issues() when you need a real count.
Create, label, comment on, and close an issueissues-and-comments
repo = g.get_repo("owner/name")
issue = repo.create_issue(
title="Nightly build failed",
body="See the run log.",
labels=["bug", "ci"],
assignees=["octocat"],
)
issue.create_comment("Retried, still failing.")
issue.add_to_labels("needs-triage")
issue.edit(state="closed", state_reason="completed")Labels must already exist on the repo or the create call fails with a 422. repo.get_issues() returns pull requests too, because GitHub models PRs as issues; filter with issue.pull_request is None if you only want real issues.
Read a file and commit a change through the APIread-and-write-files
repo = g.get_repo("owner/name")
f = repo.get_contents("README.md", ref="main")
text = f.decoded_content.decode()
repo.update_file(
path=f.path,
message="docs: fix typo",
content=text.replace("teh", "the"),
sha=f.sha, # required: the blob sha you just read
branch="main",
)
repo.create_file("docs/new.md", "docs: add page", "# Hello", branch="main")update_file needs the sha of the blob you read, and passing a stale one gives a 409 conflict, which is the API's optimistic locking. get_contents returns a list instead of a single ContentFile when the path is a directory, and it refuses files over 1 MB; use repo.get_git_blob() for those.
Create a branch and open a pull requestbranch-and-pull-request
repo = g.get_repo("owner/name")
base = repo.get_branch("main")
repo.create_git_ref(ref="refs/heads/bot/update-deps", sha=base.commit.sha)
repo.update_file("requirements.txt", "chore: bump deps", new_text, sha,
branch="bot/update-deps")
pr = repo.create_pull(
base="main",
head="bot/update-deps",
title="chore: bump dependencies",
body="Automated update.",
draft=True,
)
pr.add_to_labels("dependencies")create_pull takes base and head positionally and everything else keyword-only in 2.x, which breaks older snippets that passed title first. The ref for create_git_ref needs the full refs/heads/ prefix; passing a bare branch name returns a confusing 422.
Search repositories, code, and issuessearch
for repo in g.search_repositories(query="sqlglot in:name", sort="stars")[:10]:
print(repo.full_name, repo.stargazers_count)
open_issues = g.search_issues("repo:PyGithub/PyGithub is:issue is:open")
print(open_issues.totalCount) # the reliable way to count issuesSearch has its own rate limit of 30 requests per minute for authenticated clients and caps results at 1000 items regardless of totalCount, so paging past that returns nothing. The query string is GitHub's search syntax verbatim, which is why is:issue is needed to exclude pull requests.
Check how much quota is leftrate-limit-inspection
rl = g.get_rate_limit()
rl.resources.core.remaining # 4998
rl.resources.core.reset # datetime, UTC
rl.resources.search.remaining # 30
# cheap, no extra request: read from the last response headers
g.rate_limiting # (remaining, limit)
g.rate_limiting_resettime # epoch secondsget_rate_limit() returns a RateLimitOverview in current 2.x and the old rl.core attribute is gone, so code written against older tutorials raises AttributeError; use rl.resources.core. The call itself does not count against your quota, but g.rate_limiting is free because it reads the headers already cached from the previous request.
Catch the exceptions that actually happenhandle-errors
from github import (BadCredentialsException, GithubException,
RateLimitExceededException, UnknownObjectException)
try:
repo = g.get_repo("owner/does-not-exist")
except UnknownObjectException as e:
print("404:", e.status)
except RateLimitExceededException:
print("primary rate limit hit")
except BadCredentialsException:
print("token invalid or expired")
except GithubException as e:
print(e.status, e.data)GitHub returns 404 rather than 403 for private resources your token cannot see, so UnknownObjectException often means 'no permission', not 'does not exist'. e.data holds the API's JSON error body, which is where the useful message about a failed 422 validation lives.
Inspect and trigger GitHub Actionsactions-workflows
repo = g.get_repo("owner/name")
for run in repo.get_workflow_runs(branch=repo.get_branch("main"), status="failure")[:5]:
print(run.id, run.name, run.created_at, run.html_url)
wf = repo.get_workflow("deploy.yml")
wf.create_dispatch(ref="main", inputs={"environment": "staging"})create_dispatch returns a boolean, not the run, because the API responds 204 with no body; poll get_workflow_runs afterwards if you need the run id. The workflow must already have a workflow_dispatch trigger on the default branch or the call fails.
Call the GraphQL API when REST is not enoughgraphql-escape-hatch
query = """
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
discussions(first: 5) { nodes { title url } }
}
}
"""
headers, data = g.requester.graphql_query(query, {"owner": "owner", "name": "name"})
for node in data["data"]["repository"]["discussions"]["nodes"]:
print(node["title"])This is a raw passthrough: no objects, no pagination help, and GraphQL errors come back in the response body with a 200 status, so check data.get('errors') yourself. If most of your calls look like this, githubkit gives you a first-class GraphQL client instead.
Tune throttling, retries, and laziness for bulk worktune-throughput
from github import Auth, Github
g = Github(
auth=Auth.Token(token),
per_page=100,
seconds_between_requests=0.0, # default 0.25
seconds_between_writes=0.0, # default 1.0
retry=5,
pool_size=20,
lazy=True, # do not fetch objects until an attribute is read
)
repo = g.get_repo("owner/name", lazy=True) # zero HTTP calls until you use itThe default delays exist so scripts do not trip GitHub's secondary rate limits; turning them off means you own that problem. lazy=True avoids a round trip per object, but the first attribute access then triggers a fetch, so a loop over lazy objects reading .description is slower than not being lazy at all.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| githubkit | PyPI | You want sync and async in one client, full typing, GraphQL support, and endpoints generated from GitHub's OpenAPI spec so new API surface arrives immediately. |
| ghapi | PyPI | You want a thin, complete client generated from the OpenAPI spec with tab-completion over every endpoint and no hand-maintained object model. |
| gidgethub | PyPI | You are writing an async GitHub bot or webhook handler and want a sans-io library that works with aiohttp, httpx, or trio. |
| requests | PyPI | You call two or three endpoints and would rather own the URLs than take on a dependency with pynacl and pyjwt behind it. |