Question Banks

Go Coding & Output

15 Go interview questions on goroutines, channels, interfaces, concurrency, and common gotchas

Q1: Goroutine Leak Detection

Problem: Identify the goroutine leak and fix it.

// BUGGY: goroutine never returns
func fetchData() {
    ch := make(chan string)
    go func() {
        // Simulating work that might hang
        result := longRunningCall()
        ch <- result
    }()
    select {
    case r := <-ch:
        fmt.Println(r)
    case <-time.After(1 * time.Second):
        fmt.Println("timeout")
        // goroutine still blocked on ch <- result!
    }
}

Answer: The goroutine leaks because after timeout, nobody reads from ch. Fix with context cancellation:

func fetchData(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
    defer cancel()

    ch := make(chan string, 1) // buffered so goroutine won't block
    go func() {
        result := longRunningCall()
        select {
        case ch <- result:
        case <-ctx.Done():
            return // exit cleanly
        }
    }()

    select {
    case r := <-ch:
        fmt.Println(r)
    case <-ctx.Done():
        fmt.Println("timeout")
    }
}

Key Insight: Always provide goroutines an exit path. Use buffered channels or context cancellation.

Follow-up: How would you detect goroutine leaks in tests? → Use goleak package from Uber.


Q2: Channel Deadlock

Problem: Why does this deadlock?

func main() {
    ch := make(chan int)
    ch <- 42 // blocks forever: no receiver
    fmt.Println(<-ch)
}

Answer: Unbuffered channels block the sender until a receiver is ready. Since send and receive are in the same goroutine sequentially, the send blocks forever. Runtime detects all goroutines sleeping → fatal error: all goroutines are asleep - deadlock!

Fix: Either use a buffered channel make(chan int, 1) or put the send in a separate goroutine.

func main() {
    ch := make(chan int)
    go func() { ch <- 42 }()
    fmt.Println(<-ch) // 42
}

Follow-up: What's the difference between make(chan int) and make(chan int, 0)? → Identical. Both are unbuffered.


Q3: Slice Append Gotcha

Problem: What does this print?

func main() {
    a := make([]int, 0, 5)
    a = append(a, 1, 2, 3)
    b := append(a, 4)
    c := append(a, 5)
    fmt.Println(b[3], c[3]) // ?
}

Answer: Prints 5 5. Since a has capacity 5 and length 3, both append(a, 4) and append(a, 5) write to the same underlying array index 3. The second append overwrites the first.

Fix: Use full slice expression to limit capacity: b := append(a[:len(a):len(a)], 4)

Key Insight: Slices share underlying arrays when capacity allows. Use a[low:high:max] three-index slice to restrict capacity.


Q4: Interface Nil Check Trap

Problem: Why does this NOT print "nil"?

type MyError struct {
    Msg string
}
func (e *MyError) Error() string { return e.Msg }

func getError() error {
    var err *MyError = nil
    return err // returns (*MyError)(nil), not nil interface
}

func main() {
    if err := getError(); err != nil {
        fmt.Println("not nil!") // This prints!
    }
}

Answer: An interface in Go is a (type, value) pair. Returning a typed nil pointer wraps it as (*MyError, nil) which is NOT a nil interface. A nil interface has both type and value as nil.

Fix: Return the interface type directly:

func getError() error {
    var err *MyError = nil
    if err == nil {
        return nil // returns nil interface
    }
    return err
}

Q5: Defer Evaluation Timing

Problem: What does this print?

func main() {
    for i := 0; i < 3; i++ {
        defer fmt.Println(i)
    }
}

Answer: Prints 2 1 0. Defers execute LIFO. The argument i is evaluated at defer time (not execution time), so values 0, 1, 2 are captured. LIFO order → 2, 1, 0.

Contrast with closure:

for i := 0; i < 3; i++ {
    defer func() { fmt.Println(i) }() // Prints 3 3 3
}

