Go · nine load-bearing topics

Go Depth Chart

The nine things a Go service is actually built out of — structs, embedding, interfaces, enums, generics, goroutines, channels, mutexes, waitgroups. Each one is worked three times: the syntax, the daily usage, and the semantics that decide whether it survives production.

9 topics 3 depth levels each Go 1.22+ semantics read top–down or jump

01  Foundation

What the thing is and the syntax to write it. Enough to read a codebase without guessing.

02  Working

How it is used on a real team — the patterns you write weekly and the rules behind them.

03  Depth

Memory, scheduling, dispatch, failure modes. The answers that separate “works” from “holds up”.

T1 — Type system

Structs

A struct is a fixed layout of named fields, laid out contiguously in memory. It is a value, not a reference: assigning one copies every field. Almost every design decision in Go — pointer or value, method set, allocation — falls out of that one fact.

01  Foundation declare · construct · zero value

Define the shape with type ... struct. Capitalised field names are exported (visible outside the package); lowercase ones are package-private.

user.go — declaring and constructing
package main

import "fmt"

type User struct {
	ID        int
	Name      string
	Email     string
	Active    bool
	tokenSalt string // unexported: only this package can touch it
}

func main() {
	// 1. Zero value — every field gets its type's zero. No nil, no garbage.
	var u User
	fmt.Printf("%+v\n", u) // {ID:0 Name: Email: Active:false tokenSalt:}

	// 2. Keyed literal — the only form you should write. Order-independent,
	//    survives new fields being added to the struct later.
	a := User{ID: 1, Name: "Ada", Email: "ada@example.com", Active: true}

	// 3. Positional literal — brittle. Must list every field, in order.
	b := User{2, "Grace", "grace@example.com", true, ""}

	// 4. Pointer to a struct. &User{...} is the idiomatic constructor form.
	p := &User{ID: 3, Name: "Alan"}

	// Field access is the same through a pointer — Go auto-dereferences.
	p.Name = "Alan Turing" // shorthand for (*p).Name

	fmt.Println(a.Name, b.Name, p.Name)
}

Structs nest, and nested struct values are stored inline — an Address field is not a pointer to an address, it is the address, sitting inside the user's memory.

nesting and comparison
type Address struct {
	City string
	Zip  string
}

type Customer struct {
	Name    string
	Billing Address  // stored inline
	Shipping *Address // optional — nil means "same as billing"
}

c := Customer{
	Name:    "Ada",
	Billing: Address{City: "London", Zip: "EC1"},
}
c.Billing.City = "Cambridge" // reach straight through

// Structs are comparable with == when all their fields are comparable.
fmt.Println(Address{"London", "EC1"} == Address{"London", "EC1"}) // true
  • No constructors, no null. The zero value is always valid memory; good API design makes it useful too (var buf bytes.Buffer is ready to write to).
  • No inheritance. There is no extends. Composition and embedding fill that role (T2).
  • %+v prints field names, %#v prints Go syntax. Both are your debugging default.
02  Working methods · receivers · tags · constructors

Methods are functions with a receiver. The receiver decides whether the method sees a copy or the original — this is the single most consequential choice in day-to-day Go.

value receiver vs pointer receiver
type Counter struct {
	n int
}

// Value receiver: c is a COPY. Mutations are thrown away.
func (c Counter) IncBroken() { c.n++ }

// Pointer receiver: c points at the original. Mutations stick.
func (c *Counter) Inc() { c.n++ }

// Value receiver is right for read-only methods on small structs.
func (c Counter) Value() int { return c.n }

func main() {
	var c Counter
	c.IncBroken()
	fmt.Println(c.Value()) // 0  ← the classic beginner bug

	c.Inc()                // Go rewrites this as (&c).Inc()
	fmt.Println(c.Value()) // 1
}

The receiver rule

  • Pick one and stay consistent per type. Mixing value and pointer receivers on the same type is a code smell and confuses method sets (see T3).
  • Use a pointer receiver if the method mutates, if the struct is large, or if the struct contains a sync.Mutex, sync.WaitGroup, or any other non-copyable field.
  • Use a value receiver for small immutable value types — time.Time, Point, money amounts. It makes them safe to share across goroutines.

Constructors are ordinary functions returning the type. Return a pointer when the type is mutable or large, a value when it is a small immutable value.

constructor + validation + functional options
type Server struct {
	addr    string
	timeout time.Duration
	maxConn int
	logger  *slog.Logger
}

// Plain constructor: required args as parameters, invariants enforced here.
func NewServer(addr string) (*Server, error) {
	if addr == "" {
		return nil, errors.New("server: addr is required")
	}
	return &Server{
		addr:    addr,
		timeout: 30 * time.Second, // sensible defaults
		maxConn: 100,
		logger:  slog.Default(),
	}, nil
}

// Functional options: the Go answer to optional/named parameters.
type Option func(*Server)

func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithMaxConn(n int) Option           { return func(s *Server) { s.maxConn = n } }

func New(addr string, opts ...Option) (*Server, error) {
	s, err := NewServer(addr)
	if err != nil {
		return nil, err
	}
	for _, opt := range opts {
		opt(s)
	}
	return s, nil
}

// Call site reads like prose and stays source-compatible as options are added.
srv, err := New(":8080", WithTimeout(5*time.Second), WithMaxConn(500))

Struct tags are string metadata read by reflection at runtime — encoders, validators and ORMs all key off them.

tags: JSON encoding and friends
type Product struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Price     float64   `json:"price,string"`      // encode number as a JSON string
	Discount  *float64  `json:"discount,omitempty"` // omit when nil
	CreatedAt time.Time `json:"created_at"`
	internal  string    `json:"-"`                  // never encoded (also unexported)
	Secret    string    `json:"-"`                  // exported but explicitly skipped
}

b, _ := json.Marshal(Product{ID: 1, Name: "Cable", Price: 9.99})
// {"id":1,"name":"Cable","price":"9.99","created_at":"0001-01-01T00:00:00Z"}

Two smaller forms that show up constantly: the anonymous struct, and the zero-width struct{}.

anonymous structs & struct{}
// Anonymous struct: a one-off shape, most often a table-driven test case
// or a response body that deserves no package-level name.
tests := []struct {
	name string
	in   string
	want int
}{
	{"empty", "", 0},
	{"one", "a", 1},
}
for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) { /* ... */ })
}

// struct{} occupies ZERO bytes. Two uses dominate:
seen := map[string]struct{}{}   // a set: values cost nothing
seen["a"] = struct{}{}
_, ok := seen["a"]

done := make(chan struct{})     // a pure signal channel: "an event happened"
close(done)                     // broadcast to every receiver
03  Depth copy semantics · layout · comparability

Copying is shallow. A struct copy duplicates the fields — and a slice, map, pointer or channel field is just a header or pointer, so the copy shares the underlying data.

the shallow-copy trap
type Config struct {
	Name  string
	Tags  []string          // slice header: ptr, len, cap
	Extra map[string]string // map: a pointer to a runtime hmap
}

a := Config{Name: "a", Tags: []string{"x"}, Extra: map[string]string{"k": "v"}}
b := a // copies 3 fields — NOT the backing array or the map

b.Name = "b"          // independent: string header copied
b.Tags[0] = "MUTATED" // shared backing array → a.Tags[0] is now "MUTATED"
b.Extra["k"] = "boom" // shared map      → a.Extra["k"] is now "boom"

// A real deep copy has to be written by hand (or generated).
func (c Config) Clone() Config {
	out := c
	out.Tags = slices.Clone(c.Tags)
	out.Extra = maps.Clone(c.Extra)
	return out
}

Layout and alignment. Fields are laid out in declaration order and padded to their alignment. Ordering fields from widest to narrowest can shrink a hot struct measurably.

field ordering changes the size
type Bad struct {
	a bool  // 1 byte  + 7 padding
	b int64 // 8 bytes
	c bool  // 1 byte  + 7 padding
} // unsafe.Sizeof(Bad{}) == 24 on a 64-bit platform

type Good struct {
	b int64 // 8
	a bool  // 1
	c bool  // 1 + 6 padding
} // unsafe.Sizeof(Good{}) == 16

// Measure, don't guess:
fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{})) // 24 16
// `go vet -fieldalignment` / the fieldalignment analyzer flags these for you.

Comparability is a compile-time property. A struct supports == only if every field does. Slices, maps and funcs are not comparable, and an any/interface field defers the check to runtime — where it can panic.

Field type== on the structUsable as a map keyNote
int, string, bool, floatyesyesfield-by-field comparison
array [N]Tyesyesif T is comparable
pointer *Tyesyescompares addresses, not pointees
struct (nested)yesyesif all its fields are
slice []T, map, funccompile errornouse reflect.DeepEqual or slices.Equal
interface / anycompilescompilespanics at runtime if the dynamic type is uncomparable

Traps that reach production

  • Copying a struct that holds a sync.Mutex copies the lock state; the two copies then guard nothing. go vet catches it — always pass such structs by pointer.
  • for _, x := range structs gives you a copy. Mutating x does nothing to the slice. Write structs[i].Field = ..., or range over pointers.
  • You cannot address a map element: m["k"].Field = v is a compile error for a struct value map. Use map[string]*T, or read-modify-write the whole value back.
  • Floats break equality — a struct containing NaN is never == to itself, which quietly poisons map lookups.
  • time.Time in a struct carries a monotonic clock reading; == can be false for two times that represent the same instant. Compare with .Equal().

