---
title: 'Linker Options'
source: 'https://academia.sh/en/courses/go-production/linker-options'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures how a string value never written in the source gets embedded into the binary in three different forms from the same source, across three separate calls'
    - 'Shows that the linker never validates the embedded value, silently accepting an unformatted input as well'
    - "Distinguishes, by running it, that a linker option removing the symbol table does not change the program's output"
---

# Linker Options

It is measured that a string value written nowhere in the source gets embedded into the binary in three separate forms across three separate build calls; the source text stays character-for-character identical across all three. The limiting measurement shows, by running it, that the embedded value is never validated by the linker — that an unformatted string is accepted just as silently — and that a separate linker option removing the symbol table does not change the program's output.

In the previous lesson, the chosen target left no trace in the binary, determined only by the
build process's own tools. This lesson carries the same pattern forward, this time for a single
string value instead of a directory or an architecture: a value written nowhere in the source
enters the binary again at build time, again from outside the source. This time the party asking
the question is not the compiler but the **linker** — the tool that runs in the final step of
compiling the source, joining separately compiled parts into a single binary.

## A Value Not in the Source

A package-level variable can be declared in the source with a default value, and this value can
be changed at build time with a **linker flag**. The measurement below runs a single source — a
single `version` variable and a `main` function printing it — through three separate build
calls. The source text stays character-for-character identical across all three; the only thing
that changes is a flag supplied to the command.

The flag itself carries an equality expression: the variable's **full package path**, a dot, its
name, then the requested value. As the linker joins separately compiled package parts into a
single binary, it already keeps a table of every name each package exports — this table normally
exists to bind calls between packages together. The `-X` flag uses this same table for another
purpose: it looks up the name in the table and puts the given string in place of the value the
source declared first. This is why the target name must be written **fully** and **correctly** —
if the package path is wrong or the name itself is misspelled, the linker usually finds no match
at all and silently ignores the flag; the default value from the source stays as it was.

```go
// helpers.go — the helpers this lesson shares: setting up a temporary package and calling the tool
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
)

// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
	fmt.Printf("%-38s %-7s %s\n", info, speaker, evidence)
}

// setupModule writes the given files into a new temporary module; the path is never printed.
func setupModule(files map[string]string) string {
	dir, err := os.MkdirTemp("", "linker")
	if err != nil {
		return ""
	}
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module linktrial\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

// runTool runs the language's own command, returning the output and whether it succeeded.
func runTool(dir string, arg ...string) (string, bool) {
	cmd := exec.Command("go", arg...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	output, err := cmd.CombinedOutput()
	return string(output), err == nil
}

// run runs the source with the given linker value and returns the single line it printed. If
// apply is false, no -X flag is supplied; if true, the value is applied even if it is empty.
func run(dir string, apply bool, value string) string {
	arg := []string{"run", "."}
	if apply {
		arg = []string{"run", "-ldflags=-X main.version=" + value, "."}
	}
	output, _ := runTool(dir, arg...)
	return strings.TrimRight(output, "\n")
}

// symbolPresent says whether a given symbol name is present in a binary built with the given
// extra linker flag; the binary itself and its path are never printed anywhere.
func symbolPresent(dir, extraFlag, symbolName string) bool {
	binaryPath := filepath.Join(dir, "binary")
	arg := []string{"build", "-o", binaryPath}
	if extraFlag != "" {
		arg = append(arg, "-ldflags="+extraFlag)
	}
	arg = append(arg, ".")
	if _, ok := runTool(dir, arg...); !ok {
		return false
	}
	output, _ := runTool(dir, "tool", "nm", binaryPath)
	return strings.Contains(output, symbolName)
}
```

