Git Tags: An Introduction
Every software project reaches moments worth preserving — a stable release, a significant milestone, the version that shipped to production. Git branches are great for ongoing work, but they move every time you commit. Tags are different: they are permanent, immutable pointers that stay locked to one exact commit forever. Once you tag a commit, that label always means the same thing, no matter how many more commits accumulate on top.
Tags vs Branches — the core distinction
Both tags and branches are references stored in .git/refs/, and both ultimately point to a commit hash. The critical difference is behaviour over time.
How branches move but tags stay fixed
BEFORE a new commit:
A───B───C ◀── main (branch)
│
v1.0.0 (tag)
AFTER git commit:
A───B───C───D ◀── main (branch moved forward)
│
v1.0.0 (tag still points to C — it never moves)A branch ref file is updated by Git every time you make a new commit on that branch. A tag ref file is written once and never touched again. That immutability is what makes tags suitable for version markers: v2.3.0 will always mean exactly the code that was in commit 3f8a2c9, no matter what else happens in the repository.
Two types of tags
Git has two distinct tag flavours. Understanding the difference is important before you start tagging releases.
Lightweight tags
A lightweight tag is the simplest thing Git can do: it creates a file under .git/refs/tags/ that contains a single commit SHA, and nothing else. No author, no date, no message. It is essentially a branch that never moves.
Creating a lightweight tag
git tag v1.0-lw # Inspect what was stored — just a SHA: cat .git/refs/tags/v1.0-lw # 3f8a2c9d1b4e6a0f2c8d...
Annotated tags
An annotated tag creates a full Git tag object in the object database. That object stores the tagger name, email address, timestamp, a freeform message, and optionally a GPG cryptographic signature. The tag object then points to the commit (or tree or blob). Annotated tags are first-class Git objects, just like commits and trees.
Creating an annotated tag
git tag -a v1.0.0 -m "Release 1.0.0 — first stable version" # The tag object itself has its own SHA: cat .git/refs/tags/v1.0.0 # a4b9c3e... (this is the TAG object SHA, not the commit SHA) git cat-file -p v1.0.0 # object 3f8a2c9d1b4e6a0f... ← the commit # type commit # tag v1.0.0 # tagger Alice <alice@example.com> 1700000000 +0000 # # Release 1.0.0 — first stable version
Comparing lightweight, annotated tags, and branches
Feature | Lightweight Tag | Annotated Tag | Branch |
|---|---|---|---|
Stored as | Ref file only (SHA) | Tag object + ref file | Ref file only (SHA) |
Moves when you commit? | No — immutable | No — immutable | Yes — advances with each commit |
Stores author/date? | No | Yes | No (commit has author) |
Stores a message? | No | Yes | No |
Supports GPG signing? | No | Yes | No |
Appears in | Yes (with --tags flag) | Yes (by default) | No |
Pushed automatically? | No — must push explicitly | No — must push explicitly | No — must push explicitly |
Best use case | Local bookmarks, quick markers | Official releases, milestones | Ongoing parallel work |
When to use tags
Version releases —
v1.0.0,v2.3.1,v3.0.0-beta.1. The most common use case by far.Milestones — marking a commit that passed QA, a commit sent to a client for review, etc.
Point-in-time snapshots — "this is the codebase as of the conference demo" or "before the great refactor".
Integration anchors — marking the last commit that was successfully deployed to production.
Hotfix base points — tag the production commit before applying an emergency fix so you can always return to it.
How tags fit into the Git object model
Git stores four types of objects: blobs (file content), trees (directory listings), commits (snapshots with metadata), and tag objects (annotated tags). Every object is content-addressed — identified by the SHA-1 (or SHA-256 in newer Git) hash of its content.
Git object model with an annotated tag
tag object (v1.0.0)
├── type: tag
├── object: → commit SHA
├── tagger: Alice <alice@example.com>
├── date: 2024-03-15 14:30:00
└── message: "Release 1.0.0"
│
▼
commit object
├── tree: → root tree SHA
├── parent: → previous commit SHA
├── author: Alice
└── message: "Final tweaks for release"
│
▼
tree object
├── blob: src/index.ts
├── blob: README.md
└── tree: src/
│
▼
blob objects (actual file contents)A lightweight tag skips the tag object entirely — the ref file just points straight to the commit SHA. An annotated tag adds an indirection layer: the ref points to the tag object, which in turn points to the commit.
Semantic versioning with Git tags
The overwhelming convention in open-source and commercial software is to prefix tag names with a lowercase v, then follow Semantic Versioning (SemVer): MAJOR.MINOR.PATCH.
SemVer tag naming convention
v1.0.0 ← first stable release v1.0.1 ← patch: backward-compatible bug fix v1.1.0 ← minor: backward-compatible new feature v2.0.0 ← major: breaking change Pre-release identifiers: v2.0.0-alpha.1 v2.0.0-beta.3 v2.0.0-rc.1 ← release candidate Build metadata (rarely used in tags): v1.0.0+20240315
MAJOR — increment when you make incompatible API changes.
MINOR — increment when you add functionality in a backward-compatible manner.
PATCH — increment when you make backward-compatible bug fixes.
Pre-release versions (alpha, beta, rc) have lower precedence than the release version.
The
vprefix is a convention, not part of SemVer proper, but it is almost universal in Git repos.
Tagging a release with annotated tag and SemVer
# Make sure you are on the right commit git log --oneline -5 # Create the annotated tag git tag -a v2.1.0 -m "Release 2.1.0 Changes: - Added dark mode support - Fixed login redirect bug - Improved build performance by 30%" # Verify git show v2.1.0 # Push to remote so everyone can see it git push origin v2.1.0
Quick reference
Essential tag commands at a glance
# Create lightweight tag on current commit git tag v1.0-lw # Create annotated tag on current commit git tag -a v1.0.0 -m "Release 1.0.0" # Create annotated tag on a specific commit git tag -a v0.9.0 abc1234 -m "Pre-release snapshot" # List all tags git tag # Show tag details git show v1.0.0 # Push a tag to remote git push origin v1.0.0 # Push all local tags to remote git push origin --tags # Delete a local tag git tag -d v1.0-lw # Delete a remote tag git push origin --delete v1.0-lw