Closing Issues with Commits
Fixing a bug or finishing a feature usually means someone tracked it in a GitHub Issue, and closing that issue by hand after every merge gets tedious fast. GitHub lets you skip that step: if a commit message or pull request description contains a "closing keyword" like Fixes #42 or Closes #17, GitHub will close the matching issue automatically once that commit lands on the repository’s default branch. This lesson covers the exact keywords and syntax, what GitHub is actually doing behind the scenes when it parses your commit, and the mistakes that cause issues to silently stay open when you expected them to close.
Overview / How it works
It’s important to understand that this is a GitHub platform feature, not a git feature. Git itself has no concept of an "issue" — a commit object is just a snapshot pointer (tree + parent + author + a free-text message) stored by SHA-1 hash, and git does not parse or care what that message says. Issues live entirely in GitHub’s own database. Closing-by-commit works because GitHub’s servers scan the text of commit messages and pull request descriptions, as they arrive, looking for a small set of recognized English keywords immediately followed by # and a number.
When GitHub finds a match, it checks two things before it acts: first, whether the actor pushing the commit has write access to the target repository (so a random contributor can’t close issues they don’t have permission to touch), and second — this is the part people most often get wrong — whether the referencing commit is actually reachable from the repository’s default branch (typically main). A commit sitting on a feature branch that has a closing keyword in it does not close anything yet. GitHub records the reference (you’ll see it show up as a mention on the issue), but the actual close action only fires the moment that commit becomes part of main‘s history — whether by a direct push or by merging a pull request.
This also explains why squash merges and rebase merges still work correctly even though they replace the original commit SHA(s) with a brand-new commit on main: GitHub re-scans whatever commit message ends up on the default branch, which for a squash merge is usually built from the pull request’s title and body. That’s why the recommended habit (covered in Best Practices below) is to put your closing keyword in the pull request description rather than relying solely on an individual commit message that might get squashed away.
Cross-repository references are also supported: instead of #42, you can write owner/repo#42 to close an issue that lives in a different repository than the one you’re committing to, as long as you have write access there too.
Syntax
<keyword> #<issue-number>
<keyword> <owner>/<repo>#<issue-number>
The keyword can appear anywhere in a commit message or PR description, but is conventionally placed on its own line in the commit body. Matching is case-insensitive.
| Keyword family | Accepted forms |
|---|---|
| close | close, closes, closed |
| fix | fix, fixes, fixed |
| resolve | resolve, resolves, resolved |
Anything else — plain #42, or a word like "addresses" or "refs" — creates a link between the commit/PR and the issue on GitHub’s timeline, but does not close it. That’s useful when you want to point at related work without triggering an automatic close.
Examples
Example 1: Closing an issue with a direct commit to main
git add src/auth/token.js
git commit -m "fix(auth): correct expired token refresh logic
Fixes #42"
git push origin main
Output:
[main 8f3a1c2] fix(auth): correct expired token refresh logic
1 file changed, 6 insertions(+), 2 deletions(-)
To github.com:acme/webapp.git
1a2b3c4..8f3a1c2 main -> main
The git output looks completely ordinary — git has no idea an issue exists. But once GitHub receives this push, it scans the new commit’s message, finds Fixes #42, confirms the commit is now on main (the default branch), and closes issue #42 with a timeline entry reading "closed this issue via commit 8f3a1c2".
Example 2: Closing an issue through a pull request
git switch -c fix/login-timeout
# ...edit files...
git add .
git commit -m "fix(session): extend login timeout to 30 minutes"
git push -u origin fix/login-timeout
gh pr create --title "fix(session): extend login timeout to 30 minutes" --body "Closes #58"
Output:
Creating pull request for fix/login-timeout into main in acme/webapp
https://github.com/acme/webapp/pull/87
Notice the individual commit message doesn’t mention any issue at all — the closing keyword lives in the pull request description instead. As soon as this PR is opened, issue #58 shows a linked pull request in its sidebar, but it is still open. Merging finishes the job:
gh pr merge 87 --squash
Output:
✓ Squashed and merged pull request #87 (fix(session): extend login timeout to 30 minutes)
Because the squash commit that lands on main carries the PR title and body, GitHub re-parses it, finds Closes #58, and closes the issue the instant the merge completes.
Example 3: Closing two issues, one of them in another repository
git commit -m "fix: prevent duplicate email dispatch on retry
Fixes #101
Closes acme/notifications#12"
git push origin main
Output:
[main 5d9e0aa] fix: prevent duplicate email dispatch on retry
1 file changed, 11 insertions(+), 3 deletions(-)
To github.com:acme/webapp.git
8f3a1c2..5d9e0aa main -> main
This single commit closes issue #101 in the current repository (acme/webapp) and, because it has write access there too, issue #12 in the separate acme/notifications repository. Each issue got its own keyword — that detail matters, and it’s the subject of a common mistake below.
How it works step by step
- You write a commit message containing a closing keyword and an issue reference, then commit locally. Git stores the message as opaque text inside the commit object; nothing issue-related happens yet.
- You push the branch, or merge a pull request, so the commit becomes reachable from a ref on GitHub.
- GitHub’s servers process the push (or merge) event and scan every newly reachable commit message — plus, for a merged PR, the PR title and description — for the keyword-plus-reference pattern.
- For each match, GitHub resolves the target issue: the current repository by default, or the specified
owner/repofor a cross-repository reference. - GitHub checks that the actor has write access to the target repository and confirms the commit is now part of the default branch’s history.
- If both checks pass, GitHub closes the issue, writes a "closed via
<sha>" event to its timeline, and adds a permanent cross-reference so the issue page shows exactly which commit or PR resolved it. - If that commit is later reverted, or the pull request is reopened, GitHub does not automatically reopen the issue — reopening always requires a manual click or API call.
Common Mistakes
Mistake: expecting a feature-branch commit to close the issue immediately
git commit -m "fix: cart total rounds incorrectly
Fixes #77"
git push origin feature/cart-bug
This pushes to feature/cart-bug, not main. GitHub records the reference on issue #77, but the issue stays open until this branch is actually merged into the default branch — closing only fires at merge time.
Mistake: listing multiple issue numbers after one keyword
git commit -m "fix: normalize currency formatting
Fixes #20, #21, #22"
Only issue #20 — the one directly paired with the keyword — actually closes. Issues #21 and #22 are just plain text mentions with no closing action attached. The fix is to repeat the keyword for every issue: Fixes #20, fixes #21, fixes #22.
Mistake: referencing a cross-repo issue without the owner/repo prefix
git commit -m "fix: closes #12"
If you meant issue #12 in a different repository, this closes (or links) issue #12 in whichever repository you’re currently committing to instead — almost certainly the wrong issue. Always spell out owner/repo#12 for cross-repository references.
Mistake: squashing away the only commit that had the keyword
If your closing keyword only exists in an early commit that later gets squashed, amended, or dropped during an interactive rebase before the PR merges, the keyword can disappear from the final history entirely. Keeping the keyword in the pull request description — not just a commit — avoids this, since squash and rebase merges both preserve the PR’s title and body text.
Best Practices
- Put the closing keyword in the pull request description rather than relying only on an individual commit message, since squash and rebase merges are common and the PR body always survives to the merge commit.
- Follow Conventional Commits for the subject line and put the closing keyword on its own line in the commit body, e.g. a subject of
fix(auth): correct token refresh bugfollowed by a blank line andFixes #128. - Use a non-closing word like
Refs #42orRelated to #42when you want to link work to an issue without closing it — useful for partial progress or work that still needs QA sign-off. - Repeat the keyword for every issue you intend to close in the same commit or PR; don’t rely on comma-separated numbers after a single keyword.
- Prefer merging through a reviewed pull request rather than pushing directly to
main, so the automatic close is visible in review before it fires. - For cross-repository references, double-check the exact
owner/repo#numberspelling and confirm you have write access to the target repository.
Practice Exercises
- In a test repository, open a new issue, branch off
main, make a small fix, commit withFixes #<issue-number>in the commit body, push the branch, open a pull request, and merge it. Confirm the issue closed automatically and shows a link back to your commit. - Repeat the setup, but this time push your commit only to a feature branch without opening a pull request. Confirm the issue stays open. Then merge that branch into
mainand watch the issue close at that point. - Open two issues in the same repository, then write a single commit that closes both, using two separate keyword-plus-number references. Push to
mainand verify both issues closed.
Summary
- Closing-by-commit is a GitHub feature, not a git feature — git stores commit messages as plain text and has no concept of issues.
- Recognized keywords are
close,closes,closed,fix,fixes,fixed,resolve,resolves,resolved(case-insensitive), followed by#numberorowner/repo#number. - An issue only closes once the referencing commit reaches the repository’s default branch — a feature-branch commit merely creates a reference until it’s merged.
- Each issue you want closed needs its own keyword; comma-separated numbers after a single keyword don’t all trigger a close.
- Put closing keywords in the pull request description when possible, since squash and rebase merges preserve that text on the default branch even when individual commits don’t survive.
- Use non-closing references like
Refs #42to link an issue to your work without automatically closing it.
