Skip to main content

Go Interview Preparation

This chapter covers the most common Go interview topics, coding challenges, and system design questions you’ll encounter.

Language Fundamentals

Common Interview Questions

TypeZero Value
int, float640
string"" (empty string)
boolfalse
pointer, slice, map, channel, function, interfacenil
structAll fields set to their zero values
arrayAll elements set to their zero values
Arrays:
  • Fixed size, part of the type: [5]int[10]int
  • Value type - copied when passed
  • Size known at compile time
Slices:
  • Dynamic size, backed by an array
  • Reference type - contains pointer to underlying array
  • Has length and capacity
  • Can grow with append()
arr := [3]int{1, 2, 3}  // Array
slc := []int{1, 2, 3}   // Slice
slc = append(slc, 4)    // Can grow
  • Deferred calls are executed in LIFO order when the function returns
  • Arguments are evaluated immediately, not when deferred call runs
  • Commonly used for cleanup (closing files, unlocking mutexes)
func example() {
    defer fmt.Println("third")
    defer fmt.Println("second")
    fmt.Println("first")
}
// Output: first, second, third

// Arguments evaluated immediately
x := 10
defer fmt.Println(x)  // Prints 10
x = 20
  • new(T): Allocates zeroed storage for type T, returns *T
  • make(T, args): Creates and initializes slices, maps, channels only
// new returns pointer to zeroed value
p := new(int)     // *int pointing to 0
s := new([]int)   // *[]int pointing to nil slice

// make initializes the type
slice := make([]int, 5, 10)  // len=5, cap=10
m := make(map[string]int)    // initialized map
ch := make(chan int, 10)     // buffered channel
interface{} (or any since Go 1.18) is the empty interface that all types implement. Used for:
  • Generic containers before generics
  • JSON unmarshaling
  • Printf-style functions
Requires type assertions to use:
func printValue(v any) {
    switch val := v.(type) {
    case int:
        fmt.Printf("int: %d\n", val)
    case string:
        fmt.Printf("string: %s\n", val)
    default:
        fmt.Printf("unknown: %v\n", val)
    }
}
Methods have a receiver - they’re bound to a type:
// Function
func Add(a, b int) int {
    return a + b
}

// Method with value receiver
func (c Calculator) Add(a, b int) int {
    return a + b
}

// Method with pointer receiver (can modify receiver)
func (c *Calculator) SetPrecision(p int) {
    c.precision = p  // Modifies the actual instance
}
Use pointer receivers when:
  • You need to modify the receiver
  • The receiver is large (avoid copying)
  • Consistency with other methods on the type
  • Go uses garbage collection (concurrent, tri-color mark-and-sweep)
  • Stack: Local variables, function calls (fast, automatic)
  • Heap: Escaped variables, dynamically sized data (GC managed)
  • Escape analysis determines stack vs heap allocation
# Check escape analysis
go build -gcflags="-m" ./...

Concurrency Questions

Goroutines:
  • Lightweight (2KB initial stack)
  • Managed by Go runtime
  • M:N scheduling (many goroutines on few OS threads)
  • Fast context switching
  • Can have thousands running
OS Threads:
  • Heavy (1-2MB stack)
  • Managed by OS kernel
  • Expensive context switches
  • Limited by OS resources
Channels are typed conduits for communication between goroutines:
// Unbuffered - synchronous
ch := make(chan int)
go func() { ch <- 42 }()  // Blocks until received
val := <-ch               // Blocks until sent

// Buffered - asynchronous up to capacity
ch := make(chan int, 10)
ch <- 1  // Doesn't block (buffer not full)

// Directional channels
func send(ch chan<- int) { ch <- 1 }    // Send-only
func recv(ch <-chan int) { <-ch }       // Receive-only

// Closing channels
close(ch)
val, ok := <-ch  // ok is false if closed
Data race occurs when multiple goroutines access shared data concurrently with at least one write.Prevention methods:
// 1. Mutex
var mu sync.Mutex
mu.Lock()
counter++
mu.Unlock()

// 2. Channels (share by communicating)
updates := make(chan int)
go func() {
    for val := range updates {
        counter = val
    }
}()

// 3. Atomic operations
var counter atomic.Int64
counter.Add(1)

// 4. sync.Map for concurrent map access
var m sync.Map
m.Store("key", "value")

// Detect races
go run -race main.go
go test -race ./...
select waits on multiple channel operations:
select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case ch2 <- value:
    fmt.Println("sent to ch2")
case <-time.After(time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("no channel ready")
}
  • Blocks until one case can proceed
  • Random selection if multiple ready
  • default makes it non-blocking
Context carries deadlines, cancellation signals, and request-scoped values:
// Cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Values
ctx = context.WithValue(ctx, "userID", "123")

// Check cancellation
select {
case <-ctx.Done():
    return ctx.Err()
default:
    // continue work
}
Goroutine leaks occur when goroutines are blocked forever. Prevention:
// Always use context for cancellation
func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return  // Clean exit
        default:
            doWork()
        }
    }
}

// Close channels to signal completion
func producer(done <-chan struct{}) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; ; i++ {
            select {
            case <-done:
                return
            case out <- i:
            }
        }
    }()
    return out
}

