Learn Go Series (#1) - Introduction to Go
What will I learn
- What Go is, who built it and why, and the specific kind of pain it was designed to remove;
- Why Go deliberately has fewer features than the languages you know, and why that is a feature and not a lack;
- How to install the toolchain and run your first program with
go runandgo build; - What a module is, why every project starts with
go mod init, and how a Go program is laid out; - A first taste of the things that make Go itself: a goroutine, a slice, a struct, and the
gofmt-there-is-only-one-way philosophy; - Where Go genuinely shines (servers, CLIs, concurrency, one-binary deploys) and where it is the wrong tool.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (version 1.27 or newer, from go.dev/dl) -- the code here is written and tested against Go 1.27;
- A terminal and an editor (VS Code with the official Go extension, or any editor with
gopls, is a fine start); - If you know a little Python it will help -- I compare against it often, because the contrast is the fastest way to see what Go is doing differently;
- The ambition to learn a language you can ship real software in.
Difficulty
- Beginner
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go (this post)
Learn Go Series (#1) - Introduction to Go
Welcome to a brand new series. Over the coming episodes we are going to learn Go properly, from the ground up, and by the end you will be building real software with it: command-line tools, web servers, hypermedia apps, and a long run of systems projects that are genuinely fun to write. But we start at episode one with the question the whole language answers: what if a language optimized not for showing off, but for a team of people reading each other's code for ten years?
Go came out of Google in 2009, from Robert Griesemer, Rob Pike and Ken Thompson -- names attached to Unix, UTF-8, and Plan 9. They were tired of a specific thing: enormous C++ and Java codebases that took forever to compile, drowned in features nobody could hold in their head, and where every team wrote in a slightly different dialect. So they made a language you can learn in a weekend, that compiles a huge program in seconds, that has one obvious way to format code, and that treats concurrency as a first-class part of the language rather than a library bolted on. Nota bene: if you already know Python, keep it nearby -- Go will feel stricter and more verbose at first, and then one day you will notice you have not chased a mysterious runtime crash in weeks.
This first episode is light on syntax and heavy on getting you running. By the end you will have Go installed, a module created, and a handful of real programs compiling. We will not rush it: today the goal is not to memorise syntax but to get a working toolchain, a feel for the language's texture, and enough of a running start that episode two -- where the real study of types and values begins -- reads like a natural next step rather than a cliff. Here we go!
The smallest real Go program
Every Go program lives in a package, and the program you can actually run lives in a package called main, with a function called main. Here is the whole of "hello world", and every piece of it earns its place:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
package main says "this is a runnable program, not a library". import "fmt" pulls in the standard library's formatting package. func main() is where execution starts. And fmt.Println prints a line. That capital P in Println is not a style choice -- in Go, a name starting with a capital letter is exported (visible outside its package), and a lowercase name is private. The language uses the case of the first letter to mean something, which is the first of many "there is one rule, and it is mechanical" decisions you will meet.
To run it, you do not need a build step you think about. Save it as main.go and:
go run main.go
go run compiles and runs in one shot. When you want an actual binary to ship, go build gives you a single self-contained executable with no runtime to install on the target machine -- one of Go's genuinely great deployment stories.
Modules: how every project starts
A single file is fine for a demo, but real programs are a module. A module is a collection of packages with a go.mod file at its root that records the module's name and the Go version it targets. You create one with go mod init:
mkdir hello && cd hello
go mod init example.com/hello
go run .
That writes a go.mod that looks like this (and yes, go 1.27 here means the toolchain pins to that version, so everyone building your code uses the same one):
module example.com/hello
go 1.27
From now on go run . builds the whole package in the current directory, not just one file. This is the normal way to work, and it is worth building the habit early.
Variables, a function, and Go's formatting verbs
Let us write something with a couple of moving parts: a function that takes an argument and returns a value, and the fmt package's Printf, which formats with verbs like %s (string), %d (integer) and %q (quoted). Go infers the type of a variable declared with :=, so you rarely write types for locals:
package main
import "fmt"
func greet(name string) string {
return "Hello, " + name + "!"
}
func main() {
name := "Ada"
message := greet(name)
fmt.Printf("%s (the greeting is %d characters, quoted: %q)\n", message, len(message), message)
}
The := is short variable declaration with type inference: name is a string because "Ada" is. len is a built-in that works on strings, slices, maps and more. And Printf does not add a newline for you, which is why the format string ends with \n -- a small thing you will forget exactly once.
A slice and a loop
Go's workhorse collection is the slice: a growable view onto an array. You will use slices constantly, and we devote a whole later episode to how they really work, but here is the shape of it. append grows a slice, and for ... range walks it, handing you the index and the value:
package main
import "fmt"
func main() {
primes := []int{2, 3, 5, 7}
primes = append(primes, 11, 13)
sum := 0
for i, p := range primes {
fmt.Printf("primes[%d] = %d\n", i, p)
sum += p
}
fmt.Println("sum:", sum)
}
Notice there is exactly one loop keyword in Go: for. No while, no do. A while is just a for with only a condition, and an infinite loop is for {}. One keyword, several shapes -- the same "fewer things, used more ways" instinct again.
A map, quickly
The other everyday collection is the map, Go's hash table: keys to values, created with a literal or with make. Looking up a missing key does not blow up -- it returns the value type's zero value, and an optional second boolean tells you whether the key was actually there (the famous comma-ok idiom):
package main
import "fmt"
func main() {
ages := map[string]int{"Ada": 36, "Linus": 54}
ages["Grace"] = 85
fmt.Println("Ada is", ages["Ada"])
age, ok := ages["Nobody"]
fmt.Printf("Nobody -> value %d, present %v\n", age, ok)
}
ages["Nobody"] returns 0 (the zero value for int) with ok set to false, so you can always tell a real stored zero from a key that was never there. Maps, slices, and structs together cover the overwhelming majority of the data you will ever model in Go -- the language really is that small, and that is the point.
A struct: bundling data with a purpose
Go has no classes. It has structs (plain bundles of fields) and methods you attach to them. This is the whole of Go's type system for your own data, and it is enough. Here is a Point with a method that computes something about it:
package main
import (
"fmt"
"math"
)
type Point struct {
X, Y float64
}
func (p Point) DistanceFromOrigin() float64 {
return math.Hypot(p.X, p.Y)
}
func main() {
p := Point{X: 3, Y: 4}
fmt.Printf("point %+v is %.1f from the origin\n", p, p.DistanceFromOrigin())
}
The (p Point) before the method name is the receiver -- it says "this function is a method on Point". The %+v verb prints a struct with its field names, which is a debugging superpower. We will spend a full episode on structs, methods, and the crucial choice between value and pointer receivers, but you already have the shape.
The thing Go is famous for: a goroutine
Here is the feature that makes people reach for Go. A goroutine is a function running concurrently, started with the go keyword, and it costs almost nothing -- you can have hundreds of thousands of them. Goroutines talk to each other over channels, typed pipes you send to and receive from. This tiny program starts a goroutine, waits for it to hand back a result, and prints it:
package main
import "fmt"
func main() {
result := make(chan string)
go func() {
result <- "work done on another goroutine"
}()
fmt.Println(<-result)
}
make(chan string) creates a channel. The go func() { ... }() launches an anonymous function concurrently. Inside it, result <- "..." sends a value; back in main, <-result receives it and blocks until it arrives. That blocking receive is also the synchronisation -- main will not exit before the goroutine has delivered. Concurrency in Go is built out of exactly these two pieces, goroutines and channels, and we build all the way up to real concurrent systems from here.
Errors are values
Go has no exceptions. A function that can fail returns an error as its last value, and you check it right there, on the spot. This is the single most Go thing there is, and it is why Go code has that characteristic if err != nil rhythm. It looks verbose next to a try/except, but it puts the failure path in front of you at every step, instead of letting an exception tunnel invisibly up the stack to somewhere you forgot to catch it:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("not a number:", err)
return
}
fmt.Println("parsed and doubled:", n*2)
if _, err := strconv.Atoi("oops"); err != nil {
fmt.Println("as expected, that one failed:", err)
}
}
strconv.Atoi ("ASCII to integer") returns two values: the parsed number and an error. When the input is good, err is nil and you use the result; when it is bad, err describes what went wrong and the number is a zero value you must not trust. You will type if err != nil thousands of times, and after a while you stop seeing it as boilerplate and start seeing it as a little map of everywhere your program can go wrong -- which, honestly, is a rather healthy way to look at code. ;-)
The same idea in Python
If you come from Python, most of this maps cleanly -- the big differences are that Go is compiled and statically typed, and that it is strict about small things (an unused import or an unused variable is a compile error, not a warning). Here is the greeting program's shape in Python for contrast:
def greet(name: str) -> str:
return f"Hello, {name}!"
def main() -> None:
name = "Ada"
message = greet(name)
print(f"{message} (the greeting is {len(message)} characters)")
if __name__ == "__main__":
main()
Python infers types too, and reads a little shorter. What you get for Go's extra strictness is a compiler that catches a whole category of mistakes before the program ever runs, a single binary at the end, and goroutines that make concurrency ordinary rather than an adventure.
One way to format: gofmt
Remember that promise about one obvious way to format code? Go keeps it with a tool called gofmt. There is no debate about tabs versus spaces, where the brace goes, or how to line things up -- gofmt has an opinion, its opinion is the standard, and every editor runs it for you on save. To reformat an entire project by hand:
go fmt ./...
This sounds like a small thing. It is not. Every Go file on earth looks the same, so you never burn a code review arguing about style, and you never spend a second of your own attention on it either. Whole categories of bikeshedding simply do not exist in the Go world, and you feel the relief within a day of writing it.
The toolchain in one minute
Almost everything you need ships inside the single go command -- there is no separate build tool, test runner, package manager, or formatter to install and wire together. The subcommands you will reach for constantly:
go run . # compile and run the current package
go build # produce a single static binary
go test ./... # find and run every _test.go file
go vet ./... # flag suspicious code the compiler still allows
go fmt ./... # format to the one true style
go mod tidy # add missing and remove unused dependencies
go doc fmt.Printf # read the docs for anything, offline
That is the whole daily toolchain, and it is the same on every machine and every project. It is fast, it is built in, and it keeps quietly removing decisions you never wanted to make in the first place. Batteries included -- and the batteries are all the same brand.
Where Go shines, and where it does not
Go is a superb fit for network servers, APIs, command-line tools, build systems, and anything concurrent -- the standard library ships a production HTTP server, and one go build gives you a static binary you can scp to a box and run. It compiles fast enough that the edit-run loop feels like a scripting language. It is deliberately boring, which is exactly what you want for code a team maintains for years.
This is not a hypothetical fit, either. A striking share of the infrastructure you already rely on is written in Go -- container tooling, orchestration systems, deployment pipelines, databases, proxies, and countless network services -- precisely because the language hits a sweet spot: fast enough, safe enough, concurrent by default, and trivial to deploy. When you learn Go you are not learning a toy; you are learning the language a large slice of the modern backend is genuinely built in, which means the skills you pick up here transfer straight into real, employable work.
It is a poorer fit where you need the last drop of control over memory layout with no garbage collector (reach for Rust, Zig or C), heavy numeric or scientific work with a rich library ecosystem (Python still wins there), or complex generic-heavy abstractions (Go has generics now, but it stays intentionally modest with them). Knowing when not to use a tool is part of using it well.
Exercises
Make it yours. Change the hello-world program to print your name and today's date. Import the
timepackage and usetime.Now()-- read thefmtandtimedocs on go.dev to find how to format it. Get it running with bothgo run .and a built binary fromgo build.A second function. Write a function
area(width, height float64) float64that returns a rectangle's area, call it frommainwith a couple of values, and print the result withPrintfusing the%.2fverb (two decimal places). Note that Go lets you write the shared type once:(width, height float64).Two goroutines. Extend the goroutine example so that two goroutines each send a different string on the same channel, and
mainreceives and prints both. Think about why receiving twice is enough to see both messages, and what would happen if you only received once.
What we learned
- Go was built to make large codebases fast to compile and easy for a team to read: few features, one formatting style, concurrency in the language;
- Every program is a set of packages; a runnable one is
package mainwithfunc main, and the case of a name's first letter decides whether it is exported; - You run code with
go run ., ship it as a single static binary withgo build, and start every project withgo mod initto create a module; - Slices (
append,for range), structs with methods (the receiver), and the one-and-onlyforloop are the everyday shape of Go code; - Concurrency is built from goroutines (
go f()) and channels (make,<-), and it is cheap and ordinary rather than exotic; - Go is strict about small things on purpose (unused imports and variables are errors), trading a little verbosity for a compiler that catches mistakes early and a great deployment story.
Next episode we slow down and get precise about the raw material of every program: variables, Go's type system, constants, and the neat little iota trick for enumerations. Bring your editor -- from here on you will be writing code every episode.