Interview & Career • 11 min read • 10/05/2026

Deep Technical Go Interviews: The Traps That Separate Senior from Middle

Bài này có bản tiếng Việt. Đọc bản tiếng Việt →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

An interview conversation at a table
Photo: Tennessee Titans · CC BY 4.0

In a Go interview for a senior role, I almost never ask “what is a channel”. The real question is: what does this code print, and why did it take production down at 2 a.m. A middle-level engineer gets the mechanism right and stops. A senior has to go two steps further. One, which layer of the runtime or compiler that behavior comes from. Two, what kind of incident it turns into once 5,000 requests per second are flowing through it.

1. Typed nil in an interface: nil that isn’t nil

The question. A function returns “nil” when nothing is found, but the caller reports err != nil. Explain.

type MyError struct{ Code int }

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

func lookup(id string) error {
	var p *MyError // nil, but its type is *MyError
	if id == "" {
		return p // this is the root of the problem
	}
	return &MyError{Code: 404}
}

fmt.Println(lookup("") == nil) // false
var target *MyError
fmt.Println(errors.As(lookup(""), &target), target == nil) // true true
flowchart TB
  P["p := (*MyError)(nil)<br/>nil pointer"] --> I["var err error = p"]
  subgraph IF["error interface value"]
    T["type = *MyError"]
    V["value = nil"]
  end
  I --> IF
  IF --> Q{"err == nil ?"}
  Q -- "false ← this is the trap" --> W["The function returns a NON-nil error<br/>even though the pointer inside is nil"]

What actually happens. An interface value is a (type, value) pair, and comparing it with nil is only true when both halves are empty. Assigning a nil pointer to an interface fills in the type half, so that interface is not nil. The second half is the dangerous one: errors.As still matches on type, successfully assigns a nil pointer, and then the caller dereferences it. Since Error() is defined on *MyError, only *MyError satisfies error.

Why a senior has to know this. err != nil is the most common guard in Go code, and typed nil disables it on exactly the error branch that happy-path tests never touch. The fix is simple: don’t return p with a pointer that might be nil. Write return nil explicitly.

2. Slice aliasing: append writes into someone else’s backing array

The question. What does a print, and if you pass a slice into a function and append inside it, does the caller see anything?

a := []int{1, 2, 3, 4}

b := a[:2]             // len 2, cap 4 — shares the backing array
b = append(b, 9)       // there is still cap, so it writes straight into a[2]
fmt.Println(a)         // [1 2 9 4]

b = append(b, 7, 7, 7) // exceeds cap → allocates a new array, a is unchanged

c := a[:2:2]           // full slice expression: cap = 2
c = append(c, 99)      // always allocates a new array

func mutate(s []int) {
	s[0] = 99        // caller sees it: same backing array
	s = append(s, 7) // the caller's len doesn't change
}

What actually happens. A slice is a three-field header (pointer, len, cap), passed by value. But the pointer points at a shared backing array, so writing to an element is visible on both sides. append only allocates a new array when it exceeds cap. When there is room, it writes straight into the backing array and bumps the len of its own copy of the header. The caller doesn’t see the new len, but the cell at the old len has already been overwritten. “append doesn’t change the caller’s len” is true. “append doesn’t affect the caller” is false. To isolate completely, use the full slice expression a[:2:2], or slices.Clone.

Why a senior has to know this. This bug produces wrong data without crashing. A buffer reused inside a loop, a parser holding a sub-slice of a big array, a “read-only” function overwriting the caller’s data. No stack trace points at the culprit.

3. Loop variables before and after Go 1.22: look at the go line in go.mod

The question. What does this code print, and what does the answer depend on?

funcs := make([]func(), 3)
for i := 0; i < 3; i++ {
	funcs[i] = func() { fmt.Print(i) }
}
for _, f := range funcs {
	f()
}
// go 1.21: 333
// go 1.22+: 012

What actually happens. Before 1.22, i is declared once and shared by every iteration. Closures capture the variable, not the value, so all three closures read the same memory cell in its final state. The same goes for the value variable of for range, which brings along the &v trap where every iteration returns the same pointer. From Go 1.22, the compiler creates a fresh variable per iteration, for both forms of the loop. Here is where many people answer wrong: the new behavior is gated by the language version in go.mod, not by the toolchain. A module that declares go 1.21 still runs the old semantics even when built with Go 1.24.

Why a senior has to know this. Two consequences pulling in opposite directions. Old code still has to be fixed, because it doesn’t get the new behavior. And bumping the go line in a large repo changes the semantics of thousands of loops in a single commit.

4. Channels: nil, closed, and random select

The question. What do these four situations do: a nil channel, a closed channel, and select when several cases are ready at once?

var nilCh chan int
// nilCh <- 1   → blocks forever
// <-nilCh      → blocks forever
// select { case <-nilCh: } → this case is never chosen

ch := make(chan int, 1)
ch <- 1
close(ch)
fmt.Println(<-ch, <-ch) // 1 0 — receives the zero value immediately, no block
for range ch {          // exits immediately
}
ch <- 2 // panic: send on closed channel

