Blog

Hello, Go: The Toolchain, Modules and Your First Program

A first Go program needs one command-line tool, a go.mod file and a package called main. Here is what go run, go build, gofmt and go vet each do, and why Go refuses to build code with an unused import.

Go ships as one tool called go. It builds your code, runs it, formats it, checks it for common mistakes and manages its dependencies. You don’t pick a build system or a formatter, because they come in the box.

This post sets that tool up, writes a first program, and walks through the commands you’ll type every day. Every program below was run on Go 1.26, and its output is pasted from the run.

Installing Go and checking it works

Go installs from the downloads page at go.dev/dl, or from your system’s package manager. Either way, you end up with a go command on your path. Check it:

$ go version
go version go1.26.2 linux/amd64

The last part names your operating system and processor, so yours may say darwin/arm64 or windows/amd64. What matters is that it says go1.26 or later.

That’s the whole setup. Older tutorials tell you to create a GOPATH folder and keep all your code inside it. You don’t need to do that any more. Since modules arrived, a Go project can live in any folder on your disk.

GOPATH still exists, but it’s a place Go uses for itself. It holds a cache of downloaded dependencies, and it’s where go install puts the programs it builds. You can go a long time without looking inside it.

Your first program

A Go project starts with a folder and a go mod init command, which makes it a module:

$ mkdir hello
$ cd hello
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello

Now create a file called main.go in that folder:

package main

import "fmt"

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

It prints:

Hello, Go

Run it with go run ., where the dot means “the package in this folder”. Three lines do all the work.

package main says which package this file belongs to. Every Go file starts with a package line. A package called main is special: it’s the one that becomes a program you can run. Any other name makes a library that other code imports.

import "fmt" brings in the standard library’s formatting package. fmt.Println prints its arguments with spaces between them and a newline at the end.

func main() is where the program starts. It takes no arguments and returns nothing. When main returns, the program ends.

Both halves are required. A main package without a main function can’t become a program, and Go says so:

package main

import "fmt"

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

The build fails with:

function main is undeclared in the main package

Capital M made that a different function. Go names are case-sensitive, and as you’ll see shortly, the case of the first letter means something.

go run and go build

Go gives you two ways to run a program: go run for trying it now, and go build for making something you can hand to someone else.

$ go run .
Hello, Go
$ go build
$ ls
go.mod  hello  main.go
$ ./hello
Hello, Go

go run compiles the program to a temporary file, runs it, and throws the file away. go build compiles it and keeps the result, a file named after the module’s last path element, hello.

That file is the whole program. Copy it to another Linux machine of the same kind and it runs there, even if Go was never installed on it. There’s no runtime to install first and no folder of libraries to ship next to it.

You can also build for a different operating system from the one you’re on, by setting two environment variables:

$ GOOS=windows GOARCH=amd64 go build
$ ls
go.mod  hello  hello.exe  main.go

hello.exe is a Windows program, built on Linux, with nothing extra installed.

Explain it like I’m ten

Think of a recipe and a cake.

A script, like a Python program, is a recipe. To get cake, the person you give it to needs a kitchen, an oven and someone who reads recipes. If their oven is a different model, the cake might come out wrong.

A compiled Go program is the finished cake in a box. The cooking already happened on your computer. The person you give it to just opens the box. They don’t need a kitchen.

The precise version

The Go compiler turns your source code, and every package it imports, into machine code for one operating system and one processor. It links all of that into a single executable file. The Go runtime, which runs the garbage collector and schedules goroutines, is compiled into that same file.

A program like this one, which uses only pure Go packages, is statically linked: it doesn’t load shared libraries when it starts. That’s why the file is a couple of megabytes for a program that prints one line, and it’s also why you can copy it anywhere that matches its GOOS and GOARCH.

A Python or JavaScript program works the other way. You ship the source, and the machine needs the right interpreter installed to run it.

Where the analogy breaks: a boxed cake can be eaten anywhere, but a Go binary only runs on the system it was built for. A Linux binary won’t start on Windows. You bake a separate cake for each kind of kitchen, which Go makes cheap with GOOS and GOARCH.