// Monitor goroutine count
runtime.NumGoroutine()

Coding Challenges

Implement a LRU Cache

type LRUCache struct {
    capacity int
    cache    map[int]*Node
    head     *Node
    tail     *Node
}

type Node struct {
    key, value int
    prev, next *Node
}

func Constructor(capacity int) LRUCache {
    head := &Node{}
    tail := &Node{}
    head.next = tail
    tail.prev = head
    
    return LRUCache{
        capacity: capacity,
        cache:    make(map[int]*Node),
        head:     head,
        tail:     tail,
    }
}

func (c *LRUCache) Get(key int) int {
    if node, ok := c.cache[key]; ok {
        c.moveToFront(node)
        return node.value
    }
    return -1
}

func (c *LRUCache) Put(key, value int) {
    if node, ok := c.cache[key]; ok {
        node.value = value
        c.moveToFront(node)
        return
    }
    
    node := &Node{key: key, value: value}
    c.cache[key] = node
    c.addToFront(node)
    
    if len(c.cache) > c.capacity {
        removed := c.removeLast()
        delete(c.cache, removed.key)
    }
}

func (c *LRUCache) addToFront(node *Node) {
    node.prev = c.head
    node.next = c.head.next
    c.head.next.prev = node
    c.head.next = node
}

func (c *LRUCache) remove(node *Node) {
    node.prev.next = node.next
    node.next.prev = node.prev
}

func (c *LRUCache) moveToFront(node *Node) {
    c.remove(node)
    c.addToFront(node)
}

func (c *LRUCache) removeLast() *Node {
    node := c.tail.prev
    c.remove(node)
    return node
}

Implement Rate Limiter

type RateLimiter struct {
    tokens   float64
    capacity float64
    rate     float64
    lastTime time.Time
    mu       sync.Mutex
}

func NewRateLimiter(rate float64, capacity float64) *RateLimiter {
    return &RateLimiter{
        tokens:   capacity,
        capacity: capacity,
        rate:     rate,
        lastTime: time.Now(),
    }
}

func (r *RateLimiter) Allow() bool {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    now := time.Now()
    elapsed := now.Sub(r.lastTime).Seconds()
    r.lastTime = now
    
    // Refill tokens
    r.tokens += elapsed * r.rate
    if r.tokens > r.capacity {
        r.tokens = r.capacity
    }
    
    if r.tokens >= 1 {
        r.tokens--
        return true
    }
    return false
}

Implement Worker Pool

type Job func() error

type WorkerPool struct {
    jobs    chan Job
    results chan error
    wg      sync.WaitGroup
}

func NewWorkerPool(workers int, queueSize int) *WorkerPool {
    p := &WorkerPool{
        jobs:    make(chan Job, queueSize),
        results: make(chan error, queueSize),
    }
    
    for i := 0; i < workers; i++ {
        go p.worker()
    }
    
    return p
}

func (p *WorkerPool) worker() {
    for job := range p.jobs {
        p.results <- job()
        p.wg.Done()
    }
}

func (p *WorkerPool) Submit(job Job) {
    p.wg.Add(1)
    p.jobs <- job
}

func (p *WorkerPool) Wait() {
    p.wg.Wait()
    close(p.results)
}

func (p *WorkerPool) Close() {
    close(p.jobs)
}

func (p *WorkerPool) Results() <-chan error {
    return p.results
}

Implement Concurrent Map

const shardCount = 32

type ConcurrentMap struct {
    shards [shardCount]*mapShard
}

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

func NewConcurrentMap() *ConcurrentMap {
    m := &ConcurrentMap{}
    for i := 0; i < shardCount; i++ {
        m.shards[i] = &mapShard{items: make(map[string]interface{})}
    }
    return m
}

func (m *ConcurrentMap) getShard(key string) *mapShard {
    hash := fnv32(key)
    return m.shards[hash%shardCount]
}

func (m *ConcurrentMap) Set(key string, value interface{}) {
    shard := m.getShard(key)
    shard.mu.Lock()
    shard.items[key] = value
    shard.mu.Unlock()
}

func (m *ConcurrentMap) Get(key string) (interface{}, bool) {
    shard := m.getShard(key)
    shard.mu.RLock()
    val, ok := shard.items[key]
    shard.mu.RUnlock()
    return val, ok
}

func (m *ConcurrentMap) Delete(key string) {
    shard := m.getShard(key)
    shard.mu.Lock()
    delete(shard.items, key)
    shard.mu.Unlock()
}

func fnv32(key string) uint32 {
    hash := uint32(2166136261)
    for i := 0; i < len(key); i++ {
        hash *= 16777619
        hash ^= uint32(key[i])
    }
    return hash
}

System Design Topics

Design a URL Shortener

type URLShortener struct {
    store    map[string]string  // shortCode -> longURL
    counter  atomic.Uint64
    mu       sync.RWMutex
}

func (s *URLShortener) Shorten(longURL string) string {
    id := s.counter.Add(1)
    shortCode := base62Encode(id)
    
    s.mu.Lock()
    s.store[shortCode] = longURL
    s.mu.Unlock()
    
    return shortCode
}

func (s *URLShortener) Resolve(shortCode string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    url, ok := s.store[shortCode]
    return url, ok
}

func base62Encode(num uint64) string {
    const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    if num == 0 {
        return "0"
    }
    
    var result []byte
    for num > 0 {
        result = append([]byte{alphabet[num%62]}, result...)
        num /= 62
    }
    return string(result)
}
Scalability considerations:
  • Distributed ID generation (Snowflake, UUID)
  • Database sharding by short code prefix
  • Caching (Redis) for hot URLs
  • Rate limiting per user/IP

Design a Pub/Sub System

type Message struct {
    Topic   string
    Payload []byte
}

type Broker struct {
    mu          sync.RWMutex
    subscribers map[string][]chan Message
}

func NewBroker() *Broker {
    return &Broker{
        subscribers: make(map[string][]chan Message),
    }
}

func (b *Broker) Subscribe(topic string, bufSize int) <-chan Message {
    ch := make(chan Message, bufSize)
    
    b.mu.Lock()
    b.subscribers[topic] = append(b.subscribers[topic], ch)
    b.mu.Unlock()
    
    return ch
}

func (b *Broker) Publish(msg Message) {
    b.mu.RLock()
    subs := b.subscribers[msg.Topic]
    b.mu.RUnlock()
    
    for _, ch := range subs {
        select {
        case ch <- msg:
        default:
            // Channel full, drop or log
        }
    }
}

func (b *Broker) Unsubscribe(topic string, ch <-chan Message) {
    b.mu.Lock()
    defer b.mu.Unlock()
    
    subs := b.subscribers[topic]
    for i, sub := range subs {
        if sub == ch {
            b.subscribers[topic] = append(subs[:i], subs[i+1:]...)
            close(sub)
            break
        }
    }
}

Performance Questions

# CPU profiling
go test -cpuprofile=cpu.prof -bench=.
go tool pprof cpu.prof

# Memory profiling
go test -memprofile=mem.prof -bench=.
go tool pprof mem.prof

# HTTP pprof
import _ "net/http/pprof"
go func() { http.ListenAndServe(":6060", nil) }()

# Trace
go test -trace=trace.out
go tool trace trace.out
  • Pre-allocate slices: make([]T, 0, expectedSize)
  • Use sync.Pool for temporary objects
  • Avoid string concatenation in loops (use strings.Builder)
  • Reuse buffers
  • Use value types instead of pointers when appropriate
  • Align struct fields properly
// Check allocations in benchmarks
func BenchmarkExample(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        // ...
    }
}
Escape analysis determines whether a variable can stay on the stack or must “escape” to the heap:
// Stack allocation
func stack() int {
    x := 42
    return x  // Value copied, x stays on stack
}

// Heap allocation (escapes)
func heap() *int {
    x := 42
    return &x  // Pointer escapes, x allocated on heap
}

// Check escape decisions
go build -gcflags="-m" ./...
Stack is faster (no GC), heap is necessary for escaped values.

Best Practices Checklist

Code Quality

  • Use go fmt and go vet
  • Run golangci-lint
  • Write table-driven tests
  • Use meaningful variable names
  • Keep functions small and focused
  • Handle all errors
  • Use interfaces for dependencies

Concurrency

  • Prefer channels for communication
  • Always use context for cancellation
  • Run race detector (-race)
  • Close channels from sender side
  • Use sync.WaitGroup for goroutine synchronization

Performance

  • Profile before optimizing
  • Pre-allocate slices when size is known
  • Use sync.Pool for frequent allocations
  • Avoid defer in hot loops
  • Use buffered I/O

Production

  • Structured logging
  • Health checks
  • Graceful shutdown
  • Configuration management
  • Metrics and monitoring
  • Proper error handling with stack traces

Quick Reference Card

// Slice operations
s := make([]int, 0, 10)  // len=0, cap=10
s = append(s, 1, 2, 3)   // Append elements
copy(dest, src)          // Copy slices

// Map operations
m := make(map[string]int)
m["key"] = 1
val, ok := m["key"]  // Check existence
delete(m, "key")     // Delete key

// Channel operations
ch := make(chan int)      // Unbuffered
ch := make(chan int, 10)  // Buffered
ch <- val                 // Send
val := <-ch              // Receive
close(ch)                // Close

// Concurrency primitives
var mu sync.Mutex
var rmu sync.RWMutex
var wg sync.WaitGroup
var once sync.Once
var pool sync.Pool

// Context
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
ctx, cancel := context.WithCancel(ctx)
ctx = context.WithValue(ctx, key, val)

// Error handling
if err != nil {
    return fmt.Errorf("operation failed: %w", err)
}
errors.Is(err, target)
errors.As(err, &target)

Summary

Mastering Go for interviews requires:
  1. Strong fundamentals: Types, interfaces, error handling
  2. Concurrency expertise: Goroutines, channels, sync primitives
  3. Performance awareness: Profiling, memory management
  4. Production experience: Logging, config, deployment
  5. Coding practice: LeetCode-style problems in Go
  6. System design: Distributed systems patterns
Good luck with your interviews! 🚀