00 - Go Toolchain and Environment

From zero to a productive Go setup

1) What is Go

Overview

  • A compiled, statically typed language (by Google, 2009) designed for simplicity, fast builds, high performance, and easy concurrency (goroutines/channels).
  • No manual memory management - Go uses a garbage collector.
  • Standard toolchain includes: go build, go run, go test, go fmt, go vet, go mod, go work.

Where it’s used

  • Web services & APIs - fast servers, cloud backends, REST/GraphQL.
  • Networking & distributed systems - microservices, proxies, load balancers.
  • DevOps tools - CLIs, automation, infrastructure management.
  • Cloud platforms - containers, orchestration, serverless runtimes.
  • Data processing - streaming pipelines, concurrent jobs.

Example apps & companies

  • Docker - container platform.
  • Kubernetes - container orchestration.
  • Terraform - infrastructure as code.
  • Prometheus - monitoring system.
  • Cloudflare, Uber, Dropbox, Netflix - production systems built with Go.

2) Installation

Install & verify

Official guide: https://go.dev/doc/install

After installing, check in your terminal: go version and go env

Important variables:

  • GOROOT - This is simply the folder where Go itself is installed.
  • GOPATH: This is a working folder that Go uses in the background.

    You don’t put your projects here anymore, but Go still uses it for:

    • bin - where Go puts ready-to-use programs when you run go install.
    • pkg/mod - a storage where Go keeps downloaded dependencies (libraries).
    • pkg/sumdb - a small database Go uses to verify dependencies are safe and unchanged.

3) Creating a Basic Go Project

Initialize a module:

  • go mod init <module_name>
  • Example: go mod init example.com
  • Best practice for version control: use the repository URL as the module name like go mod init github.com/username/projectname. This ensures imports work correctly when others use your module: import "github.com/username/projectname/pkg/foo

Basic program (main.go)

main.go
package main

import "fmt"

func main() {
	fmt.Println("Hello, Go")
}

Run and build

  • go run . - compiles and runs the program.
  • go build . - builds an executable file that we can run later on.
  • Why use modules? - Since Go 1.16, module mode is the default. Enables versioning of dependencies. Guarantees reproducible builds. Without go.mod you can still run a single file (go run main.go), but for any real project you should always use modules.

4) Package and program's entrypoint

Essentials

  • package: The first line in every .go file. Use main for executable programs and any other name for libraries/modules.
  • func main(): The entry point of a Go program. An executable must have exactly one main package with exactly one func main().

Example (main.go)

main.go
package main

import "fmt"

func main() {
	fmt.Println("The program starts here")
}

Naming best practices for packages

  • Use short, lowercase names (strings, json, http).
  • Avoid underscores, dashes, or CamelCase.
  • The name should describe what the package provides, not the project (math, not mathutils).
  • Keep names singular, not plural (user, not users).
Note
NB: Avoid cyclic imports (A imports B, and B imports A).

If this happens, extract common code into a third package.


5. Go.mod and go.sum - what they are and why

Overview

go.mod

  • The recipe for your project.
  • Says: this is my module name, this is my Go version, these are the libraries (dependencies) I need.

go.sum

  • The security list. Go writes down a checksum (fingerprint) for every library version you use.
  • It is valuable because it guarantees integrity and reproducibility of dependencies, making supply-chain attacks much harder to succeed. Always commit go.sum to Git together with go.mod.
  • https://socradar.io/npm-supply-chain-attack-crypto-stealing-malware

replace (inside go.mod)

A quick way to tell Go: don’t use this version from the internet, use this other version instead.

  • Local development: replace github.com/username/lib => ../lib (use the local folder instead of downloading it)
  • Force a version: replace bad/library v1.2.3 => good/library v1.2.4 (redirects dependency to another version or fork)

6) Workspaces with go work - how, where, and why

What it is?

go work lets you develop several Go modules together so your app can use your local libraries while you code.

What is a Go module?

  • A module is a folder with its own go.mod (a versioned bundle of packages). The module line in go.mod sets the import path prefix (e.g., module example.com/mod1 => you import packages as example.com/mod1/hello).
  • You always import by the module path declared in the library’s go.mod. With a workspace, Go resolves that import to your local folder listed in go.work (not from the internet). If paths don’t match or the workspace isn’t active, Go will try to fetch online and you may see a 404.
  • Without a workspace (or a replace), Go treats example.com/mod1 as external and tries to fetch it via the module proxy/VCS (e.g., GitHub via proxy.golang.org, or a vanity path via https://example.com/mod1?go-get=1). If nothing is there, you get a 404.

When to use it?

  • Use go work only when you have several Go projects that should work together (for example, an app and your own library).
  • It lets your projects see each other’s code while you develop, without publishing anything online.

When you don’t need it

One project, one go.mod, multiple binaries. Start simple. Use workspaces only if you later split code into separate modules.

Where to put it

Create one go.work file in the repo root (a folder that contains your modules).

7) Polymorphism in Go

