---
title: Cross-Compilation
source: 'https://academia.sh/en/courses/go-production/cross-compilation'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Reads from the tool whether the same portable source compiles for three separate targets, with no target machine installed'
    - "Measures and compares how a platform-dependent call's compilability changes across the same three targets"
    - 'Bounds the claim that being compiled means it will run on the target, by modeling an assumption'
---

# Cross-Compilation

It is measured whether the same portable source compiles for three separate targets with a single command, with none of those targets installed; compilability here too is a tool fact that requires no run. A second measurement shows that a platform-dependent call's compilability changes across the same three targets. The limiting measurement shows, by modeling, that being compiled does not mean it will run on the target — that an assumption carrying the same name can hold for one target family and fail for another.

The previous lesson showed that which file in a directory enters the build can be read from the
tool alone, without running any program. This lesson carries the same question one step further:
once the files are settled, which target do they compile for? The answer again comes without
waiting for a run, but this time it carries its own limit along with it — being compiled does
not mean it will run on that target.

## One Command, Three Targets

**Cross-compilation** is the process of producing a binary for a target other than the machine
the source is built on. The language's own compiler does this job with a single command; what
selects the target is two environment variables — one sets the target operating system family,
the other the processor architecture. Both variables come from **outside** the build command,
just like the build tags of the previous lesson; the difference is that a tag's name is specific
to the source, while these two variables' names and possible values are fixed by the language
itself. No third, target machine is needed: the compiler, independent of the machine it runs on,
produces a binary for the requested target, and whether it can produce one is again information
that falls into the **tool** class — learned without running anything.

This draws on the same underlying mechanism as the previous lesson. The standard library itself
keeps many of its functions in a separate file per target family, behind a build tag carrying
that family's name (or an equivalent file-naming convention); the compiler reads which target it
is working for from the two environment variables and picks the right file set. The difference
is scale: a selection set up by hand in a six-file example in the previous lesson runs, in the
standard library itself, across thousands of files, separately for each target. This is why a
target does not need to be "installed" — all that is needed is for the compiler to know which
source files are valid for that target, and this knowledge already sits in the source itself, in
the file names and the tags.

The measurement below compiles a portable source containing no platform-specific call at all for
three separate targets and reads whether each attempt passes. The produced binary is never
written anywhere — the output is redirected to the system's null device, because the only thing
measured in this lesson is whether the build succeeds, not the produced file itself.

```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"
)

// show prints a fact, who states it, and its evidence on a single line.
func show(info, speaker, evidence string) {
	fmt.Printf("%-42s %-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("", "cross")
	if err != nil {
		return ""
	}
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module cross\n\ngo 1.24\n"), 0o644)
	for name, content := range files {
		os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
	}
	return dir
}

// compiles attempts to build the source for the given target; the output is never printed,
// only whether it succeeded is returned. The produced binary is written to the null device.
func compiles(dir string, extraEnv []string) bool {
	cmd := exec.Command("go", "build", "-o", os.DevNull, ".")
	cmd.Dir = dir
	cmd.Env = append(append(os.Environ(), "GOTOOLCHAIN=local", "CGO_ENABLED=0"), extraEnv...)
	return cmd.Run() == nil
}
```

```go
// threeTargets.go — a portable source compiles for three separate targets, with no target machine needed
package main

import "fmt"

func main() {
	dir := setupModule(map[string]string{
		"portable.go": "package main\n\nfunc add(a, b int) int { return a + b }\n\nfunc main() {\n\t_ = add(1, 2)\n}\n",
	})
	defer func() { _ = dir }()

	targets := []struct{ name, goos, goarch string }{
		{"first target", "linux", "amd64"},
		{"second target", "windows", "amd64"},
		{"third target", "darwin", "arm64"},
	}

	fmt.Println("-- one command, three targets: portable source --")
	for _, t := range targets {
		env := []string{"GOOS=" + t.goos, "GOARCH=" + t.goarch}
		show(t.name+" compiled", "tool", fmt.Sprint(compiles(dir, env)))
	}
}
```

