JavaRush /Courses /Go SELF /Embedding the version via -ldflags -X and the --version...

Embedding the version via -ldflags -X and the --version command

Go SELF
Level 54, Lesson 1
Available

1. Why embed a version in the binary

When you run your program on your own laptop, a version can feel like an unnecessary luxury: “I already remember what I just built.” But as soon as the binary starts living separately from you (on a teammate’s machine, on a server, in an archive named “final_final2_ok”), the magic ends. At that point, the question “what exactly is running right now?” turns into a very practical pain.

Imagine this situation: a user writes to support, “my todo list command sometimes prints nothing.” You ask, “which version?” The user answers, “uh… I downloaded it yesterday.” Great—an investigation at the “Sherlock and the fog” level has begun.

A version in the binary solves several problems at once.

First, it’s diagnostics. If the program can answer todo --version, you don’t guess what code is inside—it tells you.

Second, it’s release discipline. Even if you don’t do “real releases,” the habit of having a version is like the habit of wearing a seat belt: at first it feels excessive, then it becomes the norm.

Third, it’s honesty with yourself. If you embed Commit and Date, then “it worked on my machine” sounds less convincing, because it becomes obvious that “my machine” had a different commit.

2. Why the version is injected at link time: build, -ldflags, and -X

Put very simply, building a Go program is not one monolithic step of “make me a binary,” but a pipeline. The compiler turns package code into machine-code pieces, and the linker glues it all into one executable file. And it’s exactly at link time that it’s convenient to “mix in” metadata like the version.

It’s important to understand one key idea: if you write in code const Version = "1.2.3", that’s part of the sources. To change the version, you have to change the code. But a version is not program logic—it’s a build label. Logic should live in the sources, and the build label should come from “outside,” at build time.

This looks like this schematically:

flowchart LR
    A[.go sources] --> B[Package compilation]
    B --> C[Linking into a binary]
    D["-ldflags -X ..."] --> C
    C --> E[Final binary]

The -ldflags flag passes arguments to the linker. And the -X option (inside -ldflags) can set the value of a string variable during linking.

The formula is:

-X importpath.name=value

Every part matters here.

importpath is the package import path, not the package name. For example, a package can be named buildinfo, but the import path will be example.com/todo/internal/buildinfo. And the linker needs the import path.

name is the variable name in that package, for example Version.

value is the string value you want to embed (for example, v1.3.0).

To avoid confusion, it helps to keep a small table:

What we inject Where it lives in code How it looks in -X
version
internal/buildinfo.Version
-X example.com/todo/internal/buildinfo.Version=v1.0.0
commit
internal/buildinfo.Commit
-X example.com/todo/internal/buildinfo.Commit=abc1234
date
internal/buildinfo.Date
-X example.com/todo/internal/buildinfo.Date=2026-01-16

Notice the date: here we use a specific calendar date 2026-01-16 as an example of the format. You can choose a different one, but it’s better when the format is fixed.

Why does -X work only with strings? Because it’s a very pragmatic feature: it’s easiest for the linker to replace a string constant in the data section. You can do tricks with numbers, but that quickly turns into a “pain enthusiasts club.” In this lecture we choose the “it just works” path.

3. The internal/buildinfo package: a place for metadata

So the version doesn’t get “smeared” across the project, people usually make a small package that is responsible only for build information. This is convenient for two reasons: the whole project uses a single version format, and the linker variables live in one place where they’re easy to find (and hard to break accidentally).

Suppose our training project is a CLI app todo. Let’s add the internal/buildinfo package. Start with a minimal set: Version, Commit, Date. Note: these are var, not const—we’re intentionally preparing an “injection point” for the linker.

// file: internal/buildinfo/buildinfo.go
package buildinfo

// Default values for local builds.
var Version = "dev"
var Commit  = "none"
var Date    = "unknown"

These values are a safety net. If you built the program without special flags, it still won’t print emptiness—it will honestly say “dev.”

Now let’s make a neat formatted output. It’s important not to overcomplicate this: the version should be understood by people who don’t read your source code (that is, almost everyone).

// file: internal/buildinfo/format.go
package buildinfo

import "fmt"

func String() string {
	return fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, Date)
}

If you like it “shorter,” you can also add Short() (for example, only Version), but often one variant is enough.

4. The --version flag in a CLI: early exit without side effects

The --version command is useful only when it works in any situation and doesn’t require the “right state of the world.” That is, --version must not try to open data files, read configs, connect to the network, or do other things that break easily. It’s literally “print a line and exit.” The dumber, the more reliable (sometimes “dumb” is a compliment).

The most straightforward option is to add the flag in main and do an early return.

// file: cmd/todo/main.go
package main

import (
	"flag"
	"fmt"

	"example.com/todo/internal/buildinfo"
)

func main() {
	showVersion := flag.Bool("version", false, "print version and exit")
	flag.Parse()

	if *showVersion {
		fmt.Println(buildinfo.String()) // dev (commit none, built unknown)
		return
	}

	fmt.Println("todo: app started") // todo: app started
}

Notice a nice effect: even if later you add a subcommand dispatcher, positional argument parsing, and other “grown-up” CLI architecture—the --version branch stays at the very beginning and is always safe.

One more nuance: usually --version is printed to stdout, while errors and usage go to stderr. The version is normal output; people often want to parse it in scripts. If you print the version to stderr, someone will later ask, “why doesn’t my pipeline work?” (spoiler: because stderr).

5. Build examples: local dev and a “release” build with a version

Now the most pleasant part: we don’t change the code for the release version. We change only the build command. The local build stays ordinary:

go build -o todo ./cmd/todo

And ./todo --version will print something like dev (commit none, built unknown).

And now a “release” build with injection:

go build -o todo \
  -ldflags "-X example.com/todo/internal/buildinfo.Version=v0.3.0 \
            -X example.com/todo/internal/buildinfo.Commit=abc1234 \
            -X example.com/todo/internal/buildinfo.Date=2026-01-16" \
  ./cmd/todo

After that:

./todo --version

Should print:

v0.3.0 (commit abc1234, built 2026-01-16)

There’s an important engineering idea here: the same sources can be built as “dev” and as “release” just by changing build parameters. This helps keep the repository clean: the code doesn’t contain constant edits like “bump the version one more time,” which people later forget to revert.

By the way, if you care about reproducible builds, it’s useful to remember: injecting “current time” makes each artifact unique even with identical code. The Go ecosystem has its own culture around verifiable and reproducible builds, and it’s not an accident.

6. -ldflags nuances: import path, quotes, and spaces

With -ldflags, what you run into most often isn’t “hard bugs,” but “I put a quote in the wrong place and now everything fell apart.” That’s normal: the command line is one of humanity’s oldest ways to suffer.

The most common nuance is a wrong import path. The linker doesn’t look for a package by the folder name internal/buildinfo. It needs the full import path, which starts with the module path (what’s written in go.mod). If go.mod says module example.com/todo, then the path will be example.com/todo/internal/buildinfo.

The second classic is quoting -ldflags. Inside -ldflags there are usually spaces, because you list multiple -X .... So you need to pass that value as a single string, i.e., wrap it in quotes.

The third subtlety is special characters in values. For example, if you want to inject something like Version=feature/x, it’s usually fine, but if there are spaces or quotes, the command line will start “helping” you in ways you didn’t ask for. In simple projects, a good rule saves you: version and commit—no spaces.

And one more point: don’t try to make const Version = ... and then wonder why -X doesn’t work. This is a very common mistake, because “a version is a constant.” Semantically—yes; mechanically in the build process—no. A constant lives in the sources, while we want to change the value without changing the sources.

7. Common mistakes with -ldflags -X

Error #1: using const instead of var.
Intuitively, you want to declare const Version = "dev", because the version “shouldn’t change.” But -ldflags -X injects the value at link time into a variable. As a result, you’ll build a binary, but the version will stay “dev,” and then you get a bit of panic and a lot of searching for “why doesn’t it work.”

Error #2: injecting non-string types.
Sometimes you want to do var BuildNumber int and inject it. In practice, that quickly turns into fighting the tools. The most reliable and readable approach is to store everything as strings (Version, Commit, Date), and if you need a number—parse the string inside the program (and even that is rarely needed).

Error #3: mixing up the import path and the package name.
The package can be named buildinfo, but the import path will be example.com/todo/internal/buildinfo. If you write only buildinfo.Version=... in -X, the linker won’t find the target and will either fail with an error or silently not do what you expected (depending on the situation). Always use the full path.

Error #4: printing the version “later,” after application initialization.
If --version triggers reading configs, opening files, or any other actions, the version suddenly stops being reliable diagnostics. A proper version must print even when everything else is broken. That’s why --version is an early exit at the beginning of main.

Error #5: mixing the version and usage into one stream for no reason.
If the version is printed to stderr together with command help, or usage goes to stdout, scripting and output tests will be painful. The version is normal output (stdout), while help and errors are usually stderr. Yes, it’s nitpicky. Yes, this kind of nitpicking is what later makes a CLI pleasant to use.

1
Task
Go SELF, level 54, lesson 1
Locked
Version label
Version label
1
Task
Go SELF, level 54, lesson 1
Locked
Build passport
Build passport
1
Task
Go SELF, level 54, lesson 1
Locked
Version modes
Version modes
1
Task
Go SELF, level 54, lesson 1
Locked
Version command
Version command
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION