Skip to content
CalliCoder

Hello Golang: Your First Go Program

Golang 13 min read

A module, a package main with a func main, and gofmt deciding the formatting. Plus why an unused import is a compile error, and what go run does that go build does not.

A first Go program is five lines, and each of them is a rule rather than a convention. The package must be main, the function must be main, the brace must be on the same line, and an unused import will not compile. Go has very few options, and this is where that starts.

Written against Go 1.22.

Installing

# macOS
brew install go

# Linux — download the tarball, then
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
go version

GOPATH no longer matters for a new project. Modules replaced it in Go 1.11 and became the default in 1.16, so a project lives wherever you put it, not inside a mandated directory tree. Guides that tell you to create ~/go/src/github.com/you/project are describing the pre-module world.

The module

mkdir hello && cd hello
go mod init example.com/hello

That writes go.mod:

module example.com/hello

go 1.22

The module path is an identity, not a URL that has to resolve. It only needs to be fetchable if someone else will import your package, in which case it should match the repository — github.com/you/hello.

The go directive is more than a note: it selects language semantics. A module declaring go 1.21 gets the old loop-variable scoping even on a newer toolchain, which is the one case where this line changes behaviour rather than just recording intent.

The program

package main

import "fmt"

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

Four rules in five lines.

package main marks an executable. Any other name produces a library, which go run refuses with “package is not a main package”. The directory name is irrelevant, the declaration decides.

func main() is the entry point, takes no arguments and returns nothing. Command-line arguments come from os.Args rather than a parameter, and the exit code from os.Exit rather than a return value.

The opening brace is on the same line. Go inserts semicolons at the end of any line ending in a value or a closing bracket, so a brace on the next line terminates the function signature and the program does not compile. This is not a style preference the formatter enforces. It is the grammar.

Println is capitalised because Go’s export rule is capitalisation. An identifier starting with an upper-case letter is visible outside its package; a lower-case one is not. There is no public keyword, and fmt.println does not exist.

Unused things are errors

import (
	"fmt"
	"os"        // declared and not used: "os"
)

func main() {
	count := 10  // declared and not used: count
	fmt.Println("Hello")
}

Both are compile errors, not warnings. This surprises everyone coming from a language where an unused import is lint noise.

The reasoning is that unused code is either a mistake or dead weight, and a warning nobody reads is worse than no check. In practice it means the import block is always accurate, and it is the main reason goimports exists, the tool adds and removes imports as you type, because doing it by hand would be tedious.

Unused function parameters and package-level variables are allowed. It is specifically local variables and imports.

The escape hatch during debugging is _ = count, and a blank import _ "database/sql/driver" for a package imported only for its side effects.

gofmt is not configurable

gofmt -w .
go fmt ./...

Tabs for indentation, a specific brace style, aligned struct fields, sorted import groups. There are no options, and that is the design: every Go codebase looks the same, so there is nothing to argue about in review and no per-project style file.

Configure the editor to run it on save. Fighting it is the one thing guaranteed not to work, and gofmt -l . in CI catches anything that slipped through.

go run against go build

go run .                          # compile to a temporary location and execute
go build                          # produce ./hello in the current directory
go build -o bin/hello .           # name the output
go install                        # build and place it in $GOPATH/bin (usually ~/go/bin)

go run compiles to a temporary directory and discards the binary afterwards, which is what makes it feel like a script runner. It is for development; it recompiles every time.

go build produces a statically linked binary with the runtime included and no external dependencies, around 2 MB for this program. That is the property that makes Go popular for containers: the image can be FROM scratch with one file in it.

Cross-compiling is two environment variables:

GOOS=linux GOARCH=arm64 go build -o hello-linux-arm64 .

No toolchain to install, no cross-compiler. This works from any platform to any platform as long as the code does not use cgo.

Adding a dependency

go get github.com/google/uuid
import "github.com/google/uuid"

func main() {
	fmt.Println(uuid.New().String())
}

go get adds the requirement to go.mod and records a checksum in go.sum. Commit both. go.sum is a lock on the exact content of every dependency, and verifying it is what makes a build reproducible.

go mod tidy adds anything imported and missing, and removes anything required and unused. Run it before every commit; a CI check that runs it and fails on a diff keeps the file honest.

There is no central registry. A module path is a location, and the default proxy at proxy.golang.org caches whatever it resolves, which means a deleted repository does not immediately break every build, and it also means the module path in go.mod is doing real work.

The first test

Testing is in the standard library and needs no dependency, which is worth seeing on day one because it shapes how Go code is written.

// greeting.go
package greeting

func Greet(name string) string {
	if name == "" {
		return "Hello, stranger"
	}
	return "Hello, " + name
}
// greeting_test.go
package greeting

import "testing"

func TestGreet(t *testing.T) {
	tests := []struct {
		name string
		in   string
		want string
	}{
		{"named", "Ada", "Hello, Ada"},
		{"empty", "", "Hello, stranger"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Greet(tt.in); got != tt.want {
				t.Errorf("Greet(%q) = %q, want %q", tt.in, got, tt.want)
			}
		})
	}
}
go test ./...
go test -v -run TestGreet ./...
go test -cover ./...

The file must end in _test.go and the function must start with Test and take *testing.T. There are no assertion helpers in the standard library, the convention is an if and a t.Errorf naming the input, the actual value and the expected one, in that order.

The table-driven shape above is idiomatic Go and worth adopting immediately. t.Run gives each case its own name in the output, so a failure says which row broke rather than only which function.

t.Errorf records a failure and continues; t.Fatalf stops that subtest. Use the second when continuing would panic.

Layout

hello/
├── go.mod
├── go.sum
├── main.go
└── internal/
    └── greeting/
        └── greeting.go

One package per directory, and the package name conventionally matches the directory. internal/ is special to the toolchain: packages under it can only be imported by code rooted at its parent, which is the language’s only visibility control above the identifier level.

More Go in the Golang guides, starting with control flow and functions.

Frequently asked questions

Do I still need to set GOPATH?

No. Modules replaced it, and a project can live anywhere. Guides insisting on ~/go/src/... predate Go 1.11.

What does the module path have to be?

Anything unique, unless others will import it: then it must match the repository location so the toolchain can fetch it.

Why must the opening brace be on the same line?

Go inserts semicolons at line ends. A brace on the next line terminates the signature, and the code does not compile. It is grammar, not style.

Why is an unused import an error?

Deliberately: unused code is a mistake or dead weight, and a warning would be ignored. Use goimports so the block maintains itself.

Does that apply to unused function parameters?

No. Only local variables and imports. Assign to _ to silence a local temporarily.

Why is Println capitalised?

Capitalisation is Go’s export rule. Upper-case identifiers are visible outside their package; there is no public keyword.

Can I configure gofmt?

No, and that is the point. Every Go codebase formats identically, so there is no style discussion and no per-project config.

What is the difference between go run and go build?

go run compiles to a temporary location and executes, discarding the binary. go build writes a statically linked executable you can ship.

How do I cross-compile?

Set GOOS and GOARCH before go build. No extra toolchain is required unless the code uses cgo.

Should I commit go.sum?

Yes. It pins the exact content of every dependency and is what makes the build reproducible and verifiable.