Annotated vs Lightweight Tags

A Git tag marks a single commit as meaningful — almost always a release point, like v1.0.0. Git actually supports two different kinds of tags that look similar on the surface but behave very differently underneath: lightweight tags, which are barely more than a named pointer to a commit, and annotated tags, which are full objects in Git’s database carrying their own message, author, date, and optional GPG signature. Picking the right one matters: annotated tags are the standard for anything you ship as a release, while lightweight tags are best kept as quick, throwaway bookmarks for yourself.

Overview: How Tags Work

Recall that a branch is a movable pointer stored under refs/heads/<name> — it automatically moves forward to whatever commit you make next while that branch is checked out. A tag lives under refs/tags/<name> instead, and the crucial difference is that nothing ever moves a tag automatically. Once you create it, it stays pointing at exactly the commit you tagged, forever, unless you explicitly force-move or delete it. That permanence is the entire point of tagging: it freezes one specific point in the project’s history so you, and everyone else, can always get back to "the code exactly as it was for v1.0.0" with git switch --detach v1.0.0 or the older git checkout v1.0.0. Because a tag is not a branch, checking one out puts you in a detached HEAD state — HEAD points straight at the commit instead of at a branch, and any new commits you make there won’t belong to any branch unless you create one.

The two kinds of tags differ in what actually gets written to .git. A lightweight tag is just a ref file at refs/tags/<name> holding the 40-character SHA-1 of the commit you tagged (repositories using Git’s newer SHA-256 backend use 64-character hashes, but SHA-1 is still the default almost everywhere). That’s the whole thing — no new object is created in .git/objects. Structurally a lightweight tag is almost identical to a branch, except nothing ever commits on top of it.

An annotated tag, created with git tag -a, causes Git to build a fourth kind of object — alongside blobs, trees, and commits — called a tag object, and store it in .git/objects just like everything else. That tag object records the SHA and type of the thing it points to (a commit), the tag name, the tagger’s name and email, a timestamp, your full tag message, and, if you used -s, a GPG signature. The ref at refs/tags/<name> then points not directly at the commit but at this new tag object, which in turn points at the commit. So the chain of pointers is one link longer for an annotated tag: ref → tag object → commit object → tree → blobs.

Because an annotated tag is a genuine, hashed object with real metadata, tooling that describes releases relies on it. git describe, which reports something like v1.0.0-3-ga1b2c3d ("3 commits past tag v1.0.0"), only considers annotated tags by default. GitHub Releases, changelog generators, and most package managers likewise expect annotated tags for anything that’s actually shipped; lightweight tags are invisible to git describe unless you pass --tags.

Syntax

The general shape is git tag [-a|-s] [-f] -m "message" <tagname> [<commit>]. Here are the common forms in practice:

git tag
git tag v1.0.0
git tag v1.0.0 9fceb02
git tag -a v1.0.0 -m "Release message"
git tag -a v1.0.0 9fceb02 -m "Release message"
git tag -s v1.0.0 -m "Release message"
git tag -d v1.0.0
git push origin v1.0.0
git push origin --tags
git push origin --follow-tags
git show v1.0.0
Flag / command Meaning
-a Create an annotated tag (a real tag object). Opens your editor for a message if -m is omitted.
-m "msg" Supply the tag message inline for -a or -s, skipping the editor.
-s Create a signed annotated tag using your configured GPG key.
-d Delete a local tag.
-f Force-move an existing tag to a new commit. Dangerous once the tag has been shared — see Common Mistakes.
-l, --list List tags, optionally filtered by a glob, e.g. git tag -l "v1.*".
--points-at <commit> List only the tags that point at a given commit.
git push origin <tag> Push one specific tag to the remote.
git push origin --tags Push every local tag — lightweight and annotated — not already on the remote.
git push origin --follow-tags Push commits plus only the annotated tags that point at commits being pushed; skips stray lightweight tags. A good default.

Examples