```
-- one command, three targets: portable source --
first target compiled                      tool    true
second target compiled                     tool    true
third target compiled                      tool    true
```

**BD6.** All three separate targets compiled, and this machine held no real copy of any of the
three — the only thing run was the compiler itself, called three times with two environment
variables. The result again leaves the `run` column entirely empty: which targets a portable
source compiles for, without touching any of those targets, is read from a single command's
pass-or-fail answer.

## Same Call, Changing Result

The source above contained nothing platform-specific, which is why all three targets gave the
same answer. This uniformity breaks once a call binds to a platform-specific system function —
some operating system families define that function, some do not define it at all. The
measurement below tries, on the same three targets, a source that uses a single system call
from the standard library specific to one operating system family (the named-pipe creation
call).

```go
// systemCall.go — same call, three targets: a platform-dependent system call
package main

import "fmt"

func main() {
	dir := setupModule(map[string]string{
		"caller.go": "package main\n\nimport \"syscall\"\n\nfunc main() {\n\t_ = syscall.Mkfifo\n}\n",
	})
	defer func() { _ = dir }()

	targets := []struct{ name, goos, goarch string }{
		{"first target", "linux", "amd64"},
		{"second target", "windows", "amd64"},
		{"third target", "darwin", "arm64"},
	}

	fmt.Println("-- same call, three targets: a platform-dependent system call --")
	succeeded := 0
	for _, t := range targets {
		env := []string{"GOOS=" + t.goos, "GOARCH=" + t.goarch}
		c := compiles(dir, env)
		if c {
			succeeded++
		}
		show(t.name+" compiled", "tool", fmt.Sprint(c))
	}
	show("how many of three targets compiled", "tool", fmt.Sprint(succeeded))
}
```

```
-- same call, three targets: a platform-dependent system call --
first target compiled                      tool    true
second target compiled                     tool    false
third target compiled                      tool    true
how many of three targets compiled         tool    2
```

**BD7.** Same source, same three targets, but this time the result is not uniform: two targets
compile, one does not. The standard library's system call package carries a separate
implementation for each operating system family, and calling a function that does not exist at
all in one family stops the build the moment that family is targeted — and this sits one level
deeper than the "which file enters the build" question measured in the previous lesson, not in
the same layer: the file itself enters the build list for all three targets, but a name inside
that file is defined in two of the three targets and undefined in one. Which family defines
which call is, again, **tool** knowledge: a person might know this by heart, but the build
itself verifies it the same way every time, requiring no run.

The practical consequence of this difference is that a source that must support several targets
at once needs to be written one of two ways. Either the platform-specific call is never used at
all — like the portable source in the previous section, giving the same behavior on all three
targets — or the platform-specific part is split into its own file, behind its own tag, and a
separate implementation is written for each target. The second path carries the same shape as
the previous lesson's `expansion`/`trial` example: a file name or a tag decides which
implementation takes effect on which target. The difference is that this time the choice is made
automatically by the build command's own target information, not by a manually supplied flag. A
single file compiling on two of three targets and failing on the third is therefore not a flaw —
it is a sign that the source supplies no alternative file at all for the targets that do not
support that call.

## Limiting Measurement: Compiled Is Not the Same as Working

Both measurements above stayed within the same boundary: whether a source compiles for a given
target. This is a separate question from whether that source **runs** on that target, and this
lesson genuinely cannot answer that second question — two of the three targets have no real
copy on this machine, the produced binary cannot be run. Rather than hide this limit, we model
it openly: the measurement below is never run on a real target at all, two target families'
environments are set up by hand inside the source.

