Skip to main content

Your First Go Program: Toolchain, Modules, and Packages

Most "hello world" tutorials end the moment the text appears on screen. You type five lines, something prints, and you move on without knowing what just happened. Then, a week later, you create a second file, add a folder, and Go greets you with go.mod file not found or name clean not exported by package task. The five-line program never prepared you for that.

So let's do the first program properly, along with everything around it: the go command, the module file that gives your project an identity, packages, and the two tools (gofmt and go vet) every Go developer runs constantly. By the end you'll have a tiny command-line tool called taskly that we'll keep growing through this series.

Quick Reference​

When to use: Every time you start a new Go project.

Basic syntax:

mkdir taskly && cd taskly
go mod init example/taskly # creates go.mod
go run . # compile and run the package in this folder
go build -o taskly . # produce a binary named taskly
package main // an executable program always lives in package main

import "fmt"

func main() { // execution starts here
fmt.Println("taskly: 0 tasks today")
}

Common patterns:

  • go run . while developing, go build when you want a file you can ship
  • GOOS=linux GOARCH=arm64 go build to build for another operating system or CPU
  • gofmt -l . to list badly formatted files, gofmt -w . to fix them
  • go vet ./... to catch suspicious code that still compiles

Gotchas:

  • ⚠️ Lowercase names are private to their package
  • ⚠️ The go line in go.mod is an enforced minimum version

Version Information​

Written for: Go 1.27.1 (the latest stable release when this article was written)

Tested with: Go 1.24.7 on Linux amd64. Every output shown comes from a real run, except the go version line and the new go.mod, which are shown the way Go 1.27.1 prints them. Nothing else used here changed between 1.24 and 1.27.

Known differences between versions:

  • ⚠️ Go 1.26 and newer: go mod init writes a go line one minor version behind your toolchain. With Go 1.27.1 you'll see go 1.26.0 in your new go.mod. Go 1.25 and older write your exact version (go 1.24.7). Both are fine.
  • ⚠️ Go 1.21 and newer: the go line is an enforced minimum.
  • ✅ Go 1.24 and newer: go run caches the executables it builds.

What You Need to Know First​

Required reading:

Technical prerequisites:

  • No Go experience needed. Experience in any other language helps but isn't required.

Tools you'll need:

  • Go installed from go.dev/dl. Follow the official install guide for your operating system.
  • A code editor. VS Code with the official Go extension, GoLand, or Neovim with gopls all work.

What We'll Cover in This Article​

By the end of this guide, you'll understand:

  • Realistic reasons to learn Go
  • What a module is and what go.mod means
  • How package main, import, and func main fit together
  • How packages and capital letters control visibility
  • go run vs go build, and building for other platforms
  • How to use gofmt and go vet
  • Which official resources to read next
  • The Go conventions for formatting, package names, and doc comments

What We'll Explain Along the Way​

These concepts will be explained as we encounter them:

  • Compiled vs interpreted languages
  • Standard output vs standard error, and exit codes
  • Statically linked binaries and build-time variables (-ldflags)

Why Go, With the Right Expectations​

You'll often hear that Go is fast, has great concurrency, and compiles to a single binary. All true. But one of the videos this series grew out of makes a point I keep coming back to: if speed or concurrency is your main reason, you'll probably be disappointed for a while. A small API spends most of its time waiting on its database, so rewriting it in Go barely moves the response time. And concurrency tools solve problems most beginner projects don't have yet. Go looking for places to use them, and you end up adding goroutines where a plain function call was better.

This section draws on the video Why and how to learn backend engineering with Go?.

A more useful set of expectations:

  • Go is opinionated. There's usually one obvious way to do things, and formatting is automatic. You stop arguing about style.
  • Go is small. The language specification is short enough to read in an afternoon.
  • Go is different in a few specific places. Errors are returned values, not exceptions. There are no classes. Capital letters decide visibility. This series slows down at each of these.

Speed and concurrency show up later, when your project needs them.

Checking Your Install​

Once Go is installed, open a terminal and ask it who it is:

go version
go version go1.27.1 linux/amd64

That's the toolchain version, the operating system (linux), and the CPU architecture (amd64). On an Apple Silicon Mac you'd see darwin/arm64.

Go calls those last two values GOOS and GOARCH. You can ask for them directly:

go env GOOS GOARCH
linux
amd64

Remember these two names. They come back when we build for a computer we don't own.

💡 If you get command not found: go, Go's bin folder isn't on your PATH. On Linux and macOS, add export PATH=$PATH:/usr/local/go/bin to your shell profile and open a new terminal. On Windows, reopen the terminal after installing.