Where does it live? Go has no stack/heap keyword — escape analysis decides. A struct that never outlives its function stays on the stack (free); returning &T{}, storing it in an interface, or capturing it in a closure that escapes forces a heap allocation.

seeing the decision the compiler made
// go build -gcflags='-m' ./...
//   ./main.go:12:9: &User{...} escapes to heap
//   ./main.go:20:2: moved to heap: buf

func stackAllocated() int {
	u := User{ID: 1} // never escapes → stack, zero GC cost
	return u.ID
}

func heapAllocated() *User {
	return &User{ID: 1} // escapes via the return → heap
}

T2 — Type system

Struct embedding

Declare a field with a type but no name and Go promotes its fields and methods to the outer type. It looks like inheritance and reads like inheritance — but it is composition with automatic delegation, resolved entirely at compile time. There is no virtual dispatch, and the inner type never knows it was embedded.

01  Foundation promotion · named vs embedded
embedded vs. a plain named field
type Base struct {
	ID        int
	CreatedAt time.Time
}

func (b Base) Age() time.Duration { return time.Since(b.CreatedAt) }

// EMBEDDED: no field name, just the type.
type Article struct {
	Base          // ← embedded
	Title string
}

// NAMED: ordinary field. Nothing is promoted.
type Comment struct {
	Base  Base // ← named field
	Body  string
}

func main() {
	a := Article{Base: Base{ID: 1, CreatedAt: time.Now()}, Title: "Hello"}
	fmt.Println(a.ID)       // promoted field  — no a.Base.ID needed
	fmt.Println(a.Age())    // promoted method
	fmt.Println(a.Base.ID)  // the long form still works; "Base" is the implicit field name

	c := Comment{Base: Base{ID: 2}, Body: "hi"}
	fmt.Println(c.Base.ID)  // required
	// fmt.Println(c.ID)    // compile error: c.ID undefined
}
  • The field name is the type nameBase for Base, Mutex for sync.Mutex, ReadWriter for io.ReadWriter (the package qualifier is dropped).
  • Promotion is syntactic sugar. a.ID compiles to a.Base.ID. Nothing is copied, nothing is dynamic.
  • You can embed a struct, a pointer to a struct, an interface, or any named type (even type Celsius float64).
02  Working the four patterns worth knowing

1  Embed a mutex to make a type self-locking

the canonical embedded lock
type SafeCounter struct {
	sync.Mutex // promoted: c.Lock() / c.Unlock() work directly
	counts map[string]int
}

func (c *SafeCounter) Inc(key string) {
	c.Lock()
	defer c.Unlock()
	c.counts[key]++
}

// Note: embedding sync.Mutex EXPORTS Lock/Unlock on SafeCounter, letting callers
// lock your internals. For a public API, prefer an unexported named field:
type SafeCounter2 struct {
	mu     sync.Mutex
	counts map[string]int
}

2  Embed an interface to wrap it — override one method, forward the rest

middleware by embedding
// Capture the status code of an http.ResponseWriter without reimplementing
// the ~4 methods of the interface: embed it and shadow the one you care about.
type statusRecorder struct {
	http.ResponseWriter // everything else is forwarded for free
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code) // explicit delegation to the inner value
}

func LogStatus(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, req)
		slog.Info("request", "path", req.URL.Path, "status", rec.status)
	})
}

Why this works

An embedded interface means the outer struct satisfies that interface by delegating every method to whatever value you stored. If that field is nil and a non-shadowed method is called, you get a nil-pointer panic — which is exactly how partial test doubles behave: implement the two methods your test exercises, and let the rest panic loudly if they are unexpectedly reached.

3  Share behaviour across sibling types

common fields + common methods
type Model struct {
	ID        uuid.UUID
	CreatedAt time.Time
	UpdatedAt time.Time
}

func (m *Model) Touch() { m.UpdatedAt = time.Now() }
func (m Model) IsNew() bool { return m.ID == uuid.Nil }

type Order struct {
	Model            // every entity gets ID/CreatedAt/UpdatedAt + Touch/IsNew
	Total  int64
	Status Status
}

type Invoice struct {
	Model
	OrderID uuid.UUID
}

o := &Order{Total: 1999}
o.Touch()            // promoted pointer method — requires o to be addressable
fmt.Println(o.IsNew()) // true

4  Flatten JSON with an embedded struct

embedding and encoding/json
type Meta struct {
	ID      int    `json:"id"`
	Version int    `json:"version"`
}

type Doc struct {
	Meta          // no json tag → fields are FLATTENED into the parent object
	Body string `json:"body"`
}
// {"id":1,"version":2,"body":"..."}

type Doc2 struct {
	Meta Meta   `json:"meta"` // named field → NESTED
	Body string `json:"body"`
}
// {"meta":{"id":1,"version":2},"body":"..."}

type Doc3 struct {
	Meta `json:"meta"` // embedded WITH a tag → also nested
	Body string `json:"body"`
}
03  Depth shadowing · ambiguity · no dynamic dispatch

Depth wins. Promotion searches the shallowest level first. A field or method declared on the outer type shadows the embedded one; two embedded types at the same depth that both provide a name make that name ambiguous — a compile error, but only at the point of use.

shadowing and ambiguity
type A struct{ Name string }
type B struct{ Name string }

type Outer struct {
	A
	B
	Name string // depth 0 — shadows BOTH
}

o := Outer{}
o.Name = "outer"   // fine: shallowest wins
o.A.Name = "a"     // explicit path always available

type Ambiguous struct {
	A
	B
	// no Name here
}
x := Ambiguous{}
// x.Name = "?"    // compile error: ambiguous selector x.Name
x.A.Name = "ok"    // must disambiguate explicitly

Embedding is not inheritance — the one that bites

An embedded method calling another method resolves to the inner type's method, always. Overriding a method on the outer type does not change what the inner type calls. There is no super, and no virtual table.

the missing dynamic dispatch
type Animal struct{ Name string }

func (a Animal) Speak() string    { return "..." }
func (a Animal) Describe() string { return a.Name + " says " + a.Speak() }

type Dog struct{ Animal }

func (d Dog) Speak() string { return "Woof" } // "overrides" Speak on Dog

d := Dog{Animal{Name: "Rex"}}
fmt.Println(d.Speak())    // "Woof"   ← outer method, as expected
fmt.Println(d.Describe()) // "Rex says ..."  ← NOT "Woof"!
// Describe is Animal's method; inside it, the receiver IS an Animal.
// It has no idea a Dog exists.

// The Go fix: pass the behaviour in as an interface, don't inherit it.
type Speaker interface{ Speak() string }

func Describe(s Speaker, name string) string { return name + " says " + s.Speak() }

Method sets propagate through embedding, which is how embedding is used to satisfy interfaces:

Embedded asPromoted to OuterPromoted to *Outer
Base (value)value-receiver methodsvalue + pointer-receiver methods
*Base (pointer)value + pointer-receiver methodsvalue + pointer-receiver methods

Practical consequence: if Base has pointer-receiver methods and you embed it by value, only *Outer satisfies the interface — var _ Iface = Outer{} fails while var _ Iface = &Outer{} compiles.

When to reach for embedding

  • Yes: wrapping an interface to intercept one method; attaching a lock; sharing genuinely universal fields (audit columns); building type MyError struct { error }-style decorators.
  • No: modelling an is-a hierarchy, sharing code between unrelated concepts, or as a way to get "protected" members. Reach for a named field and an explicit method — the extra line of delegation is cheaper than a promotion surprise.
  • Watch the API surface: embedding promotes every exported method of the inner type, including ones added in a future version of that dependency. A named field keeps your public API yours.

T3 — Type system

Interfaces

An interface is a set of method signatures. Any type with those methods satisfies it — no implements keyword, no registration. At runtime an interface value is a two-word pair: (dynamic type, value), and nearly every interface surprise in Go is that second word being present when you expected nothing.

01  Foundation implicit satisfaction · assertions
declaring and satisfying
type Shape interface {
	Area() float64
	Perimeter() float64
}

type Rect struct{ W, H float64 }

func (r Rect) Area() float64      { return r.W * r.H }
func (r Rect) Perimeter() float64 { return 2 * (r.W + r.H) }

type Circle struct{ R float64 }

func (c Circle) Area() float64      { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }

// Rect and Circle never mention Shape. Satisfaction is structural.
func TotalArea(shapes []Shape) float64 {
	var total float64
	for _, s := range shapes {
		total += s.Area() // dynamic dispatch to the concrete method
	}
	return total
}

TotalArea([]Shape{Rect{3, 4}, Circle{1}})

Going the other way — from an interface back to a concrete type — is a type assertion. Always use the two-value form unless a failure genuinely should panic.

assertions and type switches
var s Shape = Circle{R: 2}

c := s.(Circle)      // panics if s is not a Circle
c, ok := s.(Circle)  // ok is false instead of panicking — prefer this