Example 1: A lightweight tag on a feature branch

git switch -c feature/login-page
git add login.js
git commit -m "feat: add login page script"
git tag checkpoint-1
git show checkpoint-1

Output:

commit 9fceb02c4a1e7d8f2b3c4d5e6f7a8b9c0d1e2f3a (HEAD -> feature/login-page, tag: checkpoint-1)
Author: Jordan Lee 
Date:   Mon Aug 3 10:12:44 2026 -0400

    feat: add login page script

diff --git a/login.js b/login.js
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/login.js
@@ -0,0 +1 @@
+console.log('login page loaded');

checkpoint-1 is a lightweight tag, so git show prints exactly what it would print for the bare commit — there’s no separate tag section, because no tag object exists. The tag is simply another name for that commit’s SHA.

Example 2: An annotated tag for a real release

git switch main
git log --oneline -1
git tag -a v1.0.0 -m "Release 1.0.0: first public release"
git show v1.0.0

Output:

tag v1.0.0
Tagger: Jordan Lee 
Date:   Mon Aug 3 10:20:03 2026 -0400

Release 1.0.0: first public release

commit a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 (HEAD -> main, tag: v1.0.0)
Author: Jordan Lee 
Date:   Sun Aug 2 16:05:11 2026 -0400

    feat: finalize onboarding flow

diff --git a/onboarding.js b/onboarding.js
index 8f14e45..c53a9a3 100644
--- a/onboarding.js
+++ b/onboarding.js
@@ -12,5 +12,5 @@ function startOnboarding(user) {
-  showWelcomeModal(user)
+  showWelcomeModal(user, { skipTutorial: false })
 }

This time git show prints an extra block first — the tag object itself (tag, Tagger, Date, and the message) — before it shows the commit that tag points to. That block only exists because v1.0.0 is an annotated tag with real substance behind it.

Example 3: Tagging a past commit and publishing it

git log --oneline -5
git tag -a v1.0.1 4d8f2ab -m "fix: correct off-by-one error in pagination"
git push origin v1.0.1

Output:

To github.com:jordanlee/shopfront.git
 * [new tag]         v1.0.1 -> v1.0.1

You don’t have to tag HEAD: naming a commit after the tag name (here 4d8f2ab, an already-merged bug-fix commit) tags that commit instead. Notice the final step is a separate git push for the tag — git push never sends tags on its own, because tags aren’t part of any branch’s reachable-and-tracked history the way commits are. You can confirm the tag landed with git ls-remote --tags origin.

How It Works Step by Step

For a lightweight tag, Git does three things: resolve the target (HEAD, or the commit you named), write a single file at .git/refs/tags/<name> containing that commit’s SHA (or an entry in .git/packed-refs if refs have been packed), and stop. No new object is created.

For an annotated tag, Git does one more step in the middle: it resolves the target commit, then builds a tag object containing the target’s SHA and type, the tag name, a tagger line (name, email, timestamp, timezone — the same shape as a commit’s author line), a blank line, and your message (plus a signature if you used -s). It hashes that content to produce a brand-new SHA and writes it into .git/objects, exactly like a commit or blob would be stored. Only then does it write .git/refs/tags/<name> — but this time the ref holds the SHA of the new tag object, not the commit’s SHA directly.

You can see this difference directly with git cat-file:

git cat-file -t checkpoint-1
git cat-file -t v1.0.0
git cat-file -p v1.0.0

Output:

commit
tag
object a1b2c3d4e5f60718293a4b5c6d7e8f9012345678
type commit
tag v1.0.0
tagger Jordan Lee  1785758403 -0400

Release 1.0.0: first public release

The lightweight tag’s type resolves straight to commit — there’s no intermediate object. The annotated tag’s type is tag, and cat-file -p prints the raw tag object: the commit it targets, the tagger, and the message, all of which is content-addressed and hashed just like every other object in the repository.

Common Mistakes

