00:00

Go (Golang) Tutorial

Last updated: 11 min read

Go, often called Golang, is an open-source, compiled programming language designed for building simple, reliable and efficient software. It combines the speed and type safety of a compiled language with a small, easy-to-read syntax, fast builds, built-in concurrency and a garbage collector. Much of today's cloud infrastructure, including Docker, Kubernetes and Terraform, is written in Go.

This tutorial teaches Go from scratch: installing the toolchain, modules and packages, variables and types, control flow, functions and error handling, arrays, slices and maps, structs, methods and interfaces, generics, goroutines and channels, building an HTTP server, testing and the Go tool chain. Every section includes small programs you can run.

1. What Is Go?

Go is a statically typed, compiled language with garbage collection and first-class support for concurrency. A Go program compiles into a single native executable with no external runtime to install, which makes it very easy to deploy, for example in a small container image.

Go was designed to make large software projects easier to manage. Its creators deliberately kept the language small: there are only 25 keywords, one loop statement, no classes or inheritance, no exceptions and one standard code format. The result is code that looks similar no matter who wrote it, which helps teams read and maintain each other's work.

Go is especially popular for:

  • Cloud and infrastructure tools: Docker, Kubernetes, Terraform, Prometheus and etcd are written in Go.
  • Web services and APIs: fast HTTP servers and microservices with a small memory footprint.
  • Command-line tools: single binaries that run anywhere without dependencies.
  • Networking and distributed systems: proxies, load balancers and message processing.
  • DevOps and site reliability tooling: automation, monitoring agents and deployment tools.

2. A Short History of Go

Go was designed at Google in 2007 by Robert Griesemer, Rob Pike and Ken Thompson. They were frustrated by slow builds and the complexity of large C++ code bases, and wanted a language that was fast to compile, simple to read and well suited to multicore machines and networked services.

YearMilestone
2007Design work began at Google.
2009Go was announced publicly as an open-source project.
2012Go 1.0 was released with the Go 1 compatibility promise: programs written for Go 1 keep compiling with later 1.x versions.
2015Go 1.5: the compiler and runtime were rewritten in Go, and garbage-collection pauses became much shorter.
2018Go 1.11 introduced modules for dependency management.
2022Go 1.18 added generics (type parameters) and built-in fuzz testing.
2024Go 1.22 gave each loop iteration its own variable and added method and path patterns to the HTTP router; Go 1.23 added range-over-function iterators.

A new Go version is released about every six months, usually in February and August. The two most recent versions are supported with security fixes.

3. Key Features of Go

FeatureWhat it means for you
Simple syntaxA small language that can be learned quickly and read easily.
Fast compilationEven large projects build in seconds, keeping the edit-run cycle short.
Single static binaryDeploy one file; no virtual machine or interpreter is required.
Goroutines and channelsLightweight concurrency built into the language.
Garbage collectionAutomatic memory management with very short pauses.
Strong standard libraryHTTP servers and clients, JSON, cryptography, testing and more, without third-party packages.
Built-in toolingFormatting, testing, benchmarking, profiling, vetting and documentation are part of the go command.
Easy cross-compilationBuild for Linux, Windows or macOS on any CPU from a single machine.

4. Installing Go and Your First Program

Download the installer for your operating system from the official Go website (go.dev), or use a package manager such as Homebrew (brew install go). Check the installation with go version. Visual Studio Code with the Go extension and GoLand are popular editors.

terminal
# Create a project folder and a module
mkdir hello && cd hello
go mod init example.com/hello

# After creating main.go (below), run it or build a binary
go run .
go build -o hello . && ./hello
main.go
package main

import (
	"fmt"
	"time"
)

func main() {
	name := "Gopher"
	fmt.Printf("Hello, %s! Today is %s.\n", name, time.Now().Format("Monday"))
}
  • Every Go file starts with a package clause. An executable program must have a main package with a main function.
  • import lists the packages used. Go refuses to compile if an import or a local variable is unused, which keeps code tidy.
  • := declares a variable and infers its type from the value.
  • There are no semicolons at line ends; the compiler inserts them automatically.

5. Modules and Packages

A package is a directory of .go files that are compiled together. A module is a collection of packages released together, defined by a go.mod file that records the module path, the Go version and the dependencies.

Visibility is controlled by capitalisation: names starting with an upper-case letter (such as Area) are exported and visible to other packages, while lower-case names (area) are private to their package. There are no public or private keywords.