// A type switch handles many cases at once.
func describe(v any) string {
	switch x := v.(type) {
	case nil:
		return "nil"
	case int:
		return fmt.Sprintf("int %d", x)      // x is an int here
	case string:
		return fmt.Sprintf("string %q", x)   // x is a string here
	case Shape:
		return fmt.Sprintf("shape area %.2f", x.Area())
	case error:
		return "error: " + x.Error()
	default:
		return fmt.Sprintf("unhandled %T", x)
	}
}
  • any is interface{} — an alias added in Go 1.18. The empty interface is satisfied by every type, which means it carries no information; prefer a real interface or a type parameter.
  • The nil interface is the zero value: both words nil. Calling a method on it panics.
02  Working design rules · errors · testing seams

The three rules that shape real Go code

  • Accept interfaces, return structs. Callers decide what abstraction they need; giving them a concrete type back keeps every field and method available.
  • Define the interface where it is consumed, not next to the implementation. The consumer package owns the smallest set of methods it actually calls.
  • The bigger the interface, the weaker the abstraction. One or two methods is the sweet spot — io.Reader, io.Writer, error, fmt.Stringer, http.Handler are each a single method.
the consumer defines the seam
// package billing — declares only what IT needs.
type UserStore interface {
	GetUser(ctx context.Context, id int64) (User, error)
}

type Service struct{ users UserStore }

func NewService(u UserStore) *Service { return &Service{users: u} }

func (s *Service) Charge(ctx context.Context, id int64, cents int64) error {
	u, err := s.users.GetUser(ctx, id)
	if err != nil {
		return fmt.Errorf("charge %d: %w", id, err)
	}
	...
}

// package postgres — returns a concrete type; it never imports billing.
type DB struct{ pool *pgxpool.Pool }
func New(pool *pgxpool.Pool) *DB { return &DB{pool: pool} }
func (d *DB) GetUser(ctx context.Context, id int64) (billing.User, error) { ... }

// package billing_test — a fake is now three lines.
type fakeStore struct{ u User; err error }
func (f fakeStore) GetUser(context.Context, int64) (User, error) { return f.u, f.err }

error is just an interface with one method, which is why error handling in Go is ordinary programming.

errors: wrap, Is, As
type error interface{ Error() string }

// Sentinel errors: compared by identity.
var ErrNotFound = errors.New("not found")

// Typed errors: carry structured data.
type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation: %s %s", e.Field, e.Reason)
}

func load(id int) error {
	if id == 0 {
		// %w wraps: the chain is preserved for errors.Is / errors.As.
		return fmt.Errorf("load user %d: %w", id, ErrNotFound)
	}
	return nil
}

err := load(0)
if errors.Is(err, ErrNotFound) {          // unwraps the chain, compares identity
	return http.StatusNotFound, nil
}

var ve *ValidationError
if errors.As(err, &ve) {                  // unwraps and type-asserts into ve
	log.Println("bad field:", ve.Field)
}

// %w wraps (callers can inspect). %v formats (the chain ends here — an
// intentional choice when the underlying error is an implementation detail).
compile-time satisfaction check
// Costs nothing at runtime, fails the build the moment the type drifts.
// Put it next to the implementation.
var (
	_ io.ReadWriter = (*Buffer)(nil)
	_ UserStore     = (*DB)(nil)
	_ error         = (*ValidationError)(nil)
)

Interfaces compose by embedding — this is how the standard library builds up from one-method pieces:

interface embedding
type Reader interface{ Read(p []byte) (n int, err error) }
type Writer interface{ Write(p []byte) (n int, err error) }
type Closer interface{ Close() error }

type ReadWriter interface {
	Reader
	Writer
}

type ReadWriteCloser interface {
	ReadWriter
	Closer
}

// Optional-behaviour probing: ask at runtime whether a value can do more.
func flushIfPossible(w io.Writer) error {
	if f, ok := w.(interface{ Flush() error }); ok { // anonymous interface, inline
		return f.Flush()
	}
	return nil
}
03  Depth the (type, value) pair · method sets · cost

The typed-nil bug — the most famous gotcha in Go

An interface is nil only when both words are nil. Assigning a nil *T to an interface produces a non-nil interface holding a nil pointer. It is the single most common cause of "but I returned nil!" incidents.

how a nil error is not nil
type MyError struct{ Code int }

func (e *MyError) Error() string { return fmt.Sprintf("code %d", e.Code) }

// BROKEN: the return type is the concrete pointer.
func doBroken() error {
	var e *MyError = nil // (*MyError, nil)
	return e             // wrapped into an interface: type=*MyError, value=nil
}

func main() {
	err := doBroken()
	fmt.Println(err == nil) // false !!  — type word is non-nil
	fmt.Printf("%T %v\n", err, err) // *main.MyError <nil>
	if err != nil {
		// this branch runs, then err.Error() dereferences nil → panic
	}
}

// CORRECT: only assign to the interface on the failure path.
func doFixed(fail bool) error {
	if fail {
		return &MyError{Code: 500}
	}
	return nil // an untyped nil — both words nil
}

Method sets decide satisfaction. A pointer-receiver method belongs to *T only. Go will auto-take the address for a direct call on an addressable variable, but it will not do that when storing into an interface.

value vs pointer, interface edition
type Stringer interface{ String() string }

type P struct{ n int }
func (p *P) String() string { return strconv.Itoa(p.n) } // POINTER receiver

var _ Stringer = &P{} // ok:  *P has String
// var _ Stringer = P{} // compile error: P does not implement Stringer
                        // (method String has pointer receiver)

p := P{1}
_ = p.String() // still fine: Go rewrites to (&p).String() — p is addressable

// And the reverse case, which trips people up in maps and slices:
m := map[string]P{"a": {1}}
// m["a"].String() // compile error: m["a"] is not addressable
Receiver on TMethod set of TMethod set of *T
func (t T)includedincluded
func (t *T)not includedincluded

What an interface value costs

  • Two words wide (16 bytes on 64-bit): a pointer to an itab (the concrete type plus its method pointers for this interface) and a pointer to the data.
  • Boxing allocates. Storing a non-pointer value in an interface usually copies it to the heap. Small integers 0–255 and the empty struct use pre-allocated statics, so they are free.
  • Calls are indirect and generally not inlined. Fine everywhere except the innermost loop of hot code — measure with go test -bench before restructuring.
  • Interfaces defeat escape analysis: a value passed as any to fmt.Println escapes to the heap. That is why logging in a tight loop shows up in profiles.

Design judgement

  • Do not create an interface for a single implementation "for testability" before you have a second caller or a real fake. Add the seam when the second reason arrives — Go makes that a mechanical refactor.
  • Do not put an interface in the same package as its only implementation unless multiple packages implement it (io, sort, http qualify).
  • Prefer small interfaces at the boundary, concrete types inside. Interfaces at every layer produce a codebase where nothing can be read end to end.
  • Generics do not replace interfaces. Use an interface when behaviour varies at runtime; a type parameter when only the type varies and the code is identical.

T4 — Type system

Enums & iota

Go has no enum keyword. The idiom is a defined type plus a const block using iota, and everything that a real enum would give you — printing, validation, exhaustiveness, JSON — you add yourself. The pattern is small; the discipline around the zero value is what makes it safe.

01  Foundation iota · a defined type

iota is a per-const-block counter: it is 0 in the first ConstSpec and increments by one for each following line, whether or not the line repeats the expression.

the basic enum
// Give it its own TYPE. `int` alone gives you no safety at all.
type Status int

const (
	StatusPending  Status = iota // 0
	StatusActive                 // 1  — the expression `Status = iota` repeats
	StatusSuspended              // 2
	StatusClosed                 // 3
)

func main() {
	s := StatusActive
	fmt.Println(s)       // 1  — prints the number until we add String()
	fmt.Println(s == 1)  // true: untyped constant 1 converts to Status

	var n int = 5
	// s = n            // compile error: cannot use n (int) as Status
	s = Status(n)       // explicit conversion is allowed — see the Depth level
}

The zero value is your first constant

var s Status is StatusPending, and a JSON body with no status field decodes to it too. Either make the zero value the correct default, or reserve it: StatusUnknown Status = iota so that an unset value is visibly wrong rather than silently plausible.

02  Working String · parse · validate · JSON

A production enum is the const block plus four small pieces: a name table, String(), a parser, and a validity check.

status.go — the full pattern
package order

import (
	"database/sql/driver"
	"encoding/json"
	"fmt"
)

type Status int

const (
	StatusUnknown Status = iota // reserved: an unset Status is invalid
	StatusPending
	StatusPaid
	StatusShipped
	StatusCancelled
)

var statusNames = map[Status]string{
	StatusPending:   "pending",
	StatusPaid:      "paid",
	StatusShipped:   "shipped",
	StatusCancelled: "cancelled",
}

// String makes it print nicely everywhere: fmt, logs, %v, %s.
func (s Status) String() string {
	if name, ok := statusNames[s]; ok {
		return name
	}
	return fmt.Sprintf("Status(%d)", int(s)) // never lie about a bad value
}

func (s Status) Valid() bool { _, ok := statusNames[s]; return ok }

func ParseStatus(s string) (Status, error) {
	for k, v := range statusNames {
		if v == s {
			return k, nil
		}
	}
	return StatusUnknown, fmt.Errorf("order: unknown status %q", s)
}

// Cross the wire as a string, not a fragile integer.
func (s Status) MarshalJSON() ([]byte, error) {
	if !s.Valid() {
		return nil, fmt.Errorf("order: cannot marshal %v", s)
	}
	return json.Marshal(s.String())
}

