Learn Go in a Single Post: A Complete Go Tutorial from Goroutines and Channels to Generics and the Toolchain

Go was designed at Google in 2007 to fix two problems: slow compilation of large C++ codebases, and the complexity of writing concurrent server software. The result is a small, statically typed, garbage-collected language with a CSP-based concurrency model, fast builds, and static binaries that deploy as a single file.

This post teaches the whole language in five stages with runnable snippets. By the end you’ll understand goroutines and channels, the interface system, errors-as-values, generics, escape analysis and the GC, and the go toolchain — the parts that make Go Go.

We target Go 1.22+ (range-over-int, loop-variable semantics) with notes on 1.23+ (range-over-func, iterators). Everything here compiles on a current Go toolchain.

The Roadmap

Go Roadmap

  1. Fundamentals — packages, imports, func main, vars, zero values, basic types, control flow
  2. Composite Types — arrays vs slices, make/append/copy, maps, structs, pointers
  3. Interfaces + Errors — implicit interfaces, type assertions/switches, error as value, errors.Is/As, wrapping
  4. Concurrency — goroutines, channels, select, close, sync, context, the GMP scheduler
  5. Generics + Tooling — type parameters, constraints, go mod, go test, static binaries, profiling

Stage 1 — Fundamentals

A program

package main

import "fmt"

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

Every Go program is a package main with a func main. No semicolons, no parentheses around if/for/switch conditions. gofmt is non-negotiable — there is one formatting style, enforced by the toolchain.

Run it:

go run main.go      # compile + run in one step
go build main.go    # produce a static binary

Variables and zero values

var x int          // 0   - zero value, no null
var s string       // ""  - empty string, not nil
var ok bool        // false
var p *int         // nil - only pointers, slices, maps, channels, interfaces, funcs can be nil

y := 10            // short declaration (inside funcs only), type inferred
const Pi = 3.14159
const (
    A = iota       // 0
    B              // 1
    C              // 2
)

Go has no uninitialized variables — every type has a zero value. This eliminates an entire class of bugs and makes nil pointer dereferences the main runtime error you’ll meet.

Basic types and strings

int, int32, int64       // platform-sized int is most common
float64                 // default float
bool, string            // string is immutable UTF-8 byte sequence
rune                    // alias for int32 - a Unicode code point
byte                    // alias for uint8

s := "Hello, 世界"
len(s)                  // 13 - byte length, not rune count
utf8.RuneCountInString(s) // 8

// Raw string
path := `C:\Users\ada`

Control flow

if x > 0 {                 // no parens around condition
    ...
} else if x == 0 {
    ...
} else {
    ...
}

// if with a statement
if v, ok := m[key]; ok {
    use(v)
}

for i := 0; i < 10; i++ { ... }   // Go has only 'for' - no while
for cond { ... }                   // while loop
for { ... }                        // infinite loop
for i := range 10 { ... }          // 1.22+ range over int
for k, v := range m { ... }        // range over map
for i, c := range s { ... }        // range over string -> rune

switch day {
case "Sat", "Sun":
    ...
default:
    ...
}

// Type switch
switch v := x.(type) {
case int: ...
case string: ...
default: ...
}

Functions

func add(a, b int) int { return a + b }

// Multiple return values (idiomatic for errors)
func divmod(a, b int) (int, int) { return a / b, a % b }
q, r := divmod(17, 5)

// Named returns - documented, can be naked-returned (discouraged)
func split(sum int) (x, y int) {
    x = sum * 4 / 9; y = sum - x; return
}

// Variadic
func max(nums ...int) int { ... }
max(1, 2, 3)
max(xs...)              // spread a slice

// Functions are first-class values
var f func(int) int = func(x int) int { return x * x }

Stage 2 — Composite Types

Arrays vs slices

var arr [5]int              // array - fixed length, value type (rarely used directly)
s := []int{1, 2, 3}        // slice - dynamic, reference type (the common one)