Polymorphism means one function, multiple different behaviours. In Go, there are two main forms:

Compile-time polymorphism (Generics)

  • Introduced in Go 1.18.
  • Achieved with type parameters and constraints.
  • The compiler generates specialized code for each type, so there’s no runtime overhead.
  • Similar to C++ templates.
  • Example:
generics.go
package main

import (
	"fmt"
	"golang.org/x/exp/constraints"
)

// Max works for any type that supports ordering (<, >, ==, …)
func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(2, 5))        // ints
	fmt.Println(Max(3.14, 2.71))  // floats
	fmt.Println(Max("a", "b"))    // strings
}

Runtime polymorphism (Interfaces)

  • Achieved with interfaces.
  • The concrete type is only known at runtime.
  • Method calls go through an interface table lookup (like virtual calls in C++).
interfaces.go
package main

import "fmt"

type Greeter interface {
	Greet() string
}

type Person struct{}
func (Person) Greet() string { return "Hello" }

type Robot struct{}
func (Robot) Greet() string { return "Beep Boop" }

func SayHi(g Greeter) {
	fmt.Println(g.Greet())
}

func main() {
	SayHi(Person{}) // "Hello"
	SayHi(Robot{})  // "Beep Boop"
}

Summary

  • Use generics when you want zero-cost abstraction and compile-time safety.
  • Use interfaces when you need flexibility and don’t know the concrete type until runtime.
Note
NB: We’ll explore polymorphism in Go more deeply in a separate lecture (with advanced generics, type sets, and interface internals).
Note
NB: Here, we’re just introducing the concept because it connects directly to the idea of devirtualization in the compiler pipeline.

8) Go Compiler

Module & Dependency Resolution

  • Reads go.mod / go.sum.
  • Downloads any missing dependencies into the module cache.
  • If vendor/ exists and you build with -mod=vendor, vendored code is used instead.
  • Builds happen in dependency order, using cached results if available.

Lexical Analysis (Scanning)

  • Source code is read as a stream of characters.
  • Whitespace and comments are discarded.
  • Tokens are recognized (identifiers, keywords, literals, operators, punctuation).
  • Output: token stream.

Parsing

  • Tokens are organized into an Abstract Syntax Tree (AST).
  • AST = a tree-shaped representation of your code where each node is a construct (function, block, statement, expression).
  • The AST encodes the program’s structure and hierarchy in a form the compiler can analyze.
  • Syntax errors are reported at this stage.

Type Checking & Semantic Analysis

  • Uses cmd/compile/internal/types2 (derived from go/types).
  • Verifies type correctness, function signatures, method receivers, interfaces.
  • Resolves identifiers to declarations, evaluates constants.
  • Detects errors like invalid conversions, undeclared names, wrong argument counts.
  • Output: typed AST.

Intermediate Representation Construction

  • Intermediate Representation (IR) is the compiler’s private form of your program - a simplified tree that’s easier to analyze and transform than raw Go code.
  • The typed AST is converted into Go’s IR (cmd/compile/internal/ir).
  • Go uses a Unified IR, which supports generics, cross-package inlining, and import/export of inlineable code.
  • This IR is the main input for optimizations and later passes like Walk and SSA.

Optimizations on IR

  • Escape analysis: The compiler checks if a variable can safely live on the stack (cheap, fast) instead of the heap (slower, garbage-collected). If a value’s lifetime doesn’t “escape” the function, it stays on the stack. Otherwise, it’s allocated on the heap.
  • Inlining: Small function calls may be replaced with the function’s body directly at the call site. This removes the overhead of calling a function and can unlock further optimizations.
  • Devirtualization: In Go, methods are really just functions with the struct (or pointer) passed as the first argument - similar to how C++ methods become functions with a hidden this pointer.
method-as-func.go
type Person struct{}

func (p Person) Greet() string {   // really: func Greet(p Person) string
	return "Hello"
}

Normally, when you call a method through an interface, Go must look up the method in an internal table (like a C++ vtable).

If the compiler can prove the exact concrete type, it can skip the lookup and call the function directly.

This optimization is called devirtualization, and it effectively turns runtime polymorphism into a direct compile-time call (faster).

Example where devirtualization can happen:

devirt-can.go
package main

import "fmt"

type Greeter interface {
	Greet() string
}

type Person struct{}

func (Person) Greet() string {
	return "Hello from Person"
}

func sayHi(g Greeter) string {
	return g.Greet()
}

func main() {
	// The compiler sees g is always a Person
	// Devirtualizes to a direct call Person.Greet()
	fmt.Println(sayHi(Person{}))
}

Example where devirtualization cannot happen:

devirt-cannot.go

package main

import (
	"fmt"
	"math/rand"
	"time"
)

type Greeter interface {
	Greet() string
}

type Person struct{}

func (Person) Greet() string {
	return "Hello from Person"
}

