Go Under the Hood · Part 1

From Go Source to Running Program

Follow Go code from modules and packages through compilation, linking, and runtime startup, with a runnable experiment showing what happens before main.

You run go run ., and a message appears before the first statement in main(). Where did it come from? Or you edit a source file, execute yesterday’s binary, and wonder why nothing changed.

Both situations become easier to explain when you separate building a program from starting a process. This first part of Go Under the Hood follows that boundary from source files to application code.

You should already recognise Go functions, variables, and imports. The experiment uses only the standard library and a terminal. This article targets Go 1.27, with examples verified using Go 1.27.1 on macOS ARM64. Commands use a macOS/Linux shell. Run go version before starting and use a Go 1.27 toolchain to reproduce this baseline.

The journey has two distinct stages

BUILD
go.mod + selected source files + imported packages
    → compile packages
    → link executable

RUN
operating system loads executable
    → runtime startup
    → application package initialisation
    → main.main()
    → process exits when main returns

Building prepares an executable. Running it creates a process with fresh application state. Package initialisers belong to that second stage: compiling a call to fmt.Println does not execute the call.

Keep this distinction in mind when investigating startup logs. A log line before your HTTP server starts can come from package initialisation, even though the build completed successfully earlier.

Modules organise dependencies; packages organise code

A package groups source files compiled together, normally within one directory. A module groups packages released together and has a go.mod file declaring its module path. That path forms the prefix of its package import paths. A repository can contain multiple modules. How to Write Go Code

Our experiment has one module and two packages:

startup-demo/
├── go.mod
├── main.go          # package main
└── greeting/
    └── greeting.go  # package greeting

The root package imports example.com/startup-demo/greeting. The greeting name alone is the identifier used in code; the full import path identifies the package to load. The example domain does not need to host anything because this package is inside the current module.

The go directive records the module’s required minimum Go version and influences language semantics. It does not lock every build to one exact compiler binary. Record go version separately when sharing compiler investigations. go.mod reference

Build a program that reveals its startup order

Create a fresh directory:

mkdir startup-demo
cd startup-demo
go mod init example.com/startup-demo
go mod edit -go=1.27.0
mkdir greeting

Create greeting/greeting.go:

package greeting

import "fmt"

var Message = makeMessage()

func makeMessage() string {
	fmt.Println("greeting: variable")
	return "hello"
}

func init() {
	fmt.Println("greeting: init")
}

Create main.go:

package main

import (
	"fmt"

	"example.com/startup-demo/greeting"
)

var message = prepare()

func prepare() string {
	fmt.Println("main: variable")
	return greeting.Message + ", Go"
}

func init() {
	fmt.Println("main: init")
}

func main() {
	fmt.Println("main:", message)
}

Build, then run:

go version
go build -o startup-demo .
./startup-demo

The build produces no application messages. Running the executable produces:

greeting: variable
greeting: init
main: variable
main: init
main: hello, Go

The first two lines come from the imported package. The next two come from root-package setup. Only the final line comes from main().

Run ./startup-demo again: the same five lines appear because the new process initialises its packages again. Now change hello to welcome in the source and run the existing executable. It still prints hello until you rebuild. This gives you a small, observable check of the source-to-binary boundary without a debugger.

What compilation and linking do

The Go command selects the files for the target and resolves imports. File selection matters: platform suffixes and build constraints can change which source participates in a build. The package graph must also be valid before compilation can succeed.

Inside the standard Go compiler, source is parsed and type-checked, transformed into internal representations, optimised, and lowered towards machine code. Inlining and escape analysis are compiler work; they are not actions that your application chooses anew on every launch. SSA, or static single assignment form, is an intermediate representation used for optimisation. These are implementation details, not a promise that every future compiler will use identical passes. Introduction to the Go compiler

The linker combines compiled material into the executable, resolving references between packages and including runtime support. It has internal and external linking modes; external linking can involve a platform linker. Avoid assuming every Go executable is completely independent of system libraries simply because go build emitted one file. Dependencies and build mode matter. Go linker documentation