1. Using a lightweight tag for a release.

git tag v2.0.0
git push origin v2.0.0

This works, but the tag carries no message, no tagger identity, and no signature. git describe ignores it by default, and GitHub’s release-notes tooling and most changelog generators work best against annotated tags with real metadata. Fix: use an annotated tag instead.

git tag -a v2.0.0 -m "Release 2.0.0"
git push origin v2.0.0

2. Forgetting to push the tag. Running git tag -a v1.2.0 -m "Release 1.2.0" and then just git push sends your commits but not the tag, since tags aren’t part of a branch’s history. Teammates never see it, and any GitHub Actions workflow triggered on tag push never fires. Fix: push it explicitly with git push origin v1.2.0, or set git config --global push.followTags true so future pushes automatically include reachable annotated tags.

3. Force-moving a tag that’s already been shared.

git tag -f v1.0.0 HEAD
git push --force origin v1.0.0

Tags are supposed to be immutable pointers. Anyone who already fetched v1.0.0 keeps pointing at the old commit — their checkout, their build, their cached artifact silently disagrees with yours. This is the same "don’t rewrite shared history" problem as rebasing a public branch. Fix: cut a new tag instead, and only delete a bad tag (git push origin --delete v1.0.0) if you also tell everyone it’s gone.

git tag -a v1.0.1 -m "Release 1.0.1: corrects the v1.0.0 release commit"
git push origin v1.0.1

4. Tagging the wrong commit because you forgot to switch or pull first. Running git tag -a v1.0.0 -m "..." while still on feature/login-page, or on a local main that’s behind origin/main, ships the wrong snapshot as your release. Always confirm with git status and git log --oneline -1 (or git switch main && git pull) immediately before tagging.

Best Practices

  • Default to annotated tags (git tag -a) for anything you’ll ever call a release; keep lightweight tags for personal, throwaway markers you won’t share.
  • Follow Semantic Versioning for names — vMAJOR.MINOR.PATCH, e.g. v2.1.0, with pre-release suffixes like v2.1.0-rc.1 when needed.
  • Set git config --global push.followTags true so git push also sends reachable annotated tags, rather than reaching for --tags out of habit (which also publishes any stray lightweight tags).
  • Sign release tags with git tag -s when you have GPG set up, so anyone can verify authenticity with git tag -v <name>.
  • Treat pushed tags as immutable. If a release commit was wrong, cut a new patch tag rather than force-moving the old one.
  • Write a real message with -m — even a one-liner is more useful later than an empty lightweight tag with no context.
  • On GitHub, turn a pushed tag into a Release (Releases → Draft a new release) to get release notes, binaries, and a changelog UI layered on top of the same annotated tag.

Practice Exercises

  • In a scratch repository, make one commit, then create a lightweight tag check-a and an annotated tag check-b -m "testing" on that same commit. Run git cat-file -t on both refs and explain, in your own words, why one prints commit and the other prints tag.
  • Find the SHA of the third-most-recent commit with git log --oneline -5, then create an annotated tag v0.9.0 on that older commit (not HEAD). Push it and confirm it reached the remote with git ls-remote --tags origin.
  • Deliberately tag the wrong commit, delete that tag both locally and on the remote, then recreate it correctly on the intended commit. Hint: you’ll need git tag -d <name> locally and git push origin --delete <name> remotely.

Summary

  • A tag is a ref, like a branch, but unlike a branch it never moves once created — it freezes one specific commit.
  • A lightweight tag is just a name pointing straight at a commit’s SHA; no new object is created.
  • An annotated tag creates a real tag object in .git/objects holding the tagger, date, message, and optional signature; its ref points at that object, which points at the commit.
  • git describe, GitHub Releases, and most release tooling assume annotated tags.
  • git push never sends tags automatically — push them by name, with --tags, or with --follow-tags.
  • Treat published tags as immutable; cut a new version rather than force-moving one others may already have fetched.