```go
// assumptionModel.go — limiting measurement: compiled is not the same as working, shown through a model
package main

import "fmt"

// findHomePath looks up the home directory in the given environment map by a SINGLE variable name.
func findHomePath(env map[string]string, variableName string) (string, bool) {
	value, found := env[variableName]
	return value, found
}

func main() {
	// two target families' environments are modeled here by hand, without ever really running:
	// each family offers a variable carrying the user's home directory under its own name.
	firstFamilyEnv := map[string]string{"HOME": "user-directory"}
	secondFamilyEnv := map[string]string{"USERPROFILE": "user-directory"}

	// despite the portable-looking name, the code searches for only ONE variable name.
	searchedVariable := "HOME"

	_, firstFound := findHomePath(firstFamilyEnv, searchedVariable)
	_, secondFound := findHomePath(secondFamilyEnv, searchedVariable)

	fmt.Println("-- limiting measurement: compiled is not the same as working (model) --")
	show("home path found in first target family", "tool", fmt.Sprint(firstFound))
	show("home path found in second target family", "tool", fmt.Sprint(secondFound))
}
```

```
-- limiting measurement: compiled is not the same as working (model) --
home path found in first target family     tool    true
home path found in second target family    tool    false
```

**BD8.** The `findHomePath` function contains no platform-specific call at all — it looks up a
map by a single string key — which is why, if it had been compiled for three real targets like
above, it would compile without trouble on all three. The flaw is not in the build, it is in the
**assumption**: the code looks up only a single variable name, and this name is valid in the
first target family, invalid in the second. In a real program this line might be a call trying
to find a user's settings directory; run on the second target family, it could silently return
empty, with no build error and no runtime crash. This is why the "compiled=true" line is
reliable information here too, just as in the previous two measurements — but it says what is
**true**, not what **works well**. This distinction cannot be measured without modeling it,
because a run itself never genuinely happens on a real target in this lesson.

The model itself was kept deliberately small: two maps, one string key, two lookups. In a real
program this kind of assumption is usually buried much deeper — the directory a configuration
file is searched in, the place a log file is written to, the name of a temporary file — and all
of them share the same pattern: the code accepts a single name, a single path, or a single
format as correct, this acceptance passes the build on all three targets, and it surfaces only
on the target where it is wrong, only once that target is actually run. This is exactly where the
power of the compilability measurement ends: what it says is true and reliable, but the question
it asks is narrow — it only answers "does this source turn into a binary for this target," not
"does this binary do the right thing."

The way this limit is closed in practice stays within the same three classes: the only reliable
way to show that something truly works on a target is to genuinely **run** it there — on a real
machine or in an environment simulating that target. The compilability test does not replace
this step, it precedes it: if a source does not even compile for one of three targets, there is
no need to try a run on that target at all. For a source that compiles on all three targets, the
question is still open — the compilability test removes this open question **early**, at a
cheap step, but it does not close it. In this lesson the second step (a real run) was never
taken, because two of the three targets have no real copy on this machine; this is a limit of
this measurement's scope, not a limit of cross-compilation itself.

The same limit applies to one of cross-compilation's own boundaries: when a source links to a
native library through `cgo`, the compiler usually cannot find the external compiler it needs
for the target, and the build is genuinely rejected this time — that is the subject of the
course's final lesson. The three targets measured in this lesson were all pure source; no
external compiler was ever needed, and so none of them was ever rejected.

## Summary

- Cross-compilation is the process of producing a binary for a target with no target system
  environment installed at all, with a single command; compilability here too is a **tool** fact
  that requires no run.
- A portable source gave the same answer on all three targets; once a single platform-dependent
  system call was added, only two of the same three targets compiled.
- Which target family a call is defined in is also information read from the tool — the build
  verifies this the same way on every attempt, requiring no personal knowledge.
- Limiting measurement: being compiled does not mean it will work correctly on the target; an
  assumption tied to the same name can hold for one target family and fail for another.
- This limit was shown not by running on a real target but by openly modeling two targets'
  environments inside the source; nowhere in this lesson was a real target machine ever touched.

## Next Step

In this lesson the target was a choice under which the file itself never changed — the source
was fixed, only the environment it compiled for changed. The next lesson takes up a value that
is never written into the source at all, but still enters the binary: a version fact is placed
into the binary not on any line of the source, but only at build time, by a separate tool. The
question asked this time is not "which target" but "where does a value never written in the
source come from" — and just as the target chosen in this lesson leaves no trace in the binary,
that value leaves no trace in the source itself either; both are added to the binary only by the
build process's own tools, from outside the source.
