Last modified: September 13, 2025
This article is written in: 🇺🇸
Tags mark exact commits. They’re perfect for releases, rollbacks, changelogs, and CI/CD triggers. Unlike branches, tags don’t move—ever—so you can always point to the exact build you shipped.
Think of a branch as a bookmark you keep moving, and a tag as a sticky note glued to a page.
A -- B -- C -- D -- E (main)
^
|
v1.2.0 ← frozen pointer to commit C
Tags are markers you can place on commits — often used for releases, milestones, or just to remember an important point in your project’s history. Git supports three main flavors: lightweight, annotated, and signed.
A lightweight tag is basically a named pointer to a commit. It doesn’t store extra info (no tagger, no message).
Tag the latest commit (HEAD):
git tag v2.0.0
Tag a specific commit (using its short hash):
git tag v2.0.0 b4d373a
Check what the tag points to:
git show --no-patch v2.0.0
Example output:
tag v2.0.0
Tagger: (none - lightweight tag)
commit b4d373k8990g2...
Author: Jane Dev <jane@example.com>
Date: Mon Sep 1 12:34:56 2025 +0000
Merge feature: new billing flows
Notice how there’s no tagger or message section — Git just jumps straight to the commit info. Lightweight tags are fine for personal bookmarks, but not recommended for formal releases.
An annotated tag creates a real tag object that stores metadata: who created it, when, and an optional message.
At HEAD:
git tag -a v2.0.0 -m "v2.0.0: usage-based billing, new invoices"
At a specific commit:
git tag -a v2.0.0 -m "v2.0.0: ..." b4d373k
Inspect it:
git show v2.0.0
Sample output:
tag v2.0.0
Tagger: Jane Dev <jane@example.com>
Date: Mon Sep 1 12:45:00 2025 +0000
v2.0.0: usage-based billing, new invoices
commit b4d373k8990g2...
Author: Jane Dev <jane@example.com>
Date: Mon Sep 1 12:34:56 2025 +0000
Merge feature: new billing flows
Here you see two parts:
This extra metadata is super handy for tracking releases and automation.
If you have GPG or SSH signing set up, you can cryptographically sign tags. This proves the tag really came from you.
git tag -s v2.0.0 -m "Release v2.0.0"
git tag -v v2.0.0 # verify the signature
-s
creates a signed tag.-v
verifies the signature and tells you if it matches a known key.Signed tags are especially useful in open-source or enterprise projects where you need strong guarantees about who made a release.
👉 One last note: tags don’t get pushed automatically. To share them, run:
git push origin v2.0.0 # push a single tag
git push origin --tags # push all tags
Tags pile up quickly in a project, so Git gives you ways to list, filter, and sort them.
git tag
This just prints every tag name in your repo:
v1.0.0
v1.1.0
v2.0.0
v2.1.0-rc.1
git tag -l "v2.*"
Only tags starting with v2.
show up:
v2.0.0
v2.1.0-rc.1
You can also match other patterns, like release candidates:
git tag -l "*-rc.*"
Annotated tags can have messages, and -n
lets you preview them:
git tag -n
Output:
v1.0.0 First stable release
v2.0.0 Usage-based billing, new invoices
You can also limit or filter:
git tag -n9 -l "v2.*"
This shows the first 9 lines of each tag message, but only for tags starting with v2.
. Great when you want quick release notes.
Normal sorting treats v1.10.0
as before v1.9.9
(because text sorting sees 1
vs 9
). Version-aware sort fixes that:
git tag --sort=-v:refname
Now the latest version appears first:
v2.1.0-rc.1
v2.0.0
v1.10.0
v1.9.9
git describe --tags
Example output:
v2.0.0-5-gdeadbeef
That means:
v2.0.0
deadbeef
This is super useful in CI/CD pipelines for naming builds like 2.0.0+5
.
Tags are the backbone of releases, hotfixes, and rollbacks. Here are the most common ways you’ll use them in practice.
The usual flow:
main
.Example:
A -- B -- C -- D -- E -- F (main)
^ ^
v1.1.0 v1.2.0 ← each tag starts a release build
Commands:
git tag -a v1.2.0 -m "v1.2.0: new billing"
git push origin v1.2.0
What happens next: most CI platforms (GitHub Actions, GitLab CI, CircleCI, etc.) can listen for “tag push” events. A tag like v1.2.0
often triggers a pipeline that builds artifacts, signs them, and publishes a release.
Say a bug slipped into production. You want to patch exactly what was shipped.
Instead of checking out the tag directly (which gives you a detached HEAD), make a branch starting from it:
git switch -c hotfix/invoice-rounding v1.2.0
Now you can fix, commit, and PR back into main
or your release branch.
Interpretation: you’re working from the exact code you shipped last time — no surprise commits from main
sneaking in.
Sometimes the latest release misbehaves. Tags let you roll back safely.
See what commit a tag points to:
git show --no-patch v1.2.0
Deploy tooling (Heroku, Kubernetes, GitHub Actions, etc.) often accepts a tag name directly:
deploy --ref v1.2.0
That redeploys the exact same bytes as last time. No guessing.
Need release notes or just want to see what changed? Compare two tags:
git log --oneline v1.1.0..v1.2.0
Shows commit subjects:
abc123 Fix invoice rounding error
def456 Add usage-based billing
Or see file-level stats:
git diff --stat v1.1.0..v1.2.0
Output:
billing.js | 20 +++++++++++---------
invoice.test | 12 ++++++++--
2 files changed, 22 insertions(+), 10 deletions(-)
Great for writing changelogs.
To browse code at a tag:
git switch --detach v1.2.0
# or: git checkout v1.2.0
Now you’re in a detached HEAD state — safe for read-only browsing, not great for new commits.
ASCII reminder:
(main) ──●──●──●──●
↑
v1.2.0 (HEAD is detached here)
If you do want to commit, make a branch first:
git switch -c fix-from-v1.2.0 v1.2.0
That way your work doesn’t get “lost in limbo.”
git tag -f v2.0.0 NEWCOMMIT
If it’s already on the remote, you must replace it there too:
git push origin -f v2.0.0 # force updates tag ref on remote
⚠️ Caution:
v2.0.1
) and deprecate the old.
git tag new-name old-name
git tag -d old-name
git push origin new-name
git push origin :refs/tags/old-name # or: git push origin --delete old-name
Local:
git tag -d v2.0.0
Remote:
git push origin --delete v2.0.0
# or:
git push origin :refs/tags/v2.0.0
Interpretation:
See where tags sit in history:
git log --graph --decorate --oneline --all
You’ll see tags in-line:
* 3fa1c2d (tag: v1.2.0, origin/main, main) Merge ...
* 0b7a3ff Add invoices API
* 9e0a1a1 (tag: v1.1.0) Release v1.1.0
Find tags with notes:
git tag -n | grep "billing"
Show only tags on the current branch:
git tag --merged
Pick a naming style that both humans and automation tools will love.
vMAJOR.MINOR.PATCH
makes it clear what kind of change occurred, while skipping this practice leaves version meaning ambiguous; for example, v2.0.1
signals a patch to version 2.0.-rc.1
or -beta.2
lets pipelines handle them differently, while without them it is harder to separate testing builds from production releases; for example, v2.1.0-rc.1
can be built but not shipped live.release-2025-09-12
gives simple ordering for frequent releases, while omitting structure can make it unclear when versions were cut; for example, daily builds can be tagged by date.v*
tags prevents accidental production deployments.Why bother with all this? Because automation thrives on predictable names — and so do humans reading the history months later.
“I created the tag but CI didn’t run.”
You probably forgot to push the tag. Remember:
git push origin v2.0.0
Also double-check your CI config — many pipelines only listen for certain patterns (v*
, release-*
, etc.).
“I pushed but my teammate still can’t see it.”
They need to fetch tags explicitly:
git fetch --tags
By default, git fetch
doesn’t grab new tags unless they’re tied to commits being fetched.
“git show
doesn’t display my tag message.”
That means you made a lightweight tag. Those don’t have messages. Use an annotated tag next time:
git tag -a v2.0.0 -m "Release v2.0.0"
“I checked out a tag and my commits disappeared.”
That’s the classic detached HEAD trap. Tags don’t move, so commits made in this state get “orphaned.” The fix: create a branch at the tag before committing:
git switch -c fix-from-v2.0.0 v2.0.0
“We moved a release tag and broke everything.”
Never rewrite or move public tags. Once something like v2.0.0
is out in the world, it’s immutable. If you need to patch, bump the version (v2.0.1
) instead.
Create an annotated release tag at HEAD and push:
git tag -a v2.3.0 -m "v2.3.0: SSO + revamped audit logs"
git push origin v2.3.0
Push only release tags automatically when pushing commits:
git push --follow-tags
Generate a changelog between releases:
git log v2.2.0..v2.3.0 --pretty=format:"- %s (%h)"
Diff code between releases:
git diff --stat v2.2.0..v2.3.0
Find latest version tag (nearest reachable):
git describe --tags --abbrev=0
Clean up a mistaken remote tag:
git tag -d v2.3.0
git push origin --delete v2.3.0
Retag correctly (same name, new commit), and push forcefully:
git tag -a -f v2.3.0 -m "v2.3.0: hotfix included" NEWCOMMIT
git push -f origin v2.3.0
(Again: avoid for public releases; prefer v2.3.1
.)