🚀 The Go Binary Is More Than Just Your Code: Unveiling the True Contents of a Golang Build
8 min read

🚀 The Go Binary Is More Than Just Your Code: Unveiling the True Contents of a Golang Build

Many developers assume that a Go build result is a pure binary in the sense that: it only contains the code they wrote, compiled into machine code, then done. This assumption isn’t entirely wrong — Go does produce a native executable that can run directly without an external runtime. But there’s something often not realized: a Go binary contains more than just your application code. Inside it, the Go runtime is embedded, carrying all the supporting subsystems for the language’s features. Understanding what’s truly inside a Go binary matters so you can make the right decisions about size, performance, and deployment strategy.

What Does Pure Binary Mean?

The term pure binary is often interpreted as an executable that doesn’t depend on an interpreter, VM, or external runtime — just copy the file to a server and run it. In that definition, Go does meet the criteria. But there’s an important difference often missed:

Not needing an external runtime  ≠  Having no runtime at all

Go doesn’t need an external runtime like the JVM for Java or Node.js for JavaScript. But Go still has an internal runtime that’s linked into the binary during the build process. This runtime isn’t optional — it’s a prerequisite for Go’s language features to work.

flowchart TD
    A[Source Code .go] --> B[Go Compiler]
    B --> C[Application Code]
    B --> D[Go Runtime]
    C --> E[Executable Binary]
    D --> E
    E --> F[Runs directly<br/>without any installation]

Embedded Runtime Components

The Go runtime isn’t a VM. It’s a collection of subsystems enabling Go’s language features to work. When you run go build, all these subsystems get compiled into the binary.

Garbage Collector

Go has a built-in GC with the concurrent mark-and-sweep model. This automatic memory management code is fully included in the binary — including the logic for deciding when the GC runs, how it pauses goroutines minimally, and how it returns memory to the OS.

Goroutine Scheduler (M:N Scheduler)

Go runs goroutines using the M:N model, where a number of M goroutines are mapped to a number of N OS threads. This scheduler dynamically manages that mapping — moving goroutines between threads, handling work stealing, and ensuring blocked goroutines don’t waste OS threads.

flowchart LR
    subgraph Goroutines
        G1[goroutine 1]
        G2[goroutine 2]
        G3[goroutine 3]
        G4[goroutine 4]
    end
    subgraph Scheduler["Runtime Scheduler (M:N)"]
        S[Queue & Dispatcher]
    end
    subgraph OS Threads
        T1[thread 1]
        T2[thread 2]
    end
    G1 --> S
    G2 --> S
    G3 --> S
    G4 --> S
    S --> T1
    S --> T2

Stack Management

Each goroutine’s stack can grow and shrink dynamically. New goroutines start with a small stack (~2KB), then automatically grow as needed. The stack expansion and contraction logic is entirely managed by the runtime.

Memory Allocator

Functions like make, new, map creation, and slice operations all use the allocator in the runtime — not the operating system allocator directly. Go uses a layered allocator optimized for allocation patterns common in Go programs.

Channels and Select

Channel operations aren’t an OS feature. They’re entirely implemented by the runtime, including synchronization mechanisms, queues of goroutines waiting for send/receive, and the select implementation for channel multiplexing.

Panic, Recover, and Stack Traces

Go’s low-level error handling system — panic, recover, and informative stack traces — is all handled by the runtime. When a panic occurs, the runtime prints the full stack trace to stderr.

Interface Dispatch and Reflection

Type assertions, interface dispatch, and the reflect package need type metadata stored in the binary. The runtime provides the type table (itab) enabling these operations to run efficiently.


Seeing the Binary Contents Directly

You can verify the runtime’s presence inside a binary using several standard tools.

Start with a simple program:

package main

import "fmt"

func main() {
    fmt.Println("Hello World")
}

Build:

go build -o app main.go

Then run nm to see the symbol list:

nm app | grep runtime | head -20

You’ll see hundreds of symbols with the runtime. prefix — from runtime.goexit, runtime.mallocgc, to runtime.gcBgMarkWorker. All of those are inside your “Hello World” binary.

To see the size of each section:

size app

The output will show the split between the text section (code), data (static data), and bss (uninitialized data). The text section will be far larger than your actual application code because it contains the runtime.


Static vs Dynamic Linking

By default, without CGO, Go performs static linking — all dependencies are compiled and linked directly into a single binary.

# Check whether the binary is static
ldd ./app

On a pure Go binary (without CGO), the output is:

not a dynamic executable

This means the binary is truly self-contained. No system libraries need to be installed on the target machine.