What go.mod records

The go.mod file that go mod init created is short. Here it is in full:

$ cat go.mod
module example.com/hello

go 1.26.2

The module line is the module’s path, and it’s the prefix for every import path inside it. It looks like a web address because published modules are usually found at one. For a project that never leaves your machine, any name works, but the domain style keeps you out of trouble if you do publish later.

The go line says which version of the Go language this module is written for. go mod init filled in the exact version that ran it, patch number included, so yours will match your toolchain.

That line does more than document. It switches language features on and off. Change main.go to a loop that uses range over an integer, for i := range 3, a feature added in Go 1.22. Then set the go line to an older version and run it:

$ go mod edit -go=1.21
$ go run .
# example.com/hello
./main.go:6:17: cannot range over 3 (untyped int constant): requires go1.22 or later (-lang was set to go1.21; check go.mod)

The compiler was Go 1.26 the whole time. It refused the loop because go.mod said the code targets 1.21. That’s how old modules keep building the same way as Go adds features, and the error even tells you which file to check.

When your module starts using other people’s code, go.mod also lists those dependencies and their versions. That comes back in the part on packages and modules.

Imports and exported names

The standard library is a set of packages you import by path, like fmt and strings. Once imported, you call what’s inside with the package name, a dot, and the name:

package main

import (
	"fmt"
	"strings"
)

func main() {
	title := "hello, go"
	fmt.Println(strings.ToUpper(title))
	fmt.Println(strings.Fields("  one   two three "))
	fmt.Println(strings.Contains(title, "go"))
}

It prints:

HELLO, GO
[one two three]
true

Two or more imports go in a parenthesised block, one per line. strings.ToUpper returns an upper-case copy, strings.Fields splits on runs of spaces, and strings.Contains reports whether one string appears in another.

Every name you called there starts with a capital letter: Println, ToUpper, Fields, Contains. That’s not a style choice. In Go, a name that starts with a capital letter is exported, which means code outside the package can use it. A name that starts with a lower-case letter is private to its package.

There’s no public or private keyword. The first letter is the whole rule. Get it wrong and the program doesn’t build:

package main

import (
	"fmt"
	"strings"
)

func main() {
	fmt.Println(strings.toUpper("quiet"))
}

The build fails with:

./main.go:9:22: undefined: strings.toUpper (but have ToUpper)

The compiler didn’t just say the name doesn’t exist. It found the exported name that differs only in case and suggested it. Error messages like that one are worth reading slowly, because they often contain the fix.

The rule applies to your own code too. Inside package main, a function called greeting and one called Greeting both work, because nothing outside main imports it. It starts to matter once you split code into packages of your own.

gofmt: one format for everyone

Go has one official layout for source code, and a tool called gofmt applies it. Here’s a program written with no care for layout at all:

package main
import "fmt"
func main()  {
    x:=[]int{1,2,3}
  for _,v:=range x {fmt.Println( v )}
}

It compiles and runs. Now let gofmt fix it:

$ gofmt -l .
main.go
$ gofmt -w main.go

gofmt -l lists files whose layout is off, and gofmt -w rewrites them in place. After the rewrite, the file looks like this:

package main

import "fmt"

func main() {
	x := []int{1, 2, 3}
	for _, v := range x {
		fmt.Println(v)
	}
}

It prints:

1
2
3

Tabs for indentation, spaces around := and after commas, a blank line between sections, and the loop body on its own lines. There are no options to change any of that.

That’s the point. With one format, nobody on a team argues about brace placement, and code review diffs show only real changes. Every Go project you open looks familiar. Most editors run gofmt when you save, so you rarely call it by hand. The go fmt ./... command does the same thing for every package in a module.

go vet: a bug that compiles

Some mistakes are legal Go but almost certainly wrong, and go vet looks for them. Here’s a common one, with the arguments to Printf in the wrong order:

package main

import "fmt"