terminal
# Add a third-party dependency (recorded in go.mod and go.sum)
go get github.com/google/uuid@latest

# Remove unused dependencies and add missing ones
go mod tidy

The go.sum file stores cryptographic checksums of each dependency, so builds are reproducible and tampered packages are detected. Packages are downloaded through a public module proxy and checked against a checksum database by default.

6. Variables, Types and Constants

basics.go
package main

import "fmt"

type Weekday int

const (
	Sunday Weekday = iota // iota counts up: 0, 1, 2, ...
	Monday
	Tuesday
)

const Pi = 3.14159

func main() {
	var city string = "Bengaluru" // explicit type
	var population = 13_600_000   // type inferred as int
	rating := 4.7                 // short declaration inside functions
	var active bool               // zero value: false

	var count int     // zero value: 0
	var label string  // zero value: ""

	fmt.Println(city, population, rating, active, count, label == "")
	fmt.Println(Monday, Tuesday, Pi)

	// Conversions are always explicit
	items := 7
	avg := float64(items) / 2
	fmt.Printf("avg=%.1f type=%T\n", avg, avg)

	// Strings are immutable UTF-8 byte sequences; runes are Unicode code points
	word := "naïve"
	fmt.Println(len(word), len([]rune(word))) // 6 bytes, 5 runes
}

Every type has a zero value: 0 for numbers, false for booleans, "" for strings and nil for pointers, slices, maps, channels, functions and interfaces. Variables are always initialised, so there is no "uninitialised memory" problem. Go never converts between types implicitly, even between int and int64.

7. Control Flow

Go has if, switch and a single loop keyword, for, which covers every kind of loop. Conditions need no parentheses, but braces are always required.

control.go
package main

import (
	"fmt"
	"strconv"
)

func main() {
	// Classic three-part loop
	for i := 1; i <= 3; i++ {
		fmt.Print(i, " ")
	}
	fmt.Println()

	// "while" loop
	n := 10
	for n > 1 {
		n /= 2
	}

	// range over an integer (Go 1.22+), a slice and a map
	for i := range 3 {
		fmt.Print(i, " ")
	}
	for idx, fruit := range []string{"apple", "mango"} {
		fmt.Println(idx, fruit)
	}

	// if with a short statement: the variable exists only inside the if/else
	if v, err := strconv.Atoi("42"); err == nil {
		fmt.Println("parsed", v)
	} else {
		fmt.Println("error:", err)
	}

	// switch: no fall-through by default, cases can list several values
	switch day := "sat"; day {
	case "sat", "sun":
		fmt.Println("Weekend")
	default:
		fmt.Println("Weekday")
	}

	// switch with no condition is a clean if/else chain
	score := 82
	switch {
	case score >= 90:
		fmt.Println("A")
	case score >= 75:
		fmt.Println("B")
	default:
		fmt.Println("C")
	}
}

8. Functions and Error Handling

Functions can return multiple values. Go uses this for error handling: a function that can fail returns its result plus an error as the last value, and the caller checks it explicitly. There are no exceptions for ordinary errors.

errors.go
package main

import (
	"errors"
	"fmt"
	"os"
)

var ErrInsufficientFunds = errors.New("insufficient funds") // a sentinel error

func withdraw(balance, amount float64) (float64, error) {
	if amount <= 0 {
		return balance, fmt.Errorf("invalid amount %.2f", amount)
	}
	if amount > balance {
		// %w wraps the sentinel so callers can detect it with errors.Is
		return balance, fmt.Errorf("withdraw %.2f: %w", amount, ErrInsufficientFunds)
	}
	return balance - amount, nil
}

func readConfig(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open config: %w", err)
	}
	defer f.Close() // runs when readConfig returns, whatever path it takes

	buf := make([]byte, 512)
	n, err := f.Read(buf)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	return buf[:n], nil
}

func main() {
	balance, err := withdraw(100, 250)
	if errors.Is(err, ErrInsufficientFunds) {
		fmt.Println("Declined:", err)
	}
	fmt.Println("Balance:", balance)

	if _, err := readConfig("missing.yaml"); err != nil {
		fmt.Println(err) // open config: open missing.yaml: no such file or directory
	}

	// Functions are values; closures capture surrounding variables
	counter := func() func() int {
		c := 0
		return func() int { c++; return c }
	}()
	fmt.Println(counter(), counter(), counter()) // 1 2 3
}