Key Insight: Defer arguments are evaluated immediately. Closures capture variables by reference.


Q6: sync.WaitGroup Misuse

Problem: Why does this race/crash?

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        go func(n int) {
            wg.Add(1) // BUG: Add inside goroutine
            defer wg.Done()
            fmt.Println(n)
        }(i)
    }
    wg.Wait() // might return before all goroutines start
}

Answer: wg.Add(1) must be called before the goroutine starts (in the parent). If Wait() is called before a goroutine has called Add, it may return early.

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1) // Correct: Add before launching goroutine
        go func(n int) {
            defer wg.Done()
            fmt.Println(n)
        }(i)
    }
    wg.Wait()
}

Q7: Map Concurrent Access Panic

Problem: This program crashes. Why?

func main() {
    m := make(map[string]int)
    go func() {
        for { m["a"]++ }
    }()
    go func() {
        for { m["b"]++ }
    }()
    time.Sleep(time.Second)
}

Answer: Go maps are NOT safe for concurrent use. Concurrent writes cause fatal error: concurrent map writes. This is detected at runtime (not a race condition it's a hard crash).

Fix: Use sync.Mutex or sync.Map:

var mu sync.Mutex
mu.Lock()
m["a"]++
mu.Unlock()

// Or use sync.Map for simple cases:
var sm sync.Map
sm.Store("a", 1)

Follow-up: When is sync.Map better than map + Mutex? → When keys are stable (read-heavy) or disjoint goroutines own disjoint keys.


Q8: Context Cancellation Propagation

Problem: Show how parent context cancellation propagates to children.

func main() {
    parent, cancelParent := context.WithCancel(context.Background())
    child, _ := context.WithCancel(parent)

    go func() {
        <-child.Done()
        fmt.Println("child cancelled:", child.Err())
    }()

    cancelParent() // cancels parent AND child
    time.Sleep(100 * time.Millisecond)
}

Answer: Prints child cancelled: context canceled. Cancelling a parent context automatically cancels all derived child contexts. This is the core of Go's context tree.

Key Rules:

  • Parent cancel → all children cancelled
  • Child cancel → parent NOT affected
  • Always call cancel function (even if context expires) to release resources

Q9: Select with Default (Non-blocking Read)

Problem: Implement a non-blocking channel read.

func tryReceive(ch <-chan int) (int, bool) {
    select {
    case v := <-ch:
        return v, true
    default:
        return 0, false // non-blocking: returns immediately if ch empty
    }
}

func main() {
    ch := make(chan int, 1)
    ch <- 42

    v, ok := tryReceive(ch)
    fmt.Println(v, ok) // 42 true

    v, ok = tryReceive(ch)
    fmt.Println(v, ok) // 0 false
}

Key Insight: select with default makes channel operations non-blocking. Without default, select blocks until one case is ready.

Follow-up: How does select choose when multiple cases are ready? → Uniformly random selection (prevents starvation).


Q10: Error Wrapping and Unwrapping

Problem: Explain the difference between errors.Is and type assertion for error checking.

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

func fetchUser(id int) error {
    return fmt.Errorf("fetchUser: %w", ErrNotFound) // wraps
}

func main() {
    err := fetchUser(1)

    // errors.Is: unwraps chain to match sentinel
    fmt.Println(errors.Is(err, ErrNotFound)) // true

    // Direct comparison fails on wrapped errors:
    fmt.Println(err == ErrNotFound) // false

    // errors.As: unwraps to match type
    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println(pathErr.Path)
    }
}

Answer: errors.Is traverses the wrapping chain checking value equality. errors.As traverses the chain checking type assignability. Direct == doesn't unwrap.

Rule: Always use %w verb to wrap errors. Use errors.Is for sentinels, errors.As for typed errors.


Q11: Mutex vs RWMutex Performance

Problem: When should you use sync.RWMutex over sync.Mutex?