You normally let go build coordinate these tools. Calling the compiler and linker directly adds bookkeeping that this example does not need.

Inspect the build without guessing

From the experiment directory, run:

go list ./...
go list -deps .
go build -x -work -o startup-demo .
go version -m ./startup-demo

go list ./... identifies the two local packages. -deps also exposes the imported dependency graph. The build’s -x flag prints executed commands; -work retains its temporary working directory and prints its location. go version -m inspects embedded build information. The build cache may mean you see little compiler activity on a repeat build. Go command documentation

For this tiny experiment, force rebuilding with:

go build -a -x -work -o startup-demo .

Expect substantially more output, including standard-library work. Use this deliberately when inspecting a build; it is unnecessary for ordinary development. The retained work directories are also temporary investigation artefacts, not files to commit.

go run . compiles and runs the package in one command. It does not interpret Go source. Use go run for a quick feedback loop and go build -o ... when you want an explicit executable to inspect or run repeatedly. Go command documentation

What is guaranteed before main runs?

The language specification gives these rules:

  • Imported packages initialise before their importers, once per program initialisation.
  • Within a package, variables initialise before its init functions. Variable dependencies influence the order.
  • Package initialisation proceeds sequentially in one goroutine. An init function can launch other goroutines, which may run concurrently.
  • After initialisation, the program calls main.main. Returning from it ends the program without waiting for other goroutines.

An init function takes no arguments, returns no result, and cannot be called explicitly. Across files, source presentation order matters; avoid making application correctness depend on filename tricks. Go specification: initialisation and execution

Our output follows an explicit import dependency and one initialiser per package. It does not depend on the order of unrelated packages or multiple files.

The runtime starts before your application entry point

main.main is the application’s entry function, but runtime machinery must already exist to execute it.

For the pinned Go 1.27.1 ARM64 implementation, startup assembly calls schedinit, creates a goroutine for runtime.main, and enters scheduling. In runtime.main, runtime initialisation tasks run, garbage collection is enabled, application initialisation tasks execute, and the runtime calls the function linked to main.main.

These names explain this implementation. They are not public APIs or language guarantees, and other architectures or build modes can take different paths. Inspect the matching source when studying another toolchain. Go 1.27.1 runtime startup, Go 1.27.1 runtime main

You do not need to memorise the assembly to debug this example. The useful conclusion is that scheduling and memory-management support are established before your application’s entry function runs.

A startup failure you can reproduce

Temporarily replace the greeting package’s init body with:

func init() {
	panic("greeting setup failed")
}

Rebuild and run. The build succeeds, but execution prints greeting: variable and then fails with a panic before any of the three main: messages. Restore the original function afterwards.

This is why a successful build is not proof of successful startup. If your service never reaches its first log in main, inspect imported-package initialisers as well as the entry function.

As a design choice, keep fallible setup such as configuration loading and database connections in explicit functions called from main, where callers can handle errors and arrange cleanup. Small registration tasks can suit init; network operations, retry loops, and background-worker ownership are harder to reason about there. The same concern about bounded, visible failures appears in Designing Go Services That Fail Gracefully.

Exercise: predict, run, explain

Add these declarations to main.go and print first and second inside main():

var first = second + 1
var second = 41

Predict the values before rebuilding. The result should be 42 41: the dependency causes second to initialise before first, despite declaration order. Then change second to first + 1 and rebuild. That creates an initialisation cycle, which the compiler rejects. Restore the valid declarations when finished.

You now have three distinct checkpoints: compilation can reject an invalid dependency, initialisation can fail before main, and an already-built executable can keep running older code. Identifying the checkpoint is the first useful step towards diagnosing the failure.

The next part, Values, Pointers, and Copies, moves inside the running program to examine what assignment and function calls actually copy.

Sources

Back to the journal