CHAPTER 04 · FOUNDATIONS

Hello, World & Your First Module

The "Hello, World!" program is a rite of passage. It's trivial, but writing one proves your toolchain works and gives us a tiny anchor to talk about Go's structure, the go CLI, and modules.

Learning objectives

  • Write, run, and build your first Go program.
  • Explain every line of a minimal Go source file.
  • Initialize a Go module with go mod init.
  • Choose correctly between go run, go build, and go install.
  • Format code with go fmt and cross-compile to other platforms.

Writing Hello World

Create a directory for your first program and put a single Go file in it:

mkdir -p ~/code/hello
cd ~/code/hello
touch main.go

Open main.go in your editor and type:

package main

import "fmt"

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

Seven lines of code. We'll unpack each one.

Line by line

package main

Every Go source file begins with a package declaration. The package main is special: it's the one Go recognizes as a runnable program. Any other package name (e.g. mathutil) would produce a library, not a binary. We'll revisit packages in depth in Chapter 18.

import "fmt"

import pulls in another package so you can use its exported names. fmt is Go's standard library package for formatted I/O: printing, string formatting, scanning input. You'll see fmt in almost every program.

!
Unused imports break the build If you import a package but never use it, Go refuses to compile. This prevents dead imports from accumulating in your codebase. Your editor (with goimports enabled) manages imports automatically , add or remove fmt.Println calls and the import line will update on save.

func main() { … }

The entry point. Exactly one main function must live in the main package. When Go launches your binary, main() runs and, when it returns, the program exits.

fmt.Println("Hello, World!")

fmt.Println prints its arguments to standard output, separated by spaces, followed by a newline. The string "Hello, World!" is a Go string literal (Chapter 7 is dedicated to strings).

What's absent

Notice what's not there:

  • No semicolons ending lines. Go inserts them automatically.
  • No explicit types on anything. Go can tell "Hello, World!" is a string.
  • No exception handling syntax. Errors in Go are values, returned from functions (Chapter 23).

Running it with go run

The quickest way to see output:

go run main.go

You should see:

Hello, World!

go run compiles your code to a temporary binary, executes it, then deletes the binary. It's perfect for iteration, no artifacts left behind.

You can also pass a directory or a package path:

go run .          # compile + run the current package
go run ./cmd/app  # compile + run a package at a relative path

Once your program has more than one file, you'll use go run . rather than listing individual files.

Your first module

Go projects are organized into modules. A module is just a directory with a go.mod file in it. Even for a one-file hello-world, it's good practice to initialize one:

go mod init example.com/hello

This creates a file called go.mod:

module example.com/hello

go 1.22

The module line declares the module's import path. It's typically a URL where the code lives (like github.com/you/hello) , even if you never publish it. The go line records the minimum Go version this module supports. You rarely edit go.mod by hand; Go tools do it when you add dependencies.

i
Why a module if you don't publish it? A go.mod unlocks import path resolution (you can split your program into multiple packages), dependency tracking, and reproducible builds. It's free; just do it. Chapter 18 dives deeper.

Building a binary with go build

To produce an actual executable file:

go build .

Go compiles your package and places the binary in the current directory. On macOS/Linux it's called hello; on Windows hello.exe. Run it:

./hello            # macOS / Linux
hello.exe          # Windows

You can also name the output file:

go build -o greet .
./greet

This binary is static: it has no external runtime dependencies. Copy it to a machine with the same OS/architecture and it runs. That's the Go deployment superpower in a nutshell.

go run vs build vs install

CommandWhat it doesUse when
go run Compiles to a temp binary, runs it, deletes it Iterating, changing code and re-running quickly
go build Produces a binary in the current directory (or wherever -o says) Shipping a standalone executable
go install Compiles and places the binary in $GOPATH/bin (usually ~/go/bin) Installing a CLI tool system-wide, e.g. installing someone else's tool

Typical example of go install:

# Install golangci-lint from the internet, now available on your $PATH
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

This is how you install most Go CLI tools. Make sure ~/go/bin is on your PATH (you set this up in Chapter 3).

Formatting with go fmt

Go has one canonical code style, enforced by the gofmt tool. No tabs-vs-spaces debates, no bikeshedding over where to put braces. Your editor should format on save (Chapter 3) , but you can also run it manually:

go fmt ./...

(The ./... pattern means "the current directory and all subdirectories recursively", a common Go idiom.)

An even better version is goimports, which runs gofmt and manages the import block for you:

go install golang.org/x/tools/cmd/goimports@latest
goimports -w .
Embrace gofmt It's actually freeing to not think about formatting. Every Go project you'll ever see is formatted the same way. You can skim strangers' code without friction. This is one of the small things that makes Go a pleasure to write over time.

Exit codes

Every program that finishes reports an exit code to its parent (usually the shell). 0 means success; anything non-zero means something went wrong. Go's main function returns 0 implicitly. To exit with a specific code:

package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: greet NAME")
        os.Exit(1) // exit code 1, "usage error"
    }
    fmt.Println("Hello,", os.Args[1])
}

Two new ideas:

  • os.Args is a slice containing the program name followed by command-line arguments. (We'll cover slices in Chapter 13.)
  • fmt.Fprintln(os.Stderr, ...) writes to standard error instead of standard output, the Unix-conventional channel for errors.

Check the exit code after running:

go run .          # prints usage, exits 1
echo $?           # prints: 1
go run . Alice    # prints "Hello, Alice"
echo $?           # prints: 0

Cross-compilation

Go can build binaries for other operating systems and CPU architectures from your current machine. Two environment variables control this: GOOS and GOARCH.

# From a Mac, build a Linux binary
GOOS=linux  GOARCH=amd64  go build -o hello-linux   .

# ...a Windows binary
GOOS=windows GOARCH=amd64 go build -o hello.exe      .

# ...a Raspberry Pi (ARM) binary
GOOS=linux  GOARCH=arm64  go build -o hello-pi      .

See the full list with:

go tool dist list

This is why Go has become the go-to language for shipping CLI tools and cloud-native infrastructure: one developer, on one laptop, can produce binaries for every platform the tool needs to run on, no Docker or cross-toolchain setup required.

Check your understanding

Practice exercises

EXERCISE 1

Make it yours

Modify the Hello World program so that:

  • If given a command-line argument, greet that person by name.
  • If given two arguments, greet both: "Hello, Alice and Bob!"
  • If given zero arguments, greet the world.

Run it with go run . Alice, then go run . Alice Bob, then go run ..

Show a working solution
package main

import (
    "fmt"
    "os"
    "strings"
)

func main() {
    names := os.Args[1:]
    switch len(names) {
    case 0:
        fmt.Println("Hello, World!")
    case 1:
        fmt.Printf("Hello, %s!\n", names[0])
    default:
        fmt.Printf("Hello, %s!\n", strings.Join(names, " and "))
    }
}

You'll see strings.Join, switch, and slicing again in future chapters. Don't worry if the syntax feels new, you're pattern-matching, which is exactly the right skill.

EXERCISE 2

Ship a real binary

Build your greeter as an actual binary, then run it without Go.

go build -o greet .
./greet Alice

Bonus: cross-compile for a different OS and verify you get a file with the right extension:

GOOS=linux GOARCH=amd64 go build -o greet-linux .
file greet-linux    # should say: ELF 64-bit LSB executable
What you've just done

You produced a self-contained binary with no runtime dependencies , the same kind of artifact Docker, Kubernetes, and Terraform ship. The file command confirms the binary matches the target OS.

This pattern (cross-compile to a target, upload, run) is the core of Go's deployment story.

Further reading

First program shipped, well done.