Creating Your First Module​

In Python you can write hello.py anywhere and run it. In Go, your code lives inside a module: a collection of packages that is versioned and shipped together. Think of it like this:

  • The module is a building with an ID card at the entrance. The ID card is the go.mod file.
  • Each package is a room in that building. A room is just a folder of .go files.
  • package main with func main is the front door. It's the one room you can walk into from outside to start the program.

Let's make the building:

mkdir taskly
cd taskly
go mod init example/taskly
go: creating new go.mod: module example/taskly

Now look at what Go created:

cat go.mod

With Go 1.27.1 you get:

module example/taskly

go 1.26.0

module example/taskly is the module path: your project's name. Every import of your own packages starts with it, so the task folder we'll create soon is imported as example/taskly/task. Go reads that folder straight from your disk. Nothing is downloaded, and no account or internet connection is involved.

Why example/? Go reserves the example prefix for tutorials and learning projects, so it can never clash with a real package. Go's own tutorials use it too.

You'll often see module paths that look like URLs instead, such as github.com/someone/project. That form only matters when you publish a module: it tells other people's go command where to download it from. For a project that lives on your machine, it's just a longer name. If you publish taskly one day, renaming the module takes one command (go mod edit -module github.com/you/taskly) plus updating the imports.

go 1.26.0 is the minimum Go version this module needs. Since Go 1.21, an older toolchain won't build your code. The line also decides which language features compile: a Go 1.27 feature won't work here until you raise it.

📝 Correction: older tutorials describe the go line as informational. That stopped being true in Go 1.21 (Go toolchains documentation).

You may later see a toolchain go1.27.1 line. That's only a suggestion for which toolchain to use inside this module.

If you're coming from another ecosystem, here's a rough map:

GoNode.jsPython
go.modpackage.jsonpyproject.toml / requirements.txt
go.sum (appears once you add dependencies)package-lock.jsonlock file from your tool (poetry.lock, uv.lock)
module pathpackage nameproject name
go lineengines.noderequires-python

The match isn't exact (engines.node is mostly advisory), but it gets you oriented.

Writing and Running the Program​

Create a file called main.go in the taskly folder:

// Purpose: The smallest complete Go program
// Context: The starting point for taskly
// Input: None
// Output: One line of text on standard output

package main // Step 1: this file belongs to package main, so it builds into a program

import "fmt" // Step 2: bring in the standard library's formatting package

func main() { // Step 3: the program starts running here
fmt.Println("taskly: 0 tasks today") // Step 4: print a line and a newline
}

Run it:

go run .
taskly: 0 tasks today

It works. Let's look at what each line does.

package main: every Go file starts by naming its package. main is special: it tells the compiler to build an executable. Any other name (package task) builds a library that other code imports.

import "fmt": pulls in fmt, the standard library's formatting package. Go is strict here. Delete the fmt.Println line and run again:

./main.go:8:8: "fmt" imported and not used

Unused imports don't compile, so dead imports never pile up.

func main(): the entry point. No arguments, no return value. When it returns, the program ends.

fmt.Println(...): notice the capital P. That isn't a style choice, and it's the key to the next section.

The . in go run .: "the package in the current folder". go run main.go only works while your program is one file, so . is the habit worth building.

Packages and Exported Names​

A real project doesn't stay in one file. Let's give taskly its first package. Create a folder called task and a file inside it:

taskly/
├── go.mod
├── main.go
└── task/
└── task.go
// Purpose: A small package that formats task titles
// Context: Shows how packages hide or expose names
// Input: A task title
// Output: A display line like "[ ] buy milk"

// Package task holds the building blocks for taskly's tasks.
package task

import "strings"

// Banner returns a display line for a task title.
// It starts with a capital letter, so other packages can call it.
func Banner(title string) string {
return "[ ] " + clean(title)
}

// clean trims extra spaces from a title.
// It starts with a lowercase letter, so only code inside package task can call it.
func clean(title string) string {
return strings.TrimSpace(title)
}

Here's the rule, and it's the whole visibility system in Go:

A name that starts with an uppercase letter is exported (visible outside its package). A name that starts with a lowercase letter is not.

There's no public or private keyword. Banner is exported, clean is not, and fmt.Println has a capital P for the same reason. To use the package, main.go imports it by module path plus folder name:

import "example/taskly/task"

Let's test the rule. What happens if main tries to call the private function?

fmt.Println(task.clean(" buy milk "))
./main.go:11:19: name clean not exported by package task

Helpers stay lowercase, and only the deliberate public surface gets a capital letter. The same rule applies to types, struct fields, and methods.