Conversely, when using CGO (for example to access C libraries), the binary can have dynamic dependencies:

ldd ./app-with-cgo
# linux-vdso.so.1
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# /lib64/ld-linux-x86-64.so.2

Such a binary requires those libraries installed on the target machine. To force a static build even with CGO:

CGO_ENABLED=0 go build -o app main.go
Binaries built with CGO_ENABLED=0 can’t use packages depending on C libraries like database/sql with native drivers, or packages using low-level syscall. Always verify compatibility before disabling CGO in production.

Why Is a Go Binary Larger Than C?

A comparison of simple Hello World program sizes:

LanguageExternal RuntimeEmbedded RuntimeBinary Size (approx.)
CNoMinimal (libc)~20 KB
RustNoMinimal~300 KB
GoNoYes (full)~1–2 MB
JavaYes (JVM)NoBytecode ~5 KB
PythonYesNoScript
Node.jsYesNoScript

Go sits in the middle: it doesn’t need an external runtime like Java, but its binary is larger than C because it carries a far more feature-rich runtime.

The main causes of Go’s larger binary size:

✗  The GC runtime and scheduler get bundled in
✗  The symbol table for stack traces
✗  Debug information (DWARF) by default
✗  Type metadata for reflection

To shrink the binary size in production:

# Remove the symbol table and DWARF debug info
go build -ldflags="-s -w" -o app main.go

This can reduce the binary size by up to 30–40%. But keep in mind: this flag removes debug information, so stack traces during panics become less informative.

flowchart LR
    subgraph "Go Binary (default)"
        A1[Application Code]
        A2[Go Runtime]
        A3[Symbol Table]
        A4[DWARF Debug Info]
    end
    subgraph "Go Binary (-s -w)"
        B1[Application Code]
        B2[Go Runtime]
    end
    A1 --> |"go build -ldflags='-s -w'"| B1

Architectural Implications

Understanding what’s inside a Go binary has direct implications for how you design and deploy applications.

Advantages

Distributing a Go binary is very simple. Just copy one file to the target server — no runtime installation, environment setup, or system dependency management needed. This makes Go very suitable for distribution into minimal containers like scratch or distroless:

# Multi-stage build for a minimal container
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app .

FROM scratch
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]

Such a container can be only a few MB in size — far smaller than containers that must include a JVM or Node.js runtime.

Cross-compilation is also very easy because the runtime is embedded:

# Build for Linux from macOS
GOOS=linux GOARCH=amd64 go build -o app-linux main.go

# Build for Windows from Linux
GOOS=windows GOARCH=amd64 go build -o app.exe main.go

Consequences to Note

The embedded runtime carries certain overhead. The GC needs additional memory for its internal structures. The scheduler needs CPU overhead to manage goroutines. Startup time is slightly longer than a pure C program because the runtime must be initialized before main() is called.

For serverless applications or Function-as-a-Service where cold start is a main concern, this Go runtime startup overhead can be a consideration. Binaries compiled with CGO_ENABLED=0 and the -ldflags="-s -w" flags generally have faster startups because the binary is smaller and the OS loads it into memory faster.

Checklist Before Deploying a Go Binary

BUILD:
  â–¡ Use CGO_ENABLED=0 if no C libraries are needed
  â–¡ Add -ldflags="-s -w" for production builds
  â–¡ Verify the target GOOS and GOARCH are correct
  â–¡ Run ldd to confirm the static/dynamic status matches expectations

CONTAINER:
  â–¡ Use multi-stage builds to separate the build and runtime images
  â–¡ Consider scratch or distroless as the base image
  â–¡ Verify the binary runs inside the container before pushing

DEBUGGING:
  â–¡ Keep a debug binary (without -s -w) for profiling purposes
  â–¡ Enable the pprof endpoint if profiling in production is needed

Summary

  • A Go binary isn’t a pure binary without a runtime — inside it, the full Go runtime is embedded, supporting the GC, goroutine scheduler, channels, panic/recover, and reflection.
  • No external runtime is needed — this is what distinguishes Go from Java or Python, not the absence of any runtime at all.
  • Static linking is the default — without CGO, a Go binary is fully self-contained and doesn’t depend on system libraries on the target machine.
  • The binary is larger than C — because the feature-rich runtime is bundled, not because Go is inefficient.
  • Use -ldflags="-s -w" to reduce production binary size by removing the symbol table and DWARF debug info.
  • Cross-compilation is very easy — just set GOOS and GOARCH, and the appropriate runtime gets embedded automatically.
  • A scratch container can be used because a Go binary doesn’t need system libraries to run (with CGO_ENABLED=0).

Portfolio