One of Go's advantages is being able to produce statically-linked binaries [1]. This doesn't mean that Go always produces such binaries by default, however; in some scenarios it requires extra work to make this happen. Specifics here are OS-dependent; here we focus on Unix systems.

Basics - hello world

This post goes over a series of experiments: we take simple programs and use go build to produce binaries on a Linux machine. We then examine whether the produced binary is statically or dynamically linked. The first example is a simple "hello, world":

package main

import "fmt"

func main() {
  fmt.Println("hello world")
}

After building it with go build, we get a binary. There are a few ways on Linux to determine whether a binary is statically or dynamically linked. One is the file tool:

$ file ./helloworld
helloworld: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=Flm7stIXKLPfvBhTgXmR/PPwdjFUEkc9NCSPRC7io/PofU_qoulSqJ0Ktvgx5g/eQXbAL15zCEIXOBSPZgY, with debug_info, not stripped

You can see it says "statically linked". Another way is to use ldd, which prints the shared object dependencies of a given binary:

$ ldd ./helloworld
  not a dynamic executable

Alternatively, we can also use the ubiquitous nm tool, asking it to list the undefined symbols in a binary (these are symbols the binary expects the dynamic linker to provide at run-time from shared objects):

$ nm -u ./helloworld
<empty output>

All of these tell us that a simple helloworld is a statically-linked binary. Throughout the post I'll mostly be using ldd (out of habit), but you can use any approach you like.

DNS and user groups

There are two pieces of functionality the Go standard library defers to the system's libc on Unix machines, when some conditions are met. When cgo is enabled (as it often - but not always - is on Unix machines), Go will call the C library for DNS lookups in the net package and for user and group ID lookups in the os/user package.

Let's observe this with an experiment:

package main

import (
  "fmt"
  "net"
)

func main() {
  fmt.Println(net.LookupHost("go.dev"))
}

If we build this program, we notice it's dynamically linked, expecting to load a libc shared object at run-time:

$ go build lookuphost.go
$ ldd ./lookuphost
  linux-vdso.so.1 (0x00007b50cb22a000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007b50cae00000)
  /lib64/ld-linux-x86-64.so.2 (0x00007b50cb22c000)

This is explained in the net package documentation in some detail. The Go standard library does have a pure Go implementation of this functionality (although it may lack some advanced features). We can ask the toolchain to use it in a couple of ways. First, we can set the netgo build tag:

$ go build -tags netgo lookuphost.go
$ ldd ./lookuphost
  not a dynamic executable

Second, we can disable cgo entirely with the CGO_ENABLED env var. This env var is usually on by default on Unix systems:

$ go env CGO_ENABLED
1

If we disable it explicitly for our build, we'll get a static binary again:

$ CGO_ENABLED=0 go build lookuphost.go
$ ldd ./lookuphost
  not a dynamic executable

Similarly, some of the functionality of the os/user package uses libc by default. Here's an example:

package main

import (
  "encoding/json"
  "log"
  "os"
  "os/user"
)

func main() {
  user, err := user.Lookup("bob")
  if err != nil {
    log.Fatal(err)
  }

  je := json.NewEncoder(os.Stdout)
  je.Encode(user)
}

This produces a dynamically-linked binary:

$ go build userlookup.go
$ ldd ./userlookup
  linux-vdso.so.1 (0x0000708301084000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000708300e00000)
  /lib64/ld-linux-x86-64.so.2 (0x0000708301086000)

As with net, we can ask the Go toolchain to use the pure Go implementation of this user lookup functionality. The build tag for this is osusergo:

$ go build -tags osusergo userlookup.go
$ ldd ./userlookup
  not a dynamic executable

Or, we can disable cgo:

$ CGO_ENABLED=0 go build userlookup.go
$ ldd ./userlookup
  not a dynamic executable

Linking C into our go binary

We've seen that the standard library has some functionality that may require dynamic linking by default, but this is relatively easy to override. What happens when we actually have C code as part of our Go program, though?

Go supports C extensions and FFI using cgo. For example:

package main

// #include <stdio.h>
// void helloworld() {
//   printf("hello, world from C\n");
// }
import "C"

func main() {
  C.helloworld()
}

A program built from this source will be dynamically linked, due to cgo:

$ go build cstdio.go
$ ldd ./cstdio
  linux-vdso.so.1 (0x00007bc6d68e3000)
  libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007bc6d6600000)
  /lib64/ld-linux-x86-64.so.2 (0x00007bc6d68e5000)