Here's how the pieces relate:

Diagram description: one module, defined by go.mod, contains two packages. Package main lives in the root folder and holds main.go with func main. Package task lives in the task folder and holds task.go, which defines the exported Banner and the unexported clean. main.go imports the task package and can call only Banner.

Two more rules: all .go files in one folder belong to the same package, and the package name usually matches the folder name, short and lowercase (task, not task_utils).

go run vs go build​

Go is compiled: your code becomes a machine-code binary before it runs, unlike Python, where an interpreter reads your source each time it runs. go run compiles a binary in a temporary location and runs it. go build does the same compilation but keeps the binary:

go build -o taskly .
./taskly
taskly: 0 tasks today
go run .go build -o taskly .
Compiles your codeYesYes
Leaves a binary you can useNoYes, ./taskly
Runs the programYes, right awayNo, you run it yourself
Uses the build cacheYesYes
Typical useTrying changes while developingShipping, deploying, benchmarking

📝 Correction: a common explanation says go run "compiles from scratch every time". Both commands share the build cache, and since Go 1.24 go run also caches the final executable. The only real difference is whether you keep the binary.

The binary is self-contained. Copy it to another Linux amd64 machine without Go installed, and it runs. That's a big part of why Go is popular for CLIs and containers.

Building for Other Operating Systems​

Set GOOS and GOARCH for a single command, and Go builds for a different platform. No extra compilers needed.

GOOS=linux GOARCH=arm64 go build -o taskly-linux-arm64 .
GOOS=windows GOARCH=amd64 go build -o taskly.exe .

Let's check what we got with the file command (available on Linux and macOS):

file taskly-linux-arm64 taskly.exe
taskly-linux-arm64: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, ...
taskly.exe: PE32+ executable (console) x86-64, for MS Windows, ...

"Statically linked" means everything the program needs is inside that one file. What if you run the ARM binary on an amd64 machine?

./taskly-linux-arm64
bash: ./taskly-linux-arm64: cannot execute binary file: Exec format error

That's the operating system saying the binary targets a different CPU, a common sight when a laptop and a server don't match. Check a binary with file, and a server with uname -m (x86_64 means amd64, aarch64 means arm64). go tool dist list prints every supported GOOS/GOARCH pair.

💡 This works for pure Go code. Projects using cgo (Go calling C libraries) are harder to cross-compile. We won't need cgo in this series.

gofmt and go vet: Your Two Constant Companions​

Go ships with its own formatter and bug finder. Try both on some deliberately messy code in a scratch module outside taskly:

package main
import "fmt"
func main(){
fmt.Printf("%d tasks\n","three")
}

It compiles, but it's ugly and has a bug. gofmt -l lists files that aren't formatted:

gofmt -l .
main.go

gofmt -d shows the changes it would make, and gofmt -w writes them:

gofmt -w main.go

Now it looks like every other Go file: tabs, spacing, a blank line after imports. There are no settings to argue about. Most editors run gofmt (or goimports, which also fixes imports) on save. Next, the bug finder:

go vet .
./main.go:6:2: fmt.Printf format %d has arg "three" of wrong type string

The program would run and print %!d(string=three) tasks. vet catches code that is legal but almost certainly wrong: mismatched format verbs, unreachable code, copied locks. Run it before every commit. go test runs a subset of vet checks automatically.

Building taskly v0​

Now the first real version of taskly: it takes task titles from the command line and prints a checklist. Replace main.go with this:

// Purpose: taskly v0, prints a checklist line for each title you pass in
// Context: The first version of the project we grow through this series
// Input: Task titles as command-line arguments, or --version
// Output: Checklist lines on standard output, usage help on standard error

// Command taskly prints a banner for each task title passed on the command line.
package main

import (
"fmt"
"os"

"example/taskly/task"
)

// version is overwritten at build time with -ldflags "-X main.version=...".
var version = "dev"

func main() {
// os.Args[0] is the program name; the rest are the arguments.
args := os.Args[1:]

if len(args) == 0 {
// Errors go to standard error so they never mix with real output.
fmt.Fprintln(os.Stderr, "usage: taskly <task title> [more titles...]")
fmt.Fprintln(os.Stderr, " taskly --version")
os.Exit(2) // 2 is the common exit code for "used incorrectly"
}

if args[0] == "--version" {
fmt.Println("taskly", version)
return
}

for _, title := range args {
fmt.Println(task.Banner(title))
}
}

Let's try it:

go run . "buy milk" " ship v0 "
[ ] buy milk
[ ] ship v0