type SafeCache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *SafeCache) Get(key string) (string, bool) {
    c.mu.RLock()         // multiple readers allowed
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *SafeCache) Set(key, value string) {
    c.mu.Lock()          // exclusive write lock
    defer c.mu.Unlock()
    c.items[key] = value
}

Answer:

  • sync.Mutex: Use when reads and writes are roughly equal. Simpler, less overhead per operation.
  • sync.RWMutex: Use when reads vastly outnumber writes (10:1+). Multiple goroutines can hold RLock simultaneously.
  • Caveat: RWMutex has higher per-operation overhead. If contention is low, plain Mutex may be faster.

Q12: Channel Direction Types

Problem: Explain send-only and receive-only channel types.

// producer: can only send
func produce(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

// consumer: can only receive
func consume(ch <-chan int) {
    for v := range ch {
        fmt.Println(v)
    }
}

func main() {
    ch := make(chan int, 5)
    go produce(ch)
    consume(ch)
}

Answer: Directional channels enforce compile-time safety. A bidirectional chan int implicitly converts to chan<- int (send-only) or <-chan int (receive-only). You cannot close a receive-only channel.

Best Practice: Use directional types in function signatures to document intent and prevent bugs.


Q13: Struct Embedding and Method Promotion

Problem: What does this print?

type Logger interface {
    Log(msg string)
}

type ConsoleLogger struct{"}
func (ConsoleLogger) Log(msg string) { fmt.Println("LOG:", msg) }

type Service struct {
    ConsoleLogger // embedded: promotes Log method
}

func main() {
    var s Service
    s.Log("hello") // promoted method

    var l Logger = s // Service satisfies Logger via embedding
    l.Log("world")
}

Answer: Prints LOG: hello then LOG: world. Embedding promotes all methods of the embedded type. Service implicitly satisfies the Logger interface without explicitly implementing it.

Gotcha: If Service also defines its own Log method, it shadows the embedded one.


Q14: init() Function Ordering

Problem: In what order do init functions run?

// file: a.go (package main)
func init() { fmt.Println("a.go init") }

// file: b.go (package main)
func init() { fmt.Println("b.go init 1") }
func init() { fmt.Println("b.go init 2") }

// file: main.go
import _ "myapp/config" // config's init runs first

func init() { fmt.Println("main.go init") }
func main() { fmt.Println("main") }

Answer: Order rules:

  1. Imported packages' init functions run first (dependency order)
  2. Within a package, files are processed in alphabetical order
  3. Within a file, multiple init functions run in declaration order
  4. Package-level variables are initialized before init()

Output: config init → a.go init → b.go init 1 → b.go init 2 → main.go init → main

Best Practice: Avoid init() for complex logic. Prefer explicit initialization functions for testability.


Q15: Goroutine Pool Pattern (Worker Pool)

Problem: Implement a worker pool with bounded concurrency.

func workerPool(jobs []int, numWorkers int) []int {
    jobCh := make(chan int, len(jobs))
    resultCh := make(chan int, len(jobs))

    // Start fixed number of workers
    var wg sync.WaitGroup
    for w := 0; w < numWorkers; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobCh {
                resultCh <- process(job)
            }
        }()
    }

    // Send jobs
    for _, j := range jobs {
        jobCh <- j
    }
    close(jobCh)

    // Wait for workers then close results
    go func() {
        wg.Wait()
        close(resultCh)
    }()

    // Collect results
    var results []int
    for r := range resultCh {
        results = append(results, r)
    }
    return results
}

func process(n int) int {
    time.Sleep(100 * time.Millisecond) // simulate work
    return n * 2
}

Key Design Points:

  • Fixed goroutines prevent unbounded concurrency
  • Buffered job channel acts as a queue
  • Closing jobCh signals workers to exit after draining
  • WaitGroup ensures all workers finish before closing resultCh

Follow-up: How would you add graceful shutdown with context? → Pass ctx to workers, select on ctx.Done() in the work loop.