func main() {
	name := "Ada"
	age := 36
	fmt.Printf("%s is %d years old\n", age, name)
}

%s expects a string and %d expects a whole number. This program passes them the other way round, and it still builds and runs:

$ go run .
%!s(int=36) is %!d(string=Ada) years old
$ go vet
main.go:8:14: fmt.Printf format %s has arg age of wrong type int

The compiler can’t catch this, because Printf accepts any values after the format string. At run time, fmt doesn’t crash either. It prints %!s(int=36), its way of saying “you asked for a string and gave me an int”. A line like that can sit in a log file for months before anyone notices.

go vet reads the format string, matches each verb to its argument, and names the line and the problem. Swap the arguments and the program is right:

package main

import "fmt"

func main() {
	name := "Ada"
	age := 36
	fmt.Printf("%s is %d years old\n", name, age)
}

It prints:

Ada is 36 years old

go test runs a set of these vet checks automatically, but it’s worth running go vet yourself before you commit. Every program in this series passes it.

Unused variables and imports don’t compile

Go treats an unused local variable or an unused import as a compile error, not a warning. This program declares a variable it never reads and imports a package it never calls:

package main

import (
	"fmt"
	"os"
)

func main() {
	count := 3
	fmt.Println("hello")
}

The build fails with:

./main.go:5:2: "os" imported and not used
./main.go:9:2: declared and not used: count

Both lines point at the exact line and column. The program won’t build until you remove count and the os import, or use them.

This feels strict the first time it happens, usually halfway through an edit. The reasoning is practical. An unused variable is often a bug: you computed something and then used the wrong name. An unused import slows down every build and hides what the file really depends on. Go decided both were worth stopping at the door.

When you really do need to ignore a value, the blank identifier _ says so on purpose. You saw it in for _, v := range x, where the index isn’t wanted.

Reading command-line arguments

A Go program reads the words typed after its name from os.Args, a slice of strings. os.Args[0] is the program’s own path, and the arguments start at index 1.

Keeping the logic in its own function makes it easy to test without typing anything:

package main

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

func greeting(names []string) string {
	if len(names) == 0 {
		return "Hello, whoever you are"
	}
	return "Hello, " + strings.Join(names, " and ")
}

func main() {
	fmt.Println(len(os.Args))
	fmt.Println(greeting(os.Args[1:]))
	fmt.Println(greeting([]string{"Ada", "Grace"}))
}

Run with no arguments, it prints:

1
Hello, whoever you are
Hello, Ada and Grace

len(os.Args) is 1, because the only entry is the program’s path. os.Args[1:] is then an empty slice, so greeting takes its first branch. The last line calls greeting directly with two names, which is how a test would call it.

Build it and pass real arguments, and main has something to greet:

$ go build
$ ./greet Ada Grace Linus
4
Hello, Ada and Grace and Linus
Hello, Ada and Grace

Now os.Args holds four strings: the program’s path and three names. The shell split the words on spaces before Go ever saw them.

go run passes arguments through too. Anything after the package goes to your program:

$ go run . Ada Grace
3
Hello, Ada and Grace
Hello, Ada and Grace

For anything past a handful of plain words, the standard library’s flag package parses options like -port 8080 for you.

What to remember

  • One go command builds, runs, formats, vets and manages dependencies. No GOPATH workspace is needed, and a module can live in any folder.
  • A runnable program is package main with a func main(). Any other package name makes a library.
  • go run compiles and runs in one step. go build leaves a single binary that runs without Go installed, and GOOS/GOARCH build it for other systems.
  • go.mod holds the module path and the go line, and the go line decides which language features the compiler allows.
  • A name starting with a capital letter is exported. That’s the only visibility rule.
  • gofmt gives all Go code one layout, and go vet catches legal code that’s almost certainly wrong, like a mismatched Printf verb.
  • Unused variables and imports are compile errors. Use _ when you mean to ignore a value.

Go puts the build, the format and the first round of checks in one tool, so every project starts from the same place.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.