The extra spaces around ship v0 are gone, thanks to the private clean function. Now with no arguments:

go run .
usage: taskly <task title> [more titles...]
taskly --version
exit status 2

A few small, deliberate choices here show up in professional Go code everywhere:

Standard output vs standard error. Real output goes to standard output (fmt.Println). Messages for humans go to standard error (fmt.Fprintln(os.Stderr, ...)). When someone runs taskly "a" "b" > tasks.txt, only the real output lands in the file.

Exit codes. os.Exit(2) ends the program with code 2. Zero means success, non-zero means failure, and 2 commonly means "used incorrectly". Scripts and CI systems check this number. The exit status 2 line is go run reporting it. Run the built binary directly and echo $? shows 2.

Grouped imports. Standard library first, a blank line, then your own packages. goimports creates the grouping for you.

A build-time version. version defaults to "dev", and you can replace it at build time without editing code:

go build -o taskly .
./taskly --version
taskly dev
go build -ldflags "-X main.version=0.1.0" -o taskly .
./taskly --version
taskly 0.1.0

-ldflags passes options to the linker, and -X main.version=0.1.0 sets the package-level string variable version in package main (it doesn't work on constants). Release pipelines use this to stamp a version or commit into every binary.

That's taskly v0: a module, two packages, an exported and an unexported name, proper output streams, exit codes, and a build-time version.

Where to Go From Here: The Official Learning Path​

Go's official documentation is excellent. Here's an order that works well, adapted from the path recommended in that same video and checked against go.dev/doc:

  1. Getting started tutorial: the official version of what you just did
  2. A Tour of Go: an interactive walk through every major feature, good alongside this series
  3. Go by Example: short annotated programs (a community site, not official)
  4. How to Write Go Code: modules, packages, and the go command
  5. The Go Programming Language Specification: the actual rules of the language
  6. Go Modules Reference: a reference to look things up in, not to read start to finish
  7. Effective Go: how to write Go that looks like Go
  8. Go FAQ: the "why does Go do it this way?" answers

📝 Correction: the video says Effective Go "has not been outdated". Its style advice holds up, but the document says at the top that it was written for Go's 2009 release. It doesn't cover modules or generics.

The Go Way​

Go has strong conventions, and they mostly come from a few official sources: Effective Go, the Go Code Review Comments page on the Go wiki, and Rob Pike's Go Proverbs. Following them is what makes Go code look familiar to every other Go developer. Here are the ones this article touched.

Let gofmt decide. Don't configure it, don't argue about it, just format on save. A Go Proverb sums it up: "Gofmt's style is no one's favorite, yet gofmt is everyone's favorite." Nobody gets their personal style, and in exchange, every Go codebase reads the same. (Effective Go: Formatting)

Name packages short, lowercase, and after what they provide. task, strings, http. No underscores, no mixedCaps, and no grab-bag names like util, common, or helpers, which tell the reader nothing about what's inside. (Effective Go: Package names, Code Review Comments: Package Names)

Don't repeat the package name in what it exports. Callers always write the package name first, so task.Banner reads well and task.TaskBanner stutters. It's the same reason the buffered reader in bufio is called bufio.Reader, not bufio.BufReader.

Write doc comments as full sentences that start with the name. "Banner returns a display line..." and "Package task holds...". Go's tools turn these comments into documentation, so they're worth getting right. Try it in taskly:

go doc ./task
package task // import "example/taskly/task"

Package task holds the building blocks for taskly's tasks.

func Banner(title string) string

Notice that clean doesn't appear: go doc only shows what other packages can use. The same comments power pkg.go.dev once a module is published. (Go Doc Comments, Code Review Comments: Comment Sentences)

Keep names unexported until another package needs them. This one is a habit rather than a written rule, but you'll see it in most Go code. A lowercase name like clean can be renamed or deleted at any time. Once a name is exported, other code can depend on it, and changing it breaks them.

Common Misconceptions​

❌ Misconception: "go run interprets Go like Python runs a script"​

Reality: go run compiles a full binary, runs it, and doesn't keep it. Go has no interpreter.

Why this matters: both show the same compile errors and produce equally fast programs.

❌ Misconception: "Capital letters are a naming style, like in Java"​

Reality: in Go, the first letter decides visibility. Renaming Banner to banner makes it invisible outside its package and breaks every caller.

Example:

func Banner(title string) string { ... } // ✅ callable as task.Banner from other packages
func banner(title string) string { ... } // ❌ task.banner fails: "name banner not exported by package task"

Troubleshooting Common Issues​

Problem: go.mod file not found in current directory or any parent directory​

Symptoms:

go: go.mod file not found in current directory or any parent directory; see 'go help modules'

Cause: you never ran go mod init, or you're in the wrong folder.

Solution: check pwd, then run go mod init example/taskly as the first command in every new project folder.

Problem: package example/takly/task is not in std​

Symptoms: your import of your own package fails:

main.go:8:2: package example/takly/task is not in std (/usr/local/go/src/example/takly/task)

The path in the parentheses depends on where Go is installed.

Cause: the import doesn't start with the exact module path from go.mod (here, takly is a typo for taskly), or the folder name is wrong. When an import doesn't match your module, Go stops treating it as your code and looks in the standard library instead, where it obviously isn't. Check with head -1 go.mod and ls.

Solution: make the import exactly <module path>/<folder>, for example example/taskly/task.

💡 With a URL-style module path such as github.com/you/taskly, the same typo produces no required module provides package ...; to add it: go get .... Don't run that go get: the problem is the typo, not a missing download.

Problem: go.mod requires go >= 1.27.0 (running go 1.25.x; GOTOOLCHAIN=local)​

Symptoms: a project you cloned refuses to build, or Go starts downloading a different toolchain.

Cause: the project's go line is newer than your toolchain. With the default GOTOOLCHAIN=auto, Go downloads the required version. With GOTOOLCHAIN=local, or if your network blocks the download, you get this error.

Solution: install a newer Go from go.dev/dl, or allow the automatic download. Check your setting with go env GOTOOLCHAIN.

Check Your Understanding​

Quick Quiz​

  1. What makes a Go package build into an executable instead of a library?

    Show Answer

    It must be named package main and contain a func main(). Any other package name builds a library that other packages import.

  2. What's wrong with this code in main.go?

    package main

    import (
    "fmt"
    "example/taskly/task"
    )

    func main() {
    fmt.Println(task.clean(" write docs "))
    }
    Show Answer

    clean starts with a lowercase letter, so it isn't exported from package task. The compiler reports name clean not exported by package task. Call the exported task.Banner instead, or, if other packages really need the function, rename it to Clean.

  3. This program compiles and runs. Which tool would catch the bug, and what is it?

    fmt.Printf("%s has %d tasks\n", 3, "alex")
    Show Answer

    go vet. The arguments are in the wrong order: %s gets the number 3 and %d gets the string "alex". Vet reports the first mismatch it finds: fmt.Printf format %s has arg 3 of wrong type int. Swap the arguments and both verbs line up.

Hands-On Exercise​

Challenge: add a --count flag to taskly. When the first argument is --count, print how many titles follow it instead of printing the checklist. So taskly --count "a" "b" "c" should print 3 tasks.

Starter code: begin from taskly v0 above. You only need to change main.go.

Show Solution
// Command taskly prints a banner for each task title passed on the command line.
package main

import (
"fmt"
"os"

"example/taskly/task"
)

var version = "dev"

func main() {
args := os.Args[1:]

if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: taskly <task title> [more titles...]")
fmt.Fprintln(os.Stderr, " taskly --count <task title> [more titles...]")
fmt.Fprintln(os.Stderr, " taskly --version")
os.Exit(2)
}

switch args[0] {
case "--version":
fmt.Println("taskly", version)
return
case "--count":
// Everything after --count is a title. len gives us how many.
fmt.Println(len(args[1:]), "tasks")
return
}

for _, title := range args {
fmt.Println(task.Banner(title))
}
}
go run . --count "a" "b" "c"
3 tasks

Explanation: args[1:] is everything after the flag, and len counts it. The switch replaces a chain of if checks (more on it in the control flow article). For real flags, the standard library's flag package does this properly, and taskly will switch to it later.

Summary: Key Takeaways​

  • A module is your project's identity. go mod init <path> creates go.mod, and every import of your own code starts with that path.
  • The go line is an enforced minimum since Go 1.21.
  • package main plus func main() makes a program. Every other package is a library.
  • Capital letters decide visibility. Banner is exported, clean is not.
  • go run and go build compile the same way. One discards the binary, the other keeps it.
  • GOOS and GOARCH pick the build target, and you get a single static binary.
  • Run gofmt and go vet constantly. One ends style debates, the other catches bugs that compile.

What's Next?​

You now have a working toolchain, a module, two packages, and a tiny tool you can build for any platform. But we've used values like version, args, and len(args) without talking about their types.

The next article in this section, Variables and Types in Go, covers declarations, the basic types (integers, floats, strings, runes, booleans), zero values, and why Go never converts types without asking. Every later article builds on it.

References​