s = append(s, 4)           // append returns a new slice; assign it back
len(s); cap(s)             // 4 ; capacity (>= len)
s = append(s, 5, 6, 7)     // multiple

// make preallocates
buf := make([]byte, 0, 1024)   // length 0, capacity 1024

// Sub-slicing (shares backing array)
sub := s[1:3]               // s[1], s[2]

// copy (does NOT share)
dst := make([]int, len(src)); n := copy(dst, src)

Slices are the workhorse. They’re a {pointer, length, capacity} struct pointing at an underlying array. append may reallocate and copy; the idiomatic pattern is s = append(s, x). Preallocate with make when you know the size — it avoids repeated reallocation.

Maps

m := map[string]int{"a": 1, "b": 2}
m["c"] = 3
delete(m, "a")

v, ok := m["b"]            // two-value lookup: ok = present?
if ok { use(v) }

// Iteration order is NOT specified
for k, v := range m { ... }

Maps are reference types. Never rely on iteration order. The comma-ok idiom distinguishes “absent” from “zero value” — essential because m["missing"] returns the zero value with no error.

Structs and pointers

type Point struct {
    X, Y float64
}

p := Point{1, 2}
p.X = 10

pp := &Point{1, 2}         // *Point
pp.X = 20                  // Go auto-dereferences - no -> operator

// Go has pointers but NO pointer arithmetic
// n := &p.X is fine; p++ on a pointer is not

Structs are value types — assigning or passing a struct copies it. Pass a pointer (*Point) when you want to share or mutate. Go’s automatic dereferencing (pp.X works on *Point) keeps the syntax clean.

Stage 3 — Interfaces and Errors

Implicit interfaces

type Reader interface {
    Read(p []byte) (n int, err error)
}

type File struct{ /* ... */ }
func (f *File) Read(p []byte) (int, error) { /* ... */ }

var r Reader = &File{}     // *File satisfies Reader - no 'implements'

Interfaces are satisfied implicitly — if a type has all the methods in the interface, it implements it. No implements keyword, no declarations. This is Go’s duck typing, and it enables defining interfaces after the types that satisfy them (the io.Reader story).

// Empty interface (pre-generics, for any value)
var any interface{} = 42
any := any(42)             // 1.18+ 'any' is alias for interface{}

// Type assertion
n, ok := any.(int)         // ok = true if it's an int

// Type switch
switch v := any.(type) {
case int: fmt.Println("int", v)
case string: fmt.Println("str", v)
default: fmt.Println("other")
}

Accept interfaces, return concrete types. This is the Go idiom — functions take the smallest interface that does the job (io.Reader, not *os.File), and return concrete types so callers get the full API.

Errors as values

Go has no exceptions for normal control flow. Errors are values returned alongside results:

func parse(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("parse %q: %w", s, err)   // wrap with %w
    }
    return n, nil
}

n, err := parse("42")
if err != nil {
    return err              // propagate up
}
use(n)

The if err != nil pattern is everywhere — it’s deliberate. It makes the error path visible and forces you to decide at each call site.

Wrapping and inspecting:

var ErrNotFound = errors.New("not found")

func lookup(id int) error {
    return fmt.Errorf("lookup %d: %w", id, ErrNotFound)
}

err := lookup(42)
if errors.Is(err, ErrNotFound) {        // unwraps the chain
    // handle not-found
}

var perr *fs.PathError
if errors.As(err, &perr) {              // extract a typed error
    use(perr.Path)
}

errors.Is checks identity through the wrap chain; errors.As extracts a typed error from the chain. Wrap with %w to add context while keeping the original inspectable.

panic/recover is for genuinely unrecoverable conditions (invariants, programmer errors) — not normal control flow. A panic in a goroutine crashes the whole program unless recovered in the same goroutine.

Stage 4 — Concurrency

