Prev Next

Golang / Golang Internals and Memory Management Interview Questions

1. What is the internal structure of a Go slice and how does it differ from an array? 2. How does append() work internally and what triggers a reallocation? 3. When does a variable get allocated on the stack versus the heap in Go? 4. How do goroutine stacks work and how do they grow in Go? 5. How does Go's garbage collector work? Explain the tri-color mark-and-sweep algorithm. 6. What are GOGC and GOMEMLIMIT and how do you use them to tune GC behavior? 7. How are Go maps implemented internally and what does that mean for performance? 8. When should you use sync.Map instead of a mutex-protected regular map? 9. How do pointers work in Go and when should you pass by pointer vs by value? 10. How does Go's goroutine scheduler work? Explain the GMP model. 11. How are Go channels implemented internally? 12. How are Go interfaces implemented internally and why do they matter for performance? 13. How does Go's memory allocator work? Explain mcache, mcentral, and mheap. 14. How are strings represented in Go and why are they immutable? 15. How does defer work internally in Go and what are its performance implications? 16. How do panic and recover work in Go and when should you use them? 17. How do you profile a Go application using pprof? 18. What is the Go race detector and how does it work? 19. What is the difference between sync.Mutex and sync.RWMutex and when do you use each? 20. When should you use channels versus mutexes in Go concurrency? 21. How do generics work in Go 1.18+ and how do they affect performance? 22. How does context.Context work and when do you use each context type? 23. What is the 'unsafe' package in Go and when is it used? 24. What is the nil interface pitfall in Go and how do you avoid it? 25. What are goroutine leaks and how do you detect and prevent them? 26. How does sync.WaitGroup work and what are common mistakes? 27. How do closures capture variables in Go and what is the classic goroutine loop bug? 28. How does struct embedding work in Go and how does it differ from inheritance? 29. How does error wrapping work in Go 1.13+ with errors.Is and errors.As? 30. How do you write and run benchmarks in Go? 31. How does struct field ordering affect memory layout and performance in Go? 32. How do you implement fan-out and fan-in patterns with Go goroutines? 33. How does the reflect package work in Go and when should it be used? 34. What is cgo in Go and what are its performance trade-offs? 35. What does GOMAXPROCS control and how does it affect Go's concurrency model? 36. How does sync.Once work and what are its use cases? 37. What are the most common memory leak patterns in Go and how do you diagnose them? 38. How do build constraints (build tags) work in Go? 39. How do io.Reader and io.Writer work and why are they fundamental to Go's I/O model? 40. What is the Go optimisation workflow? How do you go from a performance problem to a fix? 41. What is sync.Pool and when should you use it? 42. How do type assertions and type switches perform internally in Go? 43. How does Go's module system work and what is the role of go.sum? 44. How do you implement a worker pool in Go? 45. What static analysis tools are essential for a Go project and what does each check?
Could not find what you were looking for? send us the question and we would be happy to answer your question.

1. What is the internal structure of a Go slice and how does it differ from an array?

A Go array is a fixed-length, value-type sequence of elements stored contiguously in memory. Its length is part of its type: [5]int and [6]int are distinct types. Arrays are copied entirely when passed to functions.

A Go slice is a lightweight descriptor — a three-field struct that lives on the stack and points into an underlying array on the heap. The three fields are:

Slice Header Fields
FieldTypeMeaning
ptrunsafe.PointerPointer to the first element of the backing array
lenintNumber of elements accessible through the slice
capintTotal elements in the backing array from ptr onward
// Array — fixed, value type
arr := [5]int{1, 2, 3, 4, 5}
arrCopy := arr // full copy of all 5 ints

// Slice — descriptor pointing to arr's backing storage
s := arr[1:4]       // s.ptr = &arr[1], s.len = 3, s.cap = 4
fmt.Println(len(s), cap(s)) // 3  4

// Modifying through the slice modifies the backing array
s[0] = 99
fmt.Println(arr) // [1 99 3 4 5] — arr is mutated!

// Make allocates a fresh backing array
fresh := make([]int, 3, 6) // len=3, cap=6, new backing array

// Nil slice — zero value; all three fields are zero
var nilSlice []int
fmt.Println(nilSlice == nil, len(nilSlice)) // true 0

Because a slice is just a small header, passing a slice to a function is cheap — only the three-field struct is copied, not the underlying array. However, this means the function sees the same backing array and can mutate its elements. To avoid unintended sharing, use copy() or append() on a new slice.

What are the three fields in a Go slice header?
If s := arr[1:3] and you modify s[0], what happens to arr?
2. How does append() work internally and what triggers a reallocation?

append(s, elems...) adds elements to slice s. The critical behaviour depends on whether the backing array has spare capacity:

  • If len(s) + len(elems) <= cap(s): no new allocation. The elements are written directly into the existing backing array beyond s.len. The returned slice shares the same backing array as s but with an incremented len.
  • If capacity is exhausted: Go allocates a new, larger backing array, copies all existing elements, then appends the new ones. The returned slice points to the new array; the original backing array is now unreferenced (and eligible for GC).
s := make([]int, 3, 5) // len=3 cap=5 — room for 2 more
s2 := append(s, 10)    // fits in cap — no reallocation
// s and s2 SHARE the backing array until cap is exceeded

s3 := append(s2, 20, 30) // cap exceeded — new backing array allocated
// s, s2 still point to OLD array; s3 points to NEW array

// ALWAYS use the returned value of append
s = append(s, 99) // wrong to ignore the return — s might be outdated

// Growth strategy (Go 1.18+)
// cap < 256:  double (newcap = oldcap * 2)
// cap >= 256: grow ~25% + smooth correction to avoid thrashing

// Pre-allocate when the final size is known
names := make([]string, 0, 1000) // avoids N reallocations in a loop
for _, n := range rawNames {
    names = append(names, n)
}

The growth strategy changed in Go 1.18 from a simple doubling to a smoother formula: small slices (cap < 256) still double; larger slices grow by about 25% with a correction that blends the doubling and 25% rates. This avoids the cliff-edge behaviour at the transition point.

Hidden sharing trap: if you append to a sub-slice that still has spare capacity, the write goes into the original backing array, silently overwriting data seen by other slices sharing that array. Always use the three-index slice s[lo:hi:hi] to set cap equal to len when you want to guarantee a fresh allocation on the next append.

What happens when append() is called on a slice that has no remaining capacity?
What is the approximate growth factor for a Go slice with capacity below 256 when append triggers reallocation?
3. When does a variable get allocated on the stack versus the heap in Go?

Go does not expose manual heap allocation. Instead, the compiler uses escape analysis to decide, at compile time, whether each variable can live on the current goroutine's stack or must be moved (escape) to the heap.

Stack vs Heap in Go
AspectStackHeap
LifetimeFunction frame — freed on returnUntil GC collects (no references)
Allocation costNear zero (pointer bump)GC overhead, malloc-like
GC involvementNoneTracked by GC tri-color marking
Access speedFastest (CPU cache friendly)Slightly slower (pointer indirection)
SizeDefault 2 KB, grows dynamically to 1 GBLimited only by available RAM
// Does NOT escape — lives on stack (no reference escapes the function)
func sum(a, b int) int {
    result := a + b // stays on stack
    return result
}

// DOES escape — caller holds a pointer; Go must allocate on heap
func newCounter() *int {
    n := 0        // n escapes to heap — pointer outlives function frame
    return &n
}

// Escape via interface — any value stored in an interface box escapes
func logValue(v interface{}) { fmt.Println(v) }
x := 42
logValue(x) // x's copy escapes to heap because interface requires a pointer

// Slice backing arrays that are too large — compiler heuristic
big := make([]byte, 64*1024) // large allocation always goes to heap

// Inspect escape analysis
// go build -gcflags="-m" ./...
// go build -gcflags="-m -m" ./... (verbose, shows reason)
// Output lines like: './main.go:8:2: n escapes to heap'

Variables escape to the heap in the following common situations: the address is returned from the function; the variable is stored in a heap-allocated data structure (map, slice, interface); a closure captures it by reference; or the compiler's heuristics decide the stack is too small. Unnecessary heap allocations increase GC pressure — a hot path that constantly allocates small objects is a primary cause of GC-induced latency spikes.

Which command-line flag shows escape analysis decisions during Go compilation?
Why does returning a pointer to a locally declared variable force it to the heap?
4. How do goroutine stacks work and how do they grow in Go?

Every goroutine starts with a small stack — only 2 KB by default (as of Go 1.4). This is orders of magnitude smaller than an OS thread's typical 1–8 MB stack, which is why Go can run millions of goroutines concurrently.

Go uses a copy-on-grow (contiguous stack) strategy. When the runtime detects that the current frame needs more space than available (a stack-overflow check inserted at function entry points), it:

  1. Allocates a new, larger stack (typically double the current size).
  2. Copies the entire old stack to the new location.
  3. Updates all pointers that referred into the old stack (pointer fixup).
  4. Frees the old stack.
// Goroutines start with 2 KB stack — very cheap to spawn
for i := 0; i < 1_000_000; i++ {
    go func(id int) {
        // Each goroutine starts with a 2 KB stack
        doWork(id)
    }(i)
}

// Deeply recursive function — stack grows automatically
func fib(n int) int {
    if n <= 1 { return n }
    return fib(n-1) + fib(n-2) // stack grows as needed, up to GOMAXSTACKS
}

// Maximum goroutine stack size (configurable via GOTRACEBACK env)
// Default maximum is 1 GB

// Stack size at goroutine creation is visible in stack traces:
// goroutine 1 [running]:
// main.main()
//     /path/main.go:10 +0x... [stack: 2048]

// runtime.Stack() for diagnostics
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true) // all goroutines
fmt.Printf("%s", buf[:n])

The older segmented stack approach (Go 1.3 and earlier) allocated stack segments as a linked list. It was abandoned because of the hot-split problem: a function call at the exact segment boundary caused repeated segment allocation and deallocation in a tight loop, causing up to a 10× performance regression. The current contiguous copy model has no such problem, though it does spend time on the copy when growth is needed.

What is the default initial stack size for a newly created goroutine in Go?
Why did Go replace the segmented stack model with the contiguous (copy-on-grow) model?
5. How does Go's garbage collector work? Explain the tri-color mark-and-sweep algorithm.

Go uses a concurrent, tri-color mark-and-sweep garbage collector. The key design goal is to minimize Stop-The-World (STW) pauses to sub-millisecond levels, even on large heaps, allowing Go programs to remain responsive under continuous allocation pressure.

Tri-Color Object States
ColorMeaning
WhiteNot yet visited. At GC end, white objects are unreachable — will be freed.
GreyReachable from a root, but outgoing references not yet scanned.
BlackFully scanned. All references from this object have been processed.

Algorithm phases:

  1. STW start (~100 µs): pauses all goroutines briefly to take a consistent root snapshot and enable the write barrier.
  2. Concurrent mark: GC goroutines run alongside application goroutines, turning grey objects black by scanning their references. Application goroutines continue executing.
  3. Write barrier: while marking is concurrent, the program may create new pointers. The write barrier (Dijkstra/hybrid) intercepts pointer writes and shades the pointed-to object grey so it is not missed.
  4. Mark termination (STW) (~100 µs): a second brief pause to drain the grey queue and disable the write barrier.
  5. Concurrent sweep: white (unreachable) objects are swept back into free lists. This is concurrent — no STW needed.
// Trigger GC manually (rarely needed in production)
import "runtime"
runtime.GC() // triggers a full GC cycle

// Read GC stats
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("NumGC: %d\n", stats.NumGC)
fmt.Printf("PauseTotal: %v\n", time.Duration(stats.PauseTotalNs))
fmt.Printf("HeapAlloc: %d MB\n", stats.HeapAlloc/1024/1024)

// GOGC env — controls GC frequency
// GOGC=100 (default): GC triggers when heap grows 100% above last GC live set
// GOGC=200: GC triggers at 200% — less frequent GC, more memory used
// GOGC=off: disables GC (only for benchmarks)

// GOMEMLIMIT (Go 1.19+) — hard memory limit
// GOMEMLIMIT=500MiB triggers GC more aggressively if heap approaches 500 MB

The write barrier overhead (~5–10% CPU in allocation-heavy code) is the price paid for concurrent marking. In Go 1.22+, the runtime adaptively adjusts GC frequency using GOGC together with GOMEMLIMIT to balance latency and throughput automatically.

In Go's tri-color GC, what happens to objects that are still white at the end of the mark phase?
What is the purpose of the write barrier during concurrent GC marking?
6. What are GOGC and GOMEMLIMIT and how do you use them to tune GC behavior?

Go's GC is controlled by two primary knobs: GOGC (the classic throughput knob) and GOMEMLIMIT (the memory ceiling introduced in Go 1.19).

GC Tuning Variables
VariableDefaultMeaning
GOGC100GC triggers when live heap grows by GOGC% since last GC
GOMEMLIMITmath.MaxInt64 (off)Hard memory limit; GC runs more aggressively when heap approaches this
// GOGC examples (set as environment variable or via runtime/debug)
// GOGC=100  (default) — GC when heap is 2x last-collection live set
// GOGC=200            — GC when heap is 3x — fewer GCs, more memory
// GOGC=50             — GC when heap is 1.5x — more frequent, lower peak
// GOGC=off            — disable GC (benchmarks/short programs only)

import "runtime/debug"

// Set programmatically (returns old value)
oldGOGC := debug.SetGCPercent(200) // increase to reduce GC frequency
defer debug.SetGCPercent(oldGOGC)

// GOMEMLIMIT — prevents OOM by forcing GC before memory is exhausted
// GOMEMLIMIT=500MiB
debug.SetMemoryLimit(500 * 1024 * 1024) // 500 MB hard limit

// Best practice for containerized Go services:
// Set GOMEMLIMIT to ~90% of container memory limit
// This prevents OOM kills while allowing GC to breathe
// Example: container limit 1 GiB → GOMEMLIMIT=900MiB

// Runtime metrics (Go 1.16+)
import "runtime/metrics"
samples := []metrics.Sample{
    {Name: "/gc/cycles/total:gc-cycles"},
    {Name: "/memory/classes/heap/objects:bytes"},
}
metrics.Read(samples)
fmt.Println(samples[0].Value.Uint64()) // total GC cycles

The interaction between the two: if GOGC=100 would trigger GC at 2 GB but GOMEMLIMIT=1.5 GB, the runtime will trigger GC earlier to stay under the limit. This makes containerised deployments safer — previously, a spike in allocations could cause an OOM kill before GC had a chance to run.

With GOGC=100 (default), when does Go trigger a GC cycle?
What problem does GOMEMLIMIT (Go 1.19+) solve that GOGC alone cannot?
7. How are Go maps implemented internally and what does that mean for performance?

Go's built-in map is a hash table composed of an array of buckets. Each bucket holds up to 8 key-value pairs and a compact bitmask (the tophash) of the top 8 bits of each key's hash — used for quick equality rejection without a full key comparison.

// Map creation
m := make(map[string]int)          // empty, grows as needed
m2 := make(map[string]int, 100)    // hint: pre-allocate for ~100 entries

// The hint avoids rehashing during initial population
// Map automatically grows (rehash) when load factor ~6.5 entries/bucket

// Comma-ok idiom — distinguish missing key from zero value
m["alice"] = 0
val, ok := m["alice"]  // val=0,  ok=true   — key exists
val, ok  = m["bob"]   // val=0,  ok=false  — key absent
if !ok { fmt.Println("key not found") }

// Deleting a key
delete(m, "alice") // no-op if key absent, no panic

// Iteration order is intentionally randomised
for k, v := range m {
    fmt.Println(k, v) // order differs across runs
}

// Maps are NOT thread-safe — concurrent reads+writes cause a fatal error
// Detected by the race detector: go run -race ./...

// Safe alternatives:
// 1. sync.Mutex protecting the map
// 2. sync.RWMutex for read-heavy workloads
// 3. sync.Map (optimised for high-concurrency, low-write scenarios)
Map Internals Summary
ConceptDetail
Bucket size8 key-value pairs per bucket
TophashTop 8 bits of hash stored per slot for fast rejection
Load factorRehash triggered at ~6.5 entries/bucket (overflow buckets used before)
Iteration orderRandomised per run — the runtime deliberately adds randomisation
Thread safetyNot thread-safe — use sync.Mutex, sync.RWMutex, or sync.Map
Key requirementKeys must be comparable (==); slices, maps, functions cannot be keys
What does the comma-ok idiom 'val, ok := m[key]' let you distinguish?
Why is the iteration order of a Go map randomised?

8. When should you use sync.Map instead of a mutex-protected regular map?

sync.Map (Go 1.9+) is a specialised concurrent map optimised for specific access patterns. It is not a general-purpose replacement for map + sync.Mutex.

sync.Map vs mutex-protected map
Aspectmap + sync.Mutexsync.Map
APIStandard map syntaxLoad, Store, LoadOrStore, Delete, Range
Ideal workloadGeneral purpose, write-heavyRead-heavy, mostly stable key sets
Performance on readsGood (RWMutex)Excellent — reads often lock-free
Performance on writesGoodSlower — write path is complex
Key typesAny comparableinterface{} (loses type safety)
Zero valueMust initialise map firstReady to use as zero value
// sync.Map — optimised for: once written, many reads
var sm sync.Map

// Store
sm.Store("key", 42)

// Load
val, ok := sm.Load("key")
if ok {
    fmt.Println(val.(int)) // type assertion required
}

// LoadOrStore — atomic get-or-set
actual, loaded := sm.LoadOrStore("key", 99)
fmt.Println(actual, loaded) // 42 true (existing value returned)

// Range — iterate (snapshot semantics during iteration not guaranteed)
sm.Range(func(k, v any) bool {
    fmt.Println(k, v)
    return true // return false to stop iteration
})

// mutex-protected map — preferred for write-heavy or type-safe needs
type SafeMap[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}
func (s *SafeMap[K, V]) Get(k K) (V, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

sync.Map uses two internal maps: a read-only map (accessed without locks using atomic loads) and a dirty map (accessed under a mutex) for new and updated keys. A key migrates from dirty to read when enough dirty reads occur. This optimises read performance but makes writes heavier. Use it when: caches with stable keys (add once, read many), or when goroutines read disjoint key sets.

What internal mechanism allows sync.Map to offer lock-free reads?
For a cache that is written once at startup and then only read, which approach is more efficient?
9. How do pointers work in Go and when should you pass by pointer vs by value?

Go is pass-by-value: every function argument is a copy. For types that are large or need to be mutated by the called function, passing a pointer avoids the copy and allows in-place modification. Understanding when to use pointers is essential for both correctness and performance.

// Pass by VALUE — mutation inside the function is invisible to caller
func doubleValue(n int) { n *= 2 } // n is a copy
x := 5
doubleValue(x)
fmt.Println(x) // still 5

// Pass by POINTER — mutation is visible
func doublePtr(n *int) { *n *= 2 }
doublePtr(&x)
fmt.Println(x) // 10

// Large structs — avoid copying by passing pointer
type BigStruct struct { data [1024]byte }
func processValue(b BigStruct) {} // copies 1 KB every call
func processPtr(b *BigStruct) {}  // copies only 8 bytes (pointer)

// Value receiver vs pointer receiver on methods
type Counter struct{ count int }
func (c Counter)  Get()  int { return c.count }     // value receiver — copy
func (c *Counter) Inc()      { c.count++ }           // pointer receiver — mutates

c := Counter{}
c.Inc()              // Go automatically takes &c
fmt.Println(c.Get()) // 1

// Pointer vs nil — always check before dereferencing
var p *int
fmt.Println(p)   // 
// fmt.Println(*p) // PANIC: nil pointer dereference
Pointer vs Value — Decision Guide
Use pointer when...Use value when...
The function must mutate the receiver/argumentThe function is read-only
The type is large (>64 bytes typical threshold)The type is small (int, bool, small struct)
The type has a sync.Mutex or similar non-copyable fieldImmutability is desirable
Consistency: other methods use pointer receiverType is inherently value-like (time.Time, net/netip.Addr)
What does a pointer receiver on a method guarantee that a value receiver does not?
If a struct has a sync.Mutex field, why must its methods use pointer receivers?
10. How does Go's goroutine scheduler work? Explain the GMP model.

Go uses a cooperative/preemptive M:N scheduler — M goroutines multiplexed onto N OS threads, where N defaults to GOMAXPROCS (number of logical CPUs). The scheduler uses three key entities:

GMP Entities
EntitySymbolDescription
GoroutineGThe logical unit of work — a user-space green thread with its own stack
Machine (OS thread)MAn OS thread that executes Go code; managed by the runtime
ProcessorPA logical CPU context; holds a local run queue of goroutines waiting to run
// GOMAXPROCS controls the number of P's (and thus OS threads active)
import "runtime"
runtime.GOMAXPROCS(4) // use 4 OS threads for parallel execution
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = query without changing

// Scheduler events that cause a context switch:
// 1. Goroutine blocks on channel send/receive
// 2. Goroutine blocks on system call (M parks, P finds another M)
// 3. go statement (new G added to local run queue of current P)
// 4. runtime.Gosched() — voluntary yield
// 5. Function call (Go 1.14+: asynchronous preemption via signals)

// Work stealing: when P's local queue is empty,
// it steals half the goroutines from another P's queue

// Global run queue: accessed when local queue has > 256 Gs,
// or periodically to ensure fairness

// View scheduler decisions
// GOTRACE=scheduler ./myapp  — not a real flag, but:
// go tool trace trace.out    — after: f, _ := os.Create("trace.out")
//                              trace.Start(f); ...; trace.Stop()

System call handling: When a goroutine makes a blocking system call (e.g., reading a file), the M detaches from its P. The P then attaches to another idle M (or creates a new one), so other goroutines continue running. When the system call returns, the original M tries to reacquire a P; if none is available, it parks and the goroutine goes to the global queue.

Asynchronous preemption (Go 1.14+): the runtime sends SIGURG signals to goroutines that have run too long without a function call, forcing a context switch. This prevents a CPU-intensive goroutine from starving others even without cooperative yield points.

In Go's GMP model, what is a 'P' (Processor)?
What happens to a P when its M (OS thread) blocks on a system call?
11. How are Go channels implemented internally?

A channel is a typed, goroutine-safe FIFO queue managed by the runtime. Internally it is a hchan struct containing a circular ring buffer (for buffered channels), send and receive queues of waiting goroutines, a mutex, and metadata like element type, capacity, and current length.

// Unbuffered channel — synchronous rendezvous
ch := make(chan int) // cap=0, buf=nil
// Send blocks until a goroutine receives; receive blocks until send

// Buffered channel — asynchronous up to capacity
ch2 := make(chan int, 5) // cap=5, ring buffer of 5 ints
ch2 <- 1 // does not block (buffer not full)
ch2 <- 2
v := <-ch2 // 1 (FIFO)

// Select — multiplexed channel operations
select {
case msg := <-ch:
    fmt.Println("received", msg)
case ch2 <- 42:
    fmt.Println("sent 42")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("non-blocking — no case ready")
}

// Closing a channel — signals receivers: no more data
close(ch2)
val, ok := <-ch2 // ok=false means channel closed and empty

// Range over channel — reads until closed
for v := range ch2 {
    fmt.Println(v)
}

// Common rules:
// - Sending to a closed channel panics
// - Receiving from a closed, empty channel returns zero value, ok=false
// - Closing a nil channel panics
// - Only the SENDER should close (receiver cannot know when sender is done)

Goroutine parking: when a send finds the buffer full (or an unbuffered channel has no receiver), the goroutine is added to the sendq (a linked list of goroutines in the hchan) and parked. When a receiver arrives, it directly copies the data from the parked sender's stack — a zero-copy optimisation for unbuffered channels — and unparks the sender goroutine.

What happens when you send to a closed channel in Go?
In an unbuffered channel, how does Go optimise the data transfer between sender and receiver?
12. How are Go interfaces implemented internally and why do they matter for performance?

A Go interface value is a two-word struct: a type pointer and a data pointer. There are two variants in the runtime: iface (for interfaces with methods — has a pointer to the method table / itab) and eface (for the empty interface any — just type and data pointers).

// Empty interface (any / interface{})
// eface { type *_type, data unsafe.Pointer }
var v any = 42
// _type = pointer to int's type descriptor
// data = pointer to a heap-allocated int holding 42

// Interface with methods
// iface { itab *itab, data unsafe.Pointer }
// itab = {inter *interfacetype, type *_type, hash uint32, fun [...]uintptr}
// fun[] = the method dispatch table (virtual call table)

type Stringer interface { String() string }
type MyInt int
func (m MyInt) String() string { return fmt.Sprintf("%d", m) }

var s Stringer = MyInt(5) // iface{itab=*MyInt-Stringer-itab, data=ptr-to-5}
fmt.Println(s.String())    // dispatches via itab.fun[0]

// Type assertion — recovers the concrete type
if mi, ok := s.(MyInt); ok {
    fmt.Println(mi + 1)
}

// Type switch
switch t := v.(type) {
case int:    fmt.Println("int:", t)
case string: fmt.Println("string:", t)
default:     fmt.Println("unknown")
}

// Performance implication: values stored in interface often escape to heap
// Small values (pointer-sized or smaller) can be inlined into the data word
// Avoid interface{} in hot paths — use generics (Go 1.18+) instead

Interface comparison: two interface values are equal if and only if their type pointers are equal (same concrete type) and their data pointers point to equal values. Comparing interfaces with non-comparable concrete types (e.g., slices) panics at runtime.

Nil interface pitfall: (*MyType)(nil) stored in an interface is not a nil interface — the interface has a non-nil type pointer. Always return a nil interface (return nil) rather than a typed nil pointer when returning an interface from a function.

Why can a non-nil typed nil pointer (*MyType)(nil) stored in an interface NOT equal nil?
What is the 'itab' in a Go interface value?
13. How does Go's memory allocator work? Explain mcache, mcentral, and mheap.

Go uses a hierarchical, size-class-based allocator inspired by TCMalloc (Thread-Caching Malloc). The three levels minimise lock contention and fragmentation.

Go Allocator Layers
LayerScopeLockingPurpose
mcachePer-P (per logical CPU)Lock-freePer-CPU cache of spans for each size class — fast path
mcentralPer size class, globalMutex per size classCentral pool of spans; supplies mcache when empty
mheapGlobalMutex (large objects)OS memory; supplies mcentral with new spans; manages large (>32 KB) allocations directly
// Allocation path for a small object (<=32 KB):
// 1. Round up to nearest size class (e.g., 24 bytes → 32-byte class)
// 2. Check P's mcache for that size class — if span available, use it (no lock)
// 3. If mcache empty, fetch a span from mcentral (mutex on that size class)
// 4. If mcentral empty, request memory from mheap (global lock)
// 5. If mheap exhausted, request OS memory via mmap/VirtualAlloc

// Large object allocation (>32 KB):
// Directly from mheap — allocated as a multi-page span

// Tiny allocation (<=16 bytes, no pointers, not zero-sized):
// Multiple tiny objects packed into a single 16-byte slot
// e.g., allocating many bool or small int values is very cheap

// Size classes — Go has ~68 size classes:
// 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, ... 32768 bytes

// Inspecting allocation stats
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc      = %d KB\n", m.Alloc/1024)
fmt.Printf("TotalAlloc = %d KB\n", m.TotalAlloc/1024)
fmt.Printf("Sys        = %d KB\n", m.Sys/1024)
fmt.Printf("Mallocs    = %d\n", m.Mallocs)
fmt.Printf("Frees      = %d\n", m.Frees)

The size-class design eliminates fragmentation for small objects: each span serves exactly one size class, so all objects in a span are the same size and fit perfectly. This avoids the fragmentation of a general-purpose allocator.

Why does Go's allocator use a per-P (per-processor) mcache?
What path does Go take when allocating an object larger than 32 KB?
14. How are strings represented in Go and why are they immutable?

A Go string is a two-word struct similar to a slice header but without a capacity field: a ptr (unsafe.Pointer to the UTF-8 bytes) and a len (byte count). Strings are immutable — the bytes they point to cannot be modified through any string operation.

// String header: {ptr unsafe.Pointer, len int}
s := "hello"
fmt.Println(len(s))    // 5 (bytes, not runes)
fmt.Println(s[0])      // 104 — byte value of 'h'
// s[0] = 'H'          // compile error: cannot assign to s[0]

// UTF-8: len counts bytes, range counts runes
emoji := "Go 🚀"
fmt.Println(len(emoji))              // 7 (G=1, o=1, space=1, rocket=4 bytes)
for i, r := range emoji {
    fmt.Printf("%d: %c\n", i, r)  // i is byte offset, r is rune value
}

// String concatenation creates a NEW backing array each time
// Avoid in loops — use strings.Builder instead
var b strings.Builder
for _, s := range words {
    b.WriteString(s)
    b.WriteByte(' ')
}
result := b.String() // single allocation

// string <-> []byte conversion
// Each conversion copies the bytes (different backing arrays)
bytes := []byte(s)   // copy into a new mutable byte slice
s2    := string(bytes) // copy back — new immutable string

// Zero-copy cast with unsafe (avoid unless in hot path + well-understood)
// Not recommended for general use

Immutability allows strings to be safely shared without copying — multiple strings can point to the same backing byte array (e.g., a slice of a longer string). String constants are interned into read-only memory in the binary, so no heap allocation is needed for them. The strings.Builder type avoids repeated allocation by maintaining a []byte buffer and converting to string only at the end.

What does len(s) return for a Go string — byte count or rune count?
Why does concatenating strings in a loop using += cause performance problems?
15. How does defer work internally in Go and what are its performance implications?

defer schedules a function call to run when the surrounding function returns — whether normally or via panic. The deferred call's arguments are evaluated immediately when the defer statement is executed, not when the deferred function runs.

// Arguments evaluated immediately at defer statement
x := 10
defer fmt.Println(x) // prints 10 even if x is later changed
x = 20
// Output: 10

// Named return values + defer — modify return before it reaches caller
func divide(a, b float64) (result float64, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()
    result = a / b
    return // named return
}

// LIFO order — multiple defers run last-in first-out
func cleanup() {
    defer fmt.Println("first defer")
    defer fmt.Println("second defer")
    defer fmt.Println("third defer")
    // Output: third, second, first
}

// Performance: defer has overhead (allocates defer record pre-Go 1.14)
// Go 1.14+ inlines simple defers — near-zero overhead for non-looping, non-panic paths

// Avoid defer in tight loops — call cleanup explicitly
func processFiles(files []string) {
    for _, f := range files {
        func() { // wrap in closure so defer fires per file
            fh, _ := os.Open(f)
            defer fh.Close() // OK inside the per-file closure
            // process...
        }()
    }
}

Internal representation: in Go 1.14+, the compiler classifies defers as open-coded (inlined when statically determinable), stack-allocated, or heap-allocated. Open-coded defers have near-zero overhead — the compiler emits the deferred code at each return site. Only defers inside loops or under conditions that can vary at runtime fall back to the slower heap-allocated path.

When are the arguments of a defer statement evaluated in Go?
In what order do multiple defer statements in the same function execute?
16. How do panic and recover work in Go and when should you use them?

panic stops the normal execution of the current goroutine, unwinds the stack calling all deferred functions, and propagates up until it reaches the top of the goroutine's stack — at which point the runtime prints a stack trace and terminates the program. recover can intercept a panic but only inside a deferred function.

// Basic panic — causes runtime abort with stack trace
func mustPositive(n int) int {
    if n <= 0 {
        panic(fmt.Sprintf("expected positive, got %d", n))
    }
    return n
}

// recover — MUST be called inside a deferred function
func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered from panic: %v", r)
        }
    }()
    result = a / b // panics if b == 0
    return
}

r, err := safeDiv(10, 0)
fmt.Println(r, err) // 0  recovered from panic: runtime error: integer divide by zero

// recover() returns nil if not panicking
// It cannot recover a panic from a DIFFERENT goroutine
go func() {
    panic("goroutine panic") // crashes the whole program
}()

// When to use panic vs error:
// panic — unrecoverable programming errors (index out of bounds, nil deref)
//         or internal invariant violations that should never happen
// error — expected failure conditions (file not found, network timeout, bad input)

// Libraries should NEVER let panics propagate to callers
// Use recover at the public API boundary to convert to errors

The canonical use of panic/recover in Go is the library boundary pattern: a library may use panic internally for control flow (e.g., a parser that panics on syntax error deep in a call stack), but the exported function wraps the entire body in a deferred recover and converts the panic to an error value. This keeps the panic/recover internal and never surprises callers.

Why can a deferred recover() call not intercept a panic raised in a different goroutine?
What does recover() return when called outside a panic?
17. How do you profile a Go application using pprof?

Go ships net/http/pprof (for running services) and the runtime/pprof package for programmatic profiling. Profiles are the primary tool for diagnosing CPU hotspots, memory leaks, and goroutine leaks in production.

// ── HTTP endpoint (register once, profile on demand) ──
import _ "net/http/pprof" // blank import registers handlers
import "net/http"

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

// Profile endpoints:
// http://localhost:6060/debug/pprof/goroutine?debug=1  — goroutines
// http://localhost:6060/debug/pprof/heap               — heap snapshot
// http://localhost:6060/debug/pprof/profile?seconds=30 — CPU profile

// ── CLI usage ──
// Download and view CPU profile:
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// (pprof) top10    — top 10 functions by CPU
// (pprof) web      — opens flame graph in browser
// (pprof) list myFunc — annotated source for myFunc

// Heap profile:
// go tool pprof http://localhost:6060/debug/pprof/heap
// (pprof) top10 -cum   — cumulative allocation
// (pprof) alloc_space  — total bytes allocated (not just live)
// (pprof) inuse_space  — currently live bytes

// ── Benchmark profiling ──
// go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
// go tool pprof cpu.out

// ── Programmatic (for batch programs) ──
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// ... run workload ...
// go tool pprof cpu.prof

The execution tracer (go tool trace) complements pprof: it shows fine-grained goroutine scheduling, syscall latency, and GC events on a timeline — useful when pprof shows low CPU usage but latency is still high (often caused by goroutine blocking or GC pauses).

What does the blank import '_ "net/http/pprof"' accomplish?
What is the difference between 'inuse_space' and 'alloc_space' in a heap profile?
18. What is the Go race detector and how does it work?

The race detector (-race flag) instruments the binary to track every memory access and detect data races — concurrent reads and writes to the same memory without synchronisation. It uses the ThreadSanitizer (TSan) C library under the hood.

// Run with race detection
// go run -race main.go
// go test -race ./...
// go build -race -o myapp

// Example of a data race
var counter int

var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++ // DATA RACE: concurrent unsynchronised write
    }()
}
wg.Wait()

// Race detector output:
// ==================
// WARNING: DATA RACE
// Write at 0x00c000016090 by goroutine 7:
//   main.main.func1()
//       /tmp/main.go:14 +0x38
// Previous write at 0x00c000016090 by goroutine 6:
//   main.main.func1()
//       /tmp/main.go:14 +0x38
// ==================

// Fix: use atomic or mutex
var atomicCounter int64
atomic.AddInt64(&atomicCounter, 1) // race-free

// Race detector overhead:
// CPU: 5–20x slower
// Memory: 5–10x more
// Not suitable for production (unless you accept the overhead)
// Ideal in CI and -race enabled test suites

The race detector uses happens-before analysis (Vector Clocks) to determine whether two conflicting accesses could overlap in time. It reports every detected race with full stack traces for both the current access and the previous conflicting access, making it extremely useful for tracking down subtle bugs. It is recommended to always run go test -race ./... in CI pipelines.

What kind of analysis does the Go race detector use to detect races?
Why is the race detector not used in production binaries by default?
19. What is the difference between sync.Mutex and sync.RWMutex and when do you use each?

sync.Mutex is a mutual-exclusion lock: at most one goroutine holds the lock at any time, whether reading or writing. sync.RWMutex distinguishes readers from writers: multiple readers can hold the lock simultaneously (RLock), but a writer requires exclusive access (Lock).

// sync.Mutex — use when all accesses are writes or complex read+write ops
type SafeCounter struct {
    mu sync.Mutex
    count int
}
func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

// sync.RWMutex — use when reads dominate
type Config struct {
    mu   sync.RWMutex
    data map[string]string
}
func (c *Config) Get(key string) string {
    c.mu.RLock()          // many readers can hold RLock concurrently
    defer c.mu.RUnlock()
    return c.data[key]
}
func (c *Config) Set(key, val string) {
    c.mu.Lock()           // exclusive — no readers or writers
    defer c.mu.Unlock()
    c.data[key] = val
}

// Important: RWMutex is NOT always faster than Mutex
// RWMutex has higher per-operation overhead than Mutex
// It wins when: read:write ratio >> 10:1 and lock is held for a notable duration
// For very short critical sections, Mutex can be faster

// TryLock (Go 1.18+)
if c.mu.TryLock() {
    defer c.mu.Unlock()
    // got the lock
} else {
    // lock is held — do something else
}

Writer starvation: in Go's RWMutex, when a writer requests the lock, new readers are blocked even if current readers still hold RLock. This prevents writers from being starved by a continuous stream of readers. The writer waits only for the existing readers to finish, then gets exclusive access.

When is sync.RWMutex more efficient than sync.Mutex?
In Go's RWMutex, what happens to new read-lock (RLock) requests when a writer is waiting?
20. When should you use channels versus mutexes in Go concurrency?

Go's concurrency mantra is "Do not communicate by sharing memory; instead, share memory by communicating." Channels are the primary tool for passing ownership of data between goroutines; mutexes are for protecting shared state that multiple goroutines need to access concurrently.

Channels vs Mutexes — Decision Guide
ScenarioPreferred Tool
Passing ownership of data between goroutinesChannel
Signalling an event (done, cancel, ready)Channel (or context.Context)
Pipeline of work itemsChannel
Fan-out / fan-in patternsChannel + sync.WaitGroup
Protecting a shared cache or counterMutex (sync.Mutex or atomic)
Read-heavy shared config or registrysync.RWMutex or sync.Map
Updating a struct's fieldsMutex protecting the struct
// ── Channel: ownership transfer ──
func producer(out chan<- int) {
    for i := 0; i < 10; i++ {
        out <- i // transfer ownership of i to consumer
    }
    close(out)
}
func consumer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

// ── Mutex: protecting shared state ──
type Inventory struct {
    mu    sync.Mutex
    items map[string]int
}
func (inv *Inventory) Add(name string, qty int) {
    inv.mu.Lock()
    defer inv.mu.Unlock()
    inv.items[name] += qty
}

// ── Avoiding channel misuse ──
// Bad: using a channel as a simple mutex replacement
sem := make(chan struct{}, 1) // semaphore
sem <- struct{}{} // acquire
// critical section
<-sem             // release
// Better: just use sync.Mutex for this pattern

// Context for cancellation (not raw channels)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
select {
case result := <-doWork(ctx):
    fmt.Println(result)
case <-ctx.Done():
    fmt.Println("timeout:", ctx.Err())
}
What is Go's primary guideline for choosing between channels and mutexes?
Which Go standard library type should you use to propagate cancellation signals to goroutines?
21. How do generics work in Go 1.18+ and how do they affect performance?

Go 1.18 introduced type parameters (generics), allowing functions and types to be parameterised over types constrained by interfaces. Go uses a GCShape-based implementation: rather than generating a separate binary for each concrete type (full monomorphisation), Go creates a dictionary-based dispatch for types with the same GC shape (memory layout), reducing binary size at a small runtime cost.

// Generic function with type constraint
func Min[T constraints.Ordered](a, b T) T {
    if a < b { return a }
    return b
}

fmt.Println(Min(3, 5))         // int
fmt.Println(Min(3.14, 2.71))   // float64
fmt.Println(Min("foo", "bar")) // string

// Generic type
type Stack[T any] struct {
    items []T
}
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) }
func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.items) == 0 { return zero, false }
    n := len(s.items) - 1
    item := s.items[n]
    s.items = s.items[:n]
    return item, true
}

// Custom interface constraint
type Number interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums { total += n }
    return total
}

// ~ means: any type whose underlying type is int
type MyInt int // ~int matches MyInt
fmt.Println(Sum([]MyInt{1, 2, 3})) // 6

Performance: For pointer-sized types (interfaces, pointers, slices), Go generates a single implementation shared via a dictionary (like Java's type erasure). For value types (int, float64), Go can generate specialised code paths. In practice, generic functions are slightly slower than hand-written type-specific functions in some workloads but eliminate code duplication. Use generics when the algorithm is the same across types and the alternative is copy-paste or reflection.

What does the '~int' constraint in a Go type constraint mean?
What implementation strategy does Go use for generics with pointer-sized types (e.g., *int)?
22. How does context.Context work and when do you use each context type?

context.Context carries deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. It is the standard way to propagate cancellation in Go services.

// Context hierarchy — child inherits cancellation from parent
ctx := context.Background() // root — never cancelled, never has deadline

// WithCancel — explicit cancellation
ctx1, cancel1 := context.WithCancel(ctx)
defer cancel1() // ALWAYS defer cancel to prevent goroutine leaks

// WithTimeout — automatically cancelled after duration
ctx2, cancel2 := context.WithTimeout(ctx, 5*time.Second)
defer cancel2()

// WithDeadline — cancelled at absolute time
deadline := time.Now().Add(10 * time.Second)
ctx3, cancel3 := context.WithDeadline(ctx, deadline)
defer cancel3()

// WithValue — carry request-scoped data (use sparingly)
type ctxKey string
ctx4 := context.WithValue(ctx, ctxKey("traceID"), "abc-123")
traceID := ctx4.Value(ctxKey("traceID")).(string)

// Propagate through function calls
func fetchData(ctx context.Context, url string) ([]byte, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req) // cancels if ctx is done
    if err != nil { return nil, err }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

// Checking cancellation in a long loop
func processItems(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err() // context.DeadlineExceeded or context.Canceled
        default:
            process(item)
        }
    }
    return nil
}

Rules: (1) always pass Context as the first argument, never store it in a struct (for long-lived objects use context.Background() stored at service init). (2) always call the cancel function to avoid goroutine leaks — even if the deadline or timeout fires naturally. (3) use context.WithValue only for truly request-scoped data (trace IDs, auth tokens) — not as a general parameter-passing mechanism.

What happens to child contexts when a parent context is cancelled?
Why is it important to always defer the cancel function returned by context.WithCancel?
23. What is the 'unsafe' package in Go and when is it used?

The unsafe package lets Go code step outside the type system and interact with raw memory. Its functions and types are special — the compiler handles them intrinsically. Using unsafe bypasses garbage collection safety and may break with future Go versions, so it should be used only in well-justified, carefully tested situations.

import "unsafe"

// unsafe.Sizeof — size of a type in bytes (compile-time)
type MyStruct struct {
    a int32  // 4 bytes
    b int64  // 8 bytes (8-byte aligned)
    c int8   // 1 byte
           // 7 bytes padding to next 8-byte boundary
}
fmt.Println(unsafe.Sizeof(MyStruct{}))    // 24 (not 13!) — alignment padding
fmt.Println(unsafe.Alignof(MyStruct{}.b)) // 8
fmt.Println(unsafe.Offsetof(MyStruct{}.b)) // 8 (offset of field b)

// unsafe.Pointer — the 'escape hatch' for type-unsafe pointer conversion
// uintptr + unsafe.Pointer can do pointer arithmetic
// But: converting to uintptr makes the value opaque to GC — GC can move object!

// Zero-copy string <-> []byte (HIGH RISK — avoid in application code)
// Only safe when the lifetime and mutability constraints are guaranteed
func bytesToString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

// Struct field access via offset (used in reflect, cgo, sync internals)
// go:linkname — link to unexported symbols in other packages
// (used by stdlib, not for general use)

// Safe uses of unsafe:
// - Measuring struct size/alignment for documentation
// - Implementing generic data structures that need raw memory (arena allocators)
// - cgo interoperability
// - Performance-critical zero-copy conversions with proven safety

Critical GC hazard: when you convert unsafe.Pointer to uintptr, the GC no longer tracks it as a pointer — if the GC runs, it may move the object and the uintptr becomes a dangling reference. Always convert back to unsafe.Pointer in the same expression, never store a uintptr temporarily.

What is the critical risk of storing an unsafe.Pointer converted to uintptr in a variable?
What does unsafe.Sizeof(MyStruct{}) actually measure?
24. What is the nil interface pitfall in Go and how do you avoid it?

One of Go's most confusing bugs: a nil pointer of a concrete type, when assigned to an interface, produces a non-nil interface value. This breaks code that checks if err != nil — the check passes even though the underlying value is nil.

type MyError struct{ code int }
func (e *MyError) Error() string { return fmt.Sprintf("error %d", e.code) }

// Bug: returns a non-nil interface holding a nil *MyError
func riskyOp(fail bool) error {
    var err *MyError // nil *MyError pointer
    if fail {
        err = &MyError{code: 42}
    }
    return err // WRONG: even when fail=false, the returned error is NOT nil
              // The interface has {type=*MyError, data=nil}
}

e := riskyOp(false)
if e != nil {
    fmt.Println("ERROR:", e) // This PRINTS — interface is non-nil!
}

// Fix 1: return untyped nil when there is no error
func safeOp(fail bool) error {
    if fail {
        return &MyError{code: 42}
    }
    return nil // nil interface — both type and data are nil
}

// Fix 2: use a concrete return type if the caller always knows the type
func safeOp2(fail bool) *MyError {
    if fail { return &MyError{code: 42} }
    return nil // now nil really means nil
}

// Detect with reflect if debugging:
fmt.Println(e == nil)                   // false (interface non-nil)
fmt.Println(reflect.ValueOf(e).IsNil()) // true  (data pointer is nil)

The rule: never return a typed nil as an interface return type. If your function returns an interface (like error), always return the bare nil keyword on the success path, not a nil pointer of a concrete type. The errors.Is and errors.As functions from Go 1.13+ are also affected — they work correctly because they unwrap the interface, but the initial != nil check still fails.

An interface value 'var e error = (*MyError)(nil)' — is e == nil?
What is the correct way to return 'no error' from a function that returns the error interface?
25. What are goroutine leaks and how do you detect and prevent them?

A goroutine leak occurs when a goroutine is started but never terminates — it blocks forever waiting on a channel, lock, or I/O operation that will never complete. Leaked goroutines consume memory (their stacks) and may hold references that prevent other objects from being GC'd. In a long-running server, goroutine leaks cause steady memory growth until OOM.

// Common leak pattern 1: unbuffered channel with no receiver
func leak1() {
    ch := make(chan int)
    go func() {
        ch <- 42 // blocks forever — nobody reads
    }()
    // function returns; the goroutine is stuck sending forever
}

// Fix: use a buffered channel, or ensure the receiver runs
func fixed1() {
    ch := make(chan int, 1) // buffered: sender doesn't block
    go func() { ch <- 42 }()
    // or: read from ch here before returning
}

// Common leak pattern 2: no cancellation signal
func leak2(ctx context.Context, jobs <-chan Job) {
    go func() {
        for {
            job := <-jobs // blocks if jobs is never closed or ctx cancelled
            process(job)
        }
    }()
}

// Fix: use select with ctx.Done()
func fixed2(ctx context.Context, jobs <-chan Job) {
    go func() {
        for {
            select {
            case <-ctx.Done(): return // clean exit on cancellation
            case job := <-jobs: process(job)
            }
        }
    }()
}

// Detecting leaks
// 1. runtime.NumGoroutine() — spot trend in tests or monitoring
// 2. http://localhost:6060/debug/pprof/goroutine?debug=2 — full traces
// 3. goleak library (uber-go/goleak) — assert no goroutines leak in tests
// import goleak "go.uber.org/goleak"
// func TestMyFunc(t *testing.T) {
//     defer goleak.VerifyNone(t)
//     myFunc()
// }
What is the most reliable way to ensure a worker goroutine terminates cleanly?
What tool can detect goroutine leaks in unit tests?
26. How does sync.WaitGroup work and what are common mistakes?

sync.WaitGroup lets one goroutine wait for a collection of goroutines to finish. The three methods — Add(n), Done(), and Wait() — implement a simple counting semaphore.

var wg sync.WaitGroup

// CORRECT: Add BEFORE launching the goroutine
for i := 0; i < 5; i++ {
    wg.Add(1)                 // increment BEFORE go statement
    go func(id int) {
        defer wg.Done()       // always use defer — ensures Done even on panic
        fmt.Println("worker", id)
    }(i)
}
wg.Wait() // blocks until count reaches zero

// WRONG: Add inside the goroutine — race condition
for i := 0; i < 5; i++ {
    go func(id int) {
        wg.Add(1)   // may execute AFTER Wait() returns — data race!
        defer wg.Done()
        fmt.Println(id)
    }(i)
}
wg.Wait() // might return before all goroutines call Add

// WRONG: reusing WaitGroup before Wait returns
// Do not call Add on a WaitGroup whose Wait has not yet returned

// Pattern: limit concurrency with a semaphore channel
sem := make(chan struct{}, 10) // max 10 goroutines at once
for _, item := range items {
    wg.Add(1)
    sem <- struct{}{} // acquire slot
    go func(it Item) {
        defer func() { <-sem; wg.Done() }()
        process(it)
    }(item)
}
wg.Wait()

A WaitGroup must not be copied after first use — embedding a WaitGroup in a struct and passing the struct by value silently copies the counter state. Always pass pointers to structs containing a WaitGroup, or pass the WaitGroup itself by pointer.

Why must wg.Add(n) be called before launching goroutines, not inside them?
What happens if you copy a sync.WaitGroup?
27. How do closures capture variables in Go and what is the classic goroutine loop bug?

A closure in Go captures variables by reference — it holds a pointer to the outer variable, not a copy of its value at the time of closure creation. This means if the variable changes after the closure is created but before it executes, the closure sees the new value.

// ── Classic goroutine loop bug ──
// Go 1.21 and earlier:
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i) // captures &i — all goroutines print 3, 3, 3
    }()
}
time.Sleep(time.Second)

// Fix 1: pass as argument (creates a copy per iteration)
for i := 0; i < 3; i++ {
    go func(i int) {      // i is now a local copy
        fmt.Println(i)    // 0, 1, 2 (in some order)
    }(i)
}

// Fix 2: shadow the variable inside the loop (pre-Go 1.22)
for i := 0; i < 3; i++ {
    i := i // new i per iteration
    go func() { fmt.Println(i) }()
}

// Go 1.22+ fix: loop variables are per-iteration by default
// The behaviour changed — each loop iteration now has its own i
// So the bug no longer exists in Go 1.22+ for range loops

// Closures in non-goroutine contexts
adders := make([]func() int, 3)
for i := 0; i < 3; i++ {
    i := i // shadow is needed pre-Go-1.22
    adders[i] = func() int { return i + 10 }
}
fmt.Println(adders[0](), adders[1](), adders[2]()) // 10 11 12

Go 1.22 loop variable change: starting in Go 1.22, the loop variable in a for range loop is declared fresh each iteration (similar to JavaScript's let). This silently fixes the classic goroutine loop bug for range loops in new code. Classic three-clause for i := 0; ... loops also got the fix in 1.22. Code that depended on the old behaviour (sharing across iterations) may break.

In Go 1.21 and earlier, why do all goroutines launched in 'for i := 0; i < 3; i++ { go func() { print(i) }() }' print 3?
What changed in Go 1.22 regarding loop variable capture?
28. How does struct embedding work in Go and how does it differ from inheritance?

Go has no class hierarchy or classical inheritance. Instead it supports composition via embedding: embedding a type inside a struct promotes the embedded type's methods and fields to the outer struct. This provides code reuse and satisfies interfaces without the coupling of inheritance.

type Logger struct{ prefix string }
func (l Logger) Log(msg string) { fmt.Println(l.prefix+":", msg) }

type Server struct {
    Logger              // embedded — methods promoted
    addr   string
}

s := Server{Logger: Logger{"SERVER"}, addr: ":8080"}
s.Log("starting")  // promoted — same as s.Logger.Log("starting")
s.prefix = "SRV"   // promoted field access

// Embedding satisfies interfaces
type Loggable interface { Log(string) }
var l Loggable = s // Server satisfies Loggable via embedded Logger

// Method overriding — outer type can shadow embedded method
func (s Server) Log(msg string) {
    fmt.Printf("[%s] %s\n", s.addr, msg) // custom implementation
    // s.Logger.Log(msg) // explicitly call embedded if needed
}

// Interface embedding — compose interface contracts
type ReadWriter interface {
    io.Reader // embedded interface
    io.Writer
}

// Embedding vs named field
type WithName struct {
    myLogger Logger  // named field — NOT promoted, access as s.myLogger.Log()
}
type WithEmbed struct {
    Logger           // embedded — methods promoted to outer type
}

The key difference from inheritance: embedding is purely mechanical code promotion. The embedded type does not know about the outer type and there is no polymorphism between them unless they share an interface. You can embed multiple types (mixins), and method sets are resolved deterministically — if the outer type defines a method with the same name, it shadows the embedded one.

What does embedding a type inside a struct do in Go?
If both an outer struct and its embedded type define a method with the same name, which one is called when you call it on the outer struct?
29. How does error wrapping work in Go 1.13+ with errors.Is and errors.As?

Go 1.13 introduced a standardised error wrapping API. Errors can be wrapped using fmt.Errorf("... %w", err) to create a chain, and errors.Is / errors.As traverse that chain to find specific errors or extract their values.

import "errors"

// Sentinel errors — comparable with ==  or errors.Is
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

// Wrap — %w creates an error chain
func openFile(path string) error {
    if err := os.Open(path); err != nil {
        return fmt.Errorf("openFile %s: %w", path, err) // wrap with context
    }
    return nil
}

// errors.Is — checks if any error in the chain equals the target
err := openFile("/nonexistent")
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file does not exist")
}

// Custom error type
type ValidationError struct {
    Field   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}

func validate(name string) error {
    if name == "" {
        return fmt.Errorf("user creation: %w", &ValidationError{"name", "required"})
    }
    return nil
}

// errors.As — extracts a specific type from the chain
err = validate("")
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
}

// errors.Unwrap — one level only
inner := errors.Unwrap(err) // returns the wrapped *ValidationError

// Multiple wrapping (Go 1.20+) — join multiple errors
err1, err2 := errors.New("e1"), errors.New("e2")
joined := errors.Join(err1, err2)
errors.Is(joined, err1) // true
What does errors.Is(err, target) do that a plain '== target' comparison does not?
What format verb must you use in fmt.Errorf to create a wrapped error that errors.Is/As can traverse?
30. How do you write and run benchmarks in Go?

Go's testing package includes a built-in benchmark framework. Benchmarks are functions with the signature func BenchmarkXxx(b *testing.B) and run with go test -bench=.. The framework handles warm-up and calibrates the number of iterations automatically.

// benchmark_test.go
func BenchmarkStringConcat(b *testing.B) {
    // b.N is set by the framework — run the measured code exactly b.N times
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < 100; j++ {
            s += "x" // inefficient — baseline
        }
        _ = s
    }
}

func BenchmarkStringBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

// Run benchmarks:
// go test -bench=. -benchmem ./...
// BenchmarkStringConcat-8    123456   9876 ns/op   5264 B/op  99 allocs/op
// BenchmarkStringBuilder-8   456789   2345 ns/op    128 B/op   2 allocs/op

// -benchmem: shows memory allocations per op
// -benchtime=5s: run each benchmark for 5 seconds
// -count=5: run each benchmark 5 times for statistical stability

// Reset timer to exclude setup time
func BenchmarkWithSetup(b *testing.B) {
    data := make([]byte, 1<<20) // setup — excluded from timing
    b.ResetTimer()              // start timing from here
    for i := 0; i < b.N; i++ {
        process(data)
    }
}

// Run with pprof to get a flame graph:
// go test -bench=BenchmarkStringConcat -cpuprofile=cpu.out
// go tool pprof cpu.out

The benchstat tool (part of golang.org/x/perf) compares two sets of benchmark results statistically, accounting for variance — essential for determining whether an optimisation is genuinely significant or within noise.

What does b.ResetTimer() do in a Go benchmark?
What does the '-benchmem' flag add to benchmark output?
31. How does struct field ordering affect memory layout and performance in Go?

CPU architectures require data to be aligned — an 8-byte integer must start at an address divisible by 8, a 4-byte integer divisible by 4, etc. The Go compiler adds invisible padding bytes between struct fields to satisfy alignment requirements. Poor field ordering wastes memory; reordering fields can eliminate padding.

// Poorly ordered — wastes 7 bytes of padding
type Wasteful struct {
    a bool    // 1 byte
              // 7 bytes PADDING (for b's 8-byte alignment)
    b int64   // 8 bytes
    c bool    // 1 byte
              // 7 bytes PADDING (to align next field / end of struct)
}  // Total: 24 bytes (wastes 14 bytes)

// Well ordered — no padding (fields largest to smallest)
type Compact struct {
    b int64   // 8 bytes
    a bool    // 1 byte
    c bool    // 1 byte
              // 6 bytes padding to align to 8-byte boundary at struct end
}  // Total: 16 bytes (saves 8 bytes vs Wasteful)

// Verify with unsafe
fmt.Println(unsafe.Sizeof(Wasteful{})) // 24
fmt.Println(unsafe.Sizeof(Compact{}))  // 16

// Performance impact:
// Larger structs → more cache lines → more cache misses in hot loops
// A 50% size reduction can be a 2x performance improvement in array iteration

// Tool: fieldalignment (golang.org/x/tools/go/analysis/passes/fieldalignment)
// go install golang.org/x/tools/cmd/fieldalignment@latest
// fieldalignment ./...     — reports suboptimal struct layouts
// fieldalignment -fix ./.. — rewrites structs (review before committing!)

// go vet includes a similar check with: -structtag flag

The rule of thumb: order struct fields from largest to smallest alignment requirement. In practice: int64/float64/uintptr (8 bytes) first, then int32/float32 (4 bytes), then int16 (2 bytes), then bool/int8/byte (1 byte) last. This is particularly important for structs that appear in large arrays or slices processed in hot paths.

Why does the order of fields in a Go struct affect its memory size?
For a struct with a bool, int64, and bool, what field order minimises padding?
32. How do you implement fan-out and fan-in patterns with Go goroutines?

Fan-out: one goroutine distributes work to multiple worker goroutines. Fan-in: multiple goroutines send results back to a single aggregator. Together they form Go's most common concurrency idiom for parallel pipelines.

// Fan-out / Fan-in pipeline
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums { out <- n }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in { out <- n * n }
    }()
    return out
}

// Fan-out: start N workers on the same input channel
func fanOut(in <-chan int, workers int) []<-chan int {
    outs := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        outs[i] = square(in) // each reads from the shared input
    }
    return outs
}

// Fan-in: merge N result channels into one
func fanIn(ctx context.Context, channels ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    merged := make(chan int)

    forward := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            select {
            case merged <- n:
            case <-ctx.Done(): return
            }
        }
    }
    wg.Add(len(channels))
    for _, c := range channels { go forward(c) }

    go func() { wg.Wait(); close(merged) }()
    return merged
}

// Wire it up
nums := generate(1, 2, 3, 4, 5)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := fanIn(ctx, fanOut(nums, 3)...)
for r := range results { fmt.Println(r) }
In a fan-in pattern, what goroutine-safe mechanism is used to close the merged output channel after all source goroutines finish?
Why should a pipeline stage's output channel be closed by the goroutine that sends to it, not by the receiver?
33. How does the reflect package work in Go and when should it be used?

The reflect package provides runtime type introspection. It lets you inspect types and values at runtime, set values dynamically, and call methods whose signatures are not known at compile time. It is the foundation of JSON marshalling, ORM field mapping, and dependency injection frameworks.

import "reflect"

// reflect.TypeOf and reflect.ValueOf
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
p := Person{"Alice", 30}

t := reflect.TypeOf(p)
v := reflect.ValueOf(p)

fmt.Println(t.Name())       // "Person"
fmt.Println(t.Kind())       // struct
fmt.Println(v.Field(0))     // Alice
fmt.Println(v.Field(0).Type()) // string

// Iterating struct fields
for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    val   := v.Field(i)
    tag   := field.Tag.Get("json")
    fmt.Printf("%s (%s) [json:%s] = %v\n", field.Name, field.Type, tag, val)
}

// Setting values via reflect — requires addressable value
vp := reflect.ValueOf(&p).Elem() // dereference pointer to get addressable value
vp.FieldByName("Name").SetString("Bob")
fmt.Println(p.Name) // Bob

// Calling methods dynamically
method := v.MethodByName("String") // if Person has a String() method
if method.IsValid() {
    results := method.Call(nil)
    fmt.Println(results[0])
}

// reflect.DeepEqual — structural equality (used in tests)
fmt.Println(reflect.DeepEqual([]int{1,2,3}, []int{1,2,3})) // true

Performance warning: reflection is 10–100× slower than direct type-specific code because it bypasses compiler optimisations. Use reflection in framework/library code that genuinely needs runtime type introspection; avoid it in hot paths. Go 1.18+ generics often replace the need for reflection in type-agnostic algorithms.

Why does reflect.ValueOf(p).Field(0).SetString(...) panic?
What is the primary reason to prefer generics over reflection for type-agnostic algorithms in Go 1.18+?
34. What is cgo in Go and what are its performance trade-offs?

cgo allows Go programs to call C functions and vice versa. It is used for binding to C libraries (OpenSSL, SQLite, CUDA), OS system calls not exposed in the Go standard library, and legacy C codebases.

// Simple cgo example
package main

// #include 
// #include 
// char* greet(const char* name) {
//     char* result = malloc(100);
//     snprintf(result, 100, "Hello, %s!", name);
//     return result;
// }
import "C"
import (
    "fmt"
    "unsafe"
)

func main() {
    cname := C.CString("World")     // Go string → C string (malloc)
    defer C.free(unsafe.Pointer(cname)) // must free C memory!

    cresult := C.greet(cname)           // call C function
    result := C.GoString(cresult)        // C string → Go string (copy)
    C.free(unsafe.Pointer(cresult))

    fmt.Println(result) // Hello, World!
}

// cgo overhead per call:
// 60-200 ns per C call (vs ~1 ns for a plain Go function call)
// The runtime must save goroutine state, switch stack frames, etc.

// Cross-compilation challenge:
// CGO_ENABLED=0 disables cgo and allows full static binary cross-compilation
// CGO_ENABLED=1 (default) requires a C compiler on the target platform

// Alternatives to cgo:
// - Pure Go implementations (avoid cgo entirely)
// - WASI / WebAssembly for sandboxed native code
// - gRPC subprocess calling a C binary

Key costs of cgo: (1) each C call is ~60–200 ns overhead — avoid calling C in tight loops. (2) goroutines calling C must have the C function run on an OS thread — the Go scheduler complexity increases. (3) C memory is not managed by the Go GC — you must explicitly free it. (4) cross-compilation is much harder with cgo enabled.

Why is calling a C function from Go via cgo significantly slower than a regular Go function call?
What happens to Go's cross-compilation capability when CGO_ENABLED=1?
35. What does GOMAXPROCS control and how does it affect Go's concurrency model?

GOMAXPROCS sets the number of OS threads (Ps — Processors in the GMP model) that can execute Go code simultaneously. It defaults to the number of logical CPUs available (runtime.NumCPU()). Increasing it allows more goroutines to run in true parallel; the constraint is the number of physical cores, not the number of goroutines.

import "runtime"

// Query current GOMAXPROCS
fmt.Println(runtime.GOMAXPROCS(0))  // 0 = query without changing
fmt.Println(runtime.NumCPU())        // logical CPUs (may include HyperThreading)

// Set programmatically (returns old value)
old := runtime.GOMAXPROCS(4)

// Environment variable
// GOMAXPROCS=2 go run main.go

// Effect on CPU-bound vs I/O-bound workloads:
// CPU-bound: increasing GOMAXPROCS → true parallelism, up to NumCPU
// I/O-bound: even GOMAXPROCS=1 can handle millions of concurrent goroutines
//   (blocked goroutines don't hold a P)

// Container / cloud consideration:
// GOMAXPROCS defaults to host CPU count, NOT container CPU quota
// This causes over-scheduling in containers with CPU limits
// Fix: use uber-go/automaxprocs
// import _ "go.uber.org/automaxprocs" // reads cgroup quota automatically

// Benchmark: optimal GOMAXPROCS depends on workload
// CPU-bound matrix multiply: scales linearly to NumCPU
// I/O-bound HTTP server: GOMAXPROCS=2-4 often sufficient; more P's = more
//   scheduler overhead without extra throughput on I/O-bound work

// GOMAXPROCS=1 guarantees sequential goroutine execution (useful in tests)
// Some data-race bugs only appear with GOMAXPROCS > 1

Container pitfall: by default, Go reads GOMAXPROCS from the host's CPU count, not the container's CPU quota. A Go service running in a 0.5-CPU container may spawn 32 OS threads (matching a 32-core host), causing severe context-switching overhead and latency. The uber-go/automaxprocs library fixes this by reading the cgroup CPU quota and setting GOMAXPROCS accordingly.

What is the default value of GOMAXPROCS when a Go program starts?
Why is GOMAXPROCS set to the host CPU count a problem in containers?
36. How does sync.Once work and what are its use cases?

sync.Once guarantees that a function is executed exactly once, regardless of how many goroutines call it concurrently. It is the idiomatic Go way to implement lazy initialisation and singletons without explicit locking in user code.

var (
    instance *Database
    once     sync.Once
)

func GetDB() *Database {
    once.Do(func() {
        // This function runs exactly once across all goroutines
        instance = &Database{
            conn: openConnection(config),
        }
    })
    return instance // safe to return — once.Do blocks until init completes
}

// All concurrent callers either:
// - Execute the function themselves (first caller)
// - Block until the function completes and then return (all others)

// ERROR HANDLING in sync.Once — no built-in mechanism
// Pattern: store the error alongside the result
type singleton struct {
    db  *Database
    err error
}
var s singleton
var initOnce sync.Once

func getDB() (*Database, error) {
    initOnce.Do(func() {
        s.db, s.err = openDB()
    })
    return s.db, s.err
}

// sync.OnceFunc (Go 1.21) — wraps a function so it runs at most once
initDB := sync.OnceFunc(func() { /* initialise */ })
initDB() // safe to call from multiple goroutines
initDB() // no-op — function already ran

// sync.OnceValue / sync.OnceValues (Go 1.21) — return a value
getConfig := sync.OnceValue(func() *Config { return loadConfig() })
cfg := getConfig() // computed once, cached

sync.Once has no reset mechanism — once the function has run, there is no way to make it run again. This is by design. If you need resettable one-time execution, use a mutex-protected boolean flag instead. Go 1.21 added sync.OnceFunc, sync.OnceValue, and sync.OnceValues as ergonomic wrappers.

What does sync.Once.Do() guarantee about the wrapped function?
What happens if the function passed to sync.Once.Do() panics?
37. What are the most common memory leak patterns in Go and how do you diagnose them?

Go's GC handles most memory management, but certain patterns prevent objects from being collected even when they are logically no longer needed:

Common Go Memory Leak Patterns
PatternCauseFix
Goroutine leakGoroutine blocked forever on channel/I/OUse context cancellation or close channels
Slice sub-slice holding large backing arraySmall sub-slice keeps 100 MB backing array aliveCopy sub-slice: copy(small, big[start:end])
Global variable accumulationGlobal map or slice grown indefinitelyAdd eviction, use expiring cache, or bounded data structure
Finalizer keeping object aliveObjects with finalizers are delayed one GC cycleAvoid finalizers; use Cleaner or defer with explicit close
String conversion retaining bytes[]byte → string keeps original byte arrayUse string(bytes) to copy; beware zero-copy hacks
time.Ticker not stoppedTicker goroutine and channel alive until GCAlways call ticker.Stop() and drain the channel
// Slice leak — sub-slice keeps large backing array alive
func getHeader(data []byte) []byte {
    return data[:8]  // BAD: keeps all of data alive
}
func getHeaderFixed(data []byte) []byte {
    header := make([]byte, 8)
    copy(header, data)  // GOOD: independent small slice
    return header
}

// Ticker leak — always stop
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() // MUST stop to avoid goroutine+channel leak
for {
    select {
    case <-ticker.C: doWork()
    case <-ctx.Done(): return
    }
}

// Diagnosing memory leaks
// 1. Monitor heap via /debug/pprof/heap
// 2. Compare two heap snapshots over time:
//    go tool pprof -base old.pprof new.pprof
// 3. Watch runtime.ReadMemStats — if HeapAlloc keeps growing
//    after GC, something is holding references
// 4. goroutine profile: /debug/pprof/goroutine?debug=1
//    if NumGoroutine keeps growing, you have goroutine leaks
A function returns data[:8] from a 10 MB []byte parameter. What memory issue can this cause?
What must you always do with a time.NewTicker to prevent a resource leak?
38. How do build constraints (build tags) work in Go?

Build constraints allow you to conditionally include or exclude Go source files based on the target OS, architecture, Go version, or custom tags. They are essential for platform-specific code, test-only code, and feature flags at build time.

// Modern syntax (Go 1.17+) — //go:build directive
// Place this as the FIRST non-blank, non-comment line

//go:build linux && amd64

package main

// This file is only compiled on Linux/amd64
func platformSpecific() { /* ... */ }

// Combining constraints
//go:build (darwin || linux) && !386

// Custom tags — enable with: go build -tags integration
//go:build integration

package main

func TestIntegration(t *testing.T) { /* runs only with -tags integration */ }

// OS and architecture tags (auto-detected from filename or directive)
// file_linux.go       — only on Linux
// file_windows_amd64.go — Windows + amd64
// file_test.go        — only in test builds

// Go version constraint
//go:build go1.21

// Negate a tag
//go:build !cgo

// Run tests with a custom tag:
// go test -tags integration ./...

// Build ignoring all tags:
// go build -tags '' ./...

// Legacy syntax (still supported for compatibility):
// // +build linux
// (Note: one blank line between +build and package declaration)

The filename convention (_linux.go, _amd64.go) is a shorthand that the Go toolchain parses automatically — equivalent to a //go:build directive. File suffix constraints use underscore-separated GOOS and GOARCH values. If a file has both a filename constraint and a //go:build directive, both must be satisfied.

What is the correct modern syntax (Go 1.17+) for a build constraint that compiles a file only on Linux?
How do you run 'go test' including files that are only compiled with a custom tag 'integration'?
39. How do io.Reader and io.Writer work and why are they fundamental to Go's I/O model?

io.Reader and io.Writer are the two most important interfaces in the Go standard library. Their simplicity (one method each) enables an enormous amount of composition and abstraction across files, network connections, bytes buffers, gzip streams, crypto, and more.

// The core interfaces:
// type Reader interface { Read(p []byte) (n int, err error) }
// type Writer interface { Write(p []byte) (n int, err error) }

// io.Copy — generic transfer between any Reader and Writer
func Copy(dst io.Writer, src io.Reader) (written int64, err error)

// Examples of io.Reader implementations:
// *os.File, *bytes.Buffer, *strings.Reader, net.Conn, *gzip.Reader
// http.Response.Body, *bufio.Reader, *io.LimitedReader

// Composing readers
func processRequest(r *http.Request) {
    // Layer buffering, decompression, and limiting in one chain
    limited  := io.LimitReader(r.Body, 10<<20)      // max 10 MB
    gzr, _   := gzip.NewReader(limited)             // decompress
    buffered := bufio.NewReader(gzr)                 // buffer reads
    // Now read from buffered — all layers transparent
    line, _, _ := buffered.ReadLine()
    fmt.Println(string(line))
}

// Custom Reader — generate Fibonacci numbers as bytes
type FibReader struct{ a, b int }
func (f *FibReader) Read(p []byte) (int, error) {
    if len(p) == 0 { return 0, nil }
    n := copy(p, []byte(fmt.Sprintf("%d ", f.a)))
    f.a, f.b = f.b, f.a+f.b
    return n, nil // io.EOF to signal end of stream
}

// io.TeeReader — read from r while writing to w simultaneously
var buf bytes.Buffer
tee := io.TeeReader(r.Body, &buf) // buf captures what is read from r.Body
io.Copy(downstream, tee)

The Read contract: a successful read returns n > 0, err == nil or final data with err == io.EOF. A Read may return n > 0 together with io.EOF in the same call. Callers must process the n bytes before inspecting the error. Never assume Read fills the entire buffer — always loop until io.EOF or use io.ReadFull.

What does io.Copy do that makes it universally useful for I/O transfer?
Why is it incorrect to assume that a single Read() call fills the entire buffer?
40. What is the Go optimisation workflow? How do you go from a performance problem to a fix?

Premature optimisation is wasteful; uninformed optimisation is harmful. The Go ecosystem provides a disciplined, measurement-driven workflow: profile first, identify the actual bottleneck, optimise, verify the improvement, and repeat.

// Step 1: establish baseline with benchmarks
// go test -bench=BenchmarkFoo -count=5 -benchmem > before.txt

// Step 2: collect a CPU profile
// go test -bench=BenchmarkFoo -cpuprofile=cpu.out
// go tool pprof cpu.out
// (pprof) top10         — hottest functions
// (pprof) web           — flame graph (requires graphviz)
// (pprof) list hotFunc  — annotated source

// Step 3: collect a memory profile
// go test -bench=BenchmarkFoo -memprofile=mem.out
// go tool pprof mem.out
// (pprof) top10 -cum   — allocation hotspots

// Step 4: check escape analysis — are expected stack allocs escaping?
// go build -gcflags="-m -m" ./...

// Step 5: common optimisations to check:
// - Replace []byte string(b) conversions in hot paths
// - Pre-allocate slices with known capacity: make([]T, 0, n)
// - Use sync.Pool for frequently allocated/freed objects
// - Replace interface{} parameters with generics (Go 1.18+)
// - Replace reflect with code generation (go generate)
// - Use buffered I/O (bufio.Writer) instead of unbuffered

// Step 6: verify improvement
// go test -bench=BenchmarkFoo -count=5 -benchmem > after.txt
// benchstat before.txt after.txt
// Outputs: delta, p-value, whether improvement is statistically significant

// Step 7: run with -race to ensure no correctness regression
// go test -race ./...

The golden rule: measure first. Intuition about where time is spent is usually wrong. The pprof CPU profile shows the actual hot path. A 10% improvement in a function that takes 1% of total time is invisible; a 10% improvement in a function that takes 80% is significant. Use benchstat to confirm improvements are statistically significant and not just noise.

What is the primary tool for comparing two sets of Go benchmark results to determine if an optimisation is statistically significant?
What does 'go build -gcflags="-m"' help you identify?
41. What is sync.Pool and when should you use it?

sync.Pool is a thread-safe pool of temporarily reusable objects. It reduces GC pressure by allowing objects to be returned to the pool after use and reused by the next caller, avoiding repeated heap allocation and deallocation.

// Typical use: expensive-to-allocate, frequently used objects
var bufPool = sync.Pool{
    New: func() any {
        // Called when pool is empty — allocate a fresh object
        buf := make([]byte, 0, 4096)
        return &buf
    },
}

func processRequest(data []byte) string {
    // Get a buffer from the pool (may be a fresh allocation or reused)
    bufPtr := bufPool.Get().(*[]byte)
    buf := (*bufPtr)[:0] // reset length, keep capacity

    // ... use buf for scratch work ...
    buf = append(buf, data...)
    result := string(buf)

    // Return to pool — DON'T use buf after this
    *bufPtr = buf
    bufPool.Put(bufPtr)
    return result
}

// Important constraints:
// 1. The GC MAY clear the pool between any two GC cycles
//    Do NOT rely on objects in the pool surviving across GCs
// 2. Objects must be safe to reset to a clean state before reuse
// 3. Do NOT store pointers to pool objects — return them before the caller returns

// Real-world examples:
// - encoding/json uses sync.Pool for encoder/decoder scratch buffers
// - fmt uses sync.Pool for pp (print state) objects
// - net/http uses sync.Pool for request buffers

// Anti-patterns:
// - Pooling objects that hold open file descriptors or connections
// - Pooling objects that are expensive to validate/clean on reuse
// - Using Pool for long-lived objects (GC will evict them)

The GC clears sync.Pool during each collection cycle — pool items are not permanent. This is intentional: the pool exists only to reduce per-GC-cycle allocation pressure, not to replace a cache. If you need objects to survive GC cycles, use a channel-based pool or a properly-designed LRU cache.

What does the Go GC do to sync.Pool contents during a GC cycle?
What is the primary purpose of sync.Pool?
42. How do type assertions and type switches perform internally in Go?

Type assertions and type switches are used to recover the concrete type from an interface value. Their performance characteristics matter in hot paths because they involve pointer comparisons and potentially method table lookups.

// Single type assertion
var v interface{} = "hello"

// Panicking form — use only when you are certain of the type
s := v.(string)       // panics if v is not a string

// Safe form — never panics
s, ok := v.(string)   // ok=false if wrong type
if !ok {
    // handle mismatch
}

// Type switch — more efficient than chained if assertions
// The compiler generates optimised comparison code
func describe(v interface{}) string {
    switch x := v.(type) {
    case int:     return fmt.Sprintf("int: %d", x)
    case string:  return fmt.Sprintf("string: %q", x)
    case bool:    return fmt.Sprintf("bool: %v", x)
    case []byte:  return fmt.Sprintf("bytes: %d bytes", len(x))
    default:      return fmt.Sprintf("unknown: %T", x)
    }
}

// Performance:
// Single type assertion: ~1-3 ns — pointer comparison on the type word
// Type switch with many cases: O(n) comparisons unless compiler optimises
// For hot paths with many types: use a map[reflect.Type]func() or generics

// Interface-to-interface assertion: also cheap (same mechanism)
type Stringer interface{ String() string }
var r io.Reader = os.Stdin
if s, ok := r.(Stringer); ok {
    fmt.Println(s.String()) // os.File implements Stringer
}

A type assertion compares the concrete type pointer stored in the interface's type word against the target type. For interface-to-interface assertions, the runtime checks whether the concrete type implements the target interface by looking up the appropriate itab. The cache of itab lookups makes repeated interface assertions amortised O(1).

What is the difference between 'v.(string)' and 'v, ok := v.(string)' when the assertion fails?
In a type switch with 10 cases, what is the time complexity of finding the matching case?
43. How does Go's module system work and what is the role of go.sum?

The Go module system (introduced in Go 1.11, stable in Go 1.13) is the standard dependency management mechanism. A module is a collection of Go packages with a go.mod file at its root that declares the module path, Go version, and dependencies.

// go.mod — describes this module's dependencies
module github.com/myorg/myapp

go 1.21

require (
    github.com/gin-gonic/gin v1.9.1
    golang.org/x/sync v0.6.0
)

// go.sum — cryptographic checksums for dependencies
// github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
// github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=

// Key commands:
// go mod init github.com/myorg/myapp  — create go.mod
// go get github.com/pkg/errors@v0.9.1 — add/update a dependency
// go mod tidy                         — remove unused, add missing deps
// go mod download                     — pre-download modules to cache
// go mod vendor                       — copy deps into ./vendor
// go list -m all                      — list all deps (direct + indirect)

// Minimum Version Selection (MVS):
// Go selects the minimum version of each dependency that satisfies all requirements
// This is deterministic and avoids 'dependency hell'
// If A requires B>=1.2 and C requires B>=1.5, Go selects B@1.5 (minimum satisfying all)

// Replace directive — useful for local development or forking
replace github.com/vendor/lib => ../local-lib

// Exclude — skip a specific buggy version
exclude github.com/vendor/lib v1.2.3

go.sum contains SHA-256 checksums (h1: hashes) for every dependency's source tree and its go.mod file. The Go toolchain verifies these checksums against the checksum database (sum.golang.org) on every download. This provides supply-chain security: a tampered dependency produces a checksum mismatch and the build fails. Commit both go.mod and go.sum to version control.

What is Go's Minimum Version Selection (MVS) algorithm?
What is the purpose of the go.sum file?
44. How do you implement a worker pool in Go?

A worker pool limits the number of goroutines working concurrently, preventing resource exhaustion when processing a large number of tasks. It is one of the most common Go concurrency patterns.

// Classic worker pool pattern
func workerPool(ctx context.Context, jobs <-chan Job, results chan<- Result, numWorkers int) {
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return // cancelled
                case job, ok := <-jobs:
                    if !ok { return } // channel closed — no more jobs
                    result := process(job)
                    select {
                    case results <- result:
                    case <-ctx.Done(): return
                    }
                }
            }
        }(i)
    }

    // Close results after all workers finish
    go func() {
        wg.Wait()
        close(results)
    }()
}

// Usage
const numWorkers = 10
jobs    := make(chan Job, 100)
results := make(chan Result, 100)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

workerPool(ctx, jobs, results, numWorkers)

// Feed jobs
go func() {
    defer close(jobs)
    for _, job := range allJobs {
        select {
        case jobs <- job:
        case <-ctx.Done(): return
        }
    }
}()

// Collect results
for r := range results {
    fmt.Println(r)
}

The worker pool pattern ensures bounded concurrency — with virtual threads in other languages this is less critical, but in Go it matters when each goroutine holds OS resources (database connections, file handles) that are finite. Set numWorkers based on the resource constraint: for I/O-bound work constrained by a connection pool of size N, use N workers. For CPU-bound work, use runtime.NumCPU() workers.

Why close the 'results' channel inside a goroutine that waits for the WaitGroup rather than after workerPool() returns?
For a worker pool processing database queries with a connection pool of 20 connections, what is the optimal number of workers?
45. What static analysis tools are essential for a Go project and what does each check?

Go's tooling ecosystem provides multiple layers of static analysis, from the built-in go vet to powerful third-party linters. Using them as part of CI prevents entire categories of bugs.

Key Go Static Analysis Tools
ToolCommandWhat it catches
go vetgo vet ./...Misuse of Printf verbs, unreachable code, copying sync.Mutex, shadowed variables, incorrect struct tags
go buildgo build ./...Compile errors, unused imports, missing packages
go test -racego test -race ./...Data races — concurrent unsynchronised memory access
staticcheckstaticcheck ./...Deprecated API use, performance issues, dead code, semantic bugs go vet misses
golangci-lintgolangci-lint runMeta-linter running 50+ linters including vet, staticcheck, errcheck, gocritic
errcheckerrcheck ./...Unchecked error return values (a very common Go bug source)
govulncheckgovulncheck ./...Known vulnerabilities in dependencies (Go's official security scanner)
// .golangci.yml — configuration for golangci-lint in CI
# linters:
#   enable:
#     - govet
#     - staticcheck
#     - errcheck
#     - gosec
#     - misspell
#     - gofmt
#     - revive

// Example: errcheck catches a common bug
os.Remove(tmpFile)  // BAD: ignoring error return
if err := os.Remove(tmpFile); err != nil {  // GOOD
    log.Println("cleanup failed:", err)
}

// go vet catches Printf format mismatches
fmt.Printf("%d", "hello") // go vet: argument "hello" is a string, not int

// govulncheck — check for known CVEs
// go install golang.org/x/vuln/cmd/govulncheck@latest
// govulncheck ./...
// Vulnerability #1: GO-2023-1234 in github.com/vulnerable/pkg@v1.2.3

// Recommended CI pipeline:
// 1. go build ./...
// 2. go vet ./...
// 3. go test -race -count=1 ./...
// 4. golangci-lint run
// 5. govulncheck ./...
What class of bug does 'errcheck' find that 'go vet' misses?
Which Go official tool scans your module's dependencies for known security vulnerabilities?
«
»

Comments & Discussions