defer schedules a call to run when the surrounding function returns, which is the standard way to close files, unlock mutexes and release resources. panic and recover exist for truly unrecoverable situations, such as programming bugs, but normal failures should always be returned as error values.

9. Arrays, Slices and Maps

An array has a fixed length that is part of its type ([5]int). In practice you mostly use slices: flexible views onto an underlying array, which can grow with append. A slice is a small structure holding a pointer to the array, a length and a capacity.

A Go slice header pointing into an underlying array The slice s has a pointer to element index 1 of an underlying array of six integers, a length of 3 and a capacity of 5. The slice covers elements 20, 30 and 40. slice s := arr[1:4] ptr len = 3 cap = 5 underlying array [6]int 10 20 30 40 50 60 len: elements you can read cap: room to grow before append must reallocate
Figure 1: Slicing does not copy data. Several slices can share one array, so a change through one slice is visible through the others.
collections.go
package main

import (
	"fmt"
	"maps"
	"slices"
	"sort"
)

func main() {
	arr := [6]int{10, 20, 30, 40, 50, 60}
	s := arr[1:4]
	fmt.Println(s, len(s), cap(s)) // [20 30 40] 3 5

	s[0] = 99                       // shares memory with arr
	fmt.Println(arr[1])             // 99

	var tasks []string              // nil slice, ready to append to
	tasks = append(tasks, "write", "test", "deploy")
	fmt.Println(tasks, len(tasks))

	scores := make([]int, 0, 10)    // length 0, capacity 10: no reallocation for 10 appends
	scores = append(scores, 72, 95, 88)
	slices.Sort(scores)
	fmt.Println(scores, slices.Contains(scores, 95))

	copyOf := slices.Clone(scores)  // an independent copy
	copyOf[0] = 0
	fmt.Println(scores[0], copyOf[0])

	// Maps
	stock := map[string]int{"pen": 40, "eraser": 15}
	stock["notebook"] = 25
	delete(stock, "eraser")

	if qty, ok := stock["pencil"]; !ok { // "comma ok" tells missing from zero
		fmt.Println("no pencils, qty =", qty)
	}

	keys := slices.Collect(maps.Keys(stock))
	sort.Strings(keys)              // map iteration order is random; sort for stable output
	for _, k := range keys {
		fmt.Println(k, stock[k])
	}
}

The slices and maps packages in the standard library provide generic helpers for sorting, searching, cloning and collecting keys. Map iteration order is intentionally random, so never rely on it.

10. Structs, Methods and Pointers

Go has no classes. Instead, you define structs to group data and attach methods to any named type. Code reuse comes from embedding one struct in another rather than inheritance.

structs.go
package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Audit struct {
	CreatedAt time.Time `json:"created_at"`
}

func (a Audit) Age() time.Duration { return time.Since(a.CreatedAt) }

type Account struct {
	Audit                    // embedded: Account gets Audit's fields and methods
	Owner   string  `json:"owner"`
	Balance float64 `json:"balance"`
}

// Pointer receiver: the method can modify the account
func (a *Account) Deposit(amount float64) {
	a.Balance += amount
}

// Value receiver: works on a copy, fine for read-only methods
func (a Account) Summary() string {
	return fmt.Sprintf("%s has %.2f", a.Owner, a.Balance)
}

func NewAccount(owner string) *Account { // constructor function by convention
	return &Account{Audit: Audit{CreatedAt: time.Now()}, Owner: owner}
}

func main() {
	acc := NewAccount("Priya")
	acc.Deposit(1500)
	fmt.Println(acc.Summary())
	fmt.Println(acc.Age() < time.Second) // promoted method from Audit

	data, _ := json.Marshal(acc)          // struct tags control JSON field names
	fmt.Println(string(data))

	p := &acc.Balance                    // pointers: & takes an address, * dereferences
	*p += 100
	fmt.Println(acc.Balance)
}

Use a pointer receiver (*Account) when a method changes the struct or the struct is large; use a value receiver for small, read-only types. Go has pointers but no pointer arithmetic, so they are safe to use.

11. Interfaces

An interface is a set of method signatures. A type satisfies an interface implicitly, simply by having those methods; there is no implements keyword. This makes it easy to define small interfaces where they are used and to swap real implementations for test doubles.