Go’s concurrency is based on CSP (Communicating Sequential Processes): goroutines communicate through channels rather than by sharing memory.

Go Concurrency

Goroutines

go doWork(x)              // launches a goroutine - returns immediately

A goroutine is a lightweight thread (initial 2KB stack, grows as needed) scheduled by the Go runtime. You can run hundreds of thousands of them. The runtime uses an M:N scheduler — G goroutines run on M OS threads across P processors (GOMAXPROCS).

Channels

ch := make(chan int)      // unbuffered - synchronous handshake
ch := make(chan int, 10)  // buffered - async up to capacity

ch <- 42                  // send (blocks if full)
v := <-ch                 // receive (blocks if empty)

close(ch)                 // no more sends; receivers see zero value + ok=false
v, ok := <-ch             // ok=false after close and drain

for v := range ch { ... } // receive until closed

Unbuffered channels synchronize: the send completes only when a receiver is ready. Buffered channels decouple sender and receiver up to the buffer size.

Select

select {
case v := <-ch1: use(v)
case ch2 <- 42:           // send
case <-time.After(time.Second): return   // timeout
default:                  // non-blocking if no case ready
}

select multiplexes channel operations — it picks one ready case at random (if multiple are ready). This is the primitive for timeouts, cancellation, and fan-in/fan-out.

sync and context

var mu sync.Mutex
mu.Lock(); x++; mu.Unlock()

var wg sync.WaitGroup
for i := range 10 {
    wg.Add(1)
    go func(i int) { defer wg.Done(); work(i) }(i)
}
wg.Wait()

// Context for cancellation and deadlines
func handler(ctx context.Context) error {
    select {
    case <-ctx.Done(): return ctx.Err()
    case r := <-slow():
        return r
    }
}

Always use context.Context for operations that may need to cancel — HTTP handlers, RPC calls, any goroutine that should stop. Pass it as the first argument; honor ctx.Done().

The rule

Don’t communicate by sharing memory; share memory by communicating.

Prefer passing values through channels over locking shared variables. When you do share, use sync primitives. The combination — goroutines + channels + context — is Go’s signature strength and the reason it dominates cloud infrastructure (Docker, Kubernetes, Terraform, etcd are all Go).

Stage 5 — Generics and Tooling

Generics (1.18+)

func Map[T, U any](xs []T, f func(T) U) []U {
    us := make([]U, len(xs))
    for i, x := range xs { us[i] = f(x) }
    return us
}

nums := []int{1, 2, 3}
doubled := Map(nums, func(n int) int { return n * 2 })

// Constraints
type Number interface { ~int | ~int64 | ~float64 }
func Sum[T Number](xs []T) T {
    var s T; for _, x := range xs { s += x }; return s
}

// comparable - predeclared constraint for == and !=
func Contains[T comparable](xs []T, v T) bool {
    for _, x := range xs { if x == v { return true } }
    return false
}

// Generic types
type Tree[T any] struct { left, right *Tree[T]; value T }

Generics use type parameters with constraints. any is the unconstrained constraint; comparable is for types supporting ==. The ~ token includes underlying types (so ~int matches a named type MyInt int). Use generics for reusable data structures and algorithms — don’t over-abstract single uses.

The toolchain

Go Toolchain

# Modules (dependency management, 1.11+)
go mod init myapp
go mod tidy              # add missing, remove unused
go mod download
# go.mod declares module + deps; go.sum locks hashes

# Build a static binary
go build -o myapp .      # single static binary, no runtime deps
GOOS=linux GOARCH=arm64 go build   # cross-compile, no toolchain needed

# Test
go test ./...            # all packages
go test -run TestFoo     # by name
go test -bench=.         # benchmarks
go test -race            # race detector
go test -fuzz=FuzzParse  # fuzzing (1.18+)

# Quality
go vet ./...             # built-in static checks
go fmt ./...             # format
# staticcheck: go install honnef.co/go/tools/cmd/staticcheck@latest

