mrkeyoor.com_
Thu 06 Aug 07:39 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The object model has barely moved in a decade, but 2.x has shifted things under people: authentication migrated to the Auth module while the old positional forms linger as deprecated, create_pull made title and body keyword-only, and get_rate_limit() now returns a RateLimitOverview where the long-standing .core attribute is gone in favor of .resources.core.
Docs3/5pygithub.readthedocs.io is a generated class reference that lists every method and its GitHub API endpoint, which is accurate but not discoverable; there are few worked examples beyond the introduction, so finding the method for a given task usually means grepping the source or reading GitHub's own REST docs and guessing the Python name.
Maintenance3/5Releases still ship (2.9.1) and the repo was pushed July 2026, but 281 issues are open (406 counting PRs) and the README says outright that the project is actively seeking maintainers to triage issues, review pull requests, and cut releases.
Ecosystem5/5Roughly 17.5 million weekly downloads and the default import in countless CI scripts, release tools, and internal bots; when someone posts a Python GitHub automation snippet, it is almost always this library.

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
Skip it if

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 backwards

Set 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 issues

Search 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 seconds

get_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 it

The 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

PackageRegistryPick it when
githubkitPyPIYou 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.
ghapiPyPIYou want a thin, complete client generated from the OpenAPI spec with tab-completion over every endpoint and no hand-maintained object model.
gidgethubPyPIYou are writing an async GitHub bot or webhook handler and want a sans-io library that works with aiohttp, httpx, or trio.
requestsPyPIYou call two or three endpoints and would rather own the URLs than take on a dependency with pynacl and pyjwt behind it.