Every CLI eventually grows a --version flag. Users need it to file useful bug reports, scripts need it to gate behavior, and you need it when an issue lands and you want to know what binary the reporter is actually running. Go gives you a few ways to wire this up — none of them long, but each with different ergonomics. Here are the three I reach for.
Method 1: -ldflags -X (the classic)
The most portable approach: declare a package variable, then overwrite it from the linker at build time.
package main
import (
"flag"
"fmt"
)
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("myapp %s (commit %s, built %s)\n", Version, Commit, Date)
return
}
// ... rest of the program
}Inject the values at build time:
go build -ldflags "\
-X 'main.Version=v1.4.0' \
-X 'main.Commit=$(git rev-parse --short HEAD)' \
-X 'main.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" \
-o myapp .A few caveats worth knowing:
- The variables must be strings at the package level. The linker can’t rewrite typed constants, slices, or struct fields.
- Use
-X 'pkg.Var=value'(single quotes) when the value can contain spaces. - For internal packages, use the full import path:
-X 'github.com/me/myapp/internal/build.Version=...'.
This method is the lingua franca of Go release tooling. GoReleaser, ko, and most CI templates rely on it. The downside: someone running go install github.com/me/myapp@latest gets Version = "dev" because there’s no build script in the loop.
Method 2: runtime/debug.ReadBuildInfo (zero-config)
Since Go 1.18, the toolchain embeds module and VCS metadata into every binary. You can read it at runtime — no -ldflags, no Makefile.
package main
import (
"flag"
"fmt"
"runtime/debug"
)
func version() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
rev, dirty := "", ""
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) >= 7 {
rev = s.Value[:7]
}
case "vcs.modified":
if s.Value == "true" {
dirty = "-dirty"
}
}
}
v := info.Main.Version
if rev != "" {
return fmt.Sprintf("%s (%s%s)", v, rev, dirty)
}
return v
}
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Println("myapp", version())
return
}
}What you get for free:
info.Main.Version— the module version when installed viago install pkg@vX.Y.Z, or(devel)when built from source.vcs.revision,vcs.time,vcs.modified— populated automatically when the working tree is a Git checkout.info.GoVersion— the toolchain that built the binary.
The main quirk: when you build from inside a checked-out repo with go build, info.Main.Version is (devel) rather than a tag. The VCS revision is still there, but if you want a clean v1.4.0 string, you either tag-and-go install, or fall back to ldflags.
This is my default for new CLIs. No build flags to remember, plays nicely with go install, and the metadata is always honest about the source state.
Method 3: github.com/carlmjohnson/versioninfo (two lines)
If you don’t want to write the boilerplate from Method 2, carlmjohnson/versioninfo wraps debug.ReadBuildInfo and exposes a ready-made flag.
package main
import (
"flag"
"github.com/carlmjohnson/versioninfo"
)
func main() {
versioninfo.AddFlag(nil)
flag.Parse()
}That’s it. myapp -version prints something like:
myapp version devel
revision: a1b2c3d4
revision dirty: false
build time: 2026-05-07T10:14:22ZIt also exposes versioninfo.Short() and versioninfo.Version if you want to format the output yourself or print it elsewhere (e.g. in a log header on startup). Tiny dependency, single file, no transitive imports.
Which one to pick
| Scenario | Pick |
|---|---|
Library used as go install target | Method 2 or 3 — they Just Work |
| Released through GoReleaser / Docker / CI | Method 1 — you already have the build pipeline |
| Quick internal CLI, don’t want to think about it | Method 3 |
Need a clean v1.4.0 string regardless of how it was built | Method 1 (combine with Method 2 as a fallback) |
In practice I combine Methods 1 and 2: ldflags injects a clean tag during release builds, and debug.ReadBuildInfo provides the fallback when someone runs go install or builds locally. The --version output stays useful no matter how the binary got there.
A note on goreleaser
If you use GoReleaser, the default .goreleaser.yaml already wires Method 1 for you:
builds:
- ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.Commit={{.Commit}}
- -X main.Date={{.Date}}Match those variable names in your main package and you’re done — no extra plumbing.
A --version flag is five lines of code and a real difference in supportability. Pick the method that fits how your binary ships, and don’t ship without one.