```go
// measurement1.go — a value not written in the source, read three different ways from the same source
package main

import "fmt"

const sourceText = "package main\n\nimport \"fmt\"\n\nvar version = \"development\"\n\nfunc main() {\n\tfmt.Println(version)\n}\n"

func main() {
	dir := setupModule(map[string]string{"prog.go": sourceText})
	defer func() { _ = dir }()

	fmt.Println("-- same source, three different linker inputs --")
	show("no value supplied", "tool", run(dir, false, ""))
	show("first value supplied", "tool", run(dir, true, "version-a"))
	show("second value supplied", "tool", run(dir, true, "version-b"))
}
```

```
-- same source, three different linker inputs --
no value supplied                      tool    development
first value supplied                   tool    version-a
second value supplied                  tool    version-b
```

**BD9.** The `sourceText` constant is character-for-character identical across all three runs;
the only thing that changes is the flag given to the `run` function. The only value actually
written in the source is `"development"` — the value shown when no flag is supplied at all, the
one line belonging to the **source** class. The other two lines are nowhere in the source; the
linker replaces a package-level string variable's initial value with a string supplied from
outside, during the build, without ever touching the source. All three of these lines are written
to the **tool** class, because the value gives the same result on the same flag every time — there
is nothing changing from run to run, only which flag is supplied changes.

This mechanism can only work under two conditions at once. The variable must be at package level
— a local variable inside a function sits somewhere the linker cannot reach. And the variable's
type must be a string; the linker can only change string values this way, it cannot change an
integer or a struct's field through the same path. These two constraints keep the mechanism
deliberately narrow: it exists to place a single piece of text at build time, not a complex data
structure.

This narrowness is not a shortcoming, it is a design choice. The linker never parses the source
at all, it works only with the binary representation inside compiled object files — a string
variable's binary representation is a memory block whose length and bytes are known in advance,
so all the linker has to do is change that block's contents. The same operation would be far
more fragile for an integer or a struct: putting an arbitrary byte in place without knowing the
value's type and size would risk corrupting the rest of the binary. A string type carries no
such risk, because Go's string representation is already kept together with a length field, and
the linker can write the new value's length into that field too.

## Limiting Measurement: The Embedded Value Is Not Validated

All three values above were formatted, readable strings. The linker itself never looks at this
form at all — it places whatever it is given exactly as it is. The measurement below tests this
with two extremes: on one side a string matching no recognizable pattern at all, on the other a
completely empty string.

```go
// measurement2.go — limiting measurement: the embedded value is not validated, the symbol table is a separate matter
package main

import "fmt"

const sourceText = "package main\n\nimport \"fmt\"\n\nvar version = \"development\"\n\nfunc main() {\n\tfmt.Println(version)\n}\n"

func main() {
	dir := setupModule(map[string]string{"prog.go": sourceText})
	defer func() { _ = dir }()

	fmt.Println("-- limiting measurement: the embedded value is not validated --")
	show("unformatted value supplied", "tool", run(dir, true, "###INVALID-VERSION-999"))
	show("empty string supplied", "tool", "["+run(dir, true, "")+"]")

	fmt.Println()
	fmt.Println("-- second linker option: removing the symbol table --")
	show("symbol present in normal build", "tool", fmt.Sprint(symbolPresent(dir, "", "main.version")))
	show("symbol present with -s build", "tool", fmt.Sprint(symbolPresent(dir, "-s -w", "main.version")))
	show("does -s change behavior", "tool", "output="+run(dir, false, ""))
}
```

```
-- limiting measurement: the embedded value is not validated --
unformatted value supplied             tool    ###INVALID-VERSION-999
empty string supplied                  tool    []

-- second linker option: removing the symbol table --
symbol present in normal build         tool    true
symbol present with -s build           tool    false
does -s change behavior                tool    output=development
```

**BD10.** The first line accepted a string that does not look like a version number at all, with
no warning, no rejection; the second line accepted a completely empty string the same way — the
brackets are empty because the variable itself was set equal to an empty string. The linker
performs no type check and no format check here; all it does is put the given byte sequence in
place of the source code's initial value. This is another face of the previous lesson's "compiled
is not the same as working" limit: if the linker passes, the embedded value is never guaranteed to
be **meaningful** — only that it was placed in the right spot, with the right type.