func (s *Status) UnmarshalJSON(b []byte) error {
	var raw string
	if err := json.Unmarshal(b, &raw); err != nil {
		return err
	}
	parsed, err := ParseStatus(raw)
	if err != nil {
		return err
	}
	*s = parsed
	return nil
}

// Same idea for SQL, if the column is text.
func (s Status) Value() (driver.Value, error) { return s.String(), nil }

Generate String() instead of writing it

Add //go:generate stringer -type=Status above the type and run go generate ./.... stringer emits an efficient index-based String() and a compile-time guard that breaks the build if a constant is reordered or renamed. Install with go install golang.org/x/tools/cmd/stringer@latest. Hand-write only when you need names that differ from the identifiers.

String-backed enums

When the value is stored, logged and debugged more often than compared, skip iota entirely — the constant is its own name, and a bad database row shows up readable.

the other valid choice
type Env string

const (
	EnvDev     Env = "dev"
	EnvStaging Env = "staging"
	EnvProd    Env = "prod"
)

func (e Env) Valid() bool {
	switch e {
	case EnvDev, EnvStaging, EnvProd:
		return true
	}
	return false
}
// Trade-off: JSON and SQL work with zero extra code, and values are
// self-describing; you lose ordering and compact storage.

Bit flags

iota with a shift
type Perm uint8

const (
	PermRead  Perm = 1 << iota // 1  (0b001)
	PermWrite                  // 2  (0b010)
	PermExec                   // 4  (0b100)
)

const PermAll = PermRead | PermWrite | PermExec

func (p Perm) Has(q Perm) bool { return p&q == q }   // test
func (p Perm) Set(q Perm) Perm { return p | q }      // add
func (p Perm) Clear(q Perm) Perm { return p &^ q }   // remove (AND NOT)

p := PermRead.Set(PermWrite)
fmt.Println(p.Has(PermWrite), p.Has(PermExec)) // true false
03  Depth iota mechanics · exhaustiveness · the type hole

iota counts ConstSpecs (lines), not constants. That explains every one of its tricks:

everything iota does
const (
	_  = iota             // 0 discarded: start the enum at 1
	KB = 1 << (10 * iota) // 1 << 10
	MB                    // 1 << 20
	GB                    // 1 << 30
	TB                    // 1 << 40
)

const (
	A = iota // 0
	B        // 1
	_        // 2 skipped — a removed value, kept as a placeholder for wire compat
	D        // 3
)

const (
	// Multiple constants on one line share the same iota.
	X, XX = iota, iota * 10 // X=0, XX=0
	Y, YY                   // Y=1, YY=10
	Z, ZZ                   // Z=2, ZZ=20
)

const (
	C1 = iota + 100 // 100 — offsets work
	C2              // 101
)

// iota RESETS to 0 in every new const block. Splitting a block renumbers it —
// a silent, wire-breaking change if those numbers are persisted anywhere.

Three holes in Go's enums, and what to do about each

  • Not closed. Status(99) compiles. Any value crossing a boundary (JSON, SQL, gRPC, flags) must go through a parser or Valid(); treat the constructor as the only trusted door.
  • Not exhaustive. The compiler will not tell you that a switch missed the new constant you just added. Add a default that fails loudly, and run the exhaustive linter (bundled in golangci-lint) in CI.
  • Numbers are the contract. If a value is persisted or sent over the wire, reordering the const block silently rewrites history. Append new constants at the end; retire old ones with _.
making a missing case impossible to ignore
func (s Status) NextAction() string {
	switch s {
	case StatusPending:
		return "await payment"
	case StatusPaid:
		return "pick and pack"
	case StatusShipped:
		return "await delivery"
	case StatusCancelled:
		return "none"
	case StatusUnknown:
		return "none"
	default:
		// Fails immediately and identifies the value, instead of returning "".
		panic(fmt.Sprintf("order: unhandled status %v", s))
	}
}

// Compile-time guard against reordering. A negative untyped constant cannot
// convert to uint, so one of these two lines fails the build if the value moves.
const (
	_ = uint(StatusCancelled - 4)
	_ = uint(4 - StatusCancelled)
)

When the enum should be a struct instead

If each variant carries data or behaviour that differs, an interface with one implementation per variant models it better than an integer plus a switch — that is the Go equivalent of a sum type. Reach for it when the switch appears in more than three places.

closed set with behaviour
// A "sealed" interface: the unexported method means only this package can
// add implementations, which is as close to a closed sum type as Go gets.
type Payment interface {
	Total() int64
	isPayment()
}

type Card struct{ Cents int64; Last4 string }
func (c Card) Total() int64 { return c.Cents }
func (Card) isPayment()     {}

type Credit struct{ Cents int64; Reason string }
func (c Credit) Total() int64 { return -c.Cents }
func (Credit) isPayment()     {}

T5 — Type system

Generics

Type parameters (Go 1.18+) let one function or type work over many types without losing static typing and without any. A constraint is an interface used in a new way — it can describe a set of types, not just a set of methods. The hard part is not the syntax; it is knowing when an interface would have been better.

01  Foundation type parameters · constraints · inference
a generic function, read left to right
// [T any]  → one type parameter named T, constrained to "any type"
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
}

names := Map([]int{1, 2, 3}, func(n int) string { // T and U are INFERRED
	return strconv.Itoa(n)
})                                                 // []string{"1","2","3"}

lens := Map[string, int](names, func(s string) int { return len(s) }) // explicit

The constraint decides what you are allowed to do with a value of type T. Three you will use constantly:

any
Any type at all. You may only copy, compare to nothing, and pass it along.
comparable
Supports == and !=, so T can be a map key. Since Go 1.20 interface types satisfy it too — with the runtime-panic caveat from T1.
cmp.Ordered
Supports < <= > >=: all integers, floats and strings. In the standard library since Go 1.21 (cmp package).
constraints in action
import "cmp" // Go 1.21+

func Max[T cmp.Ordered](a, b T) T {
	if a > b { // legal ONLY because the constraint permits >
		return a
	}
	return b
}

