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
initfunctions. Variable dependencies influence the order. - Package initialisation proceeds sequentially in one goroutine. An
initfunction 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
- How to Write Go Code: packages and modules
- go.mod reference: module and Go version directives
- Go command: build, run, list, and build information
- Introduction to the Go compiler
- Go linker command
- Go specification: program initialisation and execution
- Go 1.27.1: ARM64 runtime startup assembly
- Go 1.27.1: runtime main and initialisation tasks