The only way to close this gap is to validate the embedded value outside the source, in a
separate step — the linker itself never takes on this job. In a deployment process this is
usually solved by testing, **before** the linker is ever called, whether the string to be
embedded matches a pattern known in advance to the caller — exactly as this lesson measured, the
validation comes not from the linker itself but from the discipline of the process calling it.
This lesson did not apply that discipline, deliberately: the goal was to see the linker's own
behavior in its bare form, with no surrounding validation layer at all.

**BD11.** The second measurement looks at a separate linker option: the flag that removes the
symbol table and debug information. In a normal build, the name `main.version` is present in the
binary's symbol table — this name lets a debugger or an analysis tool recognize the variable.
With the same flag added, this name is removed from the symbol table, but the program's own
output — the `run` call on the last line — does not change at all. This shows that two different
linker options work at two different layers: one changes **the program's data** (`-X`), the other
only removes **information about the program** (`-s`) — the two can be used together, and one
never affects the other's result.

This distinction is not arbitrary: the symbol table and debug information that `-s` removes are
**no part at all** of the program's own logic — extra data added to the binary only so a debugger
can read variable names, function names, and line numbers, but never used by the program itself
at run time. `-X`, by contrast, writes into exactly the opposite place — inside the program's own
runtime data. Even when both options are given in the same command, no ordering problem arises
between them, because the symbol table is removed **at the end of the build process**, once every
value has already settled into place — what gets removed is the names themselves, not the data
those names point to.

## Without Leaving a Trace in the Source

What both measurements share is that the layer the linker works in is separate from the source
itself. The source file stayed character-for-character identical across the `version-a`,
`version-b`, and `###INVALID-VERSION-999` runs; none of them appears in any version of the source
on disk. This completes the parallel pointed to at the end of the previous lesson: just as target
selection enters the binary leaving no trace at all, an embedded string enters leaving no trace
in the source either. Both are applied by the build process's own tools, as part of the build's
**input** — not the source's **output**.

The practical consequence of this is that every binary produced from the same source tree can
carry its own "identity": values like a version string, a build timestamp, a distribution ID can
be given separately to each binary, without ever changing the source, only by changing the build
call. The cost sits in the same place: the correctness of these values falls outside source
review — without a watching, reviewing person or process, a wrong value can silently enter the
binary. This difference looks sharper when compared, in the next lesson, against a version number
**genuinely written** in the source: there, the version number sits in the source itself, and a
tool only **checks** it; here, the version-like value never sits in the source at all, and no
tool checks it.

The choice between these two extremes usually depends on one question: is the value part of the
source's **identity**, or is it part of **when, in what environment** the source was built? A
library's version number is an example of the first — that value moves with the source, it does
not change without the source changing, so writing it into the source keeps a more consistent
record. A build ID or a fact about the build environment is an example of the second — this value
does not come from the source, it comes from **that particular** build call, and it can be
expected to differ on every build; writing it into the source would, in this case, mean polluting
the source with information that does not actually belong to it. A linker option meets exactly
this second case, without ever touching the source.

## Summary

- A string variable declared at package level can be filled with a different value at build time
  through a linker flag, with the source never changing; this value is never written in the
  source.
- The same source gave three different outputs across three separate calls; the difference was
  only in the flag supplied, never in any version of the source file.
- Limiting measurement: the linker never validates the embedded value — an unformatted string and
  an empty string are both accepted the same way, with no warning produced.
- A separate linker option removing the symbol table does not change the program's own output;
  the two options work in two independent layers.
- Target selection (previous lesson) and the embedded value (this lesson) share the same pattern:
  both are part of the build's input, not part of the source itself.

## Next Step

In this lesson the embedded value was never written in the source, and no tool ever checked it.
The next lesson takes up the exact opposite case: a version number is declared openly, in the
source itself — but this time a tool checks a promise that number carries, whether it stays
compatible with old code. The question is no longer "where does the value come from" but "what
does a number written in the source guarantee."
