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.
Define the shape with type ... struct. Capitalised field names are exported (visible outside the package); lowercase ones are package-private.
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.
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.Bufferis ready to write to). - No inheritance. There is no
extends. Composition and embedding fill that role (T2). %+vprints field names,%#vprints Go syntax. Both are your debugging default.
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.
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.
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.
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 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 receiverCopying 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.
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.
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 struct | Usable as a map key | Note |
|---|---|---|---|
| int, string, bool, float | yes | yes | field-by-field comparison |
| array [N]T | yes | yes | if T is comparable |
| pointer *T | yes | yes | compares addresses, not pointees |
| struct (nested) | yes | yes | if all its fields are |
| slice []T, map, func | compile error | no | use reflect.DeepEqual or slices.Equal |
| interface / any | compiles | compiles | panics at runtime if the dynamic type is uncomparable |
Traps that reach production
- Copying a struct that holds a
sync.Mutexcopies the lock state; the two copies then guard nothing.go vetcatches it — always pass such structs by pointer. for _, x := range structsgives you a copy. Mutatingxdoes nothing to the slice. Writestructs[i].Field = ..., or range over pointers.- You cannot address a map element:
m["k"].Field = vis a compile error for a struct value map. Usemap[string]*T, or read-modify-write the whole value back. - Floats break equality — a struct containing
NaNis never==to itself, which quietly poisons map lookups. time.Timein 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.
// 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.
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 name —
BaseforBase,Mutexforsync.Mutex,ReadWriterforio.ReadWriter(the package qualifier is dropped). - Promotion is syntactic sugar.
a.IDcompiles toa.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).
1 Embed a mutex to make a type self-locking
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
// 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
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()) // true4 Flatten JSON with an embedded struct
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"`
}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.
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 explicitlyEmbedding 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.
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 as | Promoted to Outer | Promoted to *Outer |
|---|---|---|
| Base (value) | value-receiver methods | value + pointer-receiver methods |
| *Base (pointer) | value + pointer-receiver methods | value + 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.
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.
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)
}
}anyisinterface{}— 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.
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.Handlerare each a single method.
// 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.
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).// 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:
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
}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.
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.
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 T | Method set of T | Method set of *T |
|---|---|---|
| func (t T) | included | included |
| func (t *T) | not included | included |
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 -benchbefore restructuring. - Interfaces defeat escape analysis: a value passed as
anytofmt.Printlnescapes 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,httpqualify). - 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.
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.
// 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.
A production enum is the const block plus four small pieces: a name table, String(), a parser, and a validity check.
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.
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
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 falseiota counts ConstSpecs (lines), not constants. That explains every one of its tricks:
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 orValid(); treat the constructor as the only trusted door. - Not exhaustive. The compiler will not tell you that a
switchmissed the new constant you just added. Add adefaultthat fails loudly, and run theexhaustivelinter (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
_.
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.
// 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.
// [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) }) // explicitThe constraint decides what you are allowed to do with a value of type T. Three you will use constantly:
== and !=, so T can be a map key. Since Go 1.20 interface types satisfy it too — with the runtime-panic caveat from T1.< <= > >=: all integers, floats and strings. In the standard library since Go 1.21 (cmp package).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.
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 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.
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)) // trueWhere generics genuinely pay off
// 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 })Inference works from arguments, not from returns. A function whose type parameter appears only in the result must be instantiated explicitly.
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 copy —
int,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 onany(v)inside the generic body if you must. - Constraints are not runtime types. You cannot switch on
Tdirectly; convert first:switch v := any(v).(type). - No parameterised struct field defaults, no variance.
[]Dogis not a[]Animal, andSet[Dog]is unrelated toSet[Animal]. - Type parameters cannot be used on the receiver of a plain function value —
s.Addon aSet[int]is fine, but you cannot pass an uninstantiated generic function where a concretefunctype 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.
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.gotakes 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.
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 = 2The 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.
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.
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.
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.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
GOMAXPROCSP'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.
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.22Goroutine leaks: the three shapes
// 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=2for a full stack dump of every live goroutine, grouped by where they are blocked.go.uber.org/goleakinTestMainfails any test that finishes with goroutines still running.go test -racefor data races, andGOTRACEBACK=allto 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.
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, capacityUnbuffered — make(chan T)
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)
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.
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:
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 intselect 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 {
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
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
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)
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
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 Tfrom constructors so callers cannot close what they do not own. - Every blocking send inside a goroutine needs an escape — a
selectwithctx.Done(), or enough buffer that it can never block.
The whole behavioural surface of a channel fits in one table. Memorise it — most channel bugs are one of these cells arriving unexpectedly.
| Operation | nil channel | open, empty | open, has value/space | closed |
|---|---|---|---|---|
send ch <- v | blocks forever | blocks (unbuffered/full) | proceeds | panic |
receive <-ch | blocks forever | blocks | proceeds | zero value, ok=false |
close(ch) | panic | ok | ok (buffered values still readable) | panic |
range ch | blocks forever | blocks | yields | drains, then exits |
len / cap | 0 / 0 | 0 / cap | n / cap | remaining / 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.
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.Afterinside 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 — buttime.NewTimerwithdefer t.Stop()and an explicitResetis 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.Mutexlock/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.
// 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.
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.
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 afterWhen 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.
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.
deferby 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.
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 vetcatches 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.
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.Mutexruns 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
// 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 vetcatches lock copying and misplaceddefer./debug/pprof/mutex(enable withruntime.SetMutexProfileFraction) shows where contention actually is;/debug/pprof/blockshows 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.
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
Addbeforego. Callingwg.Add(1)inside the goroutine races withWait(), which may see a counter of zero and return before anything started.defer wg.Done()first. An earlyreturnor a panic beforeDone()leavesWait()blocked forever.- Pass it as
*sync.WaitGroup. Passing by value copies the counter; the original never reaches zero. (go vetflags this.)
// 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 callA 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.
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
}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.
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.| Need | Reach for | Why |
|---|---|---|
| wait for N tasks | sync.WaitGroup | stdlib, zero deps, no error plumbing |
| wait + first error + cancel | errgroup.Group | the default for fan-out RPC/IO |
| wait + bounded workers | errgroup + SetLimit | replaces the semaphore channel |
| collect every error | WaitGroup + errors.Join | errgroup keeps only the first |
| stop everyone at once | context.CancelFunc | WaitGroup cannot cancel anything |
| wait with a timeout | Wait in a goroutine + select | Wait() itself is not selectable |
// 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.
}
}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 withsync: negative WaitGroup counter. That panic usually means adefer wg.Done()was written twice, orAddwas miscounted. AddafterWaithas unblocked is only legal once the counter has reached zero and all previousWaitcalls 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
Waitcalls 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.
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
| Situation | Use | Not |
|---|---|---|
| counter, flag, hot-swapped pointer | sync/atomic typed values | a mutex (slower, more code) |
| a map or cache read and written in place | map + sync.RWMutex | a channel-owning goroutine |
| hand work to N workers | a jobs channel + fixed goroutines | one goroutine per item |
| one-shot fan-out, then wait | errgroup.WithContext | WaitGroup + manual error plumbing |
| stop everything | context cancellation | a shared bool, or killing goroutines |
| broadcast an event to N listeners | close(chan struct{}) | sending N times |
| cap in-flight work | buffered chan as semaphore / SetLimit | time.Sleep pacing |
| lazy singleton | sync.OnceValue | a checked nil + mutex by hand |
| wait for a condition | a channel, or sync.Cond | a spin loop |
Value vs pointer, at a glance
| Thing | Copied on assignment? | Nil-able | Comparable with == |
|---|---|---|---|
| struct | yes, all fields | no | if all fields are |
| array [N]T | yes, all elements | no | if T is |
| slice []T | header only (shares data) | yes | only against nil |
| map | reference (shares data) | yes | only against nil |
| channel | reference (shares data) | yes | yes (identity) |
| func | reference | yes | only against nil |
| interface | the two-word pair | yes | runtime panic if dynamic type is not |
| string | header only (immutable) | no | yes |
Gotcha index
| Symptom | Cause | Topic |
|---|---|---|
| mutation didn't stick | value receiver, or ranging over copies | T1 |
| err != nil but err is nil | typed nil in an interface | T3 |
| does not implement (pointer receiver) | method set of T vs *T | T3 |
| override isn't called | embedding has no dynamic dispatch | T2 |
| ambiguous selector | two embedded types at the same depth | T2 |
| enum value came through as 0 | zero value is the first constant | T4 |
| cannot infer T | type param appears only in the result | T5 |
| all goroutines are asleep | unclosed channel, or nobody receiving | C2 |
| send on closed channel | a receiver closed, or two closers | C2 |
| select spins at 100% CPU | closed channel case never disabled | C2 |
| goroutine count only grows | a blocked send/receive with no ctx | C1 |
| passes lock by value (vet) | struct with a mutex copied | C3 |
| negative WaitGroup counter | Done() called more often than Add() | C4 |
| Wait() never returns | a missing Done() on an early return | C4 |
Commands that answer the question for you
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 parallelismR2 — 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 vetis 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
erroris declared. - Errors are wrapped with
%wand checked witherrors.Is/As. - Enum switches have a
defaultthat 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
selectincludescase <-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.Addis 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.Sleepfor synchronisation, in code or in tests. context.Contextis the first parameter and is never stored in a struct.- Every
context.With*has a matchingdefer cancel(). - Tests that start goroutines run under
-racein CI. - Generics were introduced because code repeated, not because they were available.