func Keys[K comparable, V any](m map[K]V) []K {
	out := make([]K, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

func Contains[T comparable](s []T, want T) bool {
	for _, v := range s {
		if v == want { // legal because of `comparable`
			return true
		}
	}
	return false
}

Most of this is already in the standard library

Before writing a generic helper, check slices and maps (both stdlib since Go 1.21): slices.Contains, Index, Sort, SortFunc, BinarySearch, Reverse, Clone, Equal, Max, Min, Compact, Insert, Delete, and maps.Keys, maps.Values, maps.Clone. Also cmp.Compare, cmp.Or, and min/max which became builtins in 1.21.

02  Working type sets · generic types · real uses

A constraint interface may list types as well as methods. ~ means "any type whose underlying type is this", which is what lets your own type Celsius float64 satisfy a numeric constraint.

type sets and the ~ operator
type Number interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
		~float32 | ~float64
}

func Sum[T Number](xs []T) T {
	var total T // zero value of whatever T is
	for _, x := range xs {
		total += x
	}
	return total
}

type Celsius float64
Sum([]Celsius{1.5, 2.5}) // works: underlying type of Celsius is float64
                         // without ~, `| float64` would reject Celsius

// A constraint can mix a type set with methods.
type StringableNumber interface {
	~int | ~int64
	String() string
}

Types take parameters too. The parameters are declared on the type and are in scope for its methods — but methods cannot introduce new type parameters of their own.

a generic type with methods
type Set[T comparable] struct {
	items map[T]struct{}
}

func NewSet[T comparable](vals ...T) *Set[T] {
	s := &Set[T]{items: make(map[T]struct{}, len(vals))}
	for _, v := range vals {
		s.items[v] = struct{}{}
	}
	return s
}

func (s *Set[T]) Add(v T)          { s.items[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool     { _, ok := s.items[v]; return ok }
func (s *Set[T]) Len() int         { return len(s.items) }
func (s *Set[T]) Slice() []T {
	out := make([]T, 0, len(s.items))
	for v := range s.items {
		out = append(out, v)
	}
	return out
}

// ILLEGAL — a method may not declare its own type parameter:
// func (s *Set[T]) Map[U any](f func(T) U) *Set[U] { ... }
// Write it as a free function instead:
func MapSet[T, U comparable](s *Set[T], f func(T) U) *Set[U] {
	out := NewSet[U]()
	for v := range s.items {
		out.Add(f(v))
	}
	return out
}

ids := NewSet(1, 2, 3)     // T inferred as int
fmt.Println(ids.Has(2))    // true

Where generics genuinely pay off

three patterns worth the type parameters
// 1. A typed cache/registry — previously map[string]any plus assertions.
type Cache[K comparable, V any] struct {
	mu    sync.RWMutex
	items map[K]V
}

func NewCache[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{items: make(map[K]V)}
}

func (c *Cache[K, V]) Get(k K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.items[k]
	return v, ok // returns the zero V when absent — no assertion needed
}

func (c *Cache[K, V]) Set(k K, v V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.items[k] = v
}

// 2. Pointer-to-value helpers, the death of a hundred copy-pasted funcs.
func Ptr[T any](v T) *T { return &v }
func Deref[T any](p *T, fallback T) T {
	if p == nil {
		return fallback
	}
	return *p
}
req := Request{Timeout: Ptr(30), Label: Ptr("beta")}

// 3. Grouping / indexing, once instead of per type.
func GroupBy[T any, K comparable](in []T, key func(T) K) map[K][]T {
	out := make(map[K][]T)
	for _, v := range in {
		k := key(v)
		out[k] = append(out[k], v)
	}
	return out
}
byStatus := GroupBy(orders, func(o Order) Status { return o.Status })
03  Depth inference limits · codegen · when not to

Inference works from arguments, not from returns. A function whose type parameter appears only in the result must be instantiated explicitly.

where inference stops
func Zero[T any]() T { var z T; return z }

// z := Zero()      // compile error: cannot infer T
z := Zero[int]()    // explicit instantiation required

// Constraints do not flow "backwards" either. This is a common surprise:
func Reduce[T, U any](in []T, init U, f func(U, T) U) U { ... }

// Inference succeeds because init pins U:
total := Reduce([]int{1, 2, 3}, 0, func(acc, n int) int { return acc + n })

// Partial instantiation is allowed — supply the leading params, infer the rest.
parse := Map[string, int]

How Go compiles it: GC shape stenciling

  • Go does not monomorphise per type like C++ or Rust, and does not box everything like Java. It compiles one copy per GC shape — roughly, per memory layout.
  • All pointer types share one instantiation (they have the same shape), which is passed a hidden dictionary carrying the real type info. That costs an indirection on method calls.
  • Distinct value shapes get their own copyint, float64, and each struct layout are compiled separately, so those are fast and inlinable.
  • Net effect: generic code over values is usually as fast as hand-written code and beats any-based code by avoiding boxing; generic code over pointers/interfaces can be marginally slower than a hand-written version. Benchmark before caring.

Restrictions to remember

  • No generic methods — type parameters live on the type or the function, never on a method of a generic type.
  • No specialisation — you cannot write a faster overload for T = string. Use a type switch on any(v) inside the generic body if you must.
  • Constraints are not runtime types. You cannot switch on T directly; convert first: switch v := any(v).(type).
  • No parameterised struct field defaults, no variance. []Dog is not a []Animal, and Set[Dog] is unrelated to Set[Animal].
  • Type parameters cannot be used on the receiver of a plain function values.Add on a Set[int] is fine, but you cannot pass an uninstantiated generic function where a concrete func type is expected.

The decision rule

  • Write it concretely first. Generalise on the third copy, not the first. Go's own guidance: generics are for when the code is identical and only the type differs.
  • Behaviour varies → interface. Only the type varies → type parameter. If the constraint you are reaching for is a set of methods, an ordinary interface parameter is simpler and reads better.
  • Container types and algorithms over slices/maps are the sweet spot. Business logic almost never is.
  • Do not use a type parameter that appears exactly once in the signature — that is an interface wearing a costume.

C1 — Concurrency

Goroutines

A goroutine is a function scheduled by the Go runtime rather than the OS. It starts with a ~2 KB growable stack and is multiplexed onto a small pool of OS threads, so a hundred thousand of them is routine. The cost is not creating them — it is knowing how each one ends.

01  Foundation go · ordering · main exits
starting one, and the first mistake
func main() {
	go fmt.Println("hello from a goroutine")
	// main returns immediately → the whole program exits → that line may
	// never print. When main ends, every goroutine is killed, no cleanup,
	// no deferred functions.
}

// Fix #1 (correct): wait for it explicitly.
func main() {
	done := make(chan struct{})
	go func() {
		defer close(done)
		fmt.Println("hello")
	}()
	<-done
}

// Fix #2 (NEVER in real code): time.Sleep. It is a race, not a synchronisation
// primitive. Every "flaky test" story starts here.
  • go takes a function call, and the arguments are evaluated immediately, in the calling goroutine. The body runs later.
  • No ordering guarantees. Two goroutines' output can interleave in any order, and can differ run to run.
  • No handle, no ID, no join. You cannot cancel a goroutine from outside; it must cooperate (see the Working level).
  • Goroutines are not threads. GOMAXPROCS (default: the number of CPUs) caps how many run in parallel; the count of goroutines is unrelated and can be far larger.
arguments are evaluated now, the body runs later
x := 1
go fmt.Println(x) // captures the value 1 right here
x = 2             // does not affect the line above

// Whereas a closure captures the VARIABLE:
y := 1
go func() { fmt.Println(y) }() // may print 1 or 2 — a data race
y = 2
02  Working lifecycle · context · panics · bounding

The one rule

Never start a goroutine without knowing how it will stop and who is waiting for it. Every goroutine leak in every Go postmortem is a violation of exactly this sentence.

context.Context is how cancellation travels. It is the first parameter of every function that can block, by convention, and it is never stored in a struct.

a cancellable worker
func poll(ctx context.Context, interval time.Duration, do func(context.Context) error) {
	t := time.NewTicker(interval)
	defer t.Stop() // tickers must be stopped or they keep firing

	for {
		select {
		case <-ctx.Done():
			return // the ONLY exit — cooperative cancellation
		case <-t.C:
			if err := do(ctx); err != nil {
				slog.Error("poll", "err", err)
			}
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // always defer cancel, even when you also call it explicitly

	go poll(ctx, time.Second, refreshCache)

	// Shut down cleanly on SIGINT/SIGTERM.
	sig, stop := signal.NotifyContext(context.Background(),
		os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-sig.Done()
	cancel()
}

// Deadlines and timeouts are the same mechanism:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

A panic in any goroutine kills the whole process

There is no "the goroutine died" — an unrecovered panic anywhere takes the program down, and a recover() in main cannot catch it. Any goroutine running untrusted or request-scoped work needs its own deferred recover.

a safe launcher
func Go(fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				slog.Error("goroutine panic",
					"panic", r,
					"stack", string(debug.Stack()))
			}
		}()
		fn()
	}()
}

// Note: net/http already does this per request — a handler panic returns 500
// and kills only that connection. Goroutines YOU start from a handler do not
// inherit that protection.

Bounding concurrency

"One goroutine per item" is fine for 100 items and a disaster for 100,000 — you will exhaust file descriptors, database connections, or the remote service's patience long before you exhaust memory.

semaphore: a buffered channel as a permit pool
func fetchAll(ctx context.Context, urls []string, limit int) error {
	sem := make(chan struct{}, limit) // `limit` permits
	g, ctx := errgroup.WithContext(ctx)

	for _, url := range urls {
		select {
		case sem <- struct{}{}: // acquire (blocks when all permits are out)
		case <-ctx.Done():
			return ctx.Err()
		}

		g.Go(func() error {
			defer func() { <-sem }() // release
			return fetch(ctx, url)
		})
	}
	return g.Wait()
}
// golang.org/x/sync/errgroup also has g.SetLimit(n), which does this for you.
03  Depth the scheduler · leaks · loop variables

The GMP scheduler in one paragraph

  • G = a goroutine (its stack, program counter and status). M = an OS thread. P = a processor: a scheduling context holding a local run queue. There are GOMAXPROCS P's.
  • An M must hold a P to run Go code. P's steal work from each other's queues, which is what keeps all cores busy without a global lock.
  • A goroutine blocking on a channel, mutex or network I/O is parked and its M picks up another G — the block costs nothing but a context switch in user space. The network poller (epoll/kqueue) wakes it later.
  • A goroutine blocking on a syscall or cgo call detaches the M; the runtime hands the P to a fresh M. This is why thousands of blocking file operations can create thousands of threads.
  • Scheduling is preemptive since Go 1.14 (signal-based, ~10 ms), so a tight loop with no function calls no longer starves the scheduler.

Loop variables: know your Go version

Before Go 1.22, the for loop's variables were declared once for the whole loop, so every goroutine closed over the same variable and typically saw the final value. Since Go 1.22 (for modules declaring go 1.22 or later in go.mod) each iteration gets a fresh variable and the bug is gone.

the classic, and the fix that works everywhere
for _, u := range users {
	go func() {
		process(u) // Go ≥1.22: correct.  Go <1.22: all goroutines see the last u.
	}()
}

// Portable fix #1 — pass it as an argument (evaluated at `go` time):
for _, u := range users {
	go func(u User) { process(u) }(u)
}

// Portable fix #2 — shadow it inside the loop:
for _, u := range users {
	u := u
	go func() { process(u) }()
}

// Check your module's language version — this is a per-module setting:
//   head -1 go.mod  →  module x ; go 1.22

Goroutine leaks: the three shapes

each of these blocks forever
// 1. Send on a channel nobody will read.
func leak1() {
	ch := make(chan int) // unbuffered
	go func() { ch <- expensive() }() // blocks forever if we return early
	if quick() {
		return // ← the goroutine and everything it references leak
	}
	fmt.Println(<-ch)
}
// Fix: make(chan int, 1) so the send always completes, or use context.

// 2. Receive from a channel nobody will close or send to.
func leak2(ch <-chan int) {
	go func() {
		for v := range ch { // blocks forever if the producer never closes ch
			_ = v
		}
	}()
}
// Fix: the SENDER closes the channel, always.

// 3. No cancellation path at all.
func leak3() {
	go func() {
		for {
			work() // nothing can ever stop this
		}
	}()
}
// Fix: select on ctx.Done() (see the Working level).

Finding them

  • runtime.NumGoroutine() exported as a metric — a line that only goes up is the whole diagnosis.
  • net/http/pprof: hit /debug/pprof/goroutine?debug=2 for a full stack dump of every live goroutine, grouped by where they are blocked.
  • go.uber.org/goleak in TestMain fails any test that finishes with goroutines still running.
  • go test -race for data races, and GOTRACEBACK=all to see every stack on a crash. A total deadlock (every goroutine asleep) is detected by the runtime and panics with "all goroutines are asleep"; a partial deadlock is not detected at all.

C2 — Concurrency

Channels

A channel is a typed, thread-safe queue that also synchronises: a completed send happens-before the corresponding receive completes. That second property is the point. An unbuffered channel is not a queue of size zero — it is a rendezvous, where sender and receiver meet and hand the value over directly.

01  Foundation make · send · receive · close
the five operations
ch := make(chan int)     // unbuffered
buf := make(chan int, 3) // buffered, capacity 3
var nilCh chan int       // nil — usable only in select (see Depth)

ch <- 42        // send   (blocks until a receiver takes it)
v := <-ch       // receive
v, ok := <-ch   // ok == false means: channel closed AND drained
close(ch)       // no more sends; receivers drain then get zero values
len(buf), cap(buf) // items currently queued, capacity

Unbuffered — make(chan T)

sender · parked ──▶ ◇ rendezvous ──▶ receiver · parked

Neither side proceeds until both arrive. The send and the receive complete at the same instant; nothing is stored. Use it when you need the handoff itself to be the synchronisation.

Buffered — make(chan T, 3)

sender ──▶ ──▶ receiver

The sender blocks only when the buffer is full; the receiver blocks only when it is empty. Capacity decouples the two by exactly that many items — no more. A buffer is back-pressure tuning, not a fix for a slow consumer.

producer / consumer, done properly
func main() {
	nums := make(chan int)

	// The SENDER owns the channel and is the one that closes it.
	go func() {
		defer close(nums) // signals "no more values" exactly once
		for i := 1; i <= 5; i++ {
			nums <- i * i
		}
	}()

	// range receives until the channel is closed AND drained.
	for n := range nums {
		fmt.Println(n) // 1 4 9 16 25
	}

	// Without close(), range would block forever → deadlock panic.
}

Directional types put the contract in the signature and let the compiler enforce it:

chan direction
func produce(out chan<- int)  { out <- 1; close(out) } // send-only: cannot receive or range
func consume(in <-chan int)   { for v := range in { _ = v } } // receive-only: cannot send or close

ch := make(chan int)  // bidirectional
go produce(ch)        // converts implicitly to chan<- int
consume(ch)           // converts implicitly to <-chan int
02  Working select · timeouts · the four patterns

select waits on several channel operations at once. If more than one is ready it picks uniformly at random — deliberately, to prevent starvation. A default case makes the whole select non-blocking.

select, in all its forms
select {
case v := <-in:
	handle(v)
case out <- result:      // sends can be selected too
	sent++
case <-ctx.Done():       // cancellation — put this in every long-lived select
	return ctx.Err()
case <-time.After(2 * time.Second): // per-iteration timeout
	return errors.New("timed out")
}

// Non-blocking receive: take a value if one is waiting, otherwise move on.
select {
case v := <-in:
	handle(v)
default:
	// nothing ready
}

// Non-blocking send: drop the value rather than block — the right shape for
// metrics, telemetry, and any "best effort" fan-out.
select {
case events <- e:
default:
	dropped.Add(1)
}

Pattern 1 — worker pool

fixed workers over a job channel
type Job struct{ ID int; URL string }
type Result struct{ ID int; Bytes int; Err error }

func pool(ctx context.Context, jobs <-chan Job, workers int) <-chan Result {
	results := make(chan Result)
	var wg sync.WaitGroup

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs { // every worker pulls from the SAME channel
				n, err := fetch(ctx, j.URL)
				select {
				case results <- Result{ID: j.ID, Bytes: n, Err: err}:
				case <-ctx.Done():
					return // never block forever on a send
				}
			}
		}()
	}

	// One closer, after all senders are done. This is the standard shape.
	go func() {
		wg.Wait()
		close(results)
	}()

	return results
}

Pattern 2 — pipeline with cancellation

stages connected by channels
func gen(ctx context.Context, nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func square(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // cancelling tears down every stage — no leaked goroutines
for v := range square(ctx, gen(ctx, 1, 2, 3)) {
	fmt.Println(v)
}

Pattern 3 — fan-in (merge)

many channels into one
func merge[T any](chans ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup
	wg.Add(len(chans))

	for _, c := range chans {
		go func(c <-chan T) {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}(c)
	}

	go func() { wg.Wait(); close(out) }()
	return out
}

Pattern 4 — first result wins

racing replicas
func first(ctx context.Context, replicas []string) (string, error) {
	// BUFFERED with room for every sender: losers must never block, or they leak.
	out := make(chan string, len(replicas))
	errs := make(chan error, len(replicas))

	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // tell the losers to stop as soon as we have a winner

	for _, r := range replicas {
		go func(r string) {
			v, err := query(ctx, r)
			if err != nil {
				errs <- err
				return
			}
			out <- v
		}(r)
	}

	for i := 0; i < len(replicas); i++ {
		select {
		case v := <-out:
			return v, nil
		case <-errs:
			continue // all failed → fall out of the loop
		case <-ctx.Done():
			return "", ctx.Err()
		}
	}
	return "", errors.New("all replicas failed")
}

Channel etiquette

  • The sender closes. A receiver that closes causes a panic on the next send. With multiple senders, close from a coordinator after a WaitGroup, never from a sender.
  • Closing is a broadcast, not a delete. Every receiver, present and future, is unblocked. That is why close(done) is the standard "stop everyone" signal.
  • Return <-chan T from constructors so callers cannot close what they do not own.
  • Every blocking send inside a goroutine needs an escape — a select with ctx.Done(), or enough buffer that it can never block.
03  Depth closed & nil semantics · memory model · cost

The whole behavioural surface of a channel fits in one table. Memorise it — most channel bugs are one of these cells arriving unexpectedly.

Operationnil channelopen, emptyopen, has value/spaceclosed
send ch <- vblocks foreverblocks (unbuffered/full)proceedspanic
receive <-chblocks foreverblocksproceedszero value, ok=false
close(ch)panicokok (buffered values still readable)panic
range chblocks foreverblocksyieldsdrains, then exits
len / cap0 / 00 / capn / capremaining / cap

A nil channel blocks forever — and that is useful. Setting a channel variable to nil disables that case of a select permanently, which is the cleanest way to drain two sources that finish at different times.

disabling a select case
func drain(a, b <-chan int) {
	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil // ← disable this case; a closed channel is ALWAYS ready,
				continue //   so without this the select spins at 100% CPU
			}
			fmt.Println("a:", v)
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			fmt.Println("b:", v)
		}
	}
}