type Robot struct{}

func (Robot) Greet() string {
	return "Beep Boop"
}

func sayHiDynamic(g Greeter) string {
	// Here g could be Person, Robot, or any Greeter
	// Compiler cannot know the type at compile time
	// Must do interface method lookup (runtime polymorphism)
	return g.Greet()
}

func main() {
	rand.Seed(time.Now().UnixNano())

	var g Greeter
	if rand.Intn(2) == 0 {
		g = Person{}
	} else {
		g = Robot{}
	}

	fmt.Println(sayHiDynamic(g))
}

Early dead code elimination: Any code that can never run (like if false { ... }) or whose results are never used gets removed at this stage, keeping the program smaller and faster.

Walk (Lowering Go Constructs)

  • Converts high-level Go features into simpler steps the compiler can handle.
  • switch => turns into jump tables or a chain of if statements.
  • range => becomes a normal for loop with indexes.
  • Map/channel operations => replaced with calls into the Go runtime.
  • defer / recover => expanded into helper code from the runtime.
  • Adds temporary variables to guarantee Go’s left-to-right order of evaluation.
  • Produces IR made only of primitive operations, ready for SSA.

SSA Form (Static Single Assignment)

  • IR is translated into SSA (cmd/compile/internal/ssa), where each variable gets only one assignment.
  • This makes dataflow easy to track and optimize.
  • Key optimizations at this stage:
  • Constant folding: replace 2+3 with 5.
  • Nil-check removal: skip repeated checks like if x==nil.
  • Bounds-check removal: skip slice/array checks proven safe.
  • Branch pruning: cut out if true { ... } or unreachable code.
  • CSE (common subexpr elimination): reuse the result of repeated expressions.
  • Intrinsics: swap Go functions (e.g. math.Sqrt) with fast CPU instructions.

Machine-Dependent Lowering

  • SSA is converted into code tailored for your CPU (amd64, arm64, etc.).
  • The compiler applies small CPU-specific tweaks (rewrite rules, peephole optimizations).
  • Instruction selection: choose the actual machine instructions to run.
  • Instruction scheduling: order them for best CPU performance.
  • Register allocation: put values into CPU registers; if not enough, spill extras to memory.
  • Stack frame layout: decide where local variables go on the stack and mark safe points for the garbage collector.

Code Generation

  • SSA is turned into obj.Prog instructions (Go’s own assembly format).
  • The assembler (cmd/internal/obj) translates these into real machine code.
  • The result is an object file containing:
  • The compiled instructions.
  • Export data so other packages can use this code.
  • Type info needed for reflection.
  • Debug info (symbols, line numbers) for stack traces and tooling.

Linking

  • The Go linker (cmd/link) merges all object files and the runtime.
  • Resolves symbols across packages.
  • Produces a final self-contained binary executable for the target OS/arch.

Execution

  • The binary can be run directly.
  • The Go runtime (scheduler, GC, goroutines, channels) is statically linked in, so no external dependencies are needed.

9) Build Options & Best Practices

When to use plain go build vs custom targets

Use go build when you build for your computer.

terminal
go build ./cmd/app

Use custom targets when you need a binary for a different system (release builds, CI, servers).

terminal
# Linux/amd64 (from any OS)
GOOS=linux GOARCH=amd64 go build ./cmd/app

# Windows
GOOS=windows GOARCH=amd64 go build -o app.exe ./cmd/app

# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build ./cmd/app

Should I disable cgo?

  • CGO_ENABLED=0 = pure Go. Smaller, simpler, cross-compiles easily. Use this unless you really need C code.
  • CGO_ENABLED=1 only when you import "C" or depend on C libraries.
terminal
# Pure Go build
CGO_ENABLED=0 go build ./cmd/app

Production build

Goal: small, reproducible, fast startup, easy to ship.

terminal
# pick your target
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -buildvcs=false -ldflags="-s -w" -o app .

What this does:

  • CGO_ENABLED=0 => pure Go binary (no dependency on system C libraries)
  • GOOS=linux GOARCH=amd64 => GOOS=linux GOARCH=amd64
  • -trimpath => strips local file system paths from the binary
  • -buildvcs=false => disables embedding Git metadata (commit hash, dirty state)
  • -ldflags="-s -w"
  • - -s: strip symbol table.
  • - -w: strip DWARF debug info
  • -o app => names the output binary app instead of the default.
  • . => tells go build to build the package in the current directory.

Build tags

What they are: tiny switches that decide what code gets compiled. They work at compile time, so unwanted code is not in the binary.

How to write one in a file: put it at the very top of the file.

debug.go
//go:build debug
package mypkg

This file is included only when you build with the debug tag.

How to use from CLI:

terminal
go build -tags "tag1,tag2,tag...,tagN" ./cmd/app

Common uses:

  • debug vs release files.
  • Code for a specific OS/arch (//go:build linux, //go:build windows).