interfaces.go
package main

import (
	"fmt"
	"math"
)

type Shape interface {
	Area() float64
	Name() string
}

type Circle struct{ R float64 }
type Rect struct{ W, H float64 }

func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string   { return "circle" }
func (r Rect) Area() float64    { return r.W * r.H }
func (r Rect) Name() string     { return "rectangle" }

// fmt.Stringer is a standard one-method interface
func (r Rect) String() string { return fmt.Sprintf("Rect(%gx%g)", r.W, r.H) }

func totalArea(shapes ...Shape) float64 {
	sum := 0.0
	for _, s := range shapes {
		sum += s.Area()
	}
	return sum
}

func main() {
	shapes := []Shape{Circle{1.5}, Rect{2, 3.5}}
	for _, s := range shapes {
		fmt.Printf("%-10s %.2f\n", s.Name(), s.Area())
	}
	fmt.Printf("total %.2f\n", totalArea(shapes...))

	// Type switch: act on the concrete type behind an interface value
	for _, s := range shapes {
		switch v := s.(type) {
		case Rect:
			fmt.Println("found", v) // uses String()
		case Circle:
			fmt.Println("circle with radius", v.R)
		}
	}
}

The standard library is built around tiny interfaces such as io.Reader, io.Writer, error and fmt.Stringer. A common Go saying is "accept interfaces, return structs": functions take the smallest interface they need and return concrete types.

12. Generics

Since Go 1.18, functions and types can have type parameters. A constraint (an interface) states which types are allowed.

generics.go
package main

import (
	"cmp"
	"fmt"
)

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](values []T) T {
	var total T
	for _, v := range values {
		total += v
	}
	return total
}

func Map[T, U any](in []T, f func(T) U) []U {
	out := make([]U, 0, len(in))
	for _, v := range in {
		out = append(out, f(v))
	}
	return out
}

func MaxOf[T cmp.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

// A generic type: a simple stack
type Stack[T any] struct{ items []T }

func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	v := s.items[len(s.items)-1]
	s.items = s.items[:len(s.items)-1]
	return v, true
}

func main() {
	fmt.Println(Sum([]int{1, 2, 3}), Sum([]float64{0.5, 1.25}))
	fmt.Println(Map([]int{1, 2, 3}, func(n int) string { return fmt.Sprint(n * n) }))
	fmt.Println(MaxOf("apple", "banana"), MaxOf(3, 7))

	var st Stack[string]
	st.Push("a")
	st.Push("b")
	top, _ := st.Pop()
	fmt.Println(top)
}

Use generics for data structures and algorithms that genuinely work with many types. For behaviour that varies by type, plain interfaces are often simpler.

13. Goroutines and Channels

A goroutine is a function running concurrently with others, started with the go keyword. Goroutines are extremely cheap, starting with only a few kilobytes of stack, so a program can run hundreds of thousands of them. The Go runtime schedules them onto a small number of operating-system threads.

The Go scheduler maps many goroutines onto few threads Many goroutines wait in run queues. The Go runtime scheduler assigns them to logical processors, each running on an operating-system thread, which run on CPU cores. Goroutines (G): thousands G G G G G G … and many more Go runtime scheduler OS thread on core 1 OS thread on core 2 OS thread on core N
Figure 2: The runtime multiplexes many goroutines onto a few threads (by default one per CPU core, set by GOMAXPROCS).

Goroutines communicate through channels, typed pipes that safely pass values between them. Go's motto is: "Do not communicate by sharing memory; instead, share memory by communicating."

workers.go
package main

import (
	"fmt"
	"sync"
	"time"
)

// A worker pool: 3 goroutines process jobs from one channel
func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for n := range jobs { // loop ends when the jobs channel is closed
		time.Sleep(100 * time.Millisecond) // simulate work
		results <- fmt.Sprintf("worker %d: %d squared is %d", id, n, n*n)
	}
}

func main() {
	jobs := make(chan int, 10)
	results := make(chan string, 10)
	var wg sync.WaitGroup

	for id := 1; id <= 3; id++ {
		wg.Add(1)
		go worker(id, jobs, results, &wg)
	}

	for n := 1; n <= 6; n++ {
		jobs <- n
	}
	close(jobs) // no more jobs: workers exit their loops

	go func() {
		wg.Wait()      // wait for all workers ...
		close(results) // ... then close results so the loop below ends
	}()

	for r := range results {
		fmt.Println(r)
	}

	// select waits on several channel operations, here with a timeout
	slow := make(chan string)
	go func() { time.Sleep(2 * time.Second); slow <- "done" }()
	select {
	case msg := <-slow:
		fmt.Println(msg)
	case <-time.After(500 * time.Millisecond):
		fmt.Println("timed out")
	}
}