In our C code, printf is a call to libc; even if we don't explicitly call into the C runtime in our C code, cgo may do it in the scaffolding code it generates.

Note that cgo may be involved even if your project has no C code of its own; several dependencies may bring in cgo. Some popular packages - like the go-sqlite3 driver - depend on cgo, and importing them will impose a cgo requirement on a program.

Obviously, building with CGO_ENABLED=0 is no longer an option. So what's the recourse?

Linking a libc statically

To recap, once we have C code as part of our Go binary, it's going to be dynamically linked on Unix, because:

  1. The C code calls into libc (the C runtime)
  2. The libc typically used on Unix systems is glibc
  3. The recommended way to link to glibc is dynamically (for various technical and license-related reasons that are outside the scope of this post)
  4. Therefore, go build produces dynamically-linked Go binaries

To change this flow of events, we can interpose at step (2) - use a different libc implementation, one that's statically linked. Luckily, such an implementation exists and is well used and tested - musl.

To follow along, start by installing musl. The standard instructions using ./configure --prefix=<MUSLDIR> and make / make install work well. We'll use $MUSLDIR to refer to the directory where musl is installed. musl comes with a gcc wrapper that makes it easy to pass all the right flags. To re-build our cstdio example using musl, run:

$ CC=$MUSLDIR/bin/musl-gcc go build --ldflags '-linkmode external -extldflags "-static"' cstdio.go
$ ldd ./cstdio
  not a dynamic executable

The CC env var tells go build which C compiler to use for cgo; the linker flags instruct it to use an external linker for the final build (read this for the gory details) and then to perform a static link.

This approach works for more complex use cases as well! I won't paste the code here, but the sample repository accompanying this post has a file called use-sqlite.go; it uses the go-sqlite3 package. Try go build-ing it normally and observe the dynamically linked binary produced; next, try to build it with the flags shown above to use musl, and observe that the produced binary will be statically linked.

Another curious tidbit is that we now have another way to build a statically-linked lookuphost program - by linking it with musl:

$ CC=$MUSLDIR/bin/musl-gcc go build --ldflags '-linkmode external -extldflags "-static"' lookuphost.go
$ ldd ./lookuphost
  not a dynamic executable

Since we didn't provide -tags netgo and didn't disable cgo, the Go toolchain uses calls into libc to implement DNS lookup; however, since these calls end up in the statically-linked musl, the final binary is statically linked!

Using Zig as our C compiler

Another alternative emerged recently to achieve what we want: using the Zig toolchain. Zig is a new systems programming language, which uses a bundled toolchain approach similar to Go. Its toolchain bundles together a Zig compiler, C/C++ compiler, linker and libc for static linking. Therefore, Zig can actually be used to link Go binaries statically with C code!

Instead of installing musl, we could instead install Zig and use its x86_64-linux-musl target (adjust the architecture if needed). This is done by pointing to the zig binary as our CC= env var; assuming Zig is installed in $ZIGDIR:

$ CC="$ZIGDIR/zig cc -target x86_64-linux-musl" go build cstdio.go
$ CC="$ZIGDIR/zig cc -target x86_64-linux-musl" go build use-sqlite.go

These will produce statically-linked Go binaries; the zig driver takes care of setting the right linker flags automatically, so the command-line ends up being slightly simpler than invoking musl-gcc. Another advantage of Zig here is that enables cross-compilation of Go programs that include C code [2].

I did find some issues with this approach, however; for example, attempting to link the lookuphost.go sample fails with a slew of linker errors.

Summary

Making sure Go produces a statically-linked binary on Linux takes a little bit of effort, but works well overall.

There's a long standing accepted proposal about adding a -static flag to go build that would take care of setting up all the flags required for a static build. AFAICT, the proposal is just waiting for someone with enough grit and dedication to implement and test it in all the interesting scenarios.

Code

The code for all the experiments described in this post is available on GitHub.


[1]A statically-linked binary doesn't have run-time dependencies on other libraries (typically in the form of shared objects), not even the C runtime library (libc). I wrote much more about this topic in the past.
[2]Go is well-known for its cross-compilation capabilities, but it depends on the C toolchain to compile C code. Therefore, when cgo is involved, cross-compilation is challenging. Zig can help with this because its toolchain supports cross compilation for Zig and C! It does so by bundling LLVM with a bunch of targets linked in.