Git tags in depth: Git & GitHub best practices explained
Releasing software requires precision. When a commit goes to production, you need a clear, immutable marker that everyone on the team can reference—not just a branch name that moves around. Git tags give you exactly that, but most teams use them incorrectly or inconsistently. In this article, we'll walk through the three tag types, when to use each one, and the operational patterns that keep your release process clean and trustworthy.
Why tags matter in professional workflows
A tag is a reference to a specific commit. Unlike branches, which move as you add commits, tags are fixed. They're your safety deposit box for releases. On a team, tags become the source of truth: "What code is running in production?" Answer: "The code at v2.4.1." Your CI/CD pipeline, your customers, and your on-call engineers all rely on this clarity.
The problem is that Git offers three different tag types, each with different semantics and use cases. Choose the wrong one, and you'll create confusion, security gaps, or operational headaches down the line.
Lightweight tags—just a pointer—fine for local markers
A lightweight tag is the simplest form. It's just a pointer to a commit. When you create one, Git stores a single file in .git/refs/tags/ containing the commit hash. That's it.
git tag my-local-markerThis creates a tag pointing to the current HEAD. You can also tag a specific commit:
git tag checkpoint-before-refactor abc1234Lightweight tags are ideal when you want a temporary marker within your own workflow. For example, you might tag a commit before starting a major refactor so you can easily return to it if things go wrong. They're also useful for marking intermediate points during development, like "last working state on this feature branch."
The catch: lightweight tags store no metadata. When you push them to GitHub and someone checks the tag history, they see only the tag name and the commit it points to. There's no author information, timestamp, or message. This lack of context makes them unsuitable for releases that other teams depend on.
Real-world scenario: Your team is refactoring the payment module. You tag the current state as before-payment-refactor. Over the next three days, you make breaking changes. If tests fail badly, you can quickly return to that tagged commit. Once the refactor is complete and merged, that tag becomes irrelevant—it was never meant to be permanent or published.
When you push a lightweight tag, it behaves like a branch reference on the remote:
git push origin my-local-markerBut resist the urge to use lightweight tags for published releases. Your teammates, your CI system, and future auditors will want to know who created the release and when.
Annotated tags—stores author, date and message—use for releases
An annotated tag is a full Git object. It has an author, a creation date, an optional message, and a checksum. When you create one, Git writes a tag object to its database, not just a pointer.
git tag -a v2.4.0 -m "Release version 2.4.0: added OAuth2 support"This creates an annotated tag with the specified message. You can verify it with:
git show v2.4.0The output includes:
- The tagger name and email
- The tagging date
- The tag message
- The commit it points to
- The full commit diff
This metadata is invaluable. When a production issue surfaces and you need to know who released version 2.4.0 and what they said it contained, the answer is right there in the tag object. Use annotated tags for every release that leaves your development environment.
Annotated tags are also lighter to work with at scale. If you have dozens of releases, listing them with their messages is clean:
git tag -l -n5This shows each tag with the first five lines of its message, giving you a quick changelog without reading commit logs.
Real-world scenario: Your e-commerce platform releases v3.2.1 to production. Six weeks later, a customer reports that checkout was broken for a brief period and wants to know what changed. You check GitHub releases, click on v3.2.1, and see the annotated tag message: "Fixes race condition in cart serialization; add transaction logging." You also see it was tagged by the lead backend engineer on 2025-01-14. That one tag answered all their questions and proved you have control over what ships.
When you push annotated tags, they carry all their metadata to the remote:
git push origin v2.4.0If you push with --follow-tags or --tags, all your annotated tags go up together, which most CI/CD systems expect for triggering deployments.
Signed tags—git tag -s proves who created the release
A signed tag is an annotated tag with a cryptographic signature. It uses your GPG key to sign the tag object, proving that you (and only you, with your key) created it.
git tag -s v2.4.1 -m "Release version 2.4.1: security patch"Verify the signature:
git tag -v v2.4.1Signed tags are critical in regulated industries or high-security environments. If your team handles payments, healthcare data, or financial transactions, auditors may require proof that releases were created and authorized by specific individuals. A signed tag provides that proof in a way that can't be forged.
On GitHub, signed tags appear with a "Verified" badge next to the release. This signals to users (and your compliance team) that the release came from a trusted source.
Real-world scenario: Your company undergoes a SOC 2 audit. The auditors ask: "Can you prove that production releases are authorized?" Without signed tags, you're explaining your process verbally. With signed tags, you show them a GitHub release with a "Verified" badge, proving that only the release engineer (with the correct key) could have created it. You pass the audit without friction.
Setting up signed tags requires GPG:
gpg --list-secret-keysIf you don't have a key, generate one (or let your IT team provision one). Then configure Git to use it:
git config user.signingkey YOUR_KEY_IDAfter that, git tag -s will sign with your key automatically. You can also configure Git to sign all tags by default:
git config tag.gpgSign trueFor teams, consider requiring signed tags as a branch protection rule on GitHub. This ensures that only developers with the right GPG keys can create releases, and it creates an auditable log of who released what.
Delete and move—avoid moving published tags—create a new version
Tags are supposed to be immutable, but Git allows you to delete and move them. This flexibility is dangerous if you're not careful.
Deleting a tag
To delete a local tag:
git tag -d v2.3.0To delete a remote tag:
git push origin --delete v2.3.0Only delete tags that were never released or published. If a tag points to code that's running in production or that customers have downloaded, deleting it breaks reproducibility. Someone else might have cloned that tag, and now their reference is orphaned.
Moving a tag
You can move a tag to a different commit:
git tag -f v2.4.0 new-commit-hashThis updates the local tag. To push the moved tag remotely:
git push origin -f v2.4.0Never do this for a tag that's already public. Here's why: if someone pulled version 2.4.0 yesterday, it pointed to commit A. Today, after you force-pushed, it points to commit B. Now there's ambiguity about what "v2.4.0" means. Was it A or B? If the two commits have different behavior, which one is the real release?
Real-world scenario (the wrong way): Your team releases v1.5.0 on Monday. Tuesday morning, a critical bug is discovered. The lead engineer tags the bug-fix commit as v1.5.0 as well, overwriting the original tag. Now developers, CI systems, and customers don't know which commit is "the" version 1.5.0. Your npm package, Docker image, and deployment history are all ambiguous. Chaos ensues.
Real-world scenario (the right way): Your team releases v1.5.0 on Monday. Tuesday morning, a critical bug is discovered. Instead of moving the tag, you fix the bug on a new commit, create a new tag v1.5.1, and push that. Now both tags exist and point to specific, immutable commits. Your history is clear, your deployments are reproducible, and everyone knows exactly what code shipped in each version.
Make it a team policy: never force-push a tag that has been published. Always create a new semantic version instead. This keeps your release history clean and trustworthy.
Tagging workflow in practice
Here's a typical release workflow for a professional team:
- Code is merged to
mainafter review and testing. - When ready to release, create an annotated tag with a meaningful message:
bash
git tag -a v2.5.0 -m "Feature: add streaming exports; fix: race in cache invalidation" - Push the tag (and potentially use signed tags if your security posture requires it):
- Your CI/CD pipeline detects the tag push and automatically builds, tests, and deploys to production.
- GitHub creates a release automatically (if you've set it up), or your team manually creates one from the tag.
git push origin v2.5.0This ensures every production release is tagged, dated, and attributed. Later, you can query your release history with confidence.
Common mistakes
Mistake 1: Using lightweight tags for releases. Lightweight tags lack metadata. For any release that leaves your machine, use annotated tags. You'll regret it later if you don't.
Mistake 2: Force-pushing tags. Even if you have permission, resist it. If the tag was ever published, create a new version instead. Your teammates and your systems depend on tags being stable.
Mistake 3: Tagging without a message. Annotated tags without a message defeat their purpose. Always include a brief summary of what the release contains. Future you will thank present you.
Mistake 4: Forgetting to push tags. If you create a tag locally but don't push it, your CI/CD system never sees it, and production never deploys. Make pushing tags part of your release checklist, or use git config push.followTags true to push tags automatically with commits.
Mistake 5: Mixing tag conventions. If one release is tagged as v2.3.0 and another as release-2.4.0, and another as just 2.5.0, your automation breaks. Choose a semantic versioning scheme and enforce it. Most teams use vMAJOR.MINOR.PATCH (e.g., v2.4.1).
Key takeaways
- Lightweight tags are pointers with no metadata—use them only for temporary local markers, never for releases.
- Annotated tags store author, date, and message—use these for every release that goes public or to production.
- Signed tags add GPG signatures for provenance and audit trails—adopt them if security, compliance, or regulated environments demand it.
- Never force-push a published tag. If you need to correct a release, create a new semantic version instead.
- Establish a tagging convention (e.g., semantic versioning with
vprefix) and enforce it across your team. - Integrate tags with CI/CD. Your pipeline should trigger on tag pushes, not just commits, so releases are automated and reproducible.
- Include meaningful messages. Tag messages are your future selves' documentation. Write them as if you'll need to understand the release six months later.






Comments
Loading comments…