The answers a Go interviewer works through before the hard questions start: the type system and embedding, slice and map internals, interface values, the scheduler, channels and context, and errors as values. Sixty items, nineteen of them worked through with code or a diagram.
Because implementation inheritance couples a subclass to its parent's internals,
and the designers judged that cost higher than the reuse it buys. A subclass that
overrides one method has to know which other methods call it, so the parent
cannot be refactored without breaking children, and the hierarchy ends up being
edited from the middle rather than extended at the leaves. Go replaces it with
two narrower mechanisms: composition through embedding, which reuses behaviour
without claiming an is-a relationship, and interfaces satisfied implicitly, which
give you polymorphism without a declared hierarchy at all. The practical
consequence is that there is no super, no protected members, no abstract base
class, and no place to put a template method — and the honest answer to "how do I
share this code" is usually a plain function or a field, not a type relationship.
What does struct embedding actually do?
It is field promotion with a compiler-generated forwarding rule, not
inheritance. The embedded type becomes a real field named after the type, and its
exported fields and methods can be reached without naming that field, so
s.Logger.Log may also be written s.Log.
type Logger struct{ Prefix string }
func(l Logger) Log(msg string) { fmt.Println(l.Prefix, msg) }
type Server struct {
Logger // embedded: a field literally named Logger
Addr string
}
s := Server{Logger: Logger{Prefix: "srv"}, Addr: ":8080"}
s.Log("starting") // promoted; identical to s.Logger.Log("starting")
The part that separates embedding from inheritance is what happens when the
embedded type calls a method the outer type has "overridden".
type Logger struct{ Prefix string }
func(l Logger) Log(msg string) { fmt.Println(l.Prefix, msg) }
func(l Logger) Warn(msg string) { l.Log("WARN " + msg) } // binds to Logger.Logtype Server struct{ Logger }
func(s Server) Log(msg string) { fmt.Println("server:", msg) }
Server{}.Warn("disk full") // prints via Logger.Log, NOT Server.Log
There is no dynamic dispatch back into the outer type. Logger.Warn was compiled
against Logger.Log and stays bound to it, so the "override" is invisible from
inside the embedded type. That is the whole difference: embedding delegates
outward, inheritance dispatches inward, and code ported from a language with
virtual methods breaks here silently.
Two further details get asked about. Promotion is shallowest-wins, so a field or
method declared directly on the outer struct shadows the embedded one, and two
embedded types offering the same name at the same depth is not an error until
something actually uses the ambiguous name — at which point it is a compile
error, and you disambiguate by naming the field. Embedding an interface rather
than a struct is the idiom behind partial implementations: embed io.Reader in a
struct and the struct satisfies io.Reader by forwarding to whatever was stored,
which is how test doubles are built with three lines instead of twenty.
Value or pointer receiver — how do you choose?
A value receiver gets a copy, so it cannot mutate the original and it costs a
copy of the struct on every call. A pointer receiver gets the address, so it can
mutate and copies only a word. The rule that actually decides it: use a pointer
receiver if the method mutates the receiver, if the struct is large enough that
copying it matters, or if the type contains a sync.Mutex or anything else that
must not be copied. Otherwise a value receiver is fine and marginally safer.
The stronger rule on top of that is consistency — do not mix receiver kinds on
one type, because the method set rules then make it non-obvious which values
satisfy which interfaces, and readers have to check each method individually to
know whether they are holding a copy.
Why does Go have zero values instead of constructors?
Because making every declared variable immediately usable removes an entire
class of half-initialised object. A struct is zeroed field by field, so an
int is 0, a string is "", a pointer, slice, map, channel, function and
interface are all nil, and the design goal is that the zero value should be
useful without further work. sync.Mutex locks correctly when zero,
bytes.Buffer is a working buffer when zero, and appending to a nil slice
returns a valid one-element slice. The cost is that "unset" and "legitimately
zero" are indistinguishable, which is why a count of 0 and an absent count look
the same in a decoded JSON struct, and why API types reach for pointers or an
explicit Valid bool when the difference matters. The other cost is nil maps:
reading from one works, writing to one panics.
What is the difference between a defined type and a type alias?
type Celsius float64 defines a new type with float64 as its underlying type:
it is a distinct type for assignment and method sets, so you cannot pass a
Celsius where a float64 is expected without a conversion, and you can hang
methods on it. type Celsius = float64 is an alias, meaning the two names refer
to the same type entirely — no conversion needed, no separate method set, and
declaring a method on the alias is exactly declaring it on float64, which is
forbidden for a type from another package. Defined types are how you buy type
safety for a bare string identifier or a unit of measure. Aliases exist almost
entirely for gradual code migration, where a type moves between packages and the
old name must keep compiling.
Are `new` and `make` interchangeable?
No, and the difference is what they initialise. new(T) allocates zeroed
storage for a T and returns a *T, which is useful for any type but leaves
slices, maps and channels in their nil state — so m := new(map[string]int)
gives you a pointer to a nil map, and writing through it panics. make exists
for exactly those three types, because each has a runtime header that needs
setting up: a slice's pointer, length and capacity; a map's hash table; a
channel's buffer and wait queues. make returns the type itself rather than a
pointer, and it takes size arguments that new has no way to express. In
practice new is rare in idiomatic Go, because &T{} says the same thing and
lets you set fields at the same time.
Show me the method set rule that breaks interface satisfaction.
A pointer's method set includes both pointer and value receivers; a value's
method set includes only the value receivers. So a type whose method has a
pointer receiver is satisfied by *T, never by T.
type Stringer interface{ String() string }
type Point struct{ X, Y int }
func(p *Point) String() string { return fmt.Sprint(p.X, ",", p.Y) }
var _ Stringer = &Point{} // compilesvar _ Stringer = Point{} // cannot use Point literal as Stringer:// String method has pointer receiver
The reason is addressability rather than pedantry. Calling a pointer-receiver
method needs an address to take, and an interface holds a copy of the value it
was given — a copy stored inside an interface is not addressable, so the compiler
has nowhere to take the address from. When you call p.String() on a local
variable the compiler silently inserts (&p), which is why the method call works
on a variable and the interface assignment does not.
p := Point{1, 2}
p.String() // fine: compiler rewrites to (&p).String()
m := map[string]Point{"a": {1, 2}}
// m["a"].String() // fails: map elements are not addressable
The failure mode in real code is a String or Error method with a pointer
receiver and a value passed to fmt.Println or returned as an error. Nothing
fails to compile at the call site, because fmt accepts any — the method
simply is not found at run time, and you get the default struct formatting
instead of your carefully written one. MarshalJSON with a pointer receiver on a
value stored in a slice produces the same silent wrong output.
The habit that prevents it is consistency: pick one receiver kind per type and
use it for every method. The compile-time guard is a blank assignment,
var _ Stringer = (*Point)(nil), placed next to the type, which turns a run-time
surprise into a build failure the moment the method set stops matching.
Slices, maps and strings
What is a slice, exactly?
A three-word header — a pointer to an array, a length and a capacity — passed by
value like any other struct. The array is the storage; the slice is a view over
some window of it. Length is how many elements you can index; capacity is how
many exist from the start of the view to the end of the backing array, so it
bounds how far the view can grow without reallocating. Two slices can therefore
share one array and see each other's writes, which is the source of most slice
bugs. Because the header is copied on assignment and on every function call,
mutating an element through the copy is visible to the caller while appending
through the copy may or may not be — which is the single most asked-about
behaviour in the language.
Show me the append aliasing bug.
append writes into the existing backing array when capacity allows, and
allocates a new one when it does not. Whether your write is visible somewhere
else therefore depends on a number nobody printed.
How do you actually copy a slice?
Not with assignment, which copies the three-word header and leaves both names
pointing at the same array. copy(dst, src) copies min(len(dst), len(src))
elements, so the destination must already have the length you want — a common
mistake is passing a slice made with make([]T, 0, n), which has capacity but
zero length, so copy moves nothing and reports 0. slices.Clone in the
standard library does the allocate-and-copy in one call and is the modern
default. All of these are shallow: cloning a []*User gives you a new array of
the same pointers, and cloning a [][]byte gives you new outer storage over the
same inner buffers. A deep copy needs a loop and a decision at every pointer.
Why is map iteration order random?
Because the runtime deliberately starts each range at a random bucket and a
random offset within it. This is not an implementation detail leaking out — it
was added on purpose, so that code cannot come to depend on an order the
implementation never promised and is free to change between releases. The
practical effect is that a test which passes because a two-key map happened to
iterate in insertion order will fail eventually, and probably in CI rather than
locally. When you need a stable order you sort the keys explicitly, which is why
for _, k := range slices.Sorted(maps.Keys(m)) is such a common line. The one
exception worth knowing is fmt, which sorts map keys when printing so that log
output and test golden files are reproducible.
What happens when two goroutines write to the same map?
The runtime detects it and crashes the process with "concurrent map writes" —
deliberately, and not as a recoverable panic in the usual sense, because a map
whose buckets were mutated concurrently may be structurally corrupt and
continuing would produce silent wrong answers or a segfault later. The check is
cheap and best-effort, so it usually fires but is not guaranteed to. A concurrent
read alongside a write is equally unsafe and is caught by the race detector
rather than reliably by the runtime. Concurrent reads alone are fine. The fixes
are a sync.RWMutex around the map, which is the right default, or
sync.Map, which suits the narrow case of a mostly-read cache with disjoint
keys and is slower than a plain locked map for most workloads.
Is a Go string bytes or characters?
Bytes — an immutable slice of them, conventionally holding UTF-8. Indexing and
len are byte operations, while range decodes runes.
s := "héllo"
fmt.Println(len(s)) // 6, not 5: é is two bytes in UTF-8
fmt.Println(s[1]) // 195, the first byte of é - not a characterfor i, r := range s {
fmt.Print(i, string(r), " ") // 0h 1é 3l 4l 5o
}
// note the index jumps from 1 to 3: range advances by rune width
Show me a slice pre-allocated with capacity against repeated growth.
Growth is amortised constant but not free: each reallocation copies everything
appended so far, and the allocations are what the profiler shows.
[] {
s []
i := ; i < n; i++ {
s = (s, i)
}
s
}
[] {
s := ([], , n)
i := ; i < n; i++ {
s = (s, i)
}
s
}
Why does slicing a large buffer keep it alive?
Because a slice holds a pointer into the backing array, and the collector frees
whole allocations, not the unreferenced parts of them. So taking a five-byte
identifier out of a ten-megabyte response body and storing it in a cache retains
all ten megabytes for as long as that cache entry lives. Nothing looks wrong: the
slice reports a length of five, the heap profile blames the original read, and
the leak grows with cache hits. The same applies to a substring of a string. The
fix is to copy the part you are keeping — slices.Clone, bytes.Clone, or
string(b[i:j]) which allocates fresh — at the boundary where the small value
outlives the large one. It is the Go equivalent of Java's substring retention
bug, and it is diagnosed the same way, by looking at what holds the reference.
Interfaces and methods
How is an interface satisfied in Go?
What is an interface value made of?
Show me a nil pointer inside a non-nil interface.
What was `interface{}` used for, and what replaced it?
What do generics give you that `interface{}` did not?
When should a function accept an interface?
What does a type assertion cost, and when does it panic?
Goroutines and the scheduler
What is a goroutine, and how is it not an OS thread?
Show me the scheduler's goroutine-to-thread mapping.
What does `GOMAXPROCS` control?
What is a goroutine leak?
Show me a goroutine leak from an unread channel, and the fix.
Does the Go scheduler preempt goroutines?
Channels and synchronisation
What is the difference between an unbuffered and a buffered channel?
Show me an unbuffered channel deadlock.
Who is allowed to close a channel?
What does an operation on a nil or closed channel do?
What does `select` with a `default` do?
Show me a `select` with a timeout via context.
What does `context` actually carry?
Show me a `WaitGroup` used wrongly and correctly.
When do you use a mutex instead of a channel?
Errors
Why are errors values in Go rather than exceptions?
What does wrapping with `%w` change?
Show me `errors.Is` against a sentinel and `errors.As` against a typed error.
Why is `panic` not an exception mechanism?
What does `recover` do, and where does it belong?
In what order do deferred calls run, and when are the arguments evaluated?
Show me `defer` in a loop holding resources until return.
Memory and performance
What is escape analysis?
Show me what escapes and what does not.
How does Go's garbage collector behave?
What do `GOGC` and `GOMEMLIMIT` control?
What is `sync.Pool` for?
How do you find where the allocations are?
Why is a struct's field order a performance question?
Interview traps
Show me the loop-variable capture bug, before and after Go 1.22.
Show me a data race and the detector's output.
What does the `go.mod` module model actually guarantee?
Why does comparing two structs sometimes not compile?
What does a nil map, nil slice and nil channel each do?
What does the race detector not catch?
Which Go question most reliably separates candidates?
When you are protecting shared state rather than transferring ownership of a
value. The proverb is "share memory by communicating", but a counter, a cache or
a map guarded by a sync.Mutex is simpler, faster and easier to read than the
same thing behind a goroutine and two channels — and a mutex makes the critical
section explicit in one place. Channels earn their keep when a value moves from
one goroutine to another, when you need to co-ordinate a pipeline, or when
cancellation and select are involved. sync.RWMutex helps only when reads
dominate heavily, since it costs more than a plain mutex under write contention.
The rules that prevent most mutex bugs: lock and unlock in the same function with
a deferred unlock, never copy a struct containing one, and never hold a lock
across a channel operation or an I/O call.
What does `errgroup` add over a `WaitGroup`?
Error propagation and cancellation, which are the two things a bare WaitGroup
leaves you to build. errgroup.Group.Go runs a function returning an error, and
Wait returns the first non-nil one after all of them finish. Constructed with
errgroup.WithContext, it also derives a context that is cancelled as soon as
any goroutine fails, so sibling work stops instead of running on for a result
nobody will use — the difference between a fan-out that gives up in 5
milliseconds and one that waits 30 seconds for every timeout. SetLimit bounds
concurrency, which is how you fan out over ten thousand items without opening ten
thousand connections. The caveats: only the first error is returned, so wrap with
enough context to identify which item failed, and every goroutine must still
honour the context or the cancellation achieves nothing.
Show me a worker pool with a bounded channel.
Unbounded goroutine creation is the usual first version and it moves the limit
somewhere worse — file descriptors, connections, memory. A fixed number of
workers reading one channel bounds all of it.
base := make([]int, 3, 6) // len 3, cap 6copy(base, []int{1, 2, 3})
view := base[:2] // len 2, cap 6 - same array
fmt.Println(len(view), cap(view)) // 2 6
view = append(view, 99) // capacity available, so writes in place
fmt.Println(base) // [1 2 99] <- base[2] was silently overwritten
view = append(view, 4, 5, 6, 7) // exceeds cap 6, reallocates
fmt.Println(len(view), cap(view)) // 7 12
view[0] = -1
fmt.Println(base[0]) // 1 <- no longer shared
Two appends, two entirely different semantics, and the only thing that changed
was whether the result fitted. The first clobbered an element the other slice was
still using; the second quietly stopped sharing, so writes that used to
propagate no longer do. Both are invisible in a test with different sizes.
The idiomatic trap is the same thing hidden inside a helper:
funcaddTimeout(args []string) []string {
returnappend(args, "--timeout=30") // may write into the caller's array
}
base := make([]string, 0, 4)
base = append(base, "cmd")
a := addTimeout(base)
b := append(base, "--retries=3") // overwrites a[1]
fmt.Println(a[1]) // --retries=3, not --timeout=30
The fix is to stop sharing deliberately rather than by luck. append(s[:i:j], …)
uses the three-index form to cap the capacity at j, which forces the next
append to reallocate; slices.Clone copies outright. The convention that avoids
the question entirely is that a function which appends to a slice argument must
return the result and the caller must reassign, and no function should retain a
slice it was passed unless the contract says so. If a function both keeps a slice
and the caller keeps appending, one of them is going to be surprised.
// note also the missing space between index and letter: fmt.Print only
// inserts a space when neither adjacent operand is a string, and
// string(r) is a string. Use fmt.Println or an explicit format string
// if you want the spacing you expected.
The two conversions are the part people get wrong. []byte(s) reinterprets the
same bytes and is cheap in spirit though it does copy; []rune(s) decodes UTF-8
into a slice of int32 code points, allocating four bytes per character.
fmt.Println(len([]byte("héllo"))) // 6
fmt.Println(len([]rune("héllo"))) // 5 - what "length" usually means to a user// Reversing by byte corrupts multi-byte characters; reverse runes instead.
r := []rune("héllo")
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] }
fmt.Println(string(r)) // olléh
Three consequences worth carrying. Slicing a string by byte index can split a
rune in half and produce invalid UTF-8, which is how truncating a description to
100 characters puts a replacement character at the end of every non-ASCII
record. string(65) is "A" while string(rune(65)) is the same and
strconv.Itoa(65) is "65" — the conversion from an integer means code point,
not decimal, and vet warns about it now because it was such a reliable bug.
And a rune is still not a user-perceived character: an emoji with a skin-tone
modifier is several runes, so "count the characters" needs a grapheme-aware
library, not len([]rune(s)).
Read the allocation column rather than the time. Nineteen allocations to build
ten thousand elements, and the number is worth pausing on, because the answer
people give is "log2 n" and log2 of ten thousand is about 13.3. If the capacity
really doubled every time you would see thirteen or fourteen reallocations, not
nineteen. It does not: Go doubles only while the slice is small, and above a
threshold in the low hundreds of elements the growth factor tapers towards 1.25
so that large slices do not overshoot by half their own size. A gentler factor
means more steps to reach the same length, which is where the extra five or six
allocations come from.
The trade is deliberate and worth being able to state: doubling minimises the
number of reallocations and wastes up to half the memory, 1.25 growth wastes far
less and reallocates more often, and Go picks the first for small slices and
drifts to the second for large ones. Either way each reallocation copies
everything appended so far, so the total copying is still linear in n. The bytes
figure, 357,626 against a final 81,920, is more than four times the final size
because every abandoned array is still garbage the collector has to walk and
free.
The capacity you see is not always the one you asked for:
s := make([]int, 0, 5)
s = append(s, 1, 2, 3, 4, 5, 6)
fmt.Println(len(s), cap(s)) // 6 10 - grew past 5, rounded to a size class
The runtime rounds the request up to an allocator size class, and the growth
factor is not a clean doubling for large slices — it tapers towards 1.25 above a
few hundred elements to avoid wasting memory. So cap after a growth is an
implementation output, never something to assert on.
The rule is simply to pass the length when you know it, which you usually do:
make([]T, 0, len(input)) before a transform loop, and the size hint on
make(map[K]V, n) for the same reason. The other half of the rule is not to
guess wildly — make([]byte, 0, 1<<20) for a response that is normally 200 bytes
moves the cost from the collector to the heap and keeps a megabyte alive for as
long as anything holds the slice.
Implicitly and structurally: if a type has the methods, it satisfies the
interface, with no declaration anywhere linking the two. That inverts the usual
dependency, because the interface can be declared by the consumer rather than the
producer — you can define a two-method interface in your own package and have a
third-party type satisfy it without touching that library. The idiomatic
consequence is small interfaces defined next to the code that uses them, which is
why io.Reader has one method and why most Go interfaces in good codebases have
one or two. The cost is that satisfaction is invisible: nothing in the concrete
type's source says which interfaces it implements, removing a method silently
breaks a distant assignment, and there is no compiler-checked intent unless you
write var _ Iface = (*T)(nil) yourself.
Two words: a pointer to type information and a pointer to the data. The type word
identifies the dynamic type and carries the method table used for dispatch; the
data word points at the value, which is why storing a large struct in an
interface generally means allocating a copy on the heap. An interface is nil only
when both words are nil, which is the mechanism behind the famous non-nil nil.
Two consequences follow. A method call through an interface is an indirect call,
so it cannot be inlined in the general case and costs more than a direct one —
usually irrelevant, occasionally the reason a hot loop is slow. And comparing two
interface values compares both the dynamic type and the value, so it panics at
run time if the dynamic type is not comparable, such as a slice or a map.
An interface holds a type word and a data word. Assigning a typed nil pointer
sets the type word, so the interface is not nil even though the pointer inside it
is.
type ValidationError struct{ Field string }
func(e *ValidationError) Error() string { return"invalid " + e.Field }
funcvalidate(name string)error {
var e *ValidationError // nil pointer, but a typed oneif name == "" {
e = &ValidationError{Field: "name"}
}
return e // <- the bug: always non-nil as an error
}
err := validate("gopher")
fmt.Println(err == nil) // false
fmt.Printf("%v %T\n", err, err) // <nil> *main.ValidationError
The caller's if err != nil is true, so it takes the failure path and then
calls err.Error(), which dereferences a nil *ValidationError and panics — or,
worse, does not panic because the method never touches the receiver, and the
service reports a failure with the message <nil> for a request that succeeded.
The interface value is two words, and only one of them is nil:
declared: var e *ValidationError -> pointer: nil
returned: return e -> interface: [type=*ValidationError, data=nil]
err == nil compares BOTH words. type is set, so the comparison is false.
The fix is to never return the concrete pointer type from a function typed
error. Return the literal nil:
funcvalidate(name string)error {
if name == "" {
return &ValidationError{Field: "name"}
}
returnnil// untyped nil: both words nil
}
The same shape appears wherever a concrete type is assigned into an interface on
a path where it might be nil — a *bytes.Buffer returned as an io.Writer, a
nil *sql.Tx behind an interface, a struct field of interface type never
assigned. errors.Is(err, nil) does not save you, and neither does a nil check
inside the method. The rules that do: a function returning error should declare
its return as error, not as a concrete type; and if a helper must produce a
possibly-nil concrete value, the caller converts explicitly with a check rather
than assigning it straight in. Vet catches some cases; the tests that catch the
rest are the ones asserting on the success path.
Before generics, interface{} — spelled any since 1.18 — was the only way to
write a container or helper that worked for more than one type, so a generic
Min, a cache, or a Contains had to accept any and type-assert its way back
to something usable. That moved every type error from compile time to run time
and cost a boxing allocation per value. Type parameters replaced that use:
func Min[T cmp.Ordered](a, b T) T is checked by the compiler, needs no
assertion, and can be instantiated without boxing. any is still correct where
the type genuinely is not known until run time — decoding arbitrary JSON, a
fmt-style formatter, a plugin boundary — and it remains the wrong tool the
moment the answer is "whatever the caller passed in, consistently".
Type identity across a signature. An any-based helper loses the connection
between what went in and what comes out; a type parameter keeps it.
// Before: the caller knows the type, the compiler does not.funcFirst(items []any) any { return items[0] }
xs := []any{1, 2, 3} // every element boxed into an interface
n := First(xs).(int) // assertion; panics if you were wrong// After: one type variable ties the argument, the element and the result.funcFirst[Tany](items []T) T { return items[0] }
n := First([]int{1, 2, 3}) // int, no assertion, no boxing
The interesting part is constraints, which are interfaces used as a type set
rather than a method set:
type Number interface{ ~int | ~int64 | ~float64 } // ~ means "underlying type"funcSum[TNumber](xs []T) T {
total T
_, x := xs { total += x }
total
}
Celsius
Sum([]Celsius{, })
Without the tilde, Celsius would be rejected even though its underlying type is
float64, which is the single most common constraint mistake.
Three limits worth stating, because a candidate who only lists benefits gets
asked about them. There are no methods with their own type parameters, so you
cannot write a generic Map as a method on a collection type — it has to be a
package-level function, which is why the standard library grew slices.Map-shaped
functions rather than methods. Type inference works from arguments but not from
return types, so a function whose parameter appears only in its result must be
instantiated explicitly. And generics are not a performance feature by default:
the compiler shares one instantiation per pointer-shaped type argument and
dispatches through a dictionary, so a generic function over pointer types is not
automatically faster than the interface version — it is safer, and that is the
reason to use it.
When it genuinely uses only part of a dependency and the caller may reasonably
supply something else — a different implementation, a fake in a test, a decorator.
The idiom is to accept interfaces and return concrete types: an interface
parameter keeps the function testable and substitutable, while an interface
return value hides fields and methods the caller may need and forces a type
assertion to get them back. Define the interface in the consuming package and
keep it to the methods actually called, because a five-method interface declared
for a function that calls one of them makes every test double four methods
larger. The anti-pattern is an interface per struct, mirroring the concrete type
method for method, which adds indirection and a mocking framework while removing
nothing.
v := x.(T) compares the interface's type word against T and panics with an
interface-conversion error if it does not match. The two-result form,
v, ok := x.(T), returns the zero value and false instead, which is why the
single-result form belongs only where a mismatch is genuinely a bug. Asserting to
a concrete type is a pointer comparison and effectively free; asserting to
another interface has to check the method set, which is a lookup in a cache and
measurably slower, though still cheap. A switch v := x.(type) is the readable
form for more than two cases and is what you reach for when handling a closed set
of message or node types. The design smell is a type switch on a value you
control, which usually means an interface method was the missing abstraction.
A goroutine is a function scheduled by the Go runtime rather than by the kernel,
starting with a small growable stack — a couple of kilobytes against the one to
eight megabytes an OS thread reserves. Creating one is a function call and a heap
allocation rather than a syscall, so hundreds of thousands are ordinary. Because
the runtime owns the scheduling, a goroutine that blocks on a channel or a
network read is parked and its thread is handed to something else, so blocking
costs a context switch inside the process rather than one in the kernel. The
consequences that matter in interviews: you never size a goroutine pool the way
you size a thread pool, but you do still need to bound concurrency somewhere,
because the limit has moved to the database connections, file descriptors and
memory that the cheap goroutines all want at once.
Go's scheduler is described by three letters: G is a goroutine, M is an OS thread
and P is a logical processor holding a run queue. A goroutine runs only when a P
and an M are paired to run it.
Loading diagram…
The distinction between the two blocking branches is the one worth being able to
state. Channel, mutex and network operations park the goroutine inside the
runtime, so no thread is lost — the network poller wakes it when the file
descriptor is ready. A genuinely blocking syscall, such as a local file read or
cgo, takes the thread with it, so the runtime hands the P to a fresh or idle M
and thread count can briefly exceed GOMAXPROCS.
Work stealing is why a single busy producer does not starve the other cores: a P
with an empty local queue takes half of a random victim's queue, then checks the
global queue and the network poller. There is also a per-P slot for the
most-recently-readied goroutine, which is what makes a ping-pong between two
goroutines over an unbuffered channel fast — the receiver often runs on the same
P immediately.
Two practical readings follow. Thread count is not a tuning knob: GOMAXPROCS
bounds goroutines executing Go code simultaneously, and the number of OS threads
is whatever the syscall pattern demands, which is why a program doing heavy file
I/O shows far more threads than cores and that is normal. And fairness is
coarse: since 1.14 the runtime preempts asynchronously via signals, so a tight
loop with no function calls can still be interrupted, but scheduling is
best-effort throughput-oriented, not a real-time guarantee.
The number of Ps, and therefore how many goroutines can be executing Go code at
the same instant. It defaults to the number of logical CPUs the runtime can see,
and the version-dependent detail that matters in production is whether the
runtime reads the container's CPU limit: for a long time it did not, so a
container limited to 500 millicores on a 64-core node ran with 64 Ps, producing
heavy scheduler churn and long pauses whenever the cgroup throttled. That is why
automaxprocs-style libraries existed and why newer Go versions read the cgroup
quota themselves. GOMAXPROCS does not cap OS threads — blocking syscalls create
more — and setting it to 1 does not make your program safe from data races, it
only makes them harder to reproduce.
A goroutine that will never return, so its stack, its captured variables and
anything they reference stay alive for the life of the process. There is no
supervisor and no timeout: nothing notices, nothing logs, and the runtime is
perfectly happy. The shapes are a short list — a send on a channel nobody will
receive from, a receive from a channel nobody will send on or close, a range
over a channel that is never closed, and a select with no cancellation branch.
The symptom is memory that grows with traffic and never falls back, with a
goroutine count that rises monotonically, which is why exposing
runtime.NumGoroutine as a metric is worth the two lines. Every goroutine needs
an answer to "what makes this return", and in practice that answer is a
context.
The classic shape is a timeout on the caller's side and no cancellation on the
worker's side. The caller returns; the worker blocks forever on a send.
Every timed-out request leaks one goroutine, plus the response body it is holding
and the slowGet stack beneath it. Under a partial outage — the case where
timeouts fire constantly — goroutine count and heap climb together until the
process is killed, and the profile blames slowGet rather than the code that
abandoned it.
Two fixes, and they are not equivalent. A buffer of one lets the send complete
into a channel nobody reads, so the goroutine exits and the value is garbage
collected: ch := make(chan string, 1). That stops the leak but does not stop
the work, so a timed-out request still consumes a connection and a database query
to completion.
The real fix propagates cancellation, so the work stops too:
Three details candidates miss. The defer cancel() is not optional even on the
timeout path: skipping it leaks the timer and the child context until the
deadline fires. time.After in the original allocates a timer that is not
collectable until it fires, so the same code in a hot loop leaks timers as well
as goroutines. And leaks are testable — go.uber.org/goleak in TestMain fails
the suite when a test finishes with goroutines still running, which turns this
whole class of bug into a red build rather than a 3 a.m. page.
Yes, asynchronously since Go 1.14. Before that, preemption happened only at
function calls and a few other safe points, so a goroutine running a tight loop
with no calls could hold its P indefinitely — starving other goroutines and, more
seriously, blocking garbage collection, because the collector needs every
goroutine to reach a safe point before it can stop the world. That was a real
production hazard: one busy loop froze the whole process. The runtime now sends a
signal to the thread and unwinds to a safe point, so a for {} loop is
interruptible. What it still does not give you is fairness in any strict sense.
Scheduling favours throughput, a goroutine can run for roughly ten milliseconds
before being nudged, and no goroutine has a priority you can set.
An unbuffered channel is a synchronisation point: the send does not complete
until a receiver takes the value, so both goroutines know the handoff happened
and the send establishes a happens-before edge for everything the sender did
first. A buffered channel decouples them up to its capacity, so the sender
continues as long as there is room and blocks only when the buffer is full. The
consequence people miss is that a buffer changes the failure mode rather than
removing it: with an unbuffered channel a mismatch deadlocks immediately and
loudly, while with a buffer it succeeds under light load and deadlocks or backs
up under heavy load. Capacity is a backpressure decision, not a performance knob,
and a capacity chosen to "make it not block" is a bug waiting for traffic.
An unbuffered send blocks until a receive is ready. If the only possible receiver
is the goroutine doing the sending, nothing can proceed.
funcmain() {
ch := make(chanint)
ch <- 1// blocks here forever
fmt.Println(<-ch) // never reached
}
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/main.go:5 +0x37
That message is the runtime's deadlock detector, and it only fires when every
goroutine is blocked. The version that reaches production is the one where some
other goroutine is still awake, so nothing is detected and the process simply
stops making progress on that path:
// Note that this is a called function, not main - say publish is invoked// once per request by an HTTP handler.funcpublish(values []int) {
ch := make(chanint)
gofunc() {
for v := range ch { // never terminates: nobody closes ch
fmt.Println(v)
}
}()
ch <- 1
ch <- 2// publish returns; the ranging goroutine is still blocked in a// receive, and nothing will ever unblock it because the only sender// has gone. No error, no detection - one leaked goroutine per call.
}
The choice of enclosing function is the whole point of the example. Put this
same body in main and there is no leak to speak of: main returning ends the
process and takes every goroutine with it, so the bug is invisible because the
programme was about to exit anyway. Put it in a function a server calls on every
request and the leak accumulates for as long as the process lives, one goroutine
and one channel at a time, until the memory graph bends upward and somebody
takes a profile.
The other common shape is two goroutines exchanging values in the same order:
gofunc() { a <- 1; <-b }() // both send firstgofunc() { b <- 2; <-a }() // classic circular wait, unbuffered
Three ways out, in order of how often they are right. Give the receiving side its
own goroutine and a clear termination signal — the sender closes the channel when
done, and range ends. Add a select with ctx.Done so no send or receive can
block indefinitely. Or add capacity, which only helps when the number of
outstanding values is genuinely bounded and known; using a buffer to paper over
an ordering mistake converts a deterministic crash into an intermittent one.
The rule underneath all of it: an unbuffered channel needs both parties in
different goroutines, and every blocking operation needs a story for what unblocks
it. The deadlock detector is a courtesy that fires in toy programs; in a service
with a running HTTP listener it never fires at all.
The sender, and only ever one of them. Closing says "no more values are coming",
which is information only the sending side has; a receiver closing a channel
causes the sender to panic on its next send. Closing twice also panics, which is
why a channel with multiple senders should not be closed by any of them — you
either add a sync.WaitGroup and have a coordinator close after all senders
finish, or you signal completion with a separate done channel or a context.
Closing is not required for garbage collection: an unreferenced channel is
collected whether closed or not, so you close only when receivers need to know
the stream has ended. A receive from a closed channel never blocks, returning the
zero value and false from the two-result form.
The full matrix is short and worth knowing cold, because select behaviour
depends on it.
Loading diagram…
The last box is the useful one rather than a curiosity. Because a nil channel
blocks forever, setting a channel variable to nil disables that branch of a
select permanently, which is the idiomatic way to merge two streams that end at
different times:
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok { a = nil; continue } // disable this case, keep draining b
use(v)
case v, ok := <-b:
if !ok { b = nil; continue }
use(v)
}
}
Without the a = nil, a closed channel is permanently ready and returns zero
values as fast as the loop can spin — a busy loop at 100% CPU, which is the usual
first version of this code.
Two more consequences. A closed channel as a broadcast is the standard
fan-out-to-many signal, and it is exactly what context.Done and
sync.WaitGroup-plus-close are built on: every receiver is released at once and
no value needs sending. And select with all cases nil and no default blocks
forever, which the deadlock detector reports if nothing else is running — one of
the few times the runtime tells you the truth about a design mistake for free.
It makes the whole statement non-blocking: if no case is ready at that instant,
the default branch runs immediately. That turns select into a poll, which is
the correct tool for a few narrow jobs — a non-blocking send that drops the value
when a queue is full, a metrics update you would rather skip than wait for, a
cheap check of whether a done channel has closed. It is also the standard way to
write a load-shedding handler: try to enqueue, and on default return 503 rather
than let the caller wait. The failure mode is using it inside a loop as a way of
waiting, which spins at full CPU while accomplishing nothing; if you want to
wait, the whole point of select without a default is that it waits.
The pattern is a select over the result channel and ctx.Done, so whichever
happens first wins and the other is abandoned.
funcquery(ctx context.Context, id string) (Row, error) {
ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()
out := make(chan Row, 1) // buffered: the worker can always finishgofunc() { out <- lookup(ctx, id) }()
select {
case row := <-out:
return row, nilcase <-ctx.Done():
return Row{}, ctx.Err() // context.DeadlineExceeded, or Canceled
}
}
Loading diagram…
The buffered channel is the detail that makes this leak-free. With an unbuffered
channel the worker would block on a send nobody will ever receive, so every
timeout costs a goroutine; with capacity one the send always completes, the
goroutine returns, and the unread value is collected.
Passing the same ctx into lookup is the second half. Without it the timeout
only abandons the result — the query still runs to completion, still holds a
connection, and still loads the database you were trying to protect. Cancellation
has to reach the thing doing the work, which is why every blocking standard
library call that can be cancelled takes a context.
Two points on ordering. If both cases are ready, select picks pseudo-randomly,
so a completion that lands in the same microsecond as the deadline may report
either outcome — code must not assume the happy path wins a tie. And ctx.Err()
is what distinguishes a deadline from a caller cancellation, which matters
because one is your latency budget and the other is a client that hung up; a
handler that reports both as a 504 will chase phantom slowness caused by users
closing tabs.
Three things: a cancellation signal, an optional deadline, and request-scoped
values. Its real purpose is the first two — a Done channel that closes when
work should stop, propagated down a call tree so cancelling a parent cancels
every child. Values are the part that gets abused: the contract is
request-scoped metadata such as a trace identifier or an authenticated
principal, not a way to pass dependencies or arguments a function actually needs,
because a value fetched from a context is untyped, invisible in the signature and
absent at run time when someone forgets to set it. The conventions worth stating:
context is the first parameter, never stored in a struct, never nil — use
context.TODO — and cancel is always deferred, because failing to call it
leaks the timer and the child context until the deadline expires.
Nearly every WaitGroup bug is Add in the wrong place. Called inside the
goroutine, it races with Wait.
var wg sync.WaitGroup
results := make([]int, len(ids))
for i, id := range ids {
gofunc() {
wg.Add(1) // BUG: may run after Wait has already returneddefer wg.Done()
results[i] = fetch(id)
}()
}
wg.Wait() // may see a counter of 0 and return immediately
The result is a Wait that returns before the work is done, so results is
partly zero — intermittently, and more often on a fast machine with few
goroutines, which is precisely backwards from where you would test it. Worse, if
some goroutines do register first, Wait returns after those and not the others,
so the bug is load-dependent.
var wg sync.WaitGroup
results := make([]int, len(ids))
for i, id := range ids {
wg.Add(1) // in the caller, before the goroutine startsgofunc() {
defer wg.Done() // deferred, so a panic still decrements
results[i] = fetch(id)
}()
}
wg.Wait()
Three further rules the correct version encodes. Add happens in the goroutine
that calls Wait, so the counter is guaranteed non-zero before Wait runs.
Done is deferred rather than placed at the end of the body, because an early
return or a panic otherwise leaves the counter permanently above zero and
Wait blocks forever — a hang with no error, which is the most expensive
failure in this list. And each goroutine writes to its own index rather than
appending to a shared slice, because append on a shared slice from many
goroutines is a data race even when the indices differ.
Two related notes. wg.Add(len(ids)) once before the loop is equivalent and
sometimes clearer, but only when the loop cannot continue past a goroutine
launch. And a WaitGroup must be passed by pointer if it crosses a function
boundary — copying one copies its internal counter, which the vet copylocks
check exists to catch. Since Go 1.25 wg.Go(fn) combines the Add and the
deferred Done, which removes this entire category of mistake.
Because an error returned as an ordinary value appears in the signature, so a
caller cannot fail to notice that a call can fail, and the handling sits next to
the call rather than in a distant catch block. There is no invisible control-flow
path, which means resource cleanup and partial-state repair happen where the
knowledge is. The costs are real and worth naming: the boilerplate is
substantial, roughly one in three lines of Go being an error check; the compiler
does not force you to inspect a returned error, only to handle the value, so
_ = doThing() compiles; and the errors from deep in a stack arrive without a
stack trace unless you add one. The trade is verbosity for explicitness, and
errcheck in CI is what makes the explicitness enforceable.
It records the original error inside the new one, so errors.Is and errors.As
can still find it while the message gains context. fmt.Errorf("load user %s: %v", id, err) produces the same text but discards the chain, so a caller can no
longer test whether the underlying cause was sql.ErrNoRows — the information is
in the string and nowhere else, which is how error handling degrades into
strings.Contains. The convention is one %w at each layer that adds
information, a message that names the operation and the identifier without
repeating the wrapped text, and no capital letters or trailing punctuation
because the strings compose. Wrapping is also an API decision: once a caller can
see through to sql.ErrNoRows, that becomes part of your contract, so
deliberately not wrapping is sometimes correct.
Is answers "is this that specific error"; As answers "is there an error of
this type in the chain, and give it to me". Both walk the Unwrap chain, which is
why == and a type assertion both fail on a wrapped error.
var ErrNotFound = errors.New("not found") // sentineltype ValidationError struct { // typed, carries data
Field string
Reason string
}
func(e *ValidationError) Error() string {
return fmt.Sprintf("field %s: %s", e.Field, e.Reason)
}
funcloadUser(id string) (*User, error) {
u, err := db.Get(id)
if err != nil {
returnnil, fmt.Errorf("load user %s: %w", id, err) // wraps
}
return u, nil
}
_, err := loadUser("42")
err == ErrNotFound // false: err is a *fmt.wrapError
errors.Is(err, ErrNotFound) // true: unwraps until it matchesvar ve *ValidationError
if errors.As(err, &ve) { // note: pointer to the target variable
log.Printf("bad field %s", ve.Field) // the data survives the wrapping
}
The signature of As is the part people get wrong: the second argument is a
pointer to a variable of the error type, because As assigns through it. Passing
ve rather than &ve panics with "errors: target must be a non-nil pointer".
Which to reach for follows from what the caller needs. A sentinel is right when
the only question is which of a few known conditions occurred — io.EOF,
sql.ErrNoRows, context.Canceled — and it costs nothing but carries no
detail. A typed error is right when the caller needs the specifics: which field
failed, which retry-after value, which HTTP status to map to. Both are part of
your public API the moment a caller matches on them, which is the argument for
exposing few of either.
Two further details. errors.Join produces an error whose chain has several
branches, and Is and As search all of them, which is what makes a multi-error
validation result matchable. And a custom type can implement Is(target error) bool itself to declare equivalence — useful for mapping a family of driver codes
onto one sentinel — but it is easy to make transitive nonsense, so the default
should be plain wrapping.
Because it is not for conditions a caller might handle — it is for conditions
that mean the program's assumptions are already broken, and its default behaviour
is to unwind every deferred call and kill the process with a stack dump. Using it
as flow control fails on three counts: it is invisible in the signature, so
callers cannot see the risk; it crosses package boundaries in a way Go's
conventions do not expect; and it does not cross goroutine boundaries, so a panic
in a goroutine takes down the whole process no matter what the spawning code
wrapped it in. The legitimate uses are narrow: an impossible state such as a
corrupt internal invariant, initialisation that cannot proceed at all, and the
MustCompile idiom where failure means a programming error in a literal. Library
code should recover at its boundary and return an error.
It stops a panic that is unwinding the current goroutine, but only when called
directly inside a deferred function — anywhere else it returns nil and does
nothing, which is why if recover() != nil in the middle of a function is a
common non-working attempt. Its legitimate home is a boundary you own: an HTTP
middleware that turns one handler's panic into a 500 and a log line rather than a
dead process, a worker loop that must survive one bad message, or a parser that
uses panic internally and converts it back to an error on the way out. Two
cautions. Recovering leaves the program in whatever state the panic interrupted,
so it is a containment tool rather than a repair; and it cannot catch a panic in
another goroutine, so every goroutine you launch needs its own deferred recover
if it must not kill the process.
Last in, first out, at the moment the surrounding function returns, whether it
returns normally or panics. The arguments are evaluated when the defer
statement executes, not when the call runs — so defer fmt.Println(i) captures
the value of i now, while defer func() { fmt.Println(i) }() reads it later
and sees the final value. That distinction is the whole of the trick questions on
this subject. A deferred closure can also modify a named return value after the
return statement has set it, which is how a function converts a recovered panic
into an error return. The cost is small but not zero, and the real hazard is
placement: a defer inside a loop does not run at the end of each iteration, it
queues up until the function returns.
defer is function-scoped, not block-scoped. Inside a loop it stacks up, and
nothing is released until the whole function returns.
funcprocessAll(paths []string)error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // queued, not executed - runs at function exitif err := parse(f); err != nil {
return err
}
}
returnnil
}
With ten thousand paths this holds ten thousand open file descriptors
simultaneously, and the failure is too many open files from a line that never
mentions a limit. The same shape with defer resp.Body.Close() exhausts the
connection pool, and with defer mu.Unlock() inside a loop it self-deadlocks on
the second iteration.
The fix is to give each iteration its own function, so each defer has a scope
that ends where you expect:
The alternative — calling f.Close() explicitly at the end of the loop body — is
wrong in the way that hand-written cleanup usually is: it does not run on the
early-return path, so an error from parse still leaks the descriptor. That is
the bug the closure version cannot have.
Two related points. defer f.Close() silently discards the close error, which
matters for a file being written, because a failed flush is exactly when you need
to know: assign to a named return with defer func() { err = errors.Join(err, f.Close()) }() when the write must be durable. And there is no try-style
scope guard in Go, so the extracted-function pattern is the idiom — which is
usually an improvement anyway, because the body was doing enough to deserve a
name.
A compile-time analysis of whether a value's lifetime can be proved to end when
its function returns. If it can, the value is allocated on the goroutine's stack
and disappears for free when the frame is popped; if a reference outlives the
frame, the value must be heap-allocated and becomes the garbage collector's
problem. Go's rule is about lifetime, not about new or & — taking an address
does not force a heap allocation, and returning a pointer to a local is safe
precisely because the compiler moves the local to the heap for you. The reasons a
value escapes are worth knowing by name: it is returned by pointer, stored in an
interface, captured by a closure that outlives the call, put into a slice or map
that escapes, or passed to something the compiler cannot see through. That last
one is why an argument to fmt.Println escapes.
The compiler will tell you directly, which is more reliable than reasoning about
it.
type Point struct{ X, Y int }
funcstackOnly()int {
p := &Point{1, 2} // address taken, but never leaves the framereturn p.X + p.Y
}
funcescapes() *Point {
p := &Point{1, 2} // the pointer outlives the callreturn p
}
funcalsoEscapes(n int) {
fmt.Println(n) // n becomes an interface argument to a
} // function the compiler cannot see through
$ go build -gcflags='-m' ./...
./main.go:6:10: &Point{...} does not escape
./main.go:11:10: &Point{...} escapes to heap
./main.go:16:14: ... argument does not escape
./main.go:16:14: n escapes to heap
The first two lines contradict the intuition people bring from C++: &Point{}
inside stackOnly is a stack allocation despite the &, and the identical
expression in escapes is a heap allocation. The address operator is not the
signal — outliving the frame is.
The fmt.Println line is the one that shows up in profiles. Passing an int to
a variadic ...any boxes it into an interface, and because fmt uses
reflection the compiler cannot prove the value stays local, so a debug log line
in a hot loop allocates per call. That is why structured loggers have typed
methods and why level checks are guarded before the arguments are built.
Two things this does not mean. Heap allocation is not a bug — a []byte for a
response body belongs on the heap, and forcing values onto the stack by
contorting an API is a poor trade. And large values are moved to the heap
regardless of escape, because stacks start small and growing one copies the whole
thing; a multi-megabyte array as a local is heap-bound whatever the analysis
says.
The workflow that matters is -benchmem first to see whether allocation is the
problem, pprof -alloc_objects to find which call site, and -gcflags='-m' only
then to understand why. Guessing at escape behaviour from reading code is
unreliable even for people who work on the compiler.
It is a concurrent, tri-colour mark-and-sweep collector with a very short
stop-the-world phase, tuned for latency rather than throughput. Marking runs
alongside your program, using a write barrier so that pointer updates during
marking cannot hide a live object, and the sweep is incremental. There is no
generational young space and no compaction, so Go avoids the long pauses of a
compacting collector at the cost of some fragmentation and of not being able to
collect short-lived garbage as cheaply as a generational design would. The
practical readings: pause times are typically well under a millisecond and mostly
independent of heap size, but the cost is proportional to how many pointers you
allocate, so reducing allocations is the tuning lever rather than any flag. Mark
assist is why a heavy-allocating goroutine appears to slow down — it is being
made to help.
GOGC is the heap growth target: at the default of 100 the collector aims to
start a cycle when the live heap has doubled since the last one, so raising it
trades memory for fewer cycles and lowering it does the reverse. It is a ratio,
which is the problem in a container — a small live heap means frequent
collections regardless of how much memory is free, and a large one can blow past
the limit before a cycle finishes. GOMEMLIMIT fixes that by giving the runtime a
soft total-memory ceiling: it collects more aggressively as the limit approaches
rather than only when the ratio triggers. The idiomatic setup in a container is
GOMEMLIMIT at roughly 90% of the pod limit with GOGC left alone, which
prevents most out-of-memory kills. It is soft, so a program that genuinely needs
more will still exceed it.
Reusing allocations that are expensive and short-lived, most commonly large
buffers in a request path — a bytes.Buffer per request obtained from a pool and
returned on the way out. It is not a cache and not a resource pool: entries may
be dropped at any garbage collection, there is no size limit you control, and
Get may return a brand-new object at any time, so nothing may depend on an item
surviving. The rules that make it safe are to reset the object before or after
use, since it arrives with the previous user's contents, and never to keep a
reference to something you have returned. It is also easy for it to be a
pessimisation: for small objects the pool's bookkeeping costs more than the
allocation, so it belongs behind a benchmark rather than a hunch.
Benchmark first with go test -bench=. -benchmem, because allocs/op tells you
whether allocation is the problem at all before you start guessing. Then
go tool pprof on a heap profile, either from the benchmark's -memprofile or
live from /debug/pprof/heap in a running service. The distinction that decides
what you are looking at is -alloc_space and -alloc_objects, which show
everything ever allocated and answer "what is generating garbage", against
-inuse_space and -inuse_objects, which show what is live now and answer "what
is leaking". Reaching for the wrong one is the standard mistake. -benchmem plus
benchstat over several runs is what makes an improvement believable, since a
single benchmark run varies by more than most optimisations gain.
Because fields are aligned to their size, so the compiler inserts padding
between them and the layout depends on the order you wrote. A struct of
bool, int64, bool occupies 24 bytes — one byte, seven of padding, eight, one,
seven more — while int64, bool, bool occupies 16. At a few million instances,
that is real memory and, more importantly, real cache lines: fewer bytes per
element means more elements per fetch, which matters far more than the byte count
in a loop over a large slice. The rule of thumb is to declare fields from widest
to narrowest, and to group the fields accessed together so they share a cache
line. It is a micro-optimisation for most code and worth doing only where
instance counts are large, which fieldalignment in go vet can point out.
Before Go 1.22 the range variables were declared once for the whole loop and
reassigned each iteration, so every closure captured the same variable.
// Go 1.21 and earlierfor _, v := range []int{1, 2, 3} {
gofunc() {
fmt.Println(v) // prints 3 3 3, or 2 3 3, or anything
}()
}
$ go1.21 run main.go
3
3
3
The goroutines run after the loop has finished, so they all read the final value.
The output is not even deterministic — with a time.Sleep in the loop you get
1 2 3 and conclude the code is correct, which is why this bug shipped so often.
The fix people wrote for a decade was to shadow the variable:
for _, v := range items {
v := v // new variable per iterationgofunc() { use(v) }()
}
// or pass it as an argument, which does the same by valuefor _, v := range items {
gofunc(v Item) { use(v) }(v)
}
Go 1.22 changed the language: range and three-clause for loop variables are
now declared fresh per iteration, so the original code prints 1 2 3 in some
order and the v := v line is a no-op.
$ go run main.go # go.mod says go 1.22 or later
1
3
2
Two things to say about it, because "it was fixed" is only half an answer. The
change is gated on the go directive in go.mod, per module — so a module
declaring go 1.21 still gets the old semantics on a new toolchain, and a
dependency compiled under its own older directive keeps the old behaviour too.
That makes the version line a semantic decision, not just a minimum.
And the fix does not extend to variables declared outside the loop, which is
the version of the bug that still exists:
var err errorfor _, item := range items {
gofunc() { err = save(item) }() // still a race on the shared err
}
Sharing any variable across goroutines is still a race whatever the loop
semantics are, and that is what the detector is for.
A race is two goroutines touching the same memory with at least one writing and
no synchronisation between them. The result is not merely a wrong number — the
program's behaviour is undefined.
funcmain() {
counter := 0var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
gofunc() { defer wg.Done(); counter++ }() // read, add, write
}
wg.Wait()
fmt.Println(counter) // 972, 981, 1000 - varies per run
}
$ go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c000012028 by goroutine 9:
main.main.func1()
/tmp/main.go:11 +0x3c
Previous read at 0x00c000012028 by goroutine 8:
main.main.func1()
/tmp/main.go:11 +0x2a
Goroutine 9 (running) created at:
main.main()
/tmp/main.go:10 +0x8c
==================
Found 1 data race
The output names four things: the address, the two conflicting accesses with
stacks, and where each goroutine was created. That last section is what makes it
actionable — the conflicting line is usually obvious, and the goroutine origin is
where the missing synchronisation belongs.
Two fixes, and which you pick depends on what else is happening. atomic.Int64
with Add(1) is right for a bare counter and involves no lock. A sync.Mutex
around the increment is right when the counter changes alongside other state that
must stay consistent with it.
The detector's limits are the part that separates candidates. It is a run-time
tool, not a static one, so it only reports races on code paths actually executed
with actually-interleaved timing — a race in an untested branch is invisible, and
one that needs a rare interleaving may not surface in a single run. It costs
roughly five to ten times the CPU and five times the memory, so it belongs in CI
and in load tests rather than in production. And a clean -race run is evidence,
not proof: running the concurrency tests repeatedly, with -count=20 and under
load, is what turns it into reasonable confidence.
Reproducible builds through minimal version selection: go.mod records the
minimum version of each dependency, the build picks the maximum of the minimums
requested across the whole graph, and go.sum records cryptographic hashes so
the bytes cannot change underneath you. That is deliberately different from
solvers that pick the newest matching version, and it means a build does not
change because someone published a release — upgrades are explicit commits. The
consequences to know: the major version is part of the import path from v2
onwards, so v2 and v3 of a library can coexist in one build; the go
directive selects language semantics per module, which is how the loop-variable
change was rolled out; and go mod tidy is what keeps the file honest, since
replace and exclude directives in a dependency are ignored and only apply in
the main module.
Because struct comparison is defined field by field, so a struct is comparable
only if every field is. Numbers, strings, booleans, pointers, channels,
interfaces and arrays of comparable types are all fine; slices, maps and
functions are not, so one slice field anywhere in the tree makes the whole struct
incomparable and == is a compile error. The knock-on effects are the real
answer: such a type cannot be a map key, cannot be an element of a comparable
type parameter, and cannot be compared inside an interface — that last case fails
at run time with a panic rather than at compile time, because the compiler only
sees any. The usual tools are reflect.DeepEqual, which is slow and
occasionally surprising with nil against empty slices, or a generated Equal
method, which is what you want in anything hot.
They differ, and the differences are load-bearing. A nil slice has length and
capacity zero, ranges over nothing, and append works on it — so returning a nil
slice for "no results" is idiomatic and callers need no special case. A nil map
reads as if empty, returning zero values and reporting ok false, but any write
panics with "assignment to entry in nil map", which is the trap: a struct field
of map type is unusable until someone calls make. A nil channel blocks forever
on both send and receive, which is a feature in select, where a nil case is
never chosen. A nil pointer receiver is also legal to call methods on, provided
the method does not dereference it, which is why (*Logger)(nil).Log() can be a
deliberate no-op.
Anything it does not observe. It instruments memory accesses at run time, so a
race in a branch your tests never execute, or one requiring an interleaving that
did not happen in that run, is simply not reported — a clean run is evidence
rather than proof. It also does not catch logical races that are not data races:
a check-then-act across two separate atomic operations, or two mutex-protected
critical sections whose combined effect is wrong, are correctly synchronised at
the memory level and still incorrect. Deadlocks are outside its remit too, beyond
the runtime's all-goroutines-asleep detector. And the overhead — several times
the CPU and memory — keeps it out of production. What makes it effective is
running the concurrency tests with -race -count=20 under load, in CI, so the
interleavings get a chance to happen.
"You have a handler that fans out to three services with a 200-millisecond
budget. Write it." A strong answer builds the shape without being asked: a
context with the deadline derived from the incoming request, an errgroup so the
first failure cancels the siblings, results written to pre-sized storage by index
rather than appended to a shared slice, buffered result channels so no goroutine
can block on a send after the caller has gone, and every cancel deferred. It
then names what it deliberately left out — a retry budget, whether a partial
result is acceptable, where the concurrency limit lives. A weak answer launches
three goroutines with a WaitGroup, collects into a shared slice, times out with
time.After, and leaks a goroutine per slow call. The gap is not syntax; it is
whether the candidate has an answer for what makes every goroutine return.
var
// zero value of whatever T is
for
range
return
type
float64
1.5
2.5
// works: ~float64 admits defined types
nil
// aborts when ctx is done
if
nil
return
""
// wraps context.DeadlineExceeded
defer
return
string
error
for
range
if
func()
error
// or a named method - clearer
if
nil
return
defer
// runs at the end of this iteration
return
nil
return
"process %s: %w"
return
nil
funcprocess(ctx context.Context, jobs []Job, workers int)error {
in := make(chan Job)
g, ctx := errgroup.WithContext(ctx)
// Producer: closing `in` is what terminates every worker's range loop.
g.Go(func()error {
deferclose(in)
for _, j := range jobs {
select {
case in <- j:
case <-ctx.Done(): // stop feeding a cancelled pipelinereturn ctx.Err()
}
}
returnnil
})
for i := 0; i < workers; i++ {
g.Go(func()error {
for j := range in { // ends when `in` is closedif err := handle(ctx, j); err != nil {
return fmt.Errorf("job %s: %w", j.ID, err)
}
}
returnnil
})
}
return g.Wait()
}
The shape to notice is that one goroutine owns the channel and closes it, and the
workers only receive. That is the ownership rule from the closing question applied
directly: with N workers sending, no one of them can safely close, so the producer
does it and range gives every worker a clean termination.
The select in the producer is the part that is usually missing. Without it, a
cancelled context stops the workers but the producer stays blocked on a send into
a channel nobody is reading — one leaked goroutine holding the whole job slice.
Concurrency here is workers, not len(jobs), and that number should be derived
from the scarce resource rather than the CPU count: the database connection pool
size for query work, GOMAXPROCS for CPU-bound work, something much higher for
pure network waits. An unbuffered in gives natural backpressure, so a slow
handler slows the producer instead of buffering the entire input in memory.
If results are needed, send them on a second channel with capacity equal to the
worker count and collect them in the calling goroutine, or write into a
pre-sized slice by index. What you must not do is have workers append to a
shared slice — that is a data race even though each worker touches a different
element, because append rewrites the shared header.