What the memory model guarantees

  • A send happens-before the corresponding receive completes. Everything the sender wrote before the send is visible to the receiver afterwards — this is why passing a pointer over a channel is safe without a lock.
  • On an unbuffered channel, the receive happens-before the send completes as well. The synchronisation is bidirectional.
  • close happens-before a receive that returns the zero value because the channel closed.
  • The k-th receive on a channel of capacity C happens-before the (k+C)-th send completes — the formal statement of "the buffer applies back-pressure".

Production failure modes

  • Unbuffered channel + early return = a permanently parked sender. Buffer by one, or select on ctx.Done().
  • A buffered channel is not a fix for a slow consumer, only a delay. Under sustained load it fills, and you are back to blocking — with added latency and memory. Size it for burst absorption, and monitor len(ch).
  • Double close panics. If two paths might close, use sync.Once, or restructure so exactly one owner closes.
  • time.After inside a hot loop allocated a timer per iteration that lived until it fired. Go 1.23 made unreferenced timers collectable immediately, which removes the worst of it — but time.NewTimer with defer t.Stop() and an explicit Reset is still the version-independent, allocation-free form.
  • Deadlock detection is all-or-nothing. The runtime panics only when every goroutine is asleep. A single stuck worker in a live server is invisible — that is what the goroutine profile is for.

Cost, and when a mutex is the better tool

  • A channel operation is roughly 60–120 ns uncontended: it takes an internal lock, may copy the element, and may park/unpark a goroutine. A sync.Mutex lock/unlock is ~20 ns, and an atomic add is a few.
  • Values are copied into the channel. Sending a large struct copies it twice (in and out). Send pointers for anything big — and then treat ownership as transferred.
  • Use channels to transfer ownership of data, distribute work, and communicate completion or events.
  • Use a mutex to protect shared state that is read and written in place — caches, counters, connection maps. Wrapping a map in a channel-owning goroutine is the classic over-engineering of Go's "share memory by communicating" slogan.

C3 — Concurrency

Mutexes & atomics

A sync.Mutex makes a section of code run in one goroutine at a time. It is the right tool whenever state is read and written in place rather than handed off. The zero value is a ready-to-use unlocked mutex — and it must never be copied once used.