An unbuffered channel (make(chan T)) makes the sender wait until a receiver is ready, synchronising the two goroutines. A buffered channel (make(chan T, n)) lets senders continue until the buffer is full. Only the sender should close a channel, and only when receivers need to know that no more values will come.

14. Synchronisation and Context

Channels are not the only tool. The sync package provides Mutex, RWMutex, WaitGroup and Once, and sync/atomic offers atomic counters. The context package carries cancellation signals and deadlines across goroutines and API boundaries, which is essential in servers.

context.go
package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

type Counter struct {
	mu   sync.Mutex
	hits map[string]int
}

func (c *Counter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.hits[key]++
}

func fetchReport(ctx context.Context) (string, error) {
	select {
	case <-time.After(2 * time.Second): // pretend this is a slow database query
		return "report ready", nil
	case <-ctx.Done():
		return "", ctx.Err() // context.DeadlineExceeded or context.Canceled
	}
}

func main() {
	c := Counter{hits: map[string]int{}}
	var wg sync.WaitGroup
	for range 100 {
		wg.Add(1)
		go func() { defer wg.Done(); c.Inc("/home") }()
	}
	wg.Wait()
	fmt.Println("hits:", c.hits["/home"]) // always 100

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()
	if _, err := fetchReport(ctx); errors.Is(err, context.DeadlineExceeded) {
		fmt.Println("gave up: report took too long")
	}
}
Find data races early: run tests and programs with the race detector, go test -race ./... or go run -race .. It reports any place where goroutines access the same memory without synchronisation.

15. Building a Web Server

Go's standard library includes a production-quality HTTP server. Since Go 1.22, the built-in router supports HTTP methods and path parameters, so many small services need no framework at all.

server.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"sync"
	"time"
)

type Book struct {
	ID     string `json:"id"`
	Title  string `json:"title"`
	Author string `json:"author"`
}

type Store struct {
	mu    sync.RWMutex
	books map[string]Book
}

func (s *Store) getBook(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // path parameter from the route pattern
	s.mu.RLock()
	book, ok := s.books[id]
	s.mu.RUnlock()
	if !ok {
		http.Error(w, "book not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(book)
}

func (s *Store) addBook(w http.ResponseWriter, r *http.Request) {
	var b Book
	if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.ID == "" {
		http.Error(w, "invalid book", http.StatusBadRequest)
		return
	}
	s.mu.Lock()
	s.books[b.ID] = b
	s.mu.Unlock()
	w.WriteHeader(http.StatusCreated)
}

func main() {
	store := &Store{books: map[string]Book{
		"1": {ID: "1", Title: "Learning Go", Author: "A. Gopher"},
	}}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /books/{id}", store.getBook)
	mux.HandleFunc("POST /books", store.addBook)

	srv := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second, // always set timeouts in production
		WriteTimeout: 10 * time.Second,
	}
	log.Println("listening on http://localhost:8080")
	log.Fatal(srv.ListenAndServe())
}

Run it with go run server.go, then try curl localhost:8080/books/1. Each request is handled in its own goroutine automatically, which is why the map is protected with a read-write mutex.

16. Testing in Go

Testing is built in. Test files end in _test.go, test functions start with Test and take a *testing.T. The idiomatic style is the table-driven test:

discount_test.go
package pricing

import "testing"

func ApplyDiscount(price, percent float64) float64 {
	if percent < 0 || percent > 100 {
		return price
	}
	return price * (1 - percent/100)
}

func TestApplyDiscount(t *testing.T) {
	tests := []struct {
		name           string
		price, percent float64
		want           float64
	}{
		{"ten percent", 100, 10, 90},
		{"no discount", 59.99, 0, 59.99},
		{"invalid percent", 80, 150, 80},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := ApplyDiscount(tc.price, tc.percent); got != tc.want {
				t.Errorf("ApplyDiscount(%v, %v) = %v, want %v", tc.price, tc.percent, got, tc.want)
			}
		})
	}
}