# Profiling
go test -cpuprofile cpu.out -memprofile mem.out
go tool pprof cpu.out

A minimal go.mod:

module myapp

go 1.22

require (
    github.com/spf13/cobra v1.8.0
)

Essential tooling:

  • go mod — module and dependency management. go.sum pins hashes for reproducibility.
  • go build — produces a single static binary. No runtime, no shared libraries, no LD_LIBRARY_PATH. Cross-compilation is a one-liner (GOOS/GOARCH).
  • go test — test runner, benchmarks, fuzzing, race detector, all built in.
  • gofmt / go fmt — the one true formatting style; format-on-save.
  • go vet — static checks; staticcheck adds more.
  • pprof — CPU and memory profiling built into the runtime.

Memory and escape analysis

Go Memory

Go is garbage-collected, but the compiler performs escape analysis to decide whether a value lives on the stack (fast, no GC) or the heap (GC-managed). Values that don’t escape their function (not returned, not stored, not sent to a channel) stay on the stack.

go build -gcflags='-m' main.go    # show escape analysis decisions

The GC is concurrent and low-latency — typically sub-millisecond pauses, with a write barrier and scheduler assists. You rarely tune it; the common performance lever is reducing heap allocations (reuse buffers, use sync.Pool, preallocate slices).

A Quick-Start Checklist

  1. go mod init every project; run go mod tidy after changing deps.
  2. Format with gofmt — format-on-save, no debate.
  3. Handle every errorif err != nil is not optional; wrap with %w to add context.
  4. Accept interfaces, return concretes — define small interfaces at the point of use.
  5. Use context.Context for anything cancellable; pass it as the first arg.
  6. Reach for goroutines + channels before locks; use select for multiplexing and timeouts.
  7. Preallocate slices when you know the size; make([]T, 0, n).
  8. Run go test -race in CI for any concurrent code.
  9. Ship a static binarygo build and you’re done; scratch/distroless Docker images.

Common Pitfalls

  • append return value discardedappend(s, x) may reallocate; you must write s = append(s, x).
  • Loop variable capture — pre-1.22, for i := range xs { go func() { use(i) }() } captured i by reference, so all goroutines saw the last value. 1.22+ fixes this (per-iteration i); on older versions pass i as an arg.
  • Map iteration order is unspecified and not stable — never depend on it.
  • Concurrent map access panics — use sync.Map or a mutex, or channel the work.
  • nil interface vs nil valuevar r io.Reader = (*MyReader)(nil) is a non-nil interface holding a nil pointer; r != nil is true. Check the concrete value if needed.
  • Shadowingerr declared with := inside a block shadows the outer one; the outer err stays unchecked. Use = or rename.
  • Goroutine leak — a goroutine blocked on a channel no one sends to leaks forever. Always wire context cancellation.
  • Copying a sync type — passing a sync.Mutex by value copies its state; it must be a pointer. go vet catches this.

What to Learn Next

  • A Tour of Gogo.dev/tour the official interactive walkthrough.
  • Effective Gogo.dev/doc/effective_go idioms straight from the designers.
  • The Go Programming Language by Donovan and Kernighan — the canonical book.
  • go.dev/docgo.dev/doc/ the standard library docs are excellent; read io, net/http, context, encoding/json.
  • Go by Examplegobyexample.com runnable snippets for every feature.
  • Dave Cheney’s blogdave.cheney.net deep dives on errors, concurrency, performance.
  • 100 Go Mistakes by Teiva Harsanyi — concrete pitfalls and fixes.

Go’s value proposition is simple: a small language that compiles fast, runs fast, deploys as one file, and makes concurrent server code straightforward. The interface system and the concurrency model are the heart — once goroutines, channels, and if err != nil are reflexes, you’re writing Go the way it was designed to be written.

Good luck — and run gofmt.

Resources:

Watch PyShine on YouTube

Contents