Skip to content
QSWEQB

Go fundamentals

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.

60 questions

Go deeper on Go

The type system and values

Why does Go have no inheritance?

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.Log

type 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{}   // compiles
var _ 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.

base := make([]int, 3, 6)      // len 3, cap 6
copy(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:

func addTimeout(args []string) []string {
    return append(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.

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 character

for 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
// 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)).

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.

func grow(n int) []int {
    var s []int                  // nil: cap 0
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    return s
}

func sized(n int) []int {
    s := make([]int, 0, n)       // one allocation, exactly the right size
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    return s
}
BenchmarkGrow-8     18432 ns/op    357626 B/op    19 allocs/op
BenchmarkSized-8     6871 ns/op     81920 B/op     1 allocs/op

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.

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?

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.

What is an interface value made of?

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.

Show me a nil pointer inside a non-nil interface.

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 }

func validate(name string) error {
    var e *ValidationError          // nil pointer, but a typed one
    if 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:

func validate(name string) error {
    if name == "" {
        return &ValidationError{Field: "name"}
    }
    return nil                      // 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.

What was `interface{}` used for, and what replaced it?

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".

What do generics give you that `interface{}` did not?

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.
func First(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.
func First[T any](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"

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: ~float64 admits defined types

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 should a function accept an interface?

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.

What does a type assertion cost, and when does it panic?

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.

Goroutines and the scheduler

What is a goroutine, and how is it not an OS thread?

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.

Show me the scheduler's goroutine-to-thread mapping.

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.

flowchart TD
    A[G - goroutine with a small growable stack] --> B[P local run queue, one per GOMAXPROCS]
    B --> C[M - OS thread, must hold a P to run Go code]
    C --> D{Does the G block}
    D -- On a channel or mutex --> E[G parks, M keeps its P and runs the next G]
    D -- On a blocking syscall --> F[M detaches with the G, P is handed to another M]
    D -- No --> G[G runs until it yields or is preempted]
    B --> H[Empty queue - this P steals half of another P's queue]

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.

What does `GOMAXPROCS` control?

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.

What is a goroutine leak?

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.

Show me a goroutine leak from an unread channel, and the fix.

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.

func fetch(url string, timeout time.Duration) (string, error) {
    ch := make(chan string)               // unbuffered

    go func() {
        ch <- slowGet(url)                // blocks until someone receives
    }()

    select {
    case body := <-ch:
        return body, nil
    case <-time.After(timeout):
        return "", errors.New("timeout")   // caller leaves; nobody ever receives
    }
}

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:

func fetch(ctx context.Context, url string) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()                        // releases the timer and the ctx tree

    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    resp, err := http.DefaultClient.Do(req)   // aborts when ctx is done
    if err != nil {
        return "", err                    // wraps context.DeadlineExceeded
    }
    defer resp.Body.Close()
    b, err := io.ReadAll(resp.Body)
    return string(b), err
}

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.

Does the Go scheduler preempt goroutines?

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.

Channels and synchronisation

What is the difference between an unbuffered and a buffered channel?

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.

Show me an unbuffered channel deadlock.

An unbuffered send blocks until a receive is ready. If the only possible receiver is the goroutine doing the sending, nothing can proceed.

func main() {
    ch := make(chan int)
    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.
func publish(values []int) {
    ch := make(chan int)

    go func() {
        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:

go func() { a <- 1; <-b }()     // both send first
go func() { 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.

Who is allowed to close a channel?

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.

What does an operation on a nil or closed channel do?

The full matrix is short and worth knowing cold, because select behaviour depends on it.

flowchart TD
    A[Operation on a channel] --> B{Is the channel nil}
    B -- Yes --> C[Send and receive both block forever]
    B -- No --> D{Has it been closed}
    D -- No --> E[Normal - blocks according to capacity]
    D -- Yes --> F{Send or receive}
    F -- Send --> G[Panic - send on closed channel]
    F -- Receive --> H[Drains any buffered values then returns zero and false]
    C --> I[In a select, a nil case is simply never chosen]

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.

What does `select` with a `default` do?

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.

Show me a `select` with a timeout via context.

The pattern is a select over the result channel and ctx.Done, so whichever happens first wins and the other is abandoned.

func query(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 finish
    go func() { out <- lookup(ctx, id) }()

    select {
    case row := <-out:
        return row, nil
    case <-ctx.Done():
        return Row{}, ctx.Err()       // context.DeadlineExceeded, or Canceled
    }
}
sequenceDiagram
    participant C as Caller
    participant X as Context with a 200ms deadline
    participant W as Worker goroutine
    participant R as Result channel, capacity 1
    C->>X: derive the deadline and defer cancel
    C->>W: start lookup with the same ctx
    C->>R: select on the result and on ctx.Done
    X-->>C: Done closes at 200ms
    C-->>C: return ctx.Err, deadline exceeded
    W->>R: send lands in the buffer, worker exits

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.

What does `context` actually carry?

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.

Show me a `WaitGroup` used wrongly and correctly.

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 {
    go func() {
        wg.Add(1)              // BUG: may run after Wait has already returned
        defer 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 starts
    go func() {
        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.

When do you use a mutex instead of a channel?

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.

func process(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 {
        defer close(in)
        for _, j := range jobs {
            select {
            case in <- j:
            case <-ctx.Done():          // stop feeding a cancelled pipeline
                return ctx.Err()
            }
        }
        return nil
    })

    for i := 0; i < workers; i++ {
        g.Go(func() error {
            for j := range in {         // ends when `in` is closed
                if err := handle(ctx, j); err != nil {
                    return fmt.Errorf("job %s: %w", j.ID, err)
                }
            }
            return nil
        })
    }
    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.

Errors

Why are errors values in Go rather than exceptions?

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.

What does wrapping with `%w` change?

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.

Show me `errors.Is` against a sentinel and `errors.As` against a typed error.

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")            // sentinel

type ValidationError struct {                        // typed, carries data
    Field  string
    Reason string
}

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

func loadUser(id string) (*User, error) {
    u, err := db.Get(id)
    if err != nil {
        return nil, 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 matches

var 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.

Why is `panic` not an exception mechanism?

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.

What does `recover` do, and where does it belong?

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.

In what order do deferred calls run, and when are the arguments evaluated?

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.

Show me `defer` in a loop holding resources until return.

defer is function-scoped, not block-scoped. Inside a loop it stacks up, and nothing is released until the whole function returns.

func processAll(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 exit

        if err := parse(f); err != nil {
            return err
        }
    }
    return nil
}

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:

func processAll(paths []string) error {
    for _, p := range paths {
        if err := func() error {          // or a named method - clearer
            f, err := os.Open(p)
            if err != nil {
                return err
            }
            defer f.Close()               // runs at the end of this iteration
            return parse(f)
        }(); err != nil {
            return fmt.Errorf("process %s: %w", p, err)
        }
    }
    return nil
}

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.

Memory and performance

What is escape analysis?

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.

Show me what escapes and what does not.

The compiler will tell you directly, which is more reliable than reasoning about it.

type Point struct{ X, Y int }

func stackOnly() int {
    p := &Point{1, 2}        // address taken, but never leaves the frame
    return p.X + p.Y
}

func escapes() *Point {
    p := &Point{1, 2}        // the pointer outlives the call
    return p
}

func alsoEscapes(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.

How does Go's garbage collector behave?

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.

What do `GOGC` and `GOMEMLIMIT` control?

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.

What is `sync.Pool` for?

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.

How do you find where the allocations are?

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.

Why is a struct's field order a performance question?

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.

Interview traps

Show me the loop-variable capture bug, before and after Go 1.22.

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 earlier
for _, v := range []int{1, 2, 3} {
    go func() {
        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 iteration
    go func() { use(v) }()
}
// or pass it as an argument, which does the same by value
for _, v := range items {
    go func(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 error
for _, item := range items {
    go func() { 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.

Show me a data race and the detector's output.

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.

func main() {
    counter := 0
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() { 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.

What does the `go.mod` module model actually guarantee?

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.

Why does comparing two structs sometimes not compile?

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.

What does a nil map, nil slice and nil channel each do?

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.

What does the race detector not catch?

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.

Which Go question most reliably separates candidates?

"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.