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?
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:
| Field | Type | Meaning |
|---|---|---|
| ptr | unsafe.Pointer | Pointer to the first element of the backing array |
| len | int | Number of elements accessible through the slice |
| cap | int | Total 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.
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 beyonds.len. The returned slice shares the same backing array assbut with an incrementedlen. - 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.
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.
| Aspect | Stack | Heap |
|---|---|---|
| Lifetime | Function frame — freed on return | Until GC collects (no references) |
| Allocation cost | Near zero (pointer bump) | GC overhead, malloc-like |
| GC involvement | None | Tracked by GC tri-color marking |
| Access speed | Fastest (CPU cache friendly) | Slightly slower (pointer indirection) |
| Size | Default 2 KB, grows dynamically to 1 GB | Limited 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.
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:
- Allocates a new, larger stack (typically double the current size).
- Copies the entire old stack to the new location.
- Updates all pointers that referred into the old stack (pointer fixup).
- 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.
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.
| Color | Meaning |
|---|---|
| White | Not yet visited. At GC end, white objects are unreachable — will be freed. |
| Grey | Reachable from a root, but outgoing references not yet scanned. |
| Black | Fully scanned. All references from this object have been processed. |
Algorithm phases:
- STW start (~100 µs): pauses all goroutines briefly to take a consistent root snapshot and enable the write barrier.
- Concurrent mark: GC goroutines run alongside application goroutines, turning grey objects black by scanning their references. Application goroutines continue executing.
- 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.
- Mark termination (STW) (~100 µs): a second brief pause to drain the grey queue and disable the write barrier.
- 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.
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).
| Variable | Default | Meaning |
|---|---|---|
| GOGC | 100 | GC triggers when live heap grows by GOGC% since last GC |
| GOMEMLIMIT | math.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.
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)
| Concept | Detail |
|---|---|
| Bucket size | 8 key-value pairs per bucket |
| Tophash | Top 8 bits of hash stored per slot for fast rejection |
| Load factor | Rehash triggered at ~6.5 entries/bucket (overflow buckets used before) |
| Iteration order | Randomised per run — the runtime deliberately adds randomisation |
| Thread safety | Not thread-safe — use sync.Mutex, sync.RWMutex, or sync.Map |
| Key requirement | Keys must be comparable (==); slices, maps, functions cannot be keys |
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.
| Aspect | map + sync.Mutex | sync.Map |
|---|---|---|
| API | Standard map syntax | Load, Store, LoadOrStore, Delete, Range |
| Ideal workload | General purpose, write-heavy | Read-heavy, mostly stable key sets |
| Performance on reads | Good (RWMutex) | Excellent — reads often lock-free |
| Performance on writes | Good | Slower — write path is complex |
| Key types | Any comparable | interface{} (loses type safety) |
| Zero value | Must initialise map first | Ready 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.
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
| Use pointer when... | Use value when... |
|---|---|
| The function must mutate the receiver/argument | The 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 field | Immutability is desirable |
| Consistency: other methods use pointer receiver | Type is inherently value-like (time.Time, net/netip.Addr) |
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:
| Entity | Symbol | Description |
|---|---|---|
| Goroutine | G | The logical unit of work — a user-space green thread with its own stack |
| Machine (OS thread) | M | An OS thread that executes Go code; managed by the runtime |
| Processor | P | A 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.
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.
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.
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.
| Layer | Scope | Locking | Purpose |
|---|---|---|---|
| mcache | Per-P (per logical CPU) | Lock-free | Per-CPU cache of spans for each size class — fast path |
| mcentral | Per size class, global | Mutex per size class | Central pool of spans; supplies mcache when empty |
| mheap | Global | Mutex (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.
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.
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.
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.
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).
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.
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.
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.
| Scenario | Preferred Tool |
|---|---|
| Passing ownership of data between goroutines | Channel |
| Signalling an event (done, cancel, ready) | Channel (or context.Context) |
| Pipeline of work items | Channel |
| Fan-out / fan-in patterns | Channel + sync.WaitGroup |
| Protecting a shared cache or counter | Mutex (sync.Mutex or atomic) |
| Read-heavy shared config or registry | sync.RWMutex or sync.Map |
| Updating a struct's fields | Mutex 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()) }
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.
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.
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.
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.
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() // }
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.
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.
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.
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
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.
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.
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) }
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.
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.
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.
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.
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:
| Pattern | Cause | Fix |
|---|---|---|
| Goroutine leak | Goroutine blocked forever on channel/I/O | Use context cancellation or close channels |
| Slice sub-slice holding large backing array | Small sub-slice keeps 100 MB backing array alive | Copy sub-slice: copy(small, big[start:end]) |
| Global variable accumulation | Global map or slice grown indefinitely | Add eviction, use expiring cache, or bounded data structure |
| Finalizer keeping object alive | Objects with finalizers are delayed one GC cycle | Avoid finalizers; use Cleaner or defer with explicit close |
| String conversion retaining bytes | []byte → string keeps original byte array | Use string(bytes) to copy; beware zero-copy hacks |
| time.Ticker not stopped | Ticker goroutine and channel alive until GC | Always 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
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.
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.
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.
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.
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).
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.
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.
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.
| Tool | Command | What it catches |
|---|---|---|
| go vet | go vet ./... | Misuse of Printf verbs, unreachable code, copying sync.Mutex, shadowed variables, incorrect struct tags |
| go build | go build ./... | Compile errors, unused imports, missing packages |
| go test -race | go test -race ./... | Data races — concurrent unsynchronised memory access |
| staticcheck | staticcheck ./... | Deprecated API use, performance issues, dead code, semantic bugs go vet misses |
| golangci-lint | golangci-lint run | Meta-linter running 50+ linters including vet, staticcheck, errcheck, gocritic |
| errcheck | errcheck ./... | Unchecked error return values (a very common Go bug source) |
| govulncheck | govulncheck ./... | 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 ./...