func BenchmarkApplyDiscount(b *testing.B) {
	for b.Loop() { // Go 1.24+; older versions use: for i := 0; i < b.N; i++
		ApplyDiscount(100, 10)
	}
}

Run go test ./... for all packages, go test -cover for coverage, go test -bench . for benchmarks and go test -race to detect data races. The net/http/httptest package makes it easy to test HTTP handlers without starting a real server.

17. The Go Toolchain

CommandWhat it does
go run .Compile and run the program in one step.
go buildCompile a binary.
go fmt ./... / gofmtFormat code in the one standard style.
go vet ./...Report suspicious code, such as wrong Printf arguments.
go test ./...Run tests, benchmarks, examples and fuzz tests.
go mod tidyClean up module dependencies.
go doc fmt.PrintfShow documentation for a package or symbol.
go tool pprofAnalyse CPU and memory profiles.

Cross-compiling needs only two environment variables. For example, GOOS=linux GOARCH=arm64 go build -o app-linux-arm64 . builds a Linux ARM binary from a Mac or Windows machine. Popular extra tools include golangci-lint, which runs many linters at once, and staticcheck.

18. Go vs. Java

AspectGoJava
CompilationNative binary, compiled ahead of timeBytecode run on the JVM with JIT compilation
DeploymentOne self-contained executableJAR plus a Java runtime (or a native image with GraalVM)
Startup and memoryFast startup, small memory footprintSlower startup and higher baseline memory, excellent long-run throughput
Object modelStructs, methods, implicit interfaces, embeddingClasses, inheritance, explicit interfaces
Error handlingErrors as return valuesExceptions
ConcurrencyGoroutines and channels in the languageThreads, executors and virtual threads in the library
Language sizeSmall, deliberately minimalLarge and feature-rich
EcosystemStrong for cloud, CLI and networkingHuge, mature enterprise ecosystem (Spring, Jakarta EE)

Both are excellent for back-end services. Go shines for small, fast, easily deployed services and infrastructure tools; Java shines for large business applications with rich frameworks. Compare with the Java tutorial and the Microservices tutorial on this site.

19. Best Practices and Common Mistakes

Best practices

  • Run gofmt and go vet on every save or commit.
  • Handle every error, and wrap it with context using fmt.Errorf("doing X: %w", err).
  • Keep interfaces small and define them where they are used.
  • Pass context.Context as the first parameter of functions that do I/O or may be cancelled.
  • Make zero values useful, so structs work without special initialisation where possible.
  • Know when each goroutine will stop, using contexts or closed channels.
  • Write table-driven tests and run them with -race.
  • Choose clear, short names; Go favours buf and srv in small scopes and descriptive names for exported identifiers.

Common mistakes to avoid

MistakeWhat goes wrongFix
Ignoring errors with _Failures go unnoticed and cause confusing bugs later.Check and handle or return every error.
Writing to a nil mapThe program panics.Create maps with make or a literal before writing.
Goroutine leaksGoroutines blocked forever on a channel pile up and use memory.Use context cancellation, buffered channels or close channels properly.
Sharing maps between goroutines without locksData races and crashes ("concurrent map writes").Protect with a mutex or use channels.
Assuming appended slices are independentTwo slices may still share the same underlying array.Use slices.Clone when you need a separate copy.
Using panic for normal errorsCrashes the program instead of letting callers decide.Return an error.
No timeouts on HTTP servers and clientsSlow clients or servers tie up resources indefinitely.Set server timeouts and use contexts or client timeouts.

20. Summary

  • Go is a small, statically typed, compiled language with garbage collection, built for simple and reliable software.
  • Programs are organised into packages and modules; capitalised names are exported.
  • Variables always have zero values, conversions are explicit, and for is the only loop.
  • Functions return multiple values, and errors are ordinary values checked explicitly; defer handles cleanup.
  • Slices and maps are the everyday collections; slices are views onto shared arrays.
  • Structs, methods, embedding and implicit interfaces replace classes and inheritance; generics cover type-independent code.
  • Goroutines and channels make concurrency simple; sync and context handle locking and cancellation.
  • The standard library and the go command cover HTTP servers, JSON, testing, formatting and cross-compilation.

A great next step is to extend the book server above with a database, structured logging using log/slog and table-driven tests, then build it as a single binary in a small container image. Open the FAQ tab for answers to the questions learners most often ask about Go.