01  Foundation Lock · Unlock · the race it prevents
the race, then the fix
// BROKEN: counter++ is read-modify-write, three steps, not atomic.
var counter int
for i := 0; i < 1000; i++ {
	go func() { counter++ }()
}
// Result is < 1000, non-deterministically. `go run -race` reports it.

// FIXED
type Counter struct {
	mu sync.Mutex // zero value is ready to use — no initialisation
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock() // runs even if the body panics — always defer
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n // reads need the lock too, or they can see a torn/stale value
}
  • Pointer receivers, always. A value receiver copies the mutex, and each call locks a different lock.
  • Reads need locking too. "Only writes need protection" is false: an unsynchronised read is a data race, and the compiler is allowed to assume races do not happen.
  • Keep the mutex next to the data it protects, and add a comment saying what it covers. Unexported, so callers cannot lock around your invariants.
02  Working RWMutex · Once · atomics · scope
RWMutex: many readers or one writer
type Registry struct {
	mu    sync.RWMutex // guards services
	services map[string]string
}

func (r *Registry) Get(name string) (string, bool) {
	r.mu.RLock()          // many goroutines may hold RLock simultaneously
	defer r.mu.RUnlock()
	addr, ok := r.services[name]
	return addr, ok
}

func (r *Registry) Set(name, addr string) {
	r.mu.Lock()           // exclusive: waits for all readers to finish
	defer r.mu.Unlock()
	if r.services == nil {
		r.services = make(map[string]string)
	}
	r.services[name] = addr
}

RWMutex is not a free upgrade

It carries more bookkeeping than a plain Mutex, and RLock writes to shared state, so heavy read traffic across cores causes cache-line contention anyway. It wins when reads greatly outnumber writes and the critical section is long enough to matter. For a short read of a few fields, a plain Mutex is usually faster. Benchmark with -cpu=1,4,16 before deciding.

sync.Once and lazy initialisation
var (
	once sync.Once
	conn *sql.DB
)

func DB() *sql.DB {
	once.Do(func() { // runs exactly once, and every caller waits for it
		conn = mustConnect()
	})
	return conn
}

// Go 1.21+ has typed helpers that remove the package-level variable:
var db = sync.OnceValue(func() *sql.DB { return mustConnect() })
var cfg = sync.OnceValues(func() (Config, error) { return loadConfig() })

use(db())          // initialised on first call, cached after

When the shared state is a single number or pointer, skip the mutex entirely. The typed atomics (Go 1.19+) are safer than the old function forms because the type itself is non-copyable and always correctly aligned.

sync/atomic, typed
import "sync/atomic"

type Stats struct {
	requests atomic.Int64
	errors   atomic.Int64
	shutdown atomic.Bool
	config   atomic.Pointer[Config] // lock-free hot-swappable config
}

func (s *Stats) Record(err error) {
	s.requests.Add(1)
	if err != nil {
		s.errors.Add(1)
	}
}

func (s *Stats) Snapshot() (int64, int64) {
	return s.requests.Load(), s.errors.Load()
}

// Compare-and-swap: the building block of lock-free algorithms.
for {
	old := s.requests.Load()
	if s.requests.CompareAndSwap(old, old*2) {
		break
	}
	// someone else won the race — retry with the new value
}

// Publishing a new config: readers never block, and never see a half-written one.
s.config.Store(&newCfg)
cfg := s.config.Load()

Critical-section hygiene

  • Hold the lock for as little as possible. Never do I/O, an RPC, a channel send, or a callback into unknown code while holding it — that turns a lock into a queue.
  • Copy out, work outside. Take the lock, grab what you need, release, then compute.
  • defer by default. The nanoseconds it costs are worth never leaking a lock on a panic or an early return. Drop it only in a proven-hot path, and then keep the function tiny.
  • Do not return a pointer to protected data. The caller will read it without the lock. Return a copy.
03  Depth copying · deadlock · sync.Map · fairness

Four ways to break a mutex

  • Copying it. Passing a struct that contains a used mutex by value copies the lock state; the copies then protect nothing. go vet catches most cases — keep it in CI.
  • It is not reentrant. A goroutine that locks a mutex it already holds deadlocks instantly. If method A (locked) needs method B (also locking), extract an unexported bLocked() that assumes the lock is held.
  • Inconsistent lock ordering. Goroutine 1 takes A then B while goroutine 2 takes B then A → deadlock. Define a global order and document it; better, avoid holding two locks at once.
  • Unlocking a mutex you did not lock is a fatal runtime error, not a panic you can recover.
the copy bug, and reentrancy
type Store struct {
	mu sync.Mutex
	m  map[string]int
}

// BROKEN: value receiver copies mu on every call → each call locks its own copy.
func (s Store) BadGet(k string) int { s.mu.Lock(); defer s.mu.Unlock(); return s.m[k] }
//     go vet: BadGet passes lock by value: Store contains sync.Mutex

// BROKEN: self-deadlock.
func (s *Store) Reset() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.clear() // clear() locks again → deadlock
}
func (s *Store) clear() { s.mu.Lock(); defer s.mu.Unlock(); s.m = map[string]int{} }

// CORRECT: the "already locked" convention.
func (s *Store) Reset2() {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.clearLocked()
}
// clearLocked requires s.mu to be held by the caller.
func (s *Store) clearLocked() { s.m = map[string]int{} }

Fairness & the starvation mode

  • sync.Mutex runs in normal mode: a waking waiter competes with newly-arriving goroutines, which are already running and usually win. This maximises throughput.
  • If a waiter fails to acquire for 1 ms, the mutex switches to starvation mode: ownership is handed directly to the front of the queue, FIFO, and new arrivals go straight to the back. This bounds tail latency.
  • Practical read: Go's mutex is not FIFO by default but will not starve you either. If you are measuring lock-acquisition tail latency, that 1 ms threshold is what you are seeing.

sync.Map — two narrow use cases only

when the plain map + RWMutex is not enough
// sync.Map is untyped (any/any) and beats map+RWMutex in exactly two cases:
//   1. an entry is written once and read many times (a cache that only grows)
//   2. goroutines operate on disjoint sets of keys (sharded by goroutine)
// Otherwise it is SLOWER and loses all type safety. Default to map + RWMutex,
// or shard your own map by hash if you are contended.

var cache sync.Map

cache.Store("k", &Value{})
if v, ok := cache.Load("k"); ok {
	val := v.(*Value) // type assertion required — no generics here
	_ = val
}
actual, loaded := cache.LoadOrStore("k", &Value{}) // atomic get-or-create
cache.Delete("k")
cache.Range(func(k, v any) bool { return true })   // snapshot-ish iteration

// Typed alternative: a generic wrapper around map + RWMutex (see T5 · Cache).

Detecting problems

  • go test -race / go build -race — the race detector finds real races on the paths that actually execute. Roughly 2–20× slower and ~5× more memory, so run it in CI and on a canary, not in production.
  • go vet catches lock copying and misplaced defer.
  • /debug/pprof/mutex (enable with runtime.SetMutexProfileFraction) shows where contention actually is; /debug/pprof/block shows blocking on channels and locks.
  • A race the detector never observes is still a bug. It has no false positives, but plenty of false negatives.

C4 — Concurrency

WaitGroups

A sync.WaitGroup is a counter with a blocking wait: Add(n) raises it, Done() lowers it, Wait() blocks until it reaches zero. It answers one question — “are they all finished?” — and nothing else. It carries no results and no errors, which is exactly why it composes with everything.

01  Foundation Add · Done · Wait
the shape you will write a thousand times
func main() {
	var wg sync.WaitGroup // zero value is ready; do NOT copy it afterwards

	for _, url := range urls {
		wg.Add(1) // BEFORE `go` — never inside the goroutine
		go func() {
			defer wg.Done() // first line of the body, so panics still decrement
			fetch(url)
		}()
	}

	wg.Wait() // blocks until the counter hits 0
	fmt.Println("all done")
}

Three rules, and what breaks if you ignore them

  • Add before go. Calling wg.Add(1) inside the goroutine races with Wait(), which may see a counter of zero and return before anything started.
  • defer wg.Done() first. An early return or a panic before Done() leaves Wait() blocked forever.
  • Pass it as *sync.WaitGroup. Passing by value copies the counter; the original never reaches zero. (go vet flags this.)
the pointer rule
// BROKEN — wg is a copy; the caller's Wait() never returns.
func worker(wg sync.WaitGroup, id int) { defer wg.Done(); work(id) }

// CORRECT
func worker(wg *sync.WaitGroup, id int) { defer wg.Done(); work(id) }

var wg sync.WaitGroup
wg.Add(3)
for i := 0; i < 3; i++ {
	go worker(&wg, i)
}
wg.Wait()

// Go 1.25+ removes the footgun entirely:
//   wg.Go(func() { work(id) })   // Add(1) + go + defer Done(), in one call
02  Working results · errors · errgroup

A WaitGroup gives you no results. The two correct ways to collect them: write into a pre-sized slice by index (no lock needed — each goroutine owns one element), or send over a channel.

