Managing Tags
A tag is a permanent, named pointer to a single commit — most often the exact commit that represents a software release, like v1.4.0. Unlike a branch, a tag never moves on its own: once you create it, it always points at the same snapshot of your project, which makes tags the standard way to mark release points, milestones, and versions in Git and on GitHub. This lesson covers both kinds of tags Git supports, how to create, list, inspect, sign, push, and delete them, and how GitHub turns tags into Releases.
Overview / How Tags Work
Every commit, branch, and tag in Git is stored as an object or a reference inside the .git directory. A branch is a ref file under refs/heads/<name> that holds the hash of its latest commit and moves forward automatically every time you commit. A tag is a ref file under refs/tags/<name>, but — critically — nothing in ordinary Git usage ever updates it after creation. You point it at a commit once, and it stays there.
Git actually supports two different kinds of tags, and the difference matters:
Lightweight tags are the simplest possible ref: the file refs/tags/<name> contains nothing but the 40-character SHA-1 hash of the commit it targets. No new object is written to the object database. A lightweight tag is essentially a branch that never moves.
Annotated tags are full Git objects. When you create one, Git writes a new tag object into .git/objects — the same content-addressed store that holds your blobs, trees, and commits. That tag object records the hash and type of the commit it points to, the tag name, the tagger’s name and email, the date, and a message, and it can optionally carry a GPG or SSH signature. The ref under refs/tags/<name> then points not directly at the commit, but at this tag object, which in turn points at the commit. That extra layer of indirection is what lets git show display a tag message and lets you verify who created the tag and that it hasn’t been tampered with.
Because an annotated tag carries author, date, and message metadata — the same things a commit carries — Git’s own recommendation is to use annotated tags for anything you intend to share or treat as a release, and to reserve lightweight tags for quick, private, throwaway markers.
Tags are not branches. You cannot commit new work “onto” a tag, and checking one out puts you in detached HEAD state — HEAD points directly at a commit instead of at a branch, so any new commits you make there aren’t attached to any branch and can become unreachable (and eventually garbage-collected) once you switch away, unless you create a branch to hold them.
Tags also don’t travel with git push or git fetch by default. They live in their own ref namespace on the remote and have to be pushed and fetched explicitly, which trips up a lot of newcomers who tag a release and wonder why it never showed up on GitHub.
On GitHub itself, pushing a tag doesn’t automatically create a Release — a tag is just a Git ref. A GitHub Release is a wrapper around a tag: release notes, downloadable assets, and a nicer UI, published either by picking an existing tag or by creating a new one directly from the “Draft a new release” page.
Syntax
The general forms of the tagging commands:
git tag
git tag -l "v1.*"
git tag <name>
git tag <name> <commit>
git tag -a <name> -m "<message>"
git tag -s <name> -m "<message>"
git show <name>
git tag -v <name>
git tag -d <name>
git push origin <name>
git push --tags
git push origin --delete <name>
git switch -c <branch> <tag>
| Command / flag | What it does |
|---|---|
git tag |
List all tags in the repository |
git tag -l "v1.*" |
List tags matching a glob pattern |
git tag <name> |
Create a lightweight tag on the current commit |
git tag <name> <commit> |
Create a lightweight tag on a specific commit |
-a |
Create an annotated tag (records tagger, date, message) |
-m "<message>" |
Tag message, used with -a or -s |
-s |
Create an annotated tag and sign it with GPG |
git show <name> |
Show tag metadata (if annotated) plus the target commit |
git tag -v <name> |
Verify a signed tag’s GPG signature |
git tag -d <name> |
Delete a tag locally |
git push origin <name> |
Push one specific tag to a remote |
git push --tags |
Push all local tags that aren’t already on the remote |
git push origin --delete <name> |
Delete a tag on the remote |
Examples
Example 1: A lightweight tag
git log --oneline -1
git tag v0.1.0-checkpoint
git tag
git show v0.1.0-checkpoint
Output:
a1b2c3d feat(auth): add password reset flow
v0.1.0-checkpoint
commit a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 (HEAD -> main, tag: v0.1.0-checkpoint)
Author: Priya Shah <priya@example.com>
Date: Mon Aug 3 09:12:44 2026 -0700
feat(auth): add password reset flow
diff --git a/src/auth/reset.js b/src/auth/reset.js
...
The tag is created on whatever commit HEAD currently points to. git tag with no arguments lists it, and git show jumps straight to the commit — notice there’s no tagger, date, or message block, because a lightweight tag is just a name for a commit hash, nothing more.
Example 2: An annotated tag, pushed to GitHub
git tag -a v1.0.0 -m "chore(release): v1.0.0 - first stable release"
git show v1.0.0
git push origin v1.0.0
Output:
tag v1.0.0
Tagger: Priya Shah <priya@example.com>
Date: Mon Aug 3 09:20:11 2026 -0700
chore(release): v1.0.0 - first stable release
commit a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Author: Priya Shah <priya@example.com>
Date: Mon Aug 3 09:12:44 2026 -0700
feat(auth): add password reset flow
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 178 bytes | 178.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0)
To github.com:priyashah/myapp.git
* [new tag] v1.0.0 -> v1.0.0
This time git show prints a tag header — tagger, date, message — before showing the commit it targets, because -a made Git write a real tag object. git push origin v1.0.0 uploads that tag object and creates the matching ref on GitHub; a plain git push would not have sent it.
Example 3: Listing, branching from a tag, and cleaning up
git tag -l "v1.*"
git switch -c hotfix/v1.0.1 v1.0.0
git tag -d v0.1.0-checkpoint
git push origin --delete v0.1.0-checkpoint
Output:
v1.0.0
Switched to a new branch 'hotfix/v1.0.1'
Deleted tag 'v0.1.0-checkpoint' (was e4f5a6b)
To github.com:priyashah/myapp.git
- [deleted] v0.1.0-checkpoint
git tag -l "v1.*" filters the tag list with a glob pattern. git switch -c hotfix/v1.0.1 v1.0.0 creates a real branch starting from the tagged commit, so any fixes you commit have somewhere to live — this avoids the detached-HEAD trap described below. Finally the old checkpoint tag is removed locally with -d and then on the remote with --delete; deleting locally does not delete it on GitHub, and vice versa — the two are separate operations.
How It Works Step by Step
When you run git tag -a v1.0.0 -m "...", Git: (1) resolves the current commit that HEAD points to; (2) builds a tag-object content buffer containing the target commit’s hash and type, the tag name, tagger name/email/timestamp, and the message; (3) hashes that buffer and writes the resulting compressed object into .git/objects/; (4) writes the new tag object’s hash into .git/refs/tags/v1.0.0.
A lightweight tag skips steps 2 and 3 entirely — git tag v0.1.0-checkpoint writes the commit’s own hash straight into .git/refs/tags/v0.1.0-checkpoint, with no extra object created.
git show <tag> resolves the ref, reads the object it names, and if that object is a tag object, prints the tagger and message before following its pointer down to the commit (and diff) it targets.
git push origin <tag> sends any objects the remote doesn’t already have (the tag object, and the target commit if needed) and asks the remote to create refs/tags/<tag>. By default the remote refuses to let you move an existing tag ref — you’d need -f, which is exactly why moving a published tag is discouraged.
Checking out a tag with git switch --detach v1.0.0 or the older git checkout v1.0.0 points HEAD directly at the resolved commit instead of at a branch ref. That’s detached HEAD: Git will warn you, and any commit you make there has no branch keeping it reachable.
Common Mistakes
1. Forgetting that tags don’t push automatically
git tag -a v2.0.0 -m "chore(release): v2.0.0"
git push origin main
# tag never reaches GitHub — push only sent the commits on main, not the tag
A normal git push only moves branch refs. The fix is to push the tag explicitly:
git push origin v2.0.0
# or, to push every local tag that isn't already on the remote:
git push --tags
2. Using a lightweight tag for a real release
git tag v2.0.0
# no tagger, no date, no message, and it can never be signed later
A bare release version should carry metadata and be verifiable. Use an annotated tag instead:
git tag -a v2.0.0 -m "chore(release): v2.0.0"
3. Moving a tag that’s already been shared
git tag -f v2.0.0 HEAD
git push origin v2.0.0 --force
# anyone who already fetched the old v2.0.0 now silently has a different commit under the same name
Force-moving a public tag breaks reproducibility — two people can end up with different code both labeled v2.0.0. Once a tag is pushed, treat it as immutable and cut a new version instead:
git tag -a v2.0.1 -m "chore(release): v2.0.1"
git push origin v2.0.1
4. Committing while in detached HEAD after checking out a tag
git checkout v1.0.0
git commit -m "fix(payments): patch race condition"
git switch main
# the fix commit is now unreachable from any branch and can be garbage-collected
Git warns you when you enter detached HEAD, but it’s easy to miss. Always create a branch first if you plan to commit from a tag:
git switch -c hotfix/v1.0.1 v1.0.0
git commit -m "fix(payments): patch race condition"
Best Practices
- Use annotated tags (
-a) for every public release; reserve lightweight tags for personal, local bookmarks. - Follow semantic versioning (
vMAJOR.MINOR.PATCH) so tag names sort and communicate meaning consistently. - Sign tags with
git tag -swhen authenticity matters — open-source releases, security-sensitive projects — and document how consumers can verify them withgit tag -v. - Push tags explicitly (
git push origin <tag>orgit push --tags) right after creating them; don’t assume a normal push sends them. - Treat published tags as immutable. If a release was wrong, ship a new patch version rather than force-moving the old tag.
- Pair each release tag with a GitHub Release that includes changelog notes, so humans get more than a bare version string.
- Create a branch immediately if you need to make changes starting from a tagged commit — never commit directly in detached HEAD.
Practice Exercises
- In a scratch repository, make three commits, then create an annotated tag
v1.0.0on the middle commit, not the latest one. Usegit show v1.0.0to confirm it points at the commit you intended. - Push
v1.0.0to a remote. Then create a second, different commit and try to move the same tag name onto it and push again without-f. Read the error Git gives you and think about why it refuses. - Check out
v1.0.0directly (entering detached HEAD), make a commit, and then figure out how to get that commit onto a proper branch so it isn’t lost — you should end up with a new branch containing your change on top of the tagged commit.
Summary
- Tags are permanent pointers to a single commit and, unlike branches, never move automatically.
- Lightweight tags are just a ref pointing at a commit hash; annotated tags are full objects with tagger, date, message, and optional signature.
- Tags don’t push automatically — use
git push <tag>orgit push --tags. - Checking out a tag puts you in detached HEAD; create a branch before committing there.
- Treat published tags as immutable — release a new version instead of moving one.
- GitHub Releases wrap a tag with notes and downloadable assets; pushing a tag alone doesn’t create a Release.