done := make(chan struct{})
close(done)
tick := make(chan struct{})
close(tick)
for i := 0; i < 6; i++ {
	select {
	case <-done:
		fmt.Print("d")
	case <-tick:
		fmt.Print("t")
	}
}
// example: dttdtd — both cases are always ready, picked uniformly at random

What actually happens. close doesn’t delete the channel. It’s a broadcast: it marks that no more values will be sent. That is why the sender is the one who should close, and why every receiver gets the zero value immediately instead of blocking. This is what lets for range ch terminate correctly. But for { v := <-ch } spins forever if you forget to check ok. A nil channel blocks forever, and that’s a feature: a case on a nil channel inside select is effectively disabled. Finally, select picks uniformly at random among the ready cases, with no priority at all. If you want priority, build it yourself in two layers: a select with a default that tries first, then falls through to a blocking select.

Why a senior has to know this. Sending on a closed channel is a panic that kills the whole process unless recovered, and it usually comes from a goroutine that closes the channel several layers away. A silent zero value is just as dangerous. A consumer receives 0 as a temperature and processes it as real data.

5. Concurrent map access: a throw, not a panic

The question. Where does this code die, and can recover save it?

m := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		for j := 0; j < 100_000; j++ {
			m[fmt.Sprint(id, ":", j)] = j
		}
	}(i)
}
wg.Wait()
// usually: fatal error: concurrent map writes

What actually happens. A map has no protection whatsoever against concurrent access. The runtime sets an internal flag during a write; when two goroutines write at the same time it gets detected and the runtime calls throw, not panic. throw means the process dies immediately with a stack dump of every goroutine, and recover can’t catch it. Every in-flight request is lost. Worse, detection is best-effort: some overlapping accesses don’t trip the flag, and the map’s structure gets corrupted silently. sync.Map is only optimized for two access patterns: entries written once and read many times, or goroutines working on disjoint sets of keys. It has no multi-key transactions. My default: a plain map wrapped in a sync.RWMutex.

Why a senior has to know this. All it takes is one extra goroutine added somewhere completely different: counting, caching, a metrics registry. Running -race in tests is the earliest way to catch it.

6. sync.Mutex is not reentrant: it hangs, it doesn’t panic

The question. The service hangs without panicking, CPU is near 0, and pprof shows every goroutine parked at sync.(*Mutex).Lock. What happened?

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Add() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
	c.Reset() // Reset also calls Lock → self-deadlock
}

func (c *Counter) Reset() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n = 0
}

What actually happens. sync.Mutex doesn’t track which goroutine owns it and has no recursion counter. A goroutine that already holds the lock and calls Lock again on the same mutex blocks itself. No layer detects this as “recursion” and lets it through. On top of that, Go only prints “all goroutines are asleep - deadlock!” when every goroutine is blocked. An HTTP server that still has a goroutine waiting on accept keeps the whole thing quiet: a few requests time out, no panic. Few people know this: sync.RWMutex doesn’t have a recursive read lock either, and nested RLock calls can deadlock when a writer is waiting.

Why a senior has to know this. The fix is to split functions, not to change the lock type: the public API keeps Lock, and internal methods call each other through ...Locked() variants. And because the symptom is a hang rather than a crash, you need a metric for blocked goroutines, not just alerts on error rate.

7. Goroutine leak: the function returned, the goroutine didn’t

The question. This function returns the timeout error on time, so are its resources released?