collecting results without a lock
func fetchAll(urls []string) []Result {
	results := make([]Result, len(urls)) // pre-sized: index i is owned by goroutine i
	var wg sync.WaitGroup

	for i, u := range urls {
		wg.Add(1)
		go func() {
			defer wg.Done()
			body, err := fetch(u)
			results[i] = Result{Body: body, Err: err} // distinct index → no race
		}()
	}

	wg.Wait()
	return results // order matches the input, which a channel would not give you
}
closing a results channel: the WaitGroup's other job
func stream(urls []string) <-chan Result {
	out := make(chan Result)
	var wg sync.WaitGroup

	for _, u := range urls {
		wg.Add(1)
		go func() {
			defer wg.Done()
			out <- fetch(u)
		}()
	}

	// The canonical closer goroutine: nobody else knows when all senders are done.
	go func() {
		wg.Wait()
		close(out)
	}()

	return out // caller can `range` it and will terminate correctly
}

For anything that can fail, errgroup is the better default: it is a WaitGroup that also propagates the first error and cancels a shared context.

golang.org/x/sync/errgroup
import "golang.org/x/sync/errgroup"

func loadPage(ctx context.Context, userID int64) (*Page, error) {
	var (
		page Page
		g    *errgroup.Group
	)
	// WithContext derives a ctx that is CANCELLED as soon as any task errors.
	g, ctx = errgroup.WithContext(ctx)
	g.SetLimit(8) // bound concurrency; 0 or negative means unlimited

	g.Go(func() error {
		u, err := getUser(ctx, userID)
		page.User = u
		return err // the FIRST non-nil error is what g.Wait() returns
	})
	g.Go(func() error {
		f, err := getFeed(ctx, userID)
		page.Feed = f
		return err
	})
	g.Go(func() error {
		n, err := getNotifications(ctx, userID)
		page.Notifications = n
		return err
	})

	if err := g.Wait(); err != nil { // waits for ALL, returns the first error
		return nil, fmt.Errorf("load page %d: %w", userID, err)
	}
	return &page, nil
}
// Each goroutine writes a DIFFERENT field of page, so no lock is needed.
// Writing the same field, or appending to a shared slice, would need one.
NeedReach forWhy
wait for N taskssync.WaitGroupstdlib, zero deps, no error plumbing
wait + first error + cancelerrgroup.Groupthe default for fan-out RPC/IO
wait + bounded workerserrgroup + SetLimitreplaces the semaphore channel
collect every errorWaitGroup + errors.Joinerrgroup keeps only the first
stop everyone at oncecontext.CancelFuncWaitGroup cannot cancel anything
wait with a timeoutWait in a goroutine + selectWait() itself is not selectable
03  Depth reuse rules · timeouts · error aggregation
waiting with a deadline
// Wait() cannot be used in a select. Convert it into a channel.
func waitTimeout(wg *sync.WaitGroup, d time.Duration) bool {
	done := make(chan struct{})
	go func() {
		wg.Wait()
		close(done)
	}()

	select {
	case <-done:
		return true // finished in time
	case <-time.After(d):
		return false // timed out — NOTE: the goroutines are still running.
		             // A WaitGroup cannot cancel; you need a context for that.
	}
}
collecting every error, not just the first
func processAll(ctx context.Context, items []Item) error {
	var (
		wg   sync.WaitGroup
		mu   sync.Mutex
		errs []error
	)

	for _, it := range items {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := process(ctx, it); err != nil {
				mu.Lock()
				errs = append(errs, fmt.Errorf("item %s: %w", it.ID, err))
				mu.Unlock() // append is NOT concurrency-safe — the lock is required
			}
		}()
	}
	wg.Wait()

	return errors.Join(errs...) // Go 1.20+: nil if empty; errors.Is works
	                            // against every joined error
}

Counter semantics, precisely

  • The counter must never go negative. One extra Done() panics with sync: negative WaitGroup counter. That panic usually means a defer wg.Done() was written twice, or Add was miscounted.
  • Add after Wait has unblocked is only legal once the counter has reached zero and all previous Wait calls have returned. Reusing a WaitGroup across overlapping rounds is a race: sync: WaitGroup misuse: Add called concurrently with Wait. Use a fresh WaitGroup per round.
  • Concurrent Wait calls are fine — all of them unblock when the counter hits zero.
  • A WaitGroup is not a semaphore. It counts completions, not permits. For bounded concurrency use a buffered channel or errgroup.SetLimit.

Composing the three primitives

The complete fan-out has three separate concerns, and each has its own tool: context to cancel, a semaphore or SetLimit to bound, and a WaitGroup or errgroup to wait. Trying to make one of them do all three is where concurrent Go stops being readable.

graceful shutdown: all three, together
func run(ctx context.Context, cfg Config) error {
	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer cancel()

	g, ctx := errgroup.WithContext(ctx)

	srv := &http.Server{Addr: cfg.Addr, Handler: newRouter()}

	// 1. serve
	g.Go(func() error {
		if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			return err
		}
		return nil
	})

	// 2. shut down when the context is cancelled (signal, or a sibling failing)
	g.Go(func() error {
		<-ctx.Done()
		shutdownCtx, done := context.WithTimeout(context.Background(), 20*time.Second)
		defer done()
		return srv.Shutdown(shutdownCtx) // drains in-flight requests
	})

	// 3. background worker on the same lifecycle
	g.Go(func() error {
		return runReconciler(ctx)
	})

	return g.Wait() // returns once every task has returned
}

R1 — Reference

Cheat sheet

The tables worth keeping open in a second tab.

Picking the concurrency primitive

SituationUseNot
counter, flag, hot-swapped pointersync/atomic typed valuesa mutex (slower, more code)
a map or cache read and written in placemap + sync.RWMutexa channel-owning goroutine
hand work to N workersa jobs channel + fixed goroutinesone goroutine per item
one-shot fan-out, then waiterrgroup.WithContextWaitGroup + manual error plumbing
stop everythingcontext cancellationa shared bool, or killing goroutines
broadcast an event to N listenersclose(chan struct{})sending N times
cap in-flight workbuffered chan as semaphore / SetLimittime.Sleep pacing
lazy singletonsync.OnceValuea checked nil + mutex by hand
wait for a conditiona channel, or sync.Conda spin loop

Value vs pointer, at a glance

ThingCopied on assignment?Nil-ableComparable with ==
structyes, all fieldsnoif all fields are
array [N]Tyes, all elementsnoif T is
slice []Theader only (shares data)yesonly against nil
mapreference (shares data)yesonly against nil
channelreference (shares data)yesyes (identity)
funcreferenceyesonly against nil
interfacethe two-word pairyesruntime panic if dynamic type is not
stringheader only (immutable)noyes

Gotcha index

SymptomCauseTopic
mutation didn't stickvalue receiver, or ranging over copiesT1
err != nil but err is niltyped nil in an interfaceT3
does not implement (pointer receiver)method set of T vs *TT3
override isn't calledembedding has no dynamic dispatchT2
ambiguous selectortwo embedded types at the same depthT2
enum value came through as 0zero value is the first constantT4
cannot infer Ttype param appears only in the resultT5
all goroutines are asleepunclosed channel, or nobody receivingC2
send on closed channela receiver closed, or two closersC2
select spins at 100% CPUclosed channel case never disabledC2
goroutine count only growsa blocked send/receive with no ctxC1
passes lock by value (vet)struct with a mutex copiedC3
negative WaitGroup counterDone() called more often than Add()C4
Wait() never returnsa missing Done() on an early returnC4

Commands that answer the question for you

the toolchain
go test -race ./...              # data races on the paths your tests execute
go vet ./...                     # copied locks, bad printf verbs, lost cancels
go build -gcflags='-m' ./...     # escape analysis: stack or heap?
go test -bench=. -benchmem       # ns/op, B/op, allocs/op
go test -bench=. -cpu=1,4,16     # does it actually scale?
go tool pprof -http=:8080 cpu.out
go doc sync.WaitGroup            # the docs, offline, in a second

# live process, with net/http/pprof imported:
curl localhost:6060/debug/pprof/goroutine?debug=2   # every goroutine's stack
curl localhost:6060/debug/pprof/heap                # allocations
GOTRACEBACK=all ./server                            # full stacks on a crash
GOMAXPROCS=4 ./server                               # cap parallelism

R2 — Reference

Review checklist

What to look for in a Go pull request, in the order the bugs actually appear.

  • Every struct with a mutex is passed by pointer, and go vet is green.
  • Receivers are consistent across the type — all value, or all pointer.
  • Every exported struct's zero value is either valid or explicitly rejected.
  • Interfaces are declared by the consumer, and have one or two methods.
  • No function returns a concrete error type where error is declared.
  • Errors are wrapped with %w and checked with errors.Is/As.
  • Enum switches have a default that fails loudly.
  • Enum values that are persisted are appended, never reordered.
  • Every goroutine has a documented exit path and an owner waiting on it.
  • Every long-lived select includes case <-ctx.Done().
  • Every blocking channel send can be abandoned, via buffer or select.
  • Exactly one owner closes each channel, and it is a sender.
  • Fan-out over an unbounded input is limited by a semaphore or SetLimit.
  • wg.Add is outside the goroutine; defer wg.Done() is its first line.
  • No I/O, RPC, or unknown callback runs while a lock is held.
  • Nothing relies on time.Sleep for synchronisation, in code or in tests.
  • context.Context is the first parameter and is never stored in a struct.
  • Every context.With* has a matching defer cancel().
  • Tests that start goroutines run under -race in CI.
  • Generics were introduced because code repeated, not because they were available.