GAP·MAP
← all posts
July 11, 2026questions

"Go interview questions: goroutines, channels, slices"

A Go backend loop is a small set of traps repeated across bands. Interviewers want the mechanism: the channel operation table cold, the leak spotted in a producer/consumer sketch, the slice header drawn on the whiteboard, the typed-nil surprise explained without hand-waving. This list comes from the rubric behind our Go modules and real loop reports.

At a glance, what each band clears:

AreaTypical questionStudy
channelsbuffered vs unbuffered, the closed/nil matrixGo concurrency
goroutineshow a leak happens, the loop-capture bugGo concurrency
schedulerwhy goroutines are cheap, what GMP meansGo runtime
sliceswhen append reallocates vs aliasesGo types & idioms
interfaceswhy a nil *T is a non-nil errorGo types & idioms

Channels: the semantics matrix

The single most reliable Go filter is whether you can recite what each channel operation does. There is no partial credit for "it errors."

What happens on a closed or nil channel

Receive from a closed channel returns the zero value with ok=false, after draining any buffered values first. It never blocks and never panics. Sending to a closed channel panics. Closing a closed channel panics, and so does closing a nil one.

v, ok := <-ch   // closed: zero value, ok == false, drains buffer first
ch <- v         // WRONG on a closed channel: panic("send on closed channel")

Trap the common wrong answer is "receiving from a closed channel panics." That is send. A nil channel is separate again: send and receive both block forever, no panic, no error. Inside a select, a case on a nil channel is skipped, which is the idiom for toggling a case off.

Follow-up "buffered vs unbuffered, what actually changes?" An unbuffered channel is a rendezvous: the send and the matching receive happen together. A buffered channel decouples them by up to its capacity. It changes synchronization semantics, so "buffered is a faster unbuffered" is a red flag.

Study Go concurrency

Where goroutines leak

Trap a worker sends its result on a channel, but the caller already returned on a timeout, so nobody will ever receive. That goroutine blocks forever.

func fetch(ctx context.Context) (Result, error) {
    ch := make(chan Result)      // WRONG: unbuffered; if we time out, the worker blocks forever on the send
    go func() { ch <- doWork() }()
    select {
    case r := <-ch:
        return r, nil
    case <-ctx.Done():
        return Result{}, ctx.Err()   // worker still parked on ch <- ..., leaked
    }
}

A leaked goroutine is a live root: it and everything it captured stay pinned until the process exits, so this is a memory bug, not only a hang. The fix is make(chan Result, 1) so the send cannot block, or plumbing ctx.Done() all the way down. The same discipline decides whether context cancellation, time.After in a hot select, and worker pools come out clean.

Study Go concurrency

The loop-variable capture bug

Before Go 1.22, a for range loop shared one variable across iterations, so goroutines launched inside usually all saw the last element. Go 1.22 gives each iteration its own copy. Interviewers still ask the pre-1.22 mechanism because it tests closure-over-variable understanding, and the fix is worth stating either way.

for _, v := range items {
    go func() { process(v) }()   // pre-1.22: every goroutine sees the final v
}
// Fix on any version: pass it as an argument, go func(v Item){ process(v) }(v)

The generic version of this (shared mutable state, at least one writer, no ordering) is the definition of a race. If the interviewer pivots to mutex vs channel or the Coffman deadlock conditions, that is the language-agnostic layer.

Study Concurrency fundamentals


The runtime: why a million goroutines is fine

Follow-up "why can you spawn a million goroutines but not a million threads?" A goroutine starts with a ~2KB stack that grows by copying; an OS thread reserves 1 to 8MB. The Go runtime multiplexes many goroutines (G) onto a few OS threads (M) through scheduling contexts (P), whose count is GOMAXPROCS. That M:N model is why goroutine creation costs roughly 200ns instead of the tens of microseconds a thread costs.

Two senior probes hang off this. A blocking syscall does not freeze everything: the runtime detaches the blocked M's P and hands it to another M so the rest keep running. Network I/O does not even block a thread, because the netpoller parks the goroutine and wakes it when the socket is ready. That is how a Go server rides tens of thousands of live TCP connections on a handful of threads.

Red flags "a goroutine is an OS thread," "GOMAXPROCS is the goroutine limit" (it caps parallel P count, not goroutines), and "a blocking syscall blocks all goroutines."

Study Go runtime

The Kubernetes GOMAXPROCS trap

Follow-up "your Go service is throttled in its pod but CPU looks idle, why?" Historically GOMAXPROCS defaulted to NumCPU, the host core count, ignoring the cgroup CPU limit, so the scheduler ran more Ps than the CFS quota allowed and the kernel throttled it. Since Go 1.25 the default is container-aware and reads the cgroup limit. Knowing this connects the scheduler to real container deployment behavior, which is where senior backend rounds like to land.

Study Go runtime


Slices and interfaces: the value-semantics traps

When append aliases and when it reallocates

Trap a subslice shares the backing array until an append crosses capacity.

a := []int{1, 2, 3, 4}
b := a[1:3]          // len 2, cap 3, shares a's array
b[0] = 99            // a is now [1, 99, 3, 4]  (aliasing)
b = append(b, 100)   // still within cap: overwrites a[3], a becomes [1,99,3,100]
b = append(b, 200)   // crosses cap: reallocates, b now diverges from a

The grading criterion is whether you reason with len and cap and the reallocation boundary, not whether you guess the output. Same cluster: s = append(s, x) must reassign because append may return a new header, and a small subslice pins the whole backing array alive until you copy into a fresh one.

Study Go types & idioms

The typed-nil interface trap

This is the most common single Go trap. An interface value is a (type, value) pair. Return a nil *MyErr into an error interface and the type half is set, so the interface is not nil.

func do() error {
    var e *MyErr = nil
    return e          // WRONG: interface is (*MyErr, nil), which != nil
}
if do() != nil { /* this fires, even though the pointer was nil */ }

The fix is to return the interface type and a literal nil, or return nil directly on the success path. If the interviewer keeps going into errors.Is versus errors.As, %w wrapping, and whether an exported sentinel becomes part of your API contract, that is the senior extension of the same topic.

Study Go types & idioms


Where it shows up in design

The concurrency questions rarely stay academic. A rate limiter, a fan-out worker pool feeding a queue, a feed fanout: each one forces the same choices about bounded goroutines, backpressure through a buffered channel, and cancellation that actually propagates. Interviewers reuse those primitives inside backend system design, so the channel and leak answers above are also the vocabulary for the design round.

Red flags across the Go loop: treating a receive from a closed channel as a panic, calling a leaked goroutine garbage-collected, claiming buffered channels are just faster, and "fixing" the typed-nil trap by comparing the pointer to nil after it already escaped into an interface. Any one caps the score regardless of fluency.

The free notes span the junior floor to the senior internals across Go concurrency, Go runtime, and Go types & idioms. The diagnostic tells you which band you clear today.

was this useful?

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.