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:
- Imported packages' init functions run first (dependency order)
- Within a package, files are processed in alphabetical order
- Within a file, multiple init functions run in declaration order
- 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.