Modify Variables In Go Binary During Build
You can set the variabel values during build, such as version number and more with the Build command.
by Percy Bolmér, March 18, 2022

Have you ever used hardcoded version numbers, or tried passing configurations to a binary that states the version of the software?
I have, and it works, but it is very error-prone. It is easy to forget to change that configuration etc or maintain the version across multiple merge requests.
I finally found a great solution, we can modify variables inside the binaries during the build time by adding build flags. This allows us to set up the CI/CD to pass the git commit as a version etc, or have different releases specify the runner ID based on the deployment.
Using this approach makes it easier to maintain and control the specific values since we can easily place them inside the CI/CD. We can do this by leveraging the Linker flags.
Linker & Ldflags
The go build tool allows us to pass options to the Linker, which is the component responsible for assembling the binary.
We can pass options to the Linker by using the –ldflags flag to the build tool. There are very many options you can pass, but in this article, we will focus on only one of them. –ldflags accepts a string of configurations, so the input will be enclosed by "".
You can view all the options by running the build with the –help option.
go build --ldflags="--help"
percy@DESKTOP-3DP516F:~/development/buildflags$ go build -ldflags="-help"
# programmingpercy.tech/buildflags
usage: link [options] main.o
-B note
add an ELF NT_GNU_BUILD_ID note when using ELF
-E entry
set entry symbol name
-H type
set header type
-I linker
use linker as ELF dynamic linker
-L directory
add specified directory to library path
-R quantum
set address rounding quantum (default -1)
-T address
set text segment address (default -1)
-V print version and exit
-X definition
add string value definition of the form importpath.name=value
-a no-op (deprecated)
-aslr
enable ASLR for buildmode=c-shared on windows (default true)
-benchmark string
set to 'mem' or 'cpu' to enable phase benchmarking
-benchmarkprofile base
emit phase profiles to base_phase.{cpu,mem}prof
-buildid id
record id as Go toolchain build id
-buildmode mode
set build mode
-c dump call graph
-compressdwarf
compress DWARF if possible (default true)
-cpuprofile file
write cpu profile to file
-d disable dynamic executable
-debugtextsize int
debug text section max size
-debugtramp int
debug trampolines
-dumpdep
dump symbol dependency graph
-extar string
archive program for buildmode=c-archive
-extld linker
use linker when linking in external mode
-extldflags flags
pass flags to external linker
-f ignore version mismatch
-g disable go package data checks
-h halt on error
-importcfg file
read import configuration from file
-installsuffix suffix
set package directory suffix
-k symbol
set field tracking symbol
-libgcc string
compiler support lib for internal linking; use "none" to disable
-linkmode mode
set link mode
-linkshared
link against installed Go shared libraries
-memprofile file
write memory profile to file
-memprofilerate rate
set runtime.MemProfileRate to rate
-msan
enable MSan interface
-n dump symbol table
-o file
write output to file
-pluginpath string
full path name for plugin
-r path
set the ELF dynamic linker search path to dir1:dir2:...
-race
enable race detector
-s disable symbol table
-strictdups int
sanity check duplicate symbol contents during object file reading (1=warn 2=err).
-tmpdir directory
use directory for temporary files
-v print link trace
-w disable DWARF generation
The option we are interested in using is the -X option. This is the definition from the help print.
-X definition add string value definition of the form importpath.name=value
As you can see, we can use the -X flag and target the variable we want to modify by using the import path (the path you use to import a package) and using .variableName to target a certain variable using its name, and then the value we want.
Let us try this out with a simple example project.
-X, Setting The Variable Value
Begin by creating a new project, I use the module name programmingpercy.tech/buildflags. This is important to note because it will later be used as an import path.
mkdir buildflags
go mod init programmingpercy.tech/buildflags
touch main.go
We will fill the main.go with a simple function that prints the version and client name.
package main
import "log"
var (
version = "0.0.1"
runner = "client-1.0.0"
)
func main() {
log.Println("Starting runner", runner, "version", version)
}
You can try viewing the output by building and running the program.
go build -o main &&./main
# Outputs
2022/03/08 19:23:28 Starting runner client-1.0.0 version 0.0.1
Let us try rebuilding the binary, but this time we will use the –ldflags and apply the -X command to set the runner into client-2.0.0. The package we work in is main so that will be the import path.
go build -o main --ldflags="-X 'main.runner=client-2.0.0'"
Try running the program again, and view the output change the runner name.
./main
# Outputs
2022/03/08 19:27:21 Starting runner client-2.0.0 version 0.0.1
You can modify multiple flags, all you need to do is add a new -X flag inside the command.
go build -o main --ldflags="-X 'main.runner=client-2.0.0' -X 'main.version=0.0.2'"
You should try running the binary, no surprise, the version is also changed.
Even better, you can also target sub-packages if you want to. To try this, create a subfolder named version, and add a file named version.go which contains the Version variable instead of main.go.
package version
var (
Version = "0.0.1"
)
Make sure we update the main.go to use the amazing new version package.
I don’t actually recommend you having a version package, but you get the idea
package main
import (
"log"
"programmingpercy.tech/buildflags/version"
)
var (
runner = "client-1.0.0"
)
func main() {
log.Println("Starting runner", runner, "version", version.Version)
}
Now we can try to rebuild the binary, and make sure to update the version number using the -X flag. This time, you can pass the full import path, which is the module name.
go build -o main --ldflags="-X 'programmingpercy.tech/buildflags/version.Version=0.0.2' -X 'main.runner=client-1.0.1'"
Upon running that binary, you should see the values change.
Adding Git Commit Tag As Version
One cool thing is that now when we can modify values during the build, you can automate version handling by using the git commit, etc.
In the folder of the project, we will init a git repository and commit the current files so we can get a commit hash.
git init
git add.
git commit -m "test"
To print a short commit hash, you can use the following git command.
git rev-parse --short HEAD
We can simply inject that into the -X flag, and set the value of that output.
go build -o main --ldflags="-X 'programmingpercy.tech/buildflags/version.Version=$(git rev-parse --short HEAD)' -X 'main.runner=client-1.0.1'"
Running the program with this binary for me now prints the following result
2022/03/08 20:54:35 Starting runner client-1.0.1 version f525917
Conclusion
The example we use is very simple, but you can do many things with this. I have seen versions being injected, feature flags, and more. One easy solution for feature flags that should differ between clients can be passing a true value for the enabled functions. This is probably the easiest feature flag solution to use, no need for frameworks to manage it.
The best place to have these flags is in the CI/CD according to me, to control different releases, versions, and client ids.
I hope you found this interesting, I found it very useful when I learned this.
Are you using ldflags already, if so, for what? And what are the use cases you can think of? I would love to hear from you regarding your thoughts about these flags on any of my social media.
If you enjoyed my writing, please support future articles by buying me an Coffee