func fetch(ctx context.Context) ([]Item, error) {
	ch := make(chan []Item) // unbuffered
	go func() {
		items, err := db.Query(context.Background()) // ignores ctx
		if err != nil {
			return
		}
		ch <- items // nobody is receiving anymore → blocks forever
	}()

	select {
	case items := <-ch:
		return items, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

// fix: make(chan []Item, 1) → the send never blocks,
// or inside the goroutine use select { case ch <- items: case <-ctx.Done(): }

What actually happens. The caller leaves through the ctx.Done() branch, but the goroutine inside stays parked at ch <- items forever. The GC doesn’t reclaim goroutines: a goroutine is a live object of the scheduler, holding its stack, closures, and references to DB connections and large buffers in scope. A context only provides a cancellation mechanism. Honoring it is voluntary cooperation, so every point that can block has to select on ctx.Done(). Detection comes in two parts. runtime.NumGoroutine measured over time: the absolute number means nothing, a steady upward trend after warmup is the signal. And a goroutine profile via pprof to group the stacks that are blocked.

The path of this leak is short, and a picture is quicker than the code:

sequenceDiagram
  participant C as Caller
  participant G as Goroutine
  participant D as DB
  C->>G: go fetch(ctx)
  G->>D: db.Query(context.Background())
  D-->>G: items
  G->>G: send items into the channel (unbuffered)
  C->>C: select takes the ctx.Done() branch, then returns
  Note over G: nobody is receiving, the goroutine sits there forever

Why a senior has to know this. A goroutine leak doesn’t break one request. It slowly breaks the whole service: memory grows linearly, GC pressure rises, every other request gets slower. The deadliest variant is a leaked goroutine that is holding a connection from the pool. The pool runs dry, the whole system times out, even though the bug lives in a single endpoint.

8. defer in a loop: out of file descriptors at file 900

The question. A function scans 100,000 files and dies at file 900 with too many open files. Why?

func process(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // runs when process() returns, not on each iteration
		if err := scan(f); err != nil {
			return err
		}
	}
	return nil
}

// fix: one function per loop iteration
for _, p := range paths {
	if err := func() error {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close()
		return scan(f)
	}(); err != nil {
		return err
	}
}

What actually happens. defer attaches to the frame of the function, not to a block or a loop, and deferred calls run LIFO when the function returns. 100,000 iterations means 100,000 file descriptors left open. That number blows past ulimit -n (usually 1024) long before the loop ends. The same trap applies to connections and locks: resources held longer than needed. Since Go 1.14, open-coded defers make a defer nearly as cheap as a plain function call, so don’t give up defer to close things by hand. A related trap: defer log.Println(v) evaluates its arguments at the moment of the defer, so it was correct even before 1.22. But defer func() { log.Println(v) }() reads v when the function finishes, so before 1.22 every log line printed the same final value. And defer resp.Body.Close() when resp is nil panics right at return.

Why a senior has to know this. This bug shows up with data scale. A small test fixture never sees it.

9. time.After in a hot loop: timers pile up with QPS

The question. What is wrong with this loop, and what did Go 1.23 change?

for {
	select {
	case msg := <-msgs:
		handle(msg)
	case <-time.After(200 * time.Millisecond): // allocates on every iteration
		flush()
	}
}

// reuse a single timer
t := time.NewTimer(200 * time.Millisecond)
defer t.Stop()
for {
	select {
	case msg, ok := <-msgs:
		if !ok {
			return
		}
		handle(msg)
	case <-t.C:
		flush()
		t.Reset(200 * time.Millisecond)
	}
}

What actually happens. time.After creates a new timer along with a new channel on every call. Before Go 1.23, the runtime heap kept a reference to that timer until it fired, whether or not you ever received the value. In a loop where the msg branch is almost always the one taken, timers pile up, so memory grows with QPS. From 1.23, a timer that is no longer referenced can be garbage collected even if it hasn’t been Stopped, and the timer’s channel is now unbuffered, so Stop/Reset have stronger guarantees. This change landed in 1.23. On 1.21 or 1.22 that cost is still fully there. Even on 1.23, though, NewTimer plus Reset is still the right pattern in a hot loop, because it removes the per-iteration allocation entirely.

Why a senior has to know this. This cost never shows up in unit tests. It shows up in the allocation profile and in RSS growing with traffic. And for the timeout of one specific operation, the right API is context.WithTimeout, not a select with time.After.

10. context.WithValue, errors.Is and errors.As: comparing errors after wrapping

The question. When do you use WithValue, and why is err == ErrNotFound no longer true after wrapping?

type ctxKey struct{} // the key must be an unexported type, not a string

func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, ctxKey{}, id)
}

var ErrNotFound = errors.New("user: not found")

func (s *Store) LoadUser(ctx context.Context, id string) (*User, error) {
	row, err := s.db.QueryRow(ctx, q, id)
	if err != nil {
		return nil, fmt.Errorf("load user %s: %w", id, err) // %w preserves the chain
	}
	return row, nil
}

ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
ctx = WithRequestID(ctx, r.Header.Get("X-Request-ID"))

if errors.Is(err, ErrNotFound) { /* 404 */ }
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) { /* the driver's specific error code */ }

What actually happens. WithValue is for data tied to a single request: request id, tenant, the authenticated subject. It is not a bag for optional parameters. Each WithValue layer adds one more lookup for every Value call. defer cancel() is mandatory: skip it and you keep the timer and the whole child tree alive until the parent context ends (go vet has a lostcancel analyzer that catches this). Cancellation only propagates one way. Cancel the parent and the children are cancelled; cancelling a child doesn’t touch the parent. As for errors, == compares identity, so after a fmt.Errorf with %w, where a new error wraps the old one, a direct comparison fails. errors.Is follows Unwrap through the whole chain. errors.As pulls out the concrete type so you can read its fields. As for %v, it cuts the chain, and err.Error() == "..." breaks the logic if a single word changes.

Why a senior has to know this. Retries, circuit breakers and HTTP status mapping all depend on the kind of error: timeout, deadlock and unique violation need three different behaviors. Classify by string and a driver upgrade turns a correct retry into a wrong one without a single test going red. And if the lower layers don’t honor the context, a timeout is just decoration.

How I answer questions like these

Four steps, and the last one is the one many people skip. One, guess the output, stating your assumptions about the Go version and the go line in go.mod. Two, describe the mechanism at the lowest layer: an interface is a type and value pair, a slice is a header pointing into a backing array, select picks at random, throw is not panic. Three, point out the production failure mode: does it die quietly or loudly, does it depend on load or on ordering, does it get past happy-path tests. Four, say how you’d verify: a test for the error branch, the race detector, a profile.

None of the ten traps live on the happy path. They live in the error branch, in the hundred-thousandth iteration, in the second goroutine someone adds later. So my next question is usually: has your test reached that branch yet?

Need a developer for a contract or freelance project?

I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.