Git Tags
A tag is a permanent, human-readable name attached to one specific commit — most often the commit that represents a released version of your software, like v1.0.0. Unlike a branch, a tag does not move as new commits are added; once created it points at that exact snapshot forever (unless you deliberately delete and recreate it). Tags are how teams mark “this is what we shipped,” and GitHub uses them as the foundation for its Releases feature.
Overview / How tags work
Every commit in Git already has a permanent, unique identity: its SHA-1 hash. A tag is nothing more than a friendly alias for one of those hashes, stored as a file (a ref) under .git/refs/tags/. When you run git tag v1.0.0, Git writes a ref named refs/tags/v1.0.0 that contains the current commit’s hash. From that point on, you can use v1.0.0 anywhere Git expects a commit — git show v1.0.0, git diff v1.0.0 main, git switch --detach v1.0.0 — and Git resolves it to that commit.
Git has two kinds of tags, and the difference matters:
- Lightweight tags are just the ref itself — a name pointing directly at a commit object, with no extra data. They’re created with a bare
git tag <name>. - Annotated tags are a full object stored in Git’s object database, just like a blob, tree, or commit. A tag object records the tagger’s name and email, the date, a message, and (optionally) a GPG signature — and it points at the commit it tags. Because it’s a real object with its own SHA-1,
git show v1.1.0on an annotated tag prints the tag’s own metadata before showing the commit it references. Annotated tags are created withgit tag -a.
This means Git’s object model actually has four object types, not three: blobs (file contents), trees (directory snapshots), commits (a tree plus metadata and parent links), and tag objects (metadata plus a pointer to another object, usually a commit). A branch and a lightweight tag look almost identical on disk — both are just a ref file holding a SHA — but Git treats them very differently: a branch ref is expected to move forward as you commit, while a tag ref is expected to stay put.
Because tags live under refs/tags/ rather than refs/heads/ (where branches live), HEAD can never point at a tag directly. If you check out a tag, Git puts you in detached HEAD state — you’re looking at that commit, but you are not “on” any branch, so new commits you make there won’t belong to any branch unless you create one.
Syntax
# git tag [-a] [-s] [-m "<message>"] <tagname> [<commit>]
# git tag -l [<pattern>]
# git tag -d <tagname>
# git push <remote> <tagname>
# git push <remote> --tags
| Flag | Meaning |
|---|---|
-a |
Create an annotated tag (full object with tagger, date, message). |
-m "<message>" |
Provide the annotation message inline; implies an annotated tag. Without -m, Git opens your editor. |
-s |
Create a GPG-signed annotated tag. |
-l ["pattern"] |
List existing tags, optionally filtered by a glob pattern. |
-d <tagname> |
Delete a tag locally. |
-n<num> |
When listing, show <num> lines of each annotated tag’s message. |
<commit> |
Optional commit to tag; defaults to the current HEAD if omitted. |
Examples
Example 1: A lightweight tag on the current commit
git tag v1.0.0
git tag
Output:
v1.0.0
The first command creates a lightweight tag named v1.0.0 that points at whatever commit HEAD currently refers to. The second command, git tag with no arguments, is shorthand for git tag -l and lists every tag in the repository — here, just the one we made.
Example 2: An annotated tag with a release message
git tag -a v1.1.0 -m "Release 1.1.0: add user authentication"
git show v1.1.0
Output:
tag v1.1.0
Tagger: Priya Shah <priya@example.com>
Date: Mon Aug 3 10:15:02 2026 -0400
Release 1.1.0: add user authentication
commit 9fceb02d4a1e9f3b7c5a6d21f0e88a4b2c7d5e91
Author: Priya Shah <priya@example.com>
Date: Sun Aug 2 16:40:11 2026 -0400
feat: add JWT-based login endpoint
git tag -a creates a real tag object in the object database, separate from the commit it points to. git show proves it: the output first prints the tag object’s own metadata (tagger, date, message) and only then shows the commit underneath. This is exactly the kind of provenance a lightweight tag cannot store.
Example 3: Tagging a past commit, then publishing it to GitHub
git log --oneline -3
git tag -a v1.0.1 9fceb02 -m "Hotfix 1.0.1: fix crash on empty login form"
git push origin v1.0.1
Output:
a3f8c21 (HEAD -> main) docs: update README with setup steps
1d9b04e chore: bump dependency versions
9fceb02 feat: add JWT-based login endpoint
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:yourname/webapp.git
* [new tag] v1.0.1 -> v1.0.1
You don’t have to tag the latest commit — passing a commit hash (or any other commit-ish, like a branch name) after the tag name tags that commit instead of HEAD. Notice also that git push origin v1.0.1 was required as a separate step: an ordinary git push never uploads tags automatically. To push every local tag at once, use git push origin --tags instead.
Once a tag exists on GitHub, turn it into a Release so it appears on the repository’s Releases page with formatted notes and downloadable assets:
gh release create v1.0.1 --title "v1.0.1" --notes "Fixes a crash when submitting an empty login form."
Output:
https://github.com/yourname/webapp/releases/tag/v1.0.1
The gh CLI (GitHub’s official command-line tool) does the same thing available from the web UI under Releases → Draft a new release: it takes an existing tag, attaches your release notes, and publishes a Release page with a permanent URL. A GitHub Release is really just extra metadata — notes, optional binary assets, a “latest” flag — layered on top of the tag; the tag itself still determines exactly which commit and file snapshot the release corresponds to.
How it works step by step
When you run git tag -a v1.1.0 -m "...":
- Git builds a new tag object in memory containing: the object it points to (usually the current commit’s SHA-1), the object type (
commit), the tag name, the tagger’s name/email/date, and your message. - Git hashes and writes that object into
.git/objects/, exactly like it would a blob or commit — content-addressed storage, so the tag object’s own SHA-1 is derived from its content. - Git writes a ref file at
.git/refs/tags/v1.1.0containing the new tag object’s SHA-1 (not the commit’s SHA-1 directly — that indirection is what makes it an annotated tag).
For a lightweight tag, steps 1 and 2 are skipped entirely: .git/refs/tags/v1.0.0 is written with the commit’s SHA-1 directly, with no intermediate object.
Because tags are refs, not commits, git push treats them as an opt-in category separate from branches — this is deliberate, since publishing a tag is often a one-way “this was released” announcement you shouldn’t trigger by accident on every push.
Common Mistakes
Mistake 1: Assuming a normal push also pushes new tags.
git tag -a v2.0.0 -m "Release 2.0.0"
git push origin main
This pushes your commits but not v2.0.0 — teammates and CI/CD systems that build from tags won’t see it. Push it explicitly:
git push origin v2.0.0
Mistake 2: Thinking a local tag delete also removes it from GitHub.
git tag -d v1.0.0
git tag -d only removes the ref from your local .git/refs/tags/ — the tag still exists on origin, and anyone who fetches will get it back. To actually remove it from the remote, delete it there too:
git push origin --delete v1.0.0
Mistake 3: Moving a tag that’s already been published. Retagging v1.0.0 to point at a newer commit after others have already fetched or built against it silently changes what that version means for everyone downstream — package managers and deployment pipelines pinned to v1.0.0 now get different code than before.
# Don't do this once v1.0.0 has already been pushed and used elsewhere:
# git tag -f v1.0.0 <newer-commit-sha>
# git push origin v1.0.0 --force
Treat published version tags as immutable. If you need to fix something, cut a new tag instead — v1.0.1, as in Example 3 — rather than force-moving the old one.
Best Practices
- Use Semantic Versioning (
vMAJOR.MINOR.PATCH, e.g.v2.1.4) so anyone can tell from the tag name whether a change is breaking, additive, or a fix. - Prefer annotated tags (
git tag -a) for anything you’ll ever publish or release — they carry an author, date, and message, andgit describereports them by default. Reserve lightweight tags for quick, throwaway local markers. - Write a real message with
-m(or your editor) summarizing what changed, the same way you’d write a good commit message. - Push tags explicitly and intentionally — either
git push origin <tagname>for one, orgit push origin --tagswhen you mean to publish all of them. - Pair every meaningful tag with a GitHub Release (Releases → Draft a new release in the repo UI, or
gh release create) so the tag gets human-readable release notes and downloadable build artifacts attached. - For open-source or security-sensitive projects, sign release tags with
git tag -sso consumers can verify the tag really came from you. - Treat tags as immutable once pushed — cut a new tag rather than force-moving an old one.
Practice Exercises
- In a scratch repository, make three commits on
main. Create a lightweight tag calledcheckpoint-1on the first commit and an annotated tag calledv0.1.0on the third. Usegit showon each and note the difference in the output. - Create an annotated tag
v0.2.0, push it to a remote, then delete it both locally and on the remote. Confirm withgit ls-remote --tags originthat it’s really gone. - Tag your current
HEADasv1.0.0, then check it out directly withgit switch --detach v1.0.0. Rungit statusand explain in your own words why Git calls this “detached HEAD,” and what would happen if you committed here without creating a new branch first.
Summary
- A tag is a fixed, named pointer to one commit; unlike a branch, it does not move as you add commits.
- Lightweight tags are just a ref pointing at a commit; annotated tags (
git tag -a) are full objects storing a tagger, date, message, and optional signature. git pushnever uploads tags by default — push them explicitly withgit push origin <tag>orgit push origin --tags.- Deleting a tag locally (
git tag -d) does not delete it on the remote; usegit push origin --delete <tag>for that. - Checking out a tag puts you in detached HEAD — you’re viewing that snapshot, not on a branch.
- GitHub Releases are built on top of tags, giving you a place to attach notes and binaries to a specific version.
