Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Go (Golang) Interview Questions (70+ Deep Dive Q&A)
1. Core Architecture (GMP & Runtime)
1. GMP Scheduler Model (Visualized)
1. GMP Scheduler Model (Visualized)
- G (Goroutine): The unit of work. Starts with a 2KB stack (grows dynamically up to 1GB on 64-bit). Contains function pointer, stack pointer, and status (runnable, running, waiting, dead).
- M (Machine): An actual OS thread. Executes machine instructions. The runtime caps M count at 10,000 by default (
GOMAXPROCSdoes NOT control M count — it controls P count). - P (Processor): A logical processor — the scheduling context required to execute Go code. Each P has a Local Run Queue (LRQ) of up to 256 Gs. Number of Ps =
GOMAXPROCS(defaults to number of CPU cores).
go func() is called):- New G is created and placed onto the current P’s local queue
- If the local queue is full (256 Gs), half are moved to the Global Run Queue
- When P finishes running a G, it pops the next G from its local queue
- If local queue is empty, P tries to steal half of another P’s local queue (work-stealing)
- If no other P has work, P checks the Global Queue (requires lock, so checked less frequently — every 61 scheduling ticks to prevent starvation)
- If still nothing, P checks the network poller for Gs waiting on I/O
- Work-stealing avoids the bottleneck of a single global queue. Each P’s local queue is a lock-free ring buffer
- Handoff: If an M blocks on a syscall, the runtime detaches P from that M and assigns P to a new (or idle) M so other Gs keep running. This is why Go handles blocking I/O well despite multiplexing
- Spinning threads: Idle Ms “spin” briefly before parking, reducing latency for newly created Gs (avoids the cost of waking a parked thread)
- What happens when a goroutine makes a blocking syscall like
file.Read()? (Answer: M is parked, P is handed off to another M) - How does the network poller differ from blocking syscall handling? (Answer:
netpolluses epoll/kqueue — Gs waiting on network I/O are parked without blocking any M) - What would happen if you set GOMAXPROCS to 1 in a CPU-bound workload with 1000 goroutines? (Answer: All goroutines serialize on a single P — effectively single-threaded execution, no parallelism, but concurrency still works via cooperative scheduling)
2. Goroutine vs OS Thread
2. Goroutine vs OS Thread
| Feature | Goroutine | OS Thread |
|---|---|---|
| Size | ~2KB initial (grows to 1GB) | ~1-8MB fixed (OS dependent, Linux default 8MB) |
| Creation | ~0.3 microseconds (user space) | ~10-30 microseconds (kernel syscall clone()) |
| Context Switch | ~50-200ns (save/restore ~15 registers, no kernel transition) | ~1-5 microseconds (full kernel context switch, TLB flush) |
| Scheduler | Go Runtime (cooperative + preemptive since Go 1.14) | OS Kernel (fully preemptive) |
| Max count | Millions (limited by memory) | Thousands (limited by kernel, default ~32K on Linux) |
| Stack | Dynamically grown (contiguous, copied) | Fixed at creation time |
- Before Go 1.14: Goroutines yielded only at function calls, channel ops, or syscalls. A tight
forloop with no function calls could starve other goroutines (the “tight loop” problem) - Go 1.14+: Asynchronous preemption via signals (
SIGURGon Linux). The runtime can interrupt any goroutine at safe points, even in tight loops. This was a major improvement for latency-sensitive workloads
- Can you have a goroutine leak? How would you detect it? (Answer: Yes — goroutines waiting on channels/locks that never resolve. Detect with
runtime.NumGoroutine(),pprofgoroutine profiles, or tools likegoleakfrom Uber) - What changed in Go 1.14 regarding goroutine preemption? (Answer: Asynchronous preemption via OS signals — solves the tight-loop starvation problem)
- If goroutines are so cheap, why would you ever use a worker pool instead of spawning unlimited goroutines? (Answer: To bound resource usage — e.g., max DB connections, file descriptors, or memory. Unbounded goroutines can overwhelm downstream systems or exhaust memory)
3. Garbage Collection (Tricolor Mark & Sweep)
3. Garbage Collection (Tricolor Mark & Sweep)
- Mark Phase (concurrent with application):
- Start from roots: globals, goroutine stacks, registers
- White: Not yet visited (potentially garbage)
- Grey: Visited, but children not yet scanned
- Black: Visited, all children scanned (definitely alive)
- Objects move: White -> Grey -> Black
- Write Barrier (critical for correctness):
- The hybrid write barrier (Go 1.8+) ensures the invariant: no black object points directly to a white object
- Without this, a concurrent mutator could hide a live object from the collector, causing it to be freed (use-after-free)
- The write barrier adds ~5-15% overhead to pointer writes during GC
- Sweep Phase (concurrent):
- All remaining white objects are garbage — their memory is reclaimed
- Sweeping happens lazily (on allocation) or in background goroutines
- Go’s GC has two brief STW phases: (1) enabling the write barrier at mark start, (2) re-scanning stacks at mark termination
- Target: <500 microseconds per pause. In practice, Go 1.19+ achieves <100 microseconds for most workloads
- You can observe pauses via
GODEBUG=gctrace=1or theruntime/metricspackage
GOGC(default 100): Controls GC frequency.GOGC=100means GC triggers when heap grows 100% since last collection.GOGC=200means GC triggers at 200% growth (less frequent, more memory).GOGC=offdisables GC (dangerous)GOMEMLIMIT(Go 1.19+): Soft memory limit. The runtime will GC more aggressively to stay under this limit. This was a game-changer for containerized workloads where you know your memory budget- Ballast pattern (pre-1.19): Allocate a large
[]byteto inflate heap size and reduce GC frequency. Obsoleted byGOMEMLIMIT
- Go allocates many short-lived objects on the stack (via escape analysis), which never touch the GC at all. This eliminates the main benefit of generational GC
- Generational GC requires a write barrier for ALL pointer writes (not just during GC), which would add constant overhead to every pointer assignment
- Your Go service is experiencing GC pauses of 5ms. How do you diagnose and fix it? (Answer: Enable
gctrace, usepprofheap profile, check allocation rate. Common fixes: reduce allocations withsync.Pool, increaseGOGC, setGOMEMLIMIT, pre-allocate slices) - What is
sync.Pooland how does it interact with GC? (Answer: A per-P pool of reusable objects. Pools are cleared every GC cycle. Good for reducing allocation rate of short-lived objects like buffers) - How does GOMEMLIMIT differ from setting GOGC=off with manual memory management? (Answer: GOMEMLIMIT is a soft limit — GC still runs but more aggressively near the limit.
GOGC=offdisables GC entirely, risking OOM.GOMEMLIMITgives you the best of both: low GC overhead when memory is plentiful, aggressive collection when approaching the limit)
4. Stack Growth (Contiguous vs Split)
4. Stack Growth (Contiguous vs Split)
- Allocates a new stack 2x the size of the current one
- Copies the entire old stack to the new stack
- Adjusts all pointers on the stack to point to the new locations (this is why Go doesn’t allow interior pointers to stack-allocated objects from the heap)
- Frees the old stack
- Go 1.0-1.3 used segmented (split) stacks: linked list of small stack chunks
- Problem: Hot split — if a function call sits right at a stack boundary, it repeatedly allocates and frees a new segment on every call. This caused pathological performance (10x slowdown in tight loops near the boundary)
- Contiguous stacks eliminate hot split entirely at the cost of occasional copies
- Deep recursion is fine — stacks grow dynamically. But each growth triggers a copy, so very deep recursion has hidden allocation costs
- The stack growth check (
morestack) is inserted by the compiler at function prologues. This is also the mechanism used for cooperative scheduling (pre-Go 1.14) runtime.debug.SetMaxStackcan limit maximum stack size (default 1GB on 64-bit)
- How does stack growth interact with escape analysis? (Answer: If the compiler can’t prove a variable stays on the stack, it escapes to the heap. Stack-to-heap escape avoids the problem of pointers to stack memory that might be invalidated during stack copying)
- What is the “hot split” problem and why did Go move away from segmented stacks? (Answer: Repeated allocation/deallocation of stack segments when a function call oscillates around the segment boundary)
- Could you use goroutines for deep recursive algorithms? What are the trade-offs? (Answer: Yes, stacks grow dynamically up to 1GB. But each doubling triggers a copy of the entire stack — at 1MB stack size, that is a 1MB memcpy. For very deep recursion, iterative solutions or explicit stacks may perform better)
5. Panic vs Error
5. Panic vs Error
- Error: Go’s primary mechanism for expected failures. Errors are values (not exceptions) returned as the last return value. The
errorinterface has a single method:Error() string. This design forces explicit error handling at every call site — no hidden control flow.- Examples: file not found, network timeout, invalid input, permission denied
- Pattern:
result, err := doSomething(); if err != nil { return fmt.Errorf("context: %w", err) }
- Panic: For truly unexpected failures — programmer errors or unrecoverable states. Unwinds the stack, running
deferfunctions along the way.- Examples: index out of bounds, nil pointer dereference, assertion failures in your own code
- When a goroutine panics without recovery, the entire process crashes (not just that goroutine)
- Recover:
recover()called inside adeferfunction catches the panic value and resumes normal flow. Only works in the same goroutine that panicked.- Use case: HTTP servers use
recover()in middleware to catch panics in handlers and return 500 instead of crashing the entire server
- Use case: HTTP servers use
- Panic: Initialization failures (
log.Fatalinmain), impossible states that indicate a bug, enforcing programmer invariants - Don’t panic: User input validation, network errors, database timeouts, file I/O — these are all expected failures that should be errors
- Library code should almost never panic. If it does, it should be clearly documented (e.g.,
regexp.MustCompilepanics on invalid regex — appropriate because it is called with compile-time-known patterns)
- Why did Go choose errors-as-values over exceptions? (Answer: Explicit control flow, no hidden paths, forces developers to handle errors at the call site. Exceptions in Java/Python create invisible control flow jumps that are hard to reason about)
- Can you recover from a panic in a different goroutine? (Answer: No.
recover()only works within the same goroutine that panicked. If a child goroutine panics without recovery, the entire program crashes) - What is the difference between
errors.Isanderrors.As? When do you use each? (Answer:errors.Ischecks for a specific error value in the chain (likeio.EOF).errors.Asextracts a specific error type from the chain for inspection. UseIsfor sentinel errors,Asfor typed errors)
6. `defer` Internals
6. `defer` Internals
defer schedules a function call to execute when the enclosing function returns. Deferred calls follow LIFO (Last In, First Out) order.Critical rule: Arguments to deferred functions are evaluated at the time of the defer statement, not at execution time.- Go 1.12 and earlier:
deferallocated a struct on the heap and linked it to the goroutine’s defer chain. Cost: ~35ns per defer. For hot paths (e.g., mutex lock/unlock), this was measurable - Go 1.13: Introduced stack-allocated defer records for simple cases — reduced overhead to ~6ns
- Go 1.14+: Open-coded defers — the compiler inlines defer logic directly at each return point. Nearly zero cost for defers that don’t involve loops or conditional defer statements. This made
defer mu.Unlock()free in practice
- Resource cleanup:
f, _ := os.Open("file"); defer f.Close() - Mutex release:
mu.Lock(); defer mu.Unlock() - Panic recovery:
defer func() { if r := recover(); r != nil { ... } }() - Timing:
defer func(start time.Time) { log.Println(time.Since(start)) }(time.Now())
- What does this print?
for i := 0; i < 3; i++ { defer fmt.Println(i) }(Answer:2, 1, 0— LIFO order, arguments captured at defer time) - Why is
defer mu.Unlock()idiomatic even though it delays the unlock until function return? (Answer: Clarity and safety outweigh the slightly delayed unlock. The open-coded defer optimization makes it nearly free. If the critical section is truly hot, you can manually unlock earlier) - What happens if a deferred function panics? (Answer: The panic propagates. If there is another deferred
recover()above it in the LIFO chain, it can catch it. Multiple panics during unwind — the last one wins)
7. Map Internals
7. Map Internals
map is a hash table implemented with an array of buckets.Internal structure:- Bucket: Each bucket holds 8 key-value pairs (not 1 like many hash table implementations)
tophash [8]uint8— top 8 bits of each key’s hash (used for fast comparison before checking full key)keys [8]KeyType— packed array of keysvalues [8]ValueType— packed array of values (keys and values stored separately for alignment/padding efficiency)overflow *bucket— pointer to overflow bucket if this one is full
- Load Factor: 6.5 (average 6.5 entries per bucket before growth triggers). This is higher than most hash tables (Java HashMap uses 0.75) because Go’s buckets hold 8 entries each
- Growth/Evacuation: When load factor exceeds 6.5 or too many overflow buckets exist, the map doubles in size. Evacuation is incremental — old buckets are copied to the new array gradually during subsequent map operations (not all at once, which would cause latency spikes)
- Maps are NOT thread-safe. Concurrent reads are fine, but concurrent read+write or write+write causes a fatal error (not a race condition — the runtime explicitly detects and crashes):
fatal error: concurrent map read and map write - Solutions:
sync.Mutex,sync.RWMutex, orsync.Map(for specific access patterns)
- Average lookup: O(1)
- Worst case: O(n) if all keys hash to same bucket (pathological)
- Map operations are not inlined by the compiler — each operation is a function call to the runtime
- For small maps (<8 entries), a sorted slice with binary search can be faster due to cache locality
- Why does Go use 8-element buckets instead of separate chaining with linked lists? (Answer: Cache locality. 8 entries in a contiguous array means fewer cache misses compared to pointer-chasing in a linked list. The
tophasharray enables fast rejection without comparing full keys) - What happens if you take the address of a map value? Like
&m["key"](Answer: Compile error. Map values are not addressable because the map may reallocate during growth, invalidating any pointers) - When would you use
sync.Mapvs a regular map withsync.RWMutex? (Answer:sync.Mapwins in two scenarios: (1) keys are written once and read many times, (2) goroutines access disjoint sets of keys. For general read/write patterns,RWMutex+ regular map is faster)
8. Slice vs Array
8. Slice vs Array
- Array: Value type. Fixed length at compile time.
[5]int. Copying an array copies all elements (deep copy). The size is part of the type —[5]intand[10]intare different types. - Slice: A descriptor (header struct) pointing to an underlying array. Dynamic length.
[]int.- Header struct:
{ ptr *Elem, len int, cap int }— 24 bytes on 64-bit systems - Passing a slice to a function copies the header (cheap, 24 bytes) but the
ptrstill points to the same underlying array — modifications to elements are visible to the caller - Appending may or may not allocate: if
len < cap, the element is added in-place. Iflen == cap, a new underlying array is allocated (typically 2x for small slices, 1.25x for large ones since Go 1.18), data is copied, and the old array becomes eligible for GC
- Header struct:
- If current cap < 256: new cap = old cap * 2
- If current cap >= 256: new cap = old cap + old cap/4 + 192 (smoother growth to avoid wasting memory for large slices)
- What is the output of
s := make([]int, 0, 5); s = append(s, 1); t := s; t = append(t, 2); s = append(s, 3); fmt.Println(s, t)? (Answer:[1 3] [1 2]— both share the backing array but have independent len/cap headers. The second append tosoverwrites position 1 wheretwrote 2) - How would you efficiently pre-allocate a slice if you know the final size? (Answer:
make([]T, 0, expectedSize)— avoids repeated reallocations during append. This is one of the most impactful micro-optimizations in Go) - When would you use an array instead of a slice? (Answer: When the size is known at compile time and you want value semantics — e.g.,
[32]bytefor a hash,[4]float64for a vector. Arrays are also stack-allocated when they don’t escape, avoiding GC overhead)
9. Context Package (`context`)
9. Context Package (`context`)
context package propagates cancellation signals, deadlines, and request-scoped values across API boundaries and between goroutines. It is the standard way to control the lifecycle of operations in Go.Core types:context.Background(): Top-level context. Used inmain, init, and tests. Never cancelled.context.TODO(): Placeholder when you’re unsure which context to use. Functionally identical toBackground(), but signals intent: “I need to plumb context here but haven’t figured out how yet.”WithCancel(parent): Returns a new context and acancelfunction. Callingcancel()propagates cancellation to all children.WithTimeout(parent, duration): LikeWithCancelbut auto-cancels after the duration.WithDeadline(parent, time): LikeWithTimeoutbut takes an absolute time.WithValue(parent, key, val): Attaches a key-value pair. Use sparingly — only for request-scoped data like trace IDs, auth tokens, or request IDs.
ctx.Done() (a channel that closes on cancellation).- Always pass context as the first parameter:
func DoWork(ctx context.Context, ...) error - Always defer cancel: Failing to cancel leaks goroutines and timers
- Check
ctx.Err()in long-running loops: Enables cooperative cancellation - Don’t store contexts in structs: Pass them explicitly per-call
- Use
WithValueonly for request-scoped data: Never for function parameters, config, or optional arguments — that is what function parameters are for
- Storing database connections, loggers, or config in context values — these should be explicit dependencies
- Using string keys — use unexported type keys to avoid collisions:
type ctxKey struct{};ctx = context.WithValue(ctx, ctxKey{}, val)
WithValue anti-pattern?Red flag answer: “I put the database connection in context.WithValue” — this is a well-known anti-pattern. Context values should be request-scoped metadata, not dependencies.Follow-up:- What happens if you forget to call the cancel function returned by
WithCancel? (Answer: The context and its goroutines/timers are not cleaned up until the parent is cancelled or the program exits — a resource leak.go vetwarns about this) - How does context cancellation work under the hood? (Answer:
Done()returns a channel.cancel()closes that channel. All goroutines selecting onctx.Done()unblock immediately because receiving from a closed channel returns immediately) - Your HTTP handler spawns 3 goroutines for parallel work. How do you ensure they all stop if the client disconnects? (Answer: Pass the request context
r.Context()to all goroutines. When the client disconnects, the server cancels the request context, which propagates to all children)
10. Pointers (Stack vs Heap Allocation / Escape Analysis)
10. Pointers (Stack vs Heap Allocation / Escape Analysis)
return &x— returning a pointer to a local variable- Assigning to an interface (
var i interface{} = x— the concrete value is heap-allocated) - Sending a pointer over a channel
- Captured by a closure that outlives the function
- Exceeding stack size limits (very large allocations)
append()that triggers a new backing array
- Return values instead of pointers when the struct is small (<~128 bytes):
func NewThing() Thinginstead offunc NewThing() *Thing - Pre-allocate slices to avoid
append-triggered escapes - Use
sync.Poolfor frequently allocated/deallocated objects (buffers, temporary structs) - Avoid unnecessary interface conversions in hot paths
func Parse(data []byte) (*Result, error) to func Parse(data []byte, result *Result) error (caller provides the buffer) reduced heap allocations by 40% and GC pause time by 60%.What interviewers are really testing: Do you understand the performance difference between stack and heap allocation, and can you use escape analysis to optimize code?Red flag answer: “Go automatically manages memory so you don’t need to think about allocation” — true for correctness, wrong for performance. Production Go engineers think about escape analysis constantly in hot paths.Follow-up:- You run
go build -gcflags="-m"and see “moved to heap”. What do you do? (Answer: Check if the escape is necessary. If a pointer return causes it, consider returning the value instead. If an interface assignment causes it, consider generics or concrete types in the hot path) - Does
new(T)always allocate on the heap? (Answer: No. Despite the name,new(T)can be stack-allocated if escape analysis proves the result doesn’t escape. The compiler treatsnewand&T{}identically) - How does
sync.Poolreduce GC pressure? (Answer: It caches allocated objects for reuse across GC cycles — well, objects survive until the next GC. By reusing objects instead of allocating new ones, you reduce the allocation rate, which directly reduces GC work. Common use:bytes.Bufferpools for HTTP response writing)
2. Concurrency Primitives (Channels)
11. Unbuffered vs Buffered Channels
11. Unbuffered vs Buffered Channels
- Unbuffered:
make(chan int). Synchronous rendezvous. The sender blocks until a receiver is ready, and vice versa. Both goroutines must arrive at the channel operation simultaneously. This is a synchronization primitive, not just a data pipe. - Buffered:
make(chan int, 5). Asynchronous up to buffer capacity. Sender blocks only when the buffer is full. Receiver blocks only when the buffer is empty. This decouples sender and receiver timing.
- Unbuffered: When you need a guaranteed handoff — the sender knows the receiver has the value. Great for signaling (e.g.,
done := make(chan struct{})), request-response patterns, or when you want backpressure - Buffered: When producer and consumer operate at different rates and you want to smooth bursts. Buffer size should be chosen deliberately, not arbitrarily
- Buffer of 1: Useful for “latest value” semantics or simple hand-off with one item of slack
- Buffer of N (known): When you know the exact number of items (e.g.,
make(chan result, numWorkers)to collect results from a fixed number of goroutines) - Anti-pattern: Using large buffers to “fix” slow consumers — this just delays the problem. If the consumer is slower than the producer, you need backpressure or rate limiting, not a bigger buffer
- Channels use a circular buffer (for buffered channels) with
sendxandrecvxindices - Blocked senders/receivers are queued in FIFO sudog lists (
sendqandrecvq) - When a sender sends to an unbuffered channel with a waiting receiver, the value is copied directly from sender’s stack to receiver’s stack — no buffer intermediate
- Channel operations cost ~50-100ns (involves locking the channel’s internal mutex)
- For ultra-high-throughput scenarios (>10M ops/sec), channels can become a bottleneck — consider lock-free structures or batching
- You have a producer generating 1000 events/sec and a consumer processing 500 events/sec. Does a buffered channel of size 10000 solve this? (Answer: No. It delays the problem by 10 seconds then blocks. You need to either speed up the consumer, add more consumers, or drop/batch events)
- What happens if you send to a full buffered channel inside a
selectwith adefaultcase? (Answer: Thedefaultcase fires immediately — the send is non-blocking. This is the standard pattern for “try-send” without blocking) - How are channels implemented internally? (Answer: A struct with a circular buffer, mutex, send/recv queues of waiting goroutines, and type metadata. The runtime uses
gopark/goreadyto suspend and resume goroutines waiting on channels)
12. `select` Statement
12. `select` Statement
select multiplexes across multiple channel operations. It is Go’s equivalent of Unix select(2) / epoll but for channels.Semantics:- Blocks until one case is ready
- If multiple cases are ready simultaneously, picks one uniformly at random (prevents starvation of any particular channel)
defaultcase makes it non-blocking — if no channel is ready,defaultexecutes immediately
time.After leaks a timer if the message arrives before timeout. In loops, use time.NewTimer with timer.Stop() instead.Non-blocking send/receive:select {}) blocks forever — useful for keeping main alive when goroutines do all the work. But prefer signal.NotifyContext for production servers.What interviewers are really testing: Random case selection awareness, timeout patterns, and ability to compose complex channel logic.Red flag answer: “Select picks the first ready case” — wrong. It picks randomly among ready cases. This is a very common misconception.Follow-up:- Why does Go randomize the case selection in
select? (Answer: To prevent channel starvation. If cases were evaluated top-to-bottom, the first case could monopolize selection when multiple channels are frequently ready) - What is the
time.Afterleak and how do you fix it? (Answer: Each call totime.Aftercreates a new timer that persists until it fires. In a loop, this leaks timers. Fix: usetime.NewTimer,defer timer.Stop(), andtimer.Reset()per iteration) - How would you implement a priority channel — always prefer reading from channel A over channel B? (Answer: Nested select — first try A alone with default, then select on both A and B. Not perfectly fair but gives A priority)
13. Nil Channel Behavior
13. Nil Channel Behavior
- Send to nil channel: Blocks forever (goroutine is parked permanently)
- Receive from nil channel: Blocks forever
- Close nil channel: Panic (
close of nil channel)
select cases. By setting a channel variable to nil, you effectively remove that case from future select evaluations without restructuring the code.Pattern — draining two channels until both are closed:ch1 closes, setting it to nil means the select will never choose that case again — it effectively becomes a single-channel receive on ch2. Without nil channel semantics, you would need ugly flag variables and restructured logic.What interviewers are really testing: Whether you know the practical application of nil channels (dynamic select case disabling), not just the trivia answer.Red flag answer: Knowing nil channels block but not being able to explain a real use case for that behavior.Follow-up:- You have a
selectwith 3 channels. One channel is exhausted mid-operation. How do you cleanly stop selecting on it? (Answer: Set it to nil — the nil channel case will never be selected, effectively removing it from the select) - What is the zero value of a channel? (Answer:
nil. This means an uninitializedvar ch chan intis nil — sending/receiving on it blocks forever. Always usemake(chan int)to create a usable channel) - If you have
selectwith a nil channel case and a default case, what happens? (Answer: The default case fires immediately every time — the nil channel case is never ready)
14. Closing Channels
14. Closing Channels
- Only the sender should close a channel. The receiver should not close it.
val, ok := <-ch— ifokisfalse, the channel is closed andvalis the zero value of the channel’s type- Sending to a closed channel: Panic (
send on closed channel) - Closing an already closed channel: Panic (
close of closed channel) - Receiving from a closed channel: Returns the zero value immediately (non-blocking). All remaining buffered values are drained first, then zero values are returned with
ok == false range chloops until the channel is closed — the idiomatic way to consume all values
close() a powerful one-to-many signaling mechanism.The “who closes” problem:- With one sender: sender closes
- With multiple senders: use a separate
donechannel orcontextfor cancellation. Never have multiple goroutines close the same channel — it panics - Pattern for multiple senders:
sync.WaitGroup to ensure all senders are done before closing.What interviewers are really testing: Understanding close as a broadcast mechanism, the sender-closes principle, and how to handle multiple senders.Red flag answer: “I close channels from the receiver side” — dangerous, as the sender will panic when writing to the closed channel.Follow-up:- How do you signal cancellation to multiple goroutines without closing a data channel? (Answer: Use
context.WithCancel—ctx.Done()is a channel that closes on cancellation, serving as a broadcast signal without interfering with data channels) - What happens if you range over a channel that is never closed? (Answer: The goroutine blocks forever on the range — a goroutine leak. Always ensure channels consumed with
rangeare eventually closed) - You have 10 worker goroutines sending results to one channel. How do you know when to close the results channel? (Answer: Use
sync.WaitGroup—Add(10)before starting workers,Done()in each worker, and close the channel afterWait()completes in a separate goroutine)
15. Worker Pool Pattern
15. Worker Pool Pattern
- Jobs channel: Buffered if you want the producer to work ahead; unbuffered if you want backpressure
- Results channel: Buffered to prevent workers from blocking when the consumer is slow
- Number of workers: For CPU-bound work,
runtime.NumCPU(). For I/O-bound work (HTTP calls, DB queries), empirically tune — often 10-100x CPU count - Graceful shutdown: Use
context.Contextfor cancellation,range jobsfor clean termination when the jobs channel closes
- Monitor worker pool health: track queue depth, processing latency per job, and worker utilization
- Implement dead letter queues for failed jobs
- Consider
golang.org/x/sync/errgroupfor error propagation from workers
- How would you dynamically resize a worker pool based on load? (Answer: Use a semaphore channel. To increase concurrency, increase the buffer. To decrease, drain tokens. For more sophistication, use a rate limiter or adaptive concurrency library)
- One of your workers panics. How do you prevent it from killing the pool? (Answer: Add
defer func() { if r := recover(); r != nil { log.Error(r) } }()in each worker. But also investigate the root cause — recover should be a safety net, not normal flow) - How does
errgroup.Groupfromgolang.org/x/syncimprove on manual worker pools? (Answer: It combinesWaitGroup+ first-error propagation + context cancellation. If any goroutine returns an error, the shared context is cancelled, signaling other goroutines to stop)
16. `sync.WaitGroup`
16. `sync.WaitGroup`
WaitGroup waits for a collection of goroutines to finish. It maintains an internal counter.API:Add(delta int): Increments (or decrements) the counterDone(): Decrements by 1 (equivalent toAdd(-1))Wait(): Blocks until counter reaches 0
Add()must be called BEFOREgo func(): If you callAdd(1)inside the goroutine, there is a race —Wait()might return beforeAddis called- Pass
*sync.WaitGroupto functions, notsync.WaitGroup: WaitGroup contains anoCopyfield. Copying it (value receiver) means the goroutine has its own counter —Done()decrements the copy,Wait()on the original never returns. This causes a deadlock - Counter must never go negative:
Add(-1)orDone()when counter is 0 causes a panic
errgroup for error handling: sync.WaitGroup has no error propagation. golang.org/x/sync/errgroup wraps WaitGroup with first-error semantics and context cancellation:Add before go, pointer vs value trap, and awareness of errgroup as a superior alternative.Red flag answer: Calling wg.Add(1) inside the goroutine, or passing WaitGroup by value.Follow-up:- What happens if
wg.Add(1)is called inside the goroutine instead of before? (Answer: Race condition —Wait()might return before the goroutine callsAdd, so the goroutine runs afterWaitreturns. With-raceflag, this is detected) - How is
errgroupbetter thanWaitGroupfor production code? (Answer: Error propagation, context cancellation on first error, optional concurrency limiting viaSetLimit(n)) - Can you reuse a WaitGroup after
Wait()returns? (Answer: Yes, once the counter reaches 0, you can callAdd()again. But this is fragile — prefer creating a new WaitGroup for clarity)
17. `sync.Mutex` vs `RWMutex`
17. `sync.Mutex` vs `RWMutex`
sync.Mutex: Exclusive lock.Lock()/Unlock(). Only one goroutine can hold the lock at a time — all others block, whether they want to read or write.sync.RWMutex: Reader-writer lock.RLock()/RUnlock()for readers.Lock()/Unlock()for writers.- Multiple readers can hold
RLock()simultaneously - A writer with
Lock()blocks all readers AND all other writers - A writer waiting for
Lock()blocks new readers from acquiringRLock()(prevents writer starvation)
- Multiple readers can hold
- Mutex: When reads and writes are roughly equal, or the critical section is very short (under ~100ns). The overhead of RWMutex’s more complex bookkeeping is not worth it for short critical sections
- RWMutex: When reads significantly outnumber writes (80/20 or higher ratio) AND the critical section is non-trivial. Classic example: in-memory cache read by many goroutines, updated occasionally
- Neither: For simple counters, use
atomicoperations instead — they are 5-10x faster than mutexes
RLock/RUnlock still requires atomic operations to track the reader count. With very high reader contention (millions of reads/sec), the cache line bouncing on the reader counter can be slower than a simple Mutex. Benchmark before assuming RWMutex is faster.Best practices:- Keep critical sections as short as possible
- Never hold a lock while doing I/O (network, disk). Copy data under the lock, release, then do I/O
- Use
defer mu.Unlock()for safety (ensures unlock on panic), but know it holds the lock until function return - Embed the mutex near the data it protects:
- Can a deadlock occur with a single mutex? (Answer: Yes — if a goroutine tries to
Lock()a mutex it already holds (re-entrant locking). Go mutexes are NOT re-entrant. This will deadlock) - How does
RWMutexprevent writer starvation? (Answer: Once a writer callsLock(), new readers are blocked from acquiringRLock(). Existing readers finish, then the writer proceeds. Without this, a continuous stream of readers would starve writers indefinitely) - At what read/write ratio does RWMutex start outperforming Mutex? (Answer: It depends on critical section duration. For short critical sections (<100ns), Mutex often wins regardless. For longer sections, RWMutex typically wins above ~5:1 read/write ratio. Always benchmark with your actual workload)
18. `sync.Once`
18. `sync.Once`
sync.Once guarantees a function runs exactly once, regardless of how many goroutines call it concurrently. Thread-safe initialization primitive.- Uses an
atomicflag (done uint32) for the fast path — after the first call, subsequent calls just check the atomic flag (no locking) - Uses a
Mutexfor the slow path — ensures only one goroutine executes the function while others wait - The function is guaranteed to complete before any
Docall returns — even for goroutines that were waiting
Do:
If the function passed to Do panics, Once considers it “done”. Subsequent calls to Do will NOT re-execute the function. This means initialization is permanently failed. Handle errors inside the function:sync.OnceFunc, sync.OnceValue, sync.OnceValues:Once vs init().Red flag answer: “I use init() for all initialization” — init() runs at package load time, before main(). It cannot accept parameters, return errors, or be tested easily. sync.Once is more flexible and testable.Follow-up:- What happens if
once.Do(f)is called from two goroutines simultaneously, andfpanics? (Answer: One goroutine runsf, it panics. TheOnceis marked as done. The other goroutine’sDoreturns without callingf. Both goroutines see the panic — the second one does NOT retry) - How would you implement a “retry-once” pattern where initialization is retried on failure? (Answer: Don’t use
sync.Once. Usesync.Mutexwith an explicitinitializedflag that only sets to true on success) - What is the performance of
sync.Once.Doafter the first call? (Answer: Nearly free — a single atomic load. The fast path is justif atomic.LoadUint32(&o.done) == 1 { return })
19. `atomic` Package
19. `atomic` Package
sync/atomic package provides low-level lock-free atomic operations on primitive types. These map directly to CPU atomic instructions (e.g., LOCK CMPXCHG on x86).Key operations:atomic.AddInt64(&counter, 1)— atomic incrementatomic.LoadInt64(&val)— atomic readatomic.StoreInt64(&val, 42)— atomic writeatomic.CompareAndSwapInt64(&val, old, new)— CAS: set tonewonly if currentlyold. Returns whether the swap happened. Foundation for lock-free algorithmsatomic.SwapInt64(&val, new)— atomically set and return old value
atomic.Int64, atomic.Bool, etc.:- Atomic: Simple counters, flags, single-value reads/writes. ~1-5ns per operation. No contention under low load
- Mutex: Complex invariants involving multiple variables. If you need to update two values atomically, atomic operations alone cannot guarantee consistency — you need a mutex
- Rule of thumb: If your critical section is just reading or writing a single value, use atomic. If it involves multiple operations that must be consistent, use a mutex
atomic.Value type: Stores and loads arbitrary values atomically. Useful for config hot-reload patterns:- Atomic operations only guarantee atomicity of individual operations, NOT ordering across multiple variables. For ordering, use
sync.Mutexor explicit memory barriers - On 32-bit architectures, 64-bit atomic operations require the value to be 64-bit aligned. The struct layout trap:
type T struct { x int32; y int64 }—ymight not be 8-byte aligned. Useatomic.Int64which handles alignment
- How would you implement a spinlock using
atomic.CompareAndSwap? (Answer:for !atomic.CompareAndSwapInt32(&lock, 0, 1) { runtime.Gosched() }— spin until the lock is acquired. But spinlocks are rarely the right choice in Go — the runtime scheduler does not expect goroutines to spin) - Is
atomic.Value.Storesafe to call from multiple goroutines? (Answer: Yes — that is the entire point. But the type of the stored value must be consistent across all Store calls — storing different types panics) - What is the performance difference between
atomic.AddInt64andmu.Lock(); counter++; mu.Unlock()? (Answer: Atomic is ~5-10x faster in the uncontended case. Under high contention, the gap narrows because both degrade to CPU cache-line bouncing)
20. Race Detector
20. Race Detector
- Based on the ThreadSanitizer (TSan) algorithm from Google
- At compile time, the compiler inserts instrumentation before every memory read/write
- At runtime, the instrumentation records which goroutine accessed which memory address and whether it was a read or write
- A “happens-before” graph tracks synchronization events (mutex lock/unlock, channel send/receive, WaitGroup operations)
- If two goroutines access the same memory location without a happens-before relationship, and at least one is a write, it reports a data race
- 10x CPU slowdown and 5-10x memory increase
- NOT suitable for production. Use in CI, tests, and development builds
- Some teams run race-enabled integration tests in a staging environment
- Catches: Concurrent map read/write, unsynchronized counter increments, shared struct field access without locks
- Misses: Deadlocks (different tool:
go vet -deadlock), logic bugs, races in code paths not exercised during the test run. It only detects races that actually occur during execution — it is not a static analysis tool
- Run
-racein CI for every PR — make it a blocking check - Write concurrent tests that exercise shared state to maximize detection
- When a race is reported, the output shows both goroutine stacks and the memory address — use this to identify the shared variable
- Fix races properly (mutex, atomic, channel) — do NOT suppress race reports
- The race detector reports a race but your program “works fine”. Do you ignore it? (Answer: Absolutely not. Data races are undefined behavior in Go. Even if the program appears to work, the race can cause corruption, crashes, or security vulnerabilities under different conditions, compilers, or architectures)
- Can you run the race detector in production? (Answer: Not recommended due to 10x overhead. Some teams run it in canary/shadow environments. For production race detection, consider using
go vetand static analysis tools likestaticcheck) - The race detector missed a race that only occurs under heavy load. How do you catch it? (Answer: Write stress tests that exercise the concurrent code paths. Use
-count=100to run tests many times. Consider tools likego-fuzzfor fuzz testing concurrent code)
3. Interfaces & Design
21. Implicit Interface Implementation
21. Implicit Interface Implementation
implements. This is called structural typing (similar to duck typing but checked at compile time).- Decoupling: The interface can be defined in the consumer’s package, not the provider’s. The provider doesn’t need to know about or import the interface. This inverts the dependency — the consumer defines what it needs
- Retrofitting: You can create an interface for third-party code you don’t control. If
ThirdPartyLibhas aFetch()method, you can definetype Fetcher interface { Fetch() }and use it as an abstraction without modifying the library - Testing: Define narrow interfaces for testing. Instead of mocking an entire
DatabaseClient, definetype UserStore interface { GetUser(id string) (*User, error) }and mock only what you need
io.Reader (1 method), io.Writer (1 method), fmt.Stringer (1 method), error (1 method). This enables maximum composability.Accept interfaces, return structs: This guideline means function parameters should be interfaces (flexible for callers), but return types should be concrete types (gives callers full capabilities). Don’t create interfaces preemptively — create them when you have two or more implementations.What interviewers are really testing: Do you understand the design philosophy behind implicit interfaces — decoupling, consumer-defined abstractions, and narrow interfaces?Red flag answer: “Go interfaces are like Java interfaces but without the keyword” — misses the fundamental difference. Java interfaces create a coupling between implementer and interface definition. Go interfaces are defined by the consumer, enabling true decoupling.Follow-up:- When would you define an interface with only one implementation? (Answer: For testing — defining an interface for an external dependency allows you to inject a mock. Also at package boundaries for abstraction)
- What does “accept interfaces, return structs” mean in practice? (Answer: Functions should accept
io.Readernot*os.File, but return*MyStructnotMyInterface. This gives callers flexibility in what they pass while giving full access to the return value) - How do Go interfaces compare to protocols in Swift or traits in Rust? (Answer: Go interfaces are implicitly satisfied — no declaration needed. Swift protocols and Rust traits require explicit conformance. Go’s approach enables retroactive interface satisfaction for types you don’t own)
22. Empty Interface `interface{}` (any)
22. Empty Interface `interface{}` (any)
interface{} (aliased as any since Go 1.18) holds any value. It is Go’s top type.Internal representation (eface — empty interface):- If the value is small enough (pointer-sized), it is stored directly in
data - If the value is larger,
datapoints to a heap-allocated copy - Every assignment to
interface{}may cause a heap allocation — this is whyinterface{}in hot paths hurts performance
iface) is different:itab contains a method dispatch table — this is how Go implements dynamic dispatch for interface method calls.Performance implications:- Interface method calls are indirect (through the
itabdispatch table) — ~2-5ns overhead vs direct calls - Assigning a value to an interface may allocate (~25ns for small values due to boxing)
- In hot paths (millions of calls/sec), prefer concrete types or generics (Go 1.18+) over interfaces
interface{}:- Before Go 1.18:
func Contains(slice []interface{}, item interface{}) bool— no type safety, requires runtime type assertions - Go 1.18+:
func Contains[T comparable](slice []T, item T) bool— type-safe at compile time, no boxing overhead, no allocation - Generics should replace most uses of
interface{}/anyfor type-safe generic code
interface{} for generic functions” — in Go 1.18+, generics are the correct tool. Using interface{} when generics would work is a code smell.Follow-up:- Why does assigning a small integer to
interface{}cause a heap allocation? (Answer: The runtime must create a copy of the value on the heap and store a pointer to it in theeface.datafield. The compiler optimizes small values likebooland small ints with a cached lookup table, but larger values always allocate) - What is the difference between
efaceandifaceinternally? (Answer:efaceis for empty interfaces — just type + data.ifaceis for non-empty interfaces — has anitabwhich includes method dispatch pointers for the concrete type.ifaceenables dynamic dispatch) - When would you still use
anyinstead of generics? (Answer: When the set of types is truly unknown at compile time — e.g., JSON unmarshalling intomap[string]any, reflection-based code, or variadic heterogeneous collections. If you know the type constraints, generics are better)
23. Type Assertion vs Type Switch
23. Type Assertion vs Type Switch
-
Type Assertion — check/extract a specific type:
Use when you expect a specific type.
-
Type Switch — branch on multiple possible types:
Use when the interface could be multiple types.
_type pointer in the interface. Type switches compile to a series of comparisons.When to use which:- Assertion: You know the expected type, just need to confirm. E.g., middleware extracting a user from context:
user, ok := ctx.Value(userKey).(*User) - Switch: Handling multiple possible types from a union-like interface. E.g., parsing AST nodes, handling different message types in a protocol
ok idiom, and when a type switch is more appropriate.Red flag answer: Always using val.(Type) without the ok check — this causes runtime panics on unexpected types.Follow-up:- What happens if you do
val.(string)on a nil interface? (Answer: Panic. Always use the two-value forms, ok := val.(string)or guard with a nil check) - Can you use a type switch to match on an interface type? (Answer: Yes.
case io.Reader:matches any concrete type that implementsio.Reader. The cases are evaluated in order — put more specific interfaces before general ones) - How do interface guards
var _ Interface = (*Type)(nil)work? (Answer: The nil pointer value has the correct type to trigger the interface check at compile time. The blank identifier_discards the value. If*Typedoes not implementInterface, the compiler throws an error)
24. Interface Nil vs Value Nil (The Billion-Dollar Gotcha)
24. Interface Nil vs Value Nil (The Billion-Dollar Gotcha)
eface struct: { _type, data }. When you assign p to i, the interface stores _type = *int and data = nil. Since _type is not nil, the interface is not nil.This causes real production bugs:nil explicitly, not a typed nil pointer:go vet tool and linters like nilaway from Uber can detect some instances of this pattern.What interviewers are really testing: This is THE classic Go gotcha. Every experienced Go developer has been bitten by this. If you can explain the internal representation, you demonstrate real understanding.Red flag answer: Not knowing this pitfall, or being unable to explain WHY an interface with a nil pointer is not nil.Follow-up:- How would you check if an interface’s underlying value is nil? (Answer: Use
reflect.ValueOf(i).IsNil()— but only for nilable types like pointers, channels, maps, slices, and functions. For a general check:i == nil || reflect.ValueOf(i).IsNil()) - Why doesn’t Go just make interfaces with nil values compare equal to nil? (Answer: An interface with a type but nil value is a valid, useful value — you can still call methods on it if they have pointer receivers. Making it compare to nil would break this functionality and make the type system inconsistent)
- How does this affect error handling in practice? (Answer: Functions that return concrete error types must be careful to return a bare
niland not a typed nil pointer. This is why the standard library consistently returnserrorinterfaces — never concrete error types — from public APIs)
25. Embedding (Composition over Inheritance)
25. Embedding (Composition over Inheritance)
ServerIS NOT aLogger. It HAS aLogger. You cannot pass aServerwhere aLoggeris expected (no polymorphism via embedding)- If
Serverdefines its ownLogmethod, it shadows the embedded one (no virtual dispatch, no overriding) - The embedded
Loggeris accessible explicitly:s.Logger.Log("msg") - Embedding is syntactic sugar for a named field:
type Server struct { Logger Logger }with automatic method promotion
- Name collisions: If two embedded types have the same method name, neither is promoted — accessing it requires explicit qualification
- Initialization: Embedded struct must be initialized:
Server{Logger: Logger{}}or the embedded type’s zero value is used - JSON marshalling: Embedded struct fields are “flattened” into the parent in JSON. This can cause field name conflicts
- What happens if a struct embeds two types that both have a
Close()method? (Answer: Ambiguous selector — callings.Close()is a compile error. You must calls.TypeA.Close()ors.TypeB.Close()explicitly) - Can an embedded type’s method access the outer struct’s fields? (Answer: No. The embedded type has no knowledge of the outer struct. The method operates on its own receiver. This is a key difference from inheritance where a base class method can access subclass state via
this/self) - How does embedding affect JSON marshalling? (Answer: Embedded struct fields are marshalled as if they were fields of the outer struct — flattened. If the embedded struct has a field
Nameand the outer struct also hasName, the outer struct’s field wins. Usejson:"-"on the embedded struct to suppress its fields)
26. Functional Options Pattern
26. Functional Options Pattern
- Backwards-compatible: Adding a new option is a non-breaking change. No existing callers need to update
- Self-documenting: Each option function has a clear name (
WithPort,WithTimeout) - Composable: Options can be combined into higher-level options:
func WithProductionDefaults() Option - Validation: Each option function can validate its input and return an error (variant:
type Option func(*Server) error) - Default values are explicit: The constructor sets them, options override
- Config struct:
NewServer(Config{Port: 9090})— hard to distinguish “zero value means default” from “caller intentionally set to zero”. Grows unwieldy with many fields - Builder pattern: Verbose, not idiomatic in Go. Requires a separate builder type
- Functional options: Best for public APIs with many optional parameters. Overkill for internal code with 2-3 options
google.golang.org/grpc uses this pattern extensively: grpc.NewServer(grpc.MaxRecvMsgSize(1024), grpc.UnaryInterceptor(authInterceptor))What interviewers are really testing: Can you design a clean, extensible API in Go? Do you understand the trade-offs between different configuration patterns?Red flag answer: Implementing a Java-style builder pattern in Go, or using a config struct with 20 fields.Follow-up:- How would you add validation to functional options? (Answer: Change the option type to
func(*Server) errorand check errors in the constructor loop. Return the first error to the caller) - When would you NOT use functional options? (Answer: Internal code with few options — a simple config struct or direct parameters is clearer. Functional options shine in public APIs that evolve over time)
- How do you document functional options in godoc? (Answer: Each
With*function gets its own documentation. Group them near the constructor. The function signature makes the option’s purpose immediately clear)
27. Meaning of `make` vs `new`
27. Meaning of `make` vs `new`
-
new(T): Allocates zeroed memory for type T. Returns*T(pointer). The returned value is a pointer to a zero-valued T. Works for any type.p := new(int)—*pis 0s := new(MyStruct)— all fields are zero-valued- Rarely used in practice —
&MyStruct{}is more idiomatic for structs
-
make(T, args...): Allocates AND initializes internal data structures. ReturnsT(not a pointer). Only works for slices, maps, and channels — types that need runtime initialization.make([]int, 5, 10)— creates a slice with len=5, cap=10, backed by a real arraymake(map[string]int)— initializes hash table bucketsmake(chan int, 5)— creates a channel with internal buffer of 5
new(map[string]int) gives you a pointer to a nil map — you can’t use it. make(map[string]int) gives you a usable, initialized map.&Server{}) instead of new, and make for slices/maps/channels. new is mainly useful for creating a pointer to a zero-valued non-composite type like new(int) or new(sync.Mutex).What interviewers are really testing: Understanding why make exists (initialization of internal data structures) and knowing which types require make.Red flag answer: “They’re basically the same thing” — they serve fundamentally different purposes.Follow-up:- What happens if you try to use a nil map (created with
neworvar m map[string]int)? (Answer: Reading returns zero value — no panic. Writing panics:assignment to entry in nil map) - Does
new(T)always allocate on the heap? (Answer: No — escape analysis may keep it on the stack if the pointer doesn’t escape the function) - Why doesn’t Go just have
makework for all types? (Answer: For most types, zero-value is usable. Only slices, maps, and channels need internal initialization. Havingmakefor only these types makes it clear that these types are special)
28. Standard Library Packages (Must-Know)
28. Standard Library Packages (Must-Know)
net/http: Production-quality HTTP server and client. Supports HTTP/2, TLS, and streaming natively. Many companies use it without any frameworknet: Low-level TCP/UDP, DNS resolution, IP parsingnet/url: URL parsing and buildingcrypto/tls: TLS client/server configuration
encoding/json: JSON marshal/unmarshal with struct tags. For high-performance JSON, usegithub.com/json-iterator/goorgithub.com/goccy/go-json(2-5x faster)encoding/xml,encoding/csv,encoding/gob: Other formatsencoding/binary: Binary encoding for network protocols
io: Core interfaces (Reader,Writer,Closer,ReadWriter). The foundation of Go’s I/O modelbufio: Buffered I/O — wraps readers/writers for performance.Scannerfor line-by-line readingos: File operations, environment variables, process managementfmt: Formatted I/O (printf-style)
sync: Mutex, RWMutex, WaitGroup, Once, Pool, Map, Condsync/atomic: Lock-free atomic operationscontext: Cancellation, deadlines, request-scoped values
testing: Test framework, benchmarks, fuzzing (Go 1.18+)net/http/httptest: HTTP test server and response recordertesting/fstest: In-memory filesystem for testing file operations
time: Durations, tickers, timers. Note: Go uses the reference timeMon Jan 2 15:04:05 MST 2006for formatting (notYYYY-MM-DD)strings,strconv,bytes: String and byte manipulationsort,slices(1.21+): Sorting and searchinglog/slog(1.21+): Structured logging — replaces the oldlogpackage for production use
- When would you use a web framework like Gin over
net/http? (Answer: When you need middleware chaining, parameter binding, validation, or OpenAPI generation. For simple APIs,net/httpwith Go 1.22’s enhanced mux is sufficient) - Why does Go use the reference time
Mon Jan 2 15:04:05 MST 2006instead of format specifiers? (Answer: The reference time is1-2-3-4-5-6-7(month-day-hour-minute-second-year-timezone offset). It is mnemonic — you write the format as you want the output to look) - What is
log/slogand why was it added in Go 1.21? (Answer: Structured logging with key-value pairs, log levels, and pluggable handlers (JSON, text). The oldlogpackage only supported unstructured text.slogreplaces the need forlogrus/zapin many cases)
29. Error Handling Best Practices
29. Error Handling Best Practices
fmt.Errorf("context: %w", err): Wraps error with context.%w(not%v) enables unwrappingerrors.Is(err, target): Checks if any error in the chain matchestarget. Replaces==comparison for wrapped errorserrors.As(err, &target): Extracts a specific error type from the chain. Use when you need to inspect error fieldserrors.Unwrap(err): Returns the next error in the chain (one level). Rarely used directlyerrors.Join(err1, err2)(Go 1.20+): Combines multiple errors into one. Useful for collecting validation errors
- Sentinel:
var ErrNotFound = errors.New("not found")— compare witherrors.Is. Good for well-known, stable error conditions - Error type:
type ValidationError struct { Field, Message string }— extract witherrors.As. Good when callers need to inspect error details
- Always add context:
return errloses information.return fmt.Errorf("opening config file %s: %w", path, err)is debuggable - Don’t log and return: Either log the error (and handle it) or return it (for the caller to handle). Doing both creates duplicate log entries
- Use
%wfor wrapping,%vfor opaque errors:%wexposes the underlying error to callers viaIs/As. If you want to hide implementation details, use%v - Custom error types for API boundaries: Return structured errors from APIs that clients need to inspect. Return wrapped errors internally
- Never ignore errors:
result, _ := doSomething()is a bug waiting to happen. If you truly don’t care, add a comment explaining why
Is/As semantics, and the judgment of when to wrap vs when to handle.Red flag answer: Using if err != nil { return err } everywhere without adding context — this creates error messages like “connection refused” with no idea where in the call stack it happened.Follow-up:- What is the difference between
%wand%vinfmt.Errorf? (Answer:%wwraps the error — callers can useerrors.Is/errors.Asto inspect it.%vconverts the error to a string — the original error is lost. Use%wwhen callers should be able to match on the underlying error,%vwhen you want to hide the implementation detail) - When would you use
errors.Joininstead of wrapping? (Answer: When you have multiple independent errors to report — e.g., validating a form with multiple field errors, or closing multiple resources in a defer) - How do you handle errors in goroutines that the caller needs to know about? (Answer: Send errors over a channel, or use
errgroup.Groupwhich collects the first error and cancels the context)
30. Go Modules (`go.mod`)
30. Go Modules (`go.mod`)
go.mod: Declares module path, Go version, and direct dependencies with versionsgo.sum: Cryptographic checksums of all dependencies (direct and transitive). Ensures reproducible builds. Checked against the Go checksum database (sum.golang.org)
go.mod:module github.com/myorg/myapp: Module path (import path prefix for all packages in this module)go 1.22: Minimum Go version. Also controls which language features are availablerequire: Direct dependencies with semantic versionsreplace: Override a dependency’s source — useful for local development, forks, or replacing modules:replace github.com/old/pkg => github.com/new/pkg v1.2.0exclude: Prevent a specific version from being usedretract: Mark versions of your own module as broken (advisory togo getusers)
v1.2.3: Major.Minor.Patch- Major version rule:
v2+must be in the import path:import "github.com/pkg/v2". This allows v1 and v2 to coexist in the same binary - Pseudo-versions:
v0.0.0-20210101120000-abcdef123456for unreleased commits
go mod init: Initialize a new modulego mod tidy: Add missing / remove unused dependencies. Run this before committinggo mod vendor: Copy dependencies intovendor/directory for reproducible offline buildsgo mod graph: Show dependency graphgo get -u ./...: Update all dependencies to latest minor/patch
go.work file allows multiple modules in a single workspace. Useful for monorepos or developing multiple interdependent modules simultaneously.What interviewers are really testing: Understanding of SemVer, the major version import path rule, and practical commands for dependency management.Red flag answer: “I just go get everything and don’t look at go.sum” — go.sum is critical for supply chain security and reproducible builds.Follow-up:- Why does Go require
v2+in the import path? (Answer: The import compatibility rule — if a package changes its API (major version), it must change its import path. This allows v1 and v2 to coexist in the same binary without conflicts, enabling gradual migration) - How does
go mod vendordiffer from just using the module cache? (Answer:vendor/is committed to the repo for reproducible builds without network access. The module cache ($GOPATH/pkg/mod) is local to each developer’s machine. CI environments often usevendor/for reliability) - A dependency has a critical security vulnerability in v1.3.2 but v1.3.3 is fixed. How do you force the upgrade across all transitive dependencies? (Answer:
go get github.com/vulnerable/pkg@v1.3.3and thengo mod tidy. If a transitive dependency pins v1.3.2, you may needreplaceto override it)
4. Coding Scenarios & Snippets
31. Fan-In Pattern
31. Fan-In Pattern
- Context cancellation: All goroutines exit cleanly when context is cancelled
- Proper close: Output channel closes only after all input channels are drained and goroutines exit
- Handles N channels: Variadic instead of hardcoded ch1/ch2
- No goroutine leak: WaitGroup ensures cleanup
- How does fan-in differ from merging channels with a single
select? (Answer: A singleselectcan only handle a compile-time-known number of cases. Fan-in with goroutines handles a dynamic number of channels) - What is the ordering guarantee of fan-in? (Answer: None. Values arrive in whatever order goroutines are scheduled. If ordering matters, attach timestamps or sequence numbers)
- How would you implement fan-in with priority — some channels’ messages should be processed first? (Answer: Use a priority queue on the output side, or use nested selects to prefer high-priority channels)
32. Pipeline Pattern
32. Pipeline Pattern
- Receives values from an inbound channel
- Performs a transformation
- Sends results to an outbound channel
- Each stage owns its output channel and closes it when done
- Every send/receive is wrapped in a
selectwithctx.Done()for clean cancellation - If any stage fails, cancel the context to tear down the entire pipeline
- Stages can be parallelized internally (fan-out within a stage) or composed linearly
- How does backpressure work in a channel pipeline? (Answer: If a downstream stage is slow, its input channel fills up, which blocks the upstream stage’s send. This naturally slows the entire pipeline to the speed of the slowest stage — no data loss, no unbounded buffering)
- How would you add error handling to a pipeline? (Answer: Use a result type
struct { Value int; Err error }on channels, or useerrgroupto propagate the first error and cancel the pipeline context) - What happens if you want to early-exit a pipeline after finding the first result? (Answer: Cancel the context. All stages should be checking
ctx.Done()and will exit cleanly. Without context, goroutines in earlier stages would block forever trying to send to channels nobody is reading)
33. Graceful Shutdown
33. Graceful Shutdown
srv.Shutdown() does:- Closes all listeners (stops accepting new connections)
- Closes idle connections immediately
- Waits for active connections to finish (respecting the context deadline)
- Returns when all connections are done or the context expires
- Shutdown timeout: 30 seconds is typical. Kubernetes sends
SIGTERMand waitsterminationGracePeriodSeconds(default 30s) beforeSIGKILL - Health checks: Return 503 during shutdown so load balancers stop sending traffic
- Resource cleanup order: Stop accepting new work -> drain in-flight work -> close downstream connections (DB, cache) -> exit
- Background workers: Use a WaitGroup to track background goroutines and wait for them during shutdown
os.Exit(0) in a signal handler — this kills the process immediately without draining connections.Follow-up:- How does graceful shutdown interact with Kubernetes readiness probes? (Answer: During shutdown, the readiness probe should return unhealthy (503). This causes Kubernetes to remove the pod from the service’s endpoints, stopping new traffic. The pod then drains in-flight requests before exiting)
- What happens to WebSocket connections during graceful shutdown? (Answer:
Shutdown()waits for all connections to close. Long-lived WebSocket connections should watch the request context and disconnect gracefully. You may need a shorter timeout for WebSocket drain) - How would you handle a graceful shutdown for a message queue consumer? (Answer: Stop consuming new messages, finish processing in-flight messages, commit offsets/ack messages, then exit. Similar pattern: context cancellation triggers drain mode)
34. Reverse String (Rune aware)
34. Reverse String (Rune aware)
[]rune to correctly handle multi-byte characters.U+00E9) or as two runes (e + combining acute accent U+0301). Rune reversal splits combining characters from their base character. For fully correct Unicode reversal, use golang.org/x/text/unicode/norm for normalization.Performance: []rune(s) allocates a new rune slice (O(n) memory). For hot paths, consider whether you truly need full Unicode support or if ASCII-only reversal suffices.What interviewers are really testing: UTF-8 awareness and understanding the difference between bytes, runes, and characters in Go.Red flag answer: Reversing bytes (s[i], s[j] = s[j], s[i]) — corrupts multi-byte UTF-8 characters.Follow-up:- What is the difference between
len(s)andutf8.RuneCountInString(s)fors = "Hello, 世界"? (Answer:len(s)= 13 (byte count, each Chinese character is 3 bytes).RuneCountInString= 9 (character count)) - How do you iterate over characters in a Go string? (Answer:
for i, r := range siterates by rune, not byte.iis the byte offset,ris the rune value) - What happens if a string contains invalid UTF-8? (Answer:
rangeproducesunicode.ReplacementChar(U+FFFD) for invalid bytes.[]rune()does the same. Go strings can contain arbitrary bytes — they are not guaranteed to be valid UTF-8)
35. Check Map Key Existence
35. Check Map Key Existence
KeyError) or Java (which returns null), Go returns the zero value for missing keys. Without the ok check, you cannot distinguish “key exists with zero value” from “key does not exist”:if m["key"] != 0 to check existence — this fails for zero values.Follow-up:- What is the zero value of a map access for a
map[string][]int? (Answer:nil— a nil slice. But nil slices are safe toappendto, som["key"] = append(m["key"], 1)works even for missing keys) - How would you implement a set in Go? (Answer:
map[string]struct{}—struct{}takes 0 bytes, so the map only stores keys. Check membership with_, ok := set["item"])
36. Singleton (Thread Safe)
36. Singleton (Thread Safe)
sync.Once over other approaches:init(): Runs at package load time — cannot accept parameters, cannot return errors, hard to test- Double-checked locking (
if instance == nil { lock; if instance == nil { create } }): Error-prone, need to get the memory ordering right.sync.Oncedoes this correctly sync.Once: Thread-safe, lazy initialization, simple API, near-zero overhead after first call
sync.Once, and the testability trade-off of singletons.Red flag answer: Using init() for singleton creation or implementing double-checked locking manually.Follow-up:- How would you make the singleton testable? (Answer: Don’t use a singleton. Accept the dependency as a parameter. Only wire it as a singleton in
main()or your DI container) - What if initialization can fail? (Answer: Store the error alongside the instance:
once.Do(func() { instance, initErr = loadConfig() }). Or usesync.OnceValuesin Go 1.21+)
37. Rate Limiter (Token Bucket)
37. Rate Limiter (Token Bucket)
time.Tick leaks the ticker (no way to stop it). Use time.NewTicker with defer ticker.Stop() in production.Token bucket with burst (production-grade):golang.org/x/time/rate, token bucket algorithm, and per-client limiting.Red flag answer: Implementing rate limiting with time.Sleep — this blocks the goroutine and does not handle bursts.Follow-up:- What is the difference between
limiter.Allow(),limiter.Wait(ctx), andlimiter.Reserve()? (Answer:Allow()returns true/false immediately.Wait(ctx)blocks until a token is available or context cancels.Reserve()returns a reservation with the delay time, letting you decide whether to wait) - How would you implement distributed rate limiting across multiple server instances? (Answer: Use Redis with a sliding window or token bucket (e.g.,
redis.INCRwithEXPIRE). Libraries likego-redis/redis_rateimplement this. Alternatively, use an API gateway like Kong or Envoy for centralized rate limiting) - How do you handle rate limiting in a microservice architecture where one service calls another? (Answer: Implement rate limiting at the caller side (client-side throttling) and the receiver side (server-side rate limiting). Use circuit breakers (e.g.,
sony/gobreaker) for cascading failure protection)
38. HTTP Middleware
38. HTTP Middleware
http.Handler to add cross-cutting behavior (logging, auth, CORS, rate limiting) without modifying handler logic.Pattern: A middleware function takes an http.Handler and returns a new http.Handler:- Request ID: Inject a unique ID via
context.WithValuefor distributed tracing - Panic recovery: Catch panics and return 500 instead of crashing the server
- CORS: Set appropriate headers for cross-origin requests
- Auth/AuthZ: Validate tokens, check permissions
- Rate limiting: Per-client request throttling
- Compression:
gzipresponse bodies - Timeout:
http.TimeoutHandlerwraps a handler with a deadline
- How do you capture the HTTP response status code in middleware? (Answer: Wrap
http.ResponseWriterwith a custom type that records the status code inWriteHeader. The standardResponseWriterdoes not expose the status after writing) - What is the execution order of chained middleware
A(B(C(handler)))? (Answer: A’s pre-logic runs first, then B’s pre-logic, then C’s, then the handler, then C’s post-logic, then B’s post-logic, then A’s post-logic — like nested function calls / onion model) - How does Go 1.22’s enhanced ServeMux affect middleware patterns? (Answer: With method-based routing (
mux.HandleFunc("POST /items/{id}", ...)), you can apply middleware per-route instead of globally. This reduces the need for third-party routers just for method matching)
39. Testing (Table Driven)
39. Testing (Table Driven)
- Easy to add cases: Just add a row to the table
- Subtests (
t.Run): Each case gets its own name, can be run individually (go test -run TestAdd/negative), and failures are isolated - Parallel subtests: Add
t.Parallel()insidet.Runfor concurrent execution - Consistent structure: All test cases follow the same pattern
- Benchmarks:
func BenchmarkAdd(b *testing.B) { for i := 0; i < b.N; i++ { Add(1,2) } } - Fuzzing (Go 1.18+):
func FuzzAdd(f *testing.F) { f.Add(1, 2); f.Fuzz(func(t *testing.T, a, b int) { Add(a, b) }) } - Test helpers:
t.Helper()marks a function as a test helper so that failure messages point to the caller, not the helper - Cleanup:
t.Cleanup(func() { ... })registers a cleanup function that runs after the test (and all its subtests) - HTTP testing:
httptest.NewServerfor integration tests,httptest.NewRecorderfor unit tests
assert libraries (like testify) for every assertion instead of understanding the built-in testing package. While testify is popular, knowing the standard library first is essential.Follow-up:- How do you run only tests matching a specific pattern? (Answer:
go test -run "TestAdd/negative" ./...— the regex matches test name and subtest name separated by/) - What is fuzz testing in Go 1.18+ and when would you use it? (Answer: The fuzzer generates random inputs to find edge cases. Use for parsers, validators, serialization/deserialization code — anywhere input variety matters.
go test -fuzz FuzzAdd -fuzztime 30s) - How do you test concurrent code? (Answer: Use
-raceflag, write tests that spawn goroutines and exercise shared state, usesync.WaitGroupto synchronize, and run with-count=100to increase the chance of exposing races)
40. JSON Custom Marshal
40. JSON Custom Marshal
encoding/json uses struct tags for basic serialization, but for complex transformations, implement json.Marshaler and json.Unmarshaler interfaces.Alias trick: Defining type Alias User creates a new type with the same fields but without the MarshalJSON method. This prevents infinite recursion when you call json.Marshal(Alias(u)) inside your custom marshaler.Common struct tags:`json:"name"`— field name in JSON`json:"name,omitempty"`— omit if zero value`json:"-"`— skip this field entirely`json:",string"`— encode number/bool as JSON string
encoding/json uses reflection and is relatively slow (~5-10x slower than code-generated alternatives). For high-throughput services, consider:github.com/json-iterator/go: Drop-in replacement, 2-3x fastergithub.com/goccy/go-json: 3-5x fastergithub.com/bytedance/sonic: 5-10x faster (uses JIT on amd64)
encoding/json uses reflection.Follow-up:- Why does
json.Marshaluse reflection and what is the performance cost? (Answer: It inspects struct tags and field types at runtime. Cost: ~1-3 microseconds per small struct. For 50K req/s, that is measurable. Use code-generated or JIT marshalers for hot paths) - How do you handle
nullvs missing fields in JSON unmarshalling? (Answer: Use pointer fields:*string. If the JSON key is missing, the pointer is nil. If the value isnull, the pointer is nil. If a value is present, the pointer is non-nil. For distinguishing missing vs null, use a custom unmarshaler or a library likeguregu/null) - What is the
json.RawMessagetype and when would you use it? (Answer: It stores raw JSON bytes without parsing. Useful for: (1) lazy parsing — parse the outer structure first, inner later; (2) polymorphic JSON — determine the type from one field, then parse the payload accordingly)
5. Edge Cases & Trivia
41. `range` Loop Variable Trap
41. `range` Loop Variable Trap
for _, v := range items was reused across iterations — it was the same memory address updated each loop. This caused subtle bugs with closures and goroutines:v, which is the same variable for every iteration. By the time the goroutines execute, the loop has finished and v holds the last value.The fix (pre-1.22): Shadow the variable inside the loop:- Does this bug only affect goroutines? (Answer: No. Any closure that captures the loop variable has this issue — including
defer, function literals passed to other functions, or storing&vin a slice) - How does Go 1.22 implement the fix under the hood? (Answer: The compiler creates a new copy of the variable at the start of each iteration. It is functionally equivalent to the
v := vshadowing trick, but automatic) - Does this also affect
for i := range n(range over integer, new in Go 1.22)? (Answer: Yes —iis a new variable each iteration, consistent with the new behavior for allforloops)
42. Slice Capacity Leak
42. Slice Capacity Leak
- Reading large files/HTTP responses and keeping a small substring
- Parsing protocols where you extract a header from a large packet
- Any function that returns a sub-slice of a large input
append can also cause unexpected sharing:slices.Clone: result := slices.Clone(data[:2]) is a clean way to create an independent copy.What interviewers are really testing: Understanding of slice backing arrays, GC interaction, and memory management awareness in Go.Red flag answer: “Go handles memory automatically so I don’t worry about slices holding references” — this leads to memory leaks in production.Follow-up:- How would you detect a slice capacity leak in production? (Answer:
pprofheap profile showing unexpectedly high memory retention. Look for large[]byteallocations that should have been GC’d.runtime.MemStatscan showHeapInusevsHeapAllocdiscrepancies) - Does
appendalways create a new backing array when capacity is exceeded? (Answer: Yes. Whenlen == cap,appendallocates a new, larger array and copies. But whenlen < cap, it writes to the existing array — which might be shared with other slices) - What does the three-index slice
a[low:high:max]do? (Answer: Sets both length (high-low) and capacity (max-low) of the new slice. This prevents the new slice from accidentally appending into the parent’s backing array beyondmax)
43. `init()` Function
43. `init()` Function
init() is a special function that runs automatically before main(). It cannot be called or referenced directly.Execution order (within a single package):- Import dependencies (their
initfunctions run first, recursively) - Package-level constants are evaluated
- Package-level variables are initialized
init()functions execute (in source order within a file, file order is unspecified but deterministic)
- A single file can have multiple
init()functions — they run in order of appearance init()takes no arguments and returns no values- You cannot call
init()— it is invoked by the runtime
init() is appropriate:- Registering drivers/codecs:
import _ "image/png"triggersinitin thepngpackage which registers the PNG decoder - Computing derived constants or lookup tables that can’t be done at declaration time
- Verifying program invariants at startup
init() is harmful:- Side effects (opening DB connections, starting goroutines): Makes the package hard to test —
initruns beforeTestMain, and you can’t control its timing - Panicking: An
initpanic crashes the program beforemainstarts. Hard to diagnose - Import ordering sensitivity: If
initin package A depends oninitin package B, you’ve created a hidden dependency that breaks if import order changes
init() usage. Prefer explicit initialization functions called from main():init is appropriate vs harmful, and testability considerations.Red flag answer: “I use init() to set up database connections” — this makes the package untestable without a running database.Follow-up:- Can you have multiple
init()functions in the same file? (Answer: Yes. They run in the order they appear in the source file) - What does
import _ "package"do? (Answer: Imports the package for its side effects only — itsinit()functions run, but no exported identifiers are accessible. Common for registering database drivers:import _ "github.com/lib/pq") - How does
init()interact with testing? (Answer:init()runs beforeTestMain. You cannot skip or mock it. This is why heavy initialization ininit()is a testing anti-pattern. UseTestMain(m *testing.M)for test setup instead)
44. Stack vs Heap (Size Limits)
44. Stack vs Heap (Size Limits)
- Goroutine Stack: Starts at 2KB (since Go 1.4, was 4KB before). Grows dynamically by copying to a 2x larger allocation. Maximum size: 1GB on 64-bit, 250MB on 32-bit. Configurable via
runtime/debug.SetMaxStack() - Heap: Limited only by available RAM (and OS virtual memory). Managed by Go’s garbage collector
- Stack: Local variables that don’t escape the function, function parameters, return values. Allocation is just a stack pointer bump — essentially free
- Heap: Variables that escape (returned pointers, interface assignments, closures, large allocations),
make’d slices/maps/channels, anything allocated withnewthat escapes
[1024]byte buffer on the stack (no escape) is dramatically faster than one that allocates make([]byte, 1024) on the heap (escape). In a hot path called 1M times/sec, this difference is measurable.What interviewers are really testing: Understanding of the stack/heap performance difference and how escape analysis determines allocation location.Red flag answer: “The heap is for dynamically allocated data and the stack is for local variables” — this is correct for C but wrong for Go. In Go, the compiler decides based on escape analysis, not the programmer.Follow-up:- What happens if a goroutine exceeds its maximum stack size? (Answer:
runtime: goroutine stack exceeds X-byte limit— the runtime panics with a stack overflow. This is not recoverable) - Can you force a variable onto the stack? (Answer: Not directly. You can influence it by avoiding escape — don’t return pointers, avoid interface conversions, keep allocations small. But the compiler makes the final decision)
- Why is the initial goroutine stack 2KB and not 8KB like most OS thread stacks? (Answer: To support millions of goroutines. 1M goroutines x 2KB = 2GB. At 8KB, that would be 8GB just for stacks. The dynamic growth ensures goroutines only use the memory they need)
45. Why No Generics Until Go 1.18?
45. Why No Generics Until Go 1.18?
- Fast compilation: C++ templates cause slow compilation (the template instantiation model duplicates code for every type combination)
- Fast execution: Java generics use type erasure — everything is
Objectat runtime, requiring boxing and unboxing, which hurts performance - Simple syntax and concepts: Previous proposals were too complex or too limiting
- Type parameters:
func Map[T any, U any](s []T, f func(T) U) []U - Constraints: Interfaces that define type sets:
type Number interface { int | float64 | int64 } - Implementation: Stenciling + Dictionaries hybrid — the compiler generates specialized code for some type instantiations (like C++ templates) and uses shared code with type dictionaries for others (like Java erasure). This balances code size vs performance
- No generic methods (only generic functions and types)
- No type parameter specialization
- No variance (covariance/contravariance)
- Type inference is limited in some cases
interface{}/anyfor generic data structures (now type-safe)- Code generation (
go generate) for type-specific implementations - Copy-paste for similar functions with different types
- What is the
comparableconstraint in Go? (Answer: A built-in constraint for types that support==and!=. Needed for map keys:func Contains[T comparable](s []T, item T) bool. Not all types are comparable — slices, maps, and functions are not) - How do Go generics differ from C++ templates? (Answer: Go generics are constrained by interfaces — the compiler checks at the definition site that only allowed operations are used. C++ templates are unconstrained — errors appear at the instantiation site, often with cryptic messages. Go trades some flexibility for much better error messages and compilation speed)
- When would you NOT use generics? (Answer: When
interface{}or a concrete type works fine. Don’t make code generic preemptively. If you have one implementation, use a concrete type. If you have two, consider an interface. If you have three or more with the same algorithm, consider generics)
46. `struct{}` (Empty Struct)
46. `struct{}` (Empty Struct)
struct{} is a type with zero bytes. It is the smallest type in Go — unsafe.Sizeof(struct{}{}) returns 0.Uses:- Set implementation:
map[string]struct{}— a set that uses no memory for values (vsmap[string]boolwhich uses 1 byte per entry). At 1M keys, this saves ~1MB - Signal-only channels:
chan struct{}— for channels that carry no data, just a signal. Idiomatic for done/quit channels: - Method-only types:
type handler struct{}— when you need a type to implement an interface but it has no state - Embedded for interface satisfaction: Embed an interface in a struct without adding size
struct{} values share the same address (runtime.zerobase), so even a [1000000]struct{} array uses 0 bytes (the array variable itself has an address, but it points to the shared zero-size base).What interviewers are really testing: Understanding of memory optimization in Go and idiomatic signal patterns.Red flag answer: Using chan bool or map[string]bool when the value is never read — wasting memory.Follow-up:- Is
struct{}{}the same address for every allocation? (Answer: Yes — the Go runtime uses a globalzerobaseaddress for all zero-size allocations.&struct{}{}always returns the same address within a single binary. This is an implementation detail, not a language guarantee) - What is the size of
[]struct{}{struct{}{}, struct{}{}, struct{}{}}in memory? (Answer: The slice header is 24 bytes (ptr + len + cap), but the backing array is 0 bytes because each element is 0 bytes. Total: 24 bytes regardless of length) - When would you use
chan struct{}vscontext.Contextfor cancellation? (Answer:context.Contextis preferred for most cases — it supports deadlines, values, and propagation.chan struct{}is simpler for one-off done signals within a single function or goroutine group)
47. Method Receiver (Value vs Pointer)
47. Method Receiver (Value vs Pointer)
- Value receiver
func (s MyStruct) Method(): Operates on a copy of the struct. Changes inside the method are not visible to the caller. - Pointer receiver
func (s *MyStruct) Method(): Operates on the original struct via pointer. Changes are visible to the caller.
- Pointer receiver: When the method modifies state, when the struct is large (avoid copy overhead), or when the type has a mix of pointer and value receivers (consistency — Go convention is to not mix)
- Value receiver: When the method does not modify state AND the struct is small (<~64 bytes), or when you intentionally want snapshot semantics
- Why can’t you call a pointer receiver method on a value stored in an interface? (Answer: The interface stores a copy of the value. The pointer receiver method expects a pointer to the original. The interface cannot provide the address of the original value because it might not be addressable)
- What is the performance difference between value and pointer receivers for a
struct { x, y float64 }? (Answer: Negligible — 16 bytes fits in registers. Value receiver might be faster due to no indirection. Benchmark before optimizing) - You have a type with 10 methods. 9 are read-only, 1 mutates state. What receiver type do you use? (Answer: Pointer receiver for ALL 10 methods. Go convention is consistency — if any method needs a pointer receiver, all should use pointer receivers)
48. Deadlock Detection: `fatal error: all goroutines are asleep`
48. Deadlock Detection: `fatal error: all goroutines are asleep`
- All goroutines are blocked on channel operations, mutexes, select, or sleep
- No goroutine is running or runnable
- No timer or I/O operation is pending that could unblock a goroutine
fatal error, not a panic. recover() cannot catch it. The runtime calls runtime.throw() which bypasses the panic/recover mechanism entirely. The process exits with exit code 2.Common causes:- Unbuffered channel with no receiver:
ch := make(chan int); ch <- 1in a single goroutine - WaitGroup mismatch:
wg.Add(1)without correspondingwg.Done() - Mutex double-lock: Goroutine tries to
Lock()a mutex it already holds (Go mutexes are NOT re-entrant) - Circular channel dependency: Goroutine A waits on B’s channel, B waits on A’s channel
- Partial deadlocks: If one goroutine is still running (e.g., the main goroutine with a
time.Sleep), the runtime does NOT detect the deadlock among other goroutines. This is the common case in real applications - Goroutine leaks: Goroutines blocked forever on channels nobody will send to — this is a leak, not a deadlock (other goroutines are still running)
SIGQUIT(Ctrl+\ on Unix): Prints all goroutine stack tracespprofgoroutine profile: Shows goroutine states and stack tracesruntime.NumGoroutine(): Monitor for unexpected growth
- How do you detect a partial deadlock where some goroutines are stuck but the program is still running? (Answer: Monitor
runtime.NumGoroutine()over time — unexpected growth indicates leaks. Usepprofgoroutine profiles to find blocked goroutines and their stack traces. Set up alerts for goroutine count in production monitoring) - Is Go’s deadlock detection useful in production code? (Answer: No — in production, there is always at least one goroutine running (main, HTTP server, etc.), so the all-goroutines-blocked condition never triggers. It is mainly useful during development for catching trivial bugs)
- How would you prevent deadlocks when using multiple mutexes? (Answer: Always acquire mutexes in a consistent global order. If you need lock A and lock B, always lock A first, then B — never the reverse. This eliminates circular wait conditions)
49. Internal ABI (Application Binary Interface)
49. Internal ABI (Application Binary Interface)
- Before 1.17: All function arguments and return values were passed on the stack. Each function call pushed arguments onto the stack and popped return values — many memory operations
- Go 1.17+: Arguments are passed in CPU registers (RAX, RBX, RCX, RDI, RSI, R8-R11 for integers; XMM0-XMM14 for floats). Only when registers are exhausted do arguments spill to the stack
- ~5% average speedup in benchmarks
- Up to 15% improvement in function-call-heavy code
- Larger improvement for functions with many small arguments (which all fit in registers)
- Goroutine stack copying: When a stack grows, all pointers on the stack must be adjusted. With register-based calling, the runtime must also know which registers hold pointers. The compiler generates metadata for this
- Garbage collector: The GC must scan registers for pointers during stack scanning (STW phases)
reflectpackage: Uses the ABI to call functions dynamically — must understand both conventions
.s files), you must be aware of the new calling convention for Go 1.17+ targets.What interviewers are really testing: Understanding of calling conventions, why registers are faster than stack, and awareness of Go’s evolving performance profile.Red flag answer: “Go uses the C calling convention” — it does not. Go has its own internal ABI, which is not ABI-compatible with C (that is what cgo bridges).Follow-up:- Why are register-based calls faster than stack-based? (Answer: Registers are on-chip — access takes ~1 clock cycle. Stack memory requires cache access — L1 is ~4 cycles, L2 is ~12 cycles. For functions called millions of times, this adds up)
- How does this interact with cgo? (Answer:
cgocalls still use the platform C ABI. There is a trampoline at the Go/C boundary that converts between Go’s internal ABI and the system ABI. This conversion is one reason cgo calls are expensive (~100ns overhead)) - Does this affect function inlining? (Answer: Indirectly — with cheaper function calls, the threshold for inlining can be relaxed. The compiler does not need to inline as aggressively to achieve the same performance. But the inline budget is set independently)
50. Reflect Package Performance
50. Reflect Package Performance
reflect package provides runtime type introspection and dynamic value manipulation. It is powerful but comes with significant costs.Performance characteristics:- Reflect method calls are 10-100x slower than direct calls
reflect.ValueOf()allocates (escapes to heap) — adds GC pressure- Reflect bypasses the type system — errors are runtime panics instead of compile-time errors
encoding/json: Marshal/Unmarshal (inspects struct tags, field types)fmt.Printf: Format verbs use reflect to inspect argument typesdatabase/sql: Scanning rows into struct fieldstext/templateandhtml/template: Evaluating template expressions
- Generics (Go 1.18+): Replace reflect for type-parameterized functions
- Code generation:
go generatewith tools likestringer,easyjson, orentfor ORM code - Interface-based dispatch: Define interfaces and let concrete types implement them — polymorphism without reflect
- Type switches: For a known set of types, a type switch is orders of magnitude faster than reflect
- Building generic serialization libraries that must handle arbitrary types
- ORM/database libraries that map rows to user-defined structs
- Dependency injection frameworks
- Testing utilities (deep equality comparison, struct comparison)
- How much slower is
reflect.ValueOf(x).Method(0).Call(nil)compared tox.Method()? (Answer: Roughly 50-100x slower. The reflect call involves allocation, method lookup via string/index, argument marshalling, and an indirect call. Benchmark: direct call ~2ns, reflect call ~200ns) - How does
encoding/jsonmitigate reflect overhead? (Answer: It caches struct field information (names, tags, types, offsets) after the first encode/decode of each type. Subsequent operations use the cached metadata, avoiding repeated reflect calls. But the initial encode is still slow) - What is the
reflect.Typevsreflect.Valuedistinction? (Answer:Typeis the type metadata — reusable, cheap, no allocation.Valueis the runtime value — requires allocation, holds the actual data. CacheTypeobjects; minimizeValuecreation in hot paths)
6. Advanced Go 1.22+ Features
51. Loop Variable Fix (Go 1.22)
51. Loop Variable Fix (Go 1.22)
for loop variables: each iteration now creates a new variable instead of reusing the same one.Before Go 1.22:v := v workaround.Backwards compatibility: This change was enabled per-module based on the go directive in go.mod. Modules with go 1.22 or higher get the new behavior. Modules with go 1.21 or lower keep the old behavior. This ensures existing programs are not broken.Detection: go vet with loopclosure checker detects the pre-1.22 pattern. Also: GOEXPERIMENT=loopvar was available in Go 1.21 for testing.What interviewers are really testing: Awareness of this critical language change and understanding of how it was rolled out safely.Red flag answer: Not knowing this change happened, or thinking v := v is still required in Go 1.22+.Follow-up:- How did Go make this change backwards-compatible? (Answer: The
goversion ingo.modacts as a feature gate. Only modules declaringgo 1.22+get the new loop variable semantics. This per-module versioning prevents breaking existing code) - Does this change affect performance? (Answer: Negligibly. The compiler may allocate one additional variable per iteration, but in most cases this is optimized away. The correctness benefit far outweighs any micro-performance cost)
- Are there any programs that relied on the old behavior? (Answer: Theoretically yes — a program that intentionally shared a loop variable across iterations. But such code was almost always a bug, not intentional design. The Go team analyzed the entire public Go corpus and found that nearly all instances of shared loop variables were bugs)
52. `slices` Package (Go 1.21+)
52. `slices` Package (Go 1.21+)
slices package (standard library, Go 1.21) provides generic utility functions for slices, replacing many hand-written loops and third-party libraries.Key functions:slices.Sort(s): Sorts in-place using pattern-defeating quicksort (faster thansort.Slicefor most inputs)slices.SortFunc(s, cmp): Sort with custom comparison functionslices.BinarySearch(s, target): Binary search in a sorted sliceslices.Contains(s, v): Linear search for a valueslices.Index(s, v): Find index of first occurrenceslices.Compact(s): Remove consecutive duplicate elementsslices.Clone(s): Create an independent copyslices.Equal(s1, s2): Element-wise equalityslices.Reverse(s): Reverse in-placeslices.Max(s),slices.Min(s): Find extremes
- Written with generics — type-safe, no
interface{}conversion - Optimized implementations — often faster than hand-rolled loops
- Standard across the ecosystem — no need for third-party slice utilities
maps package (Go 1.21) provides similar utilities for maps: maps.Keys, maps.Values, maps.Clone, maps.Equal, maps.DeleteFunc.What interviewers are really testing: Awareness of modern Go standard library additions and preference for standard library over custom implementations.Red flag answer: Writing custom sort/search functions when slices package provides them.Follow-up:- How does
slices.Sortdiffer fromsort.Slice? (Answer:slices.Sortuses generics — no interface boxing overhead. It also uses a more modern algorithm (pattern-defeating quicksort). It is ~15-30% faster for most inputs) - What is
slices.Grow(s, n)used for? (Answer: Pre-allocates capacity for at leastnmore elements without changing length. Equivalent toappend(s, make([]T, n)...)[:len(s)]but clearer. Useful before a loop that will append known number of elements) - Why does
slices.Containsuse linear search instead of hash lookup? (Answer: Slices are ordered sequences, not sets. For O(1) lookup, use amap[T]struct{}.Containsis O(n) but has no setup cost — good for small slices or occasional lookups)
53. Range over Integers (Go 1.22)
53. Range over Integers (Go 1.22)
for i := 0; i < 10; i++ but more concise and less error-prone.Details:- The iteration variable
istarts at 0 and goes up to (but not including) the integer value - Works with any integer type:
var n uint = 5; for i := range n { ... } - The loop variable
iis a new variable each iteration (consistent with the Go 1.22 loop variable fix) - If the integer is 0 or negative, the loop body does not execute
for...range.What interviewers are really testing: Awareness of modern Go syntax additions and understanding of the iterator protocol.Red flag answer: Not knowing this feature exists while claiming Go 1.22 experience.Follow-up:- How does
range 10handle the loop variable compared tofor i := 0; i < 10; i++? (Answer: Both create a newiper iteration in Go 1.22+. Before 1.22, the traditional for loop always created a new variable per iteration — the loop variable fix only affectedfor...rangeloops) - What is the
iter.Seqtype and how does it enable custom iterators? (Answer:iter.Seq[V]isfunc(yield func(V) bool). The function callsyieldfor each value. Ifyieldreturns false, iteration stops. This is Go’s answer to Python generators or Rust iterators)
54. `http.ServeMux` Enhancements (Go 1.22)
54. `http.ServeMux` Enhancements (Go 1.22)
http.ServeMux with features that previously required third-party routers (Chi, Gorilla Mux, httprouter).New features:- HTTP method matching:
- Path wildcards with named parameters:
- Wildcard with
...for catch-all: - Precedence rules: More specific patterns take priority over general ones.
GET /items/{id}beatsGET /items/{name}if registered first (patterns are checked in order of specificity, not registration)
- Before:
mux.HandleFunc("/items", handler)— no method filtering, handler had to checkr.Methodmanually. No path parameters without regex parsing - Now: Method + path matching is native. Path parameters are extracted without regex. Covers 80-90% of routing needs
- Regex-based path matching
- Route grouping with shared middleware (Chi’s
r.Group()) - Automatic OPTIONS/405 responses
- OpenAPI generation from routes
- How do you access path parameters in Go 1.22’s ServeMux? (Answer:
r.PathValue("paramName")— returns the string value of the named wildcard from the route pattern) - What happens if two patterns conflict, like
GET /users/{id}andGET /users/admin? (Answer: The more specific pattern (/users/admin) takes precedence. The mux uses a most-specific-wins rule) - How does the new ServeMux handle methods not listed? (Answer: If you register
GET /itemsandPOST /items, aDELETE /itemsrequest gets a 405 Method Not Allowed response automatically, with anAllowheader listing the valid methods)
55. Arena (Experimental Memory Management)
55. Arena (Experimental Memory Management)
GOEXPERIMENT=arenas) provide manual region-based memory management that bypasses the garbage collector.How it works:- Create an arena:
a := arena.NewArena() - Allocate objects in the arena:
p := arena.New[MyStruct](a) - Free all objects at once:
a.Free()— reclaims all memory allocated from this arena in one operation - No individual object deallocation — the entire arena is freed as a unit
- High-frequency trading: Microsecond-level latency requirements
- Game servers: Allocate all per-frame objects in an arena, free at frame end
- Request processing: Allocate all request-scoped objects in an arena, free when the response is sent
- Pro: Zero GC overhead for arena-allocated objects. Bulk deallocation is extremely fast
- Con: Manual memory management — use-after-free bugs if you access arena memory after
Free(). Objects in the arena cannot be referenced after the arena is freed - Con: Experimental — API may change or be removed. Not recommended for production use yet
GOMEMLIMIT approach (Go 1.19) solved many of the same problems with less complexity.What interviewers are really testing: Understanding of GC-free allocation strategies and their trade-offs, and awareness of Go’s experimental features.Red flag answer: “I’d use arenas in production right now” — they are experimental and the API is unstable. Use GOMEMLIMIT and sync.Pool first.Follow-up:- How does arena allocation compare to
sync.Pool? (Answer:sync.Poolreuses objects across GC cycles but objects are still GC-tracked. Arenas bypass GC entirely. Pool is safer and production-ready; arenas are experimental and riskier) - What happens if you access memory from a freed arena? (Answer: Undefined behavior in theory. In practice, the runtime may detect it and panic, or you may get corrupted data. This is the primary safety concern with arenas)
- What alternatives exist for reducing GC overhead without arenas? (Answer:
GOMEMLIMITto control GC pacing,sync.Poolfor object reuse, reducing allocations via escape analysis optimization, pre-allocated buffers, off-heap storage viammaporcgo)
56. `sync.Map`
56. `sync.Map`
sync.Map is a concurrent map optimized for two specific access patterns:- Write-once, read-many (cache-like): Keys are written once and then read millions of times
- Disjoint key access: Different goroutines operate on different subsets of keys with minimal overlap
- Uses two internal maps: a
readmap (atomic, lock-free) and adirtymap (requires mutex) - Read path: Checks the
readmap first (atomic, no lock). If found, returns immediately (~5ns) - Write path: If key is not in
readmap, acquires mutex and writes todirtymap - Promotion: After enough misses on the
readmap, thedirtymap is promoted to become the newreadmap (atomic swap)
sync.Map:- General-purpose concurrent map with mixed reads/writes on the same keys — a
RWMutex+ regular map is faster - When you need typed keys/values (sync.Map uses
any) — the type assertions add overhead and lose type safety - When you need consistent iteration (Range is not a snapshot — concurrent modifications may or may not be visible)
- Read-heavy (99% reads):
sync.Mapwins — lock-free reads are ~3x faster thanRWMutex.RLock() - Write-heavy (50% writes):
RWMutex+ map wins —sync.Map’s promotion overhead hurts - Disjoint keys:
sync.Mapwins — no contention on the internalreadmap
sync.Map is appropriate (it is NOT a general-purpose concurrent map) and awareness of its internal two-map design.Red flag answer: “I use sync.Map whenever I need a concurrent map” — this is wrong. sync.Map is optimized for specific patterns and slower than RWMutex for general use.Follow-up:- Why doesn’t Go just make the built-in
mapthread-safe? (Answer: Performance. Mutex overhead on every map operation would penalize the common case of single-goroutine access. Go prefers making concurrency explicit) - How would you build a type-safe concurrent map in Go 1.18+? (Answer: Use generics:
type ConcurrentMap[K comparable, V any] struct { mu sync.RWMutex; data map[K]V }. This gives type safety withoutsync.Map’sanycasts) - What is the memory overhead of
sync.Mapcompared to a regular map? (Answer: Roughly 2x — it maintains two copies of the data (read map and dirty map) during transition periods. After promotion, the dirty map is nil until the next write miss)
57. Profile-Guided Optimization (PGO)
57. Profile-Guided Optimization (PGO)
- Collect a profile: Run your production workload and capture a CPU profile:
- Place the profile: Put
default.pgoin the main package directory - Build:
go buildautomatically detects and usesdefault.pgo
- Hot function inlining: Functions called frequently (according to the profile) get more aggressive inlining, even if they exceed the normal inline budget
- Devirtualization: If an interface method call almost always dispatches to the same concrete type, PGO can specialize the call site with a direct call + fallback
- Block layout: Reorders basic blocks in functions to minimize branch mispredictions on hot paths
- Collect profiles from production (or realistic staging). Synthetic benchmarks may not represent real call patterns
- Update profiles periodically as traffic patterns change
- The profile does not need to be from the exact same binary — PGO is robust to minor code changes
- You can check if PGO is active:
go version -m binary | grep buildshows-pgo=flag
- What happens if the profile is stale (from an old version of the code)? (Answer: PGO degrades gracefully. Stale profiles still provide useful signal about hot code paths. The compiler ignores profile entries that don’t match current code)
- Can PGO make performance worse? (Answer: In theory, if the profile is extremely unrepresentative of actual usage. In practice, Go’s PGO is conservative — it only applies optimizations when the profile signal is strong. Benchmarking is always recommended)
- How does PGO compare to Java’s JIT compilation? (Answer: JIT compiles and optimizes at runtime — adapts to current workload but has warmup time. PGO is ahead-of-time — no warmup, but the profile is a snapshot of past behavior. PGO cannot adapt to changing patterns without recompilation)
58. Finalizers (`SetFinalizer`)
58. Finalizers (`SetFinalizer`)
runtime.SetFinalizer(obj, func) registers a function to be called when the GC determines that obj is unreachable.How it works:- When the GC finds an object with a finalizer, it does NOT free the object. Instead, it queues the finalizer to run
- The finalizer runs in a dedicated goroutine at some indeterminate point in the future
- After the finalizer runs, the object is eligible for collection in the next GC cycle (the object survives one extra GC cycle)
- Unpredictable timing: May run immediately, minutes later, or never (if the program exits first)
- Delayed GC: The finalized object and everything it references survives an extra GC cycle — increases memory pressure
- Single-shot: If the finalizer makes the object reachable again (resurrection), the finalizer is NOT re-registered. You must call
SetFinalizeragain manually - Order undefined: Finalizers on related objects may run in any order — you cannot depend on A’s finalizer running before B’s
- No guarantee of execution: If the program crashes or
os.Exit()is called, pending finalizers do NOT run
- Last-resort cleanup for system resources (file descriptors, C allocations via cgo) when the caller forgot to call
Close() - Safety net in libraries: warn (log) when a resource was not explicitly closed
runtime.AddCleanup: A safer alternative that avoids finalization pitfalls. Cleanups run when the object is unreachable and do not delay GC of the object itself.The right pattern: Use defer resource.Close() or io.Closer interface. Finalizers are the backup, not the primary mechanism.What interviewers are really testing: Understanding why finalizers are an anti-pattern in Go and knowledge of proper resource management.Red flag answer: “I use finalizers to close database connections” — this is unreliable. Use defer, Close(), or resource pools.Follow-up:- What happens if a finalizer panics? (Answer: The finalizer goroutine crashes. Other pending finalizers may not run. The program may or may not crash depending on the Go version and whether there is a recovery mechanism)
- How do finalizers interact with GC cycles? (Answer: Finalized objects survive at least one extra GC cycle — they are not freed when first found unreachable. They are queued for finalization, the finalizer runs, and the object is freed in the NEXT collection. This delays memory reclamation)
- What is
runtime.AddCleanupand how is it better thanSetFinalizer? (Answer:AddCleanup(Go 1.24) takes a cleanup function and a separate value to clean up — the cleanup function does not receive the original object, preventing resurrection. Multiple cleanups can be attached to one object. Objects are freed immediately, not delayed by one GC cycle)
59. `unsafe` Package
59. `unsafe` Package
unsafe package bypasses Go’s type system. It provides raw memory access through unsafe.Pointer (equivalent to C’s void*).Key types and functions:unsafe.Pointer: Can be converted to/from any pointer type. Cannot be dereferenced directly. Can be converted touintptrfor arithmeticunsafe.Sizeof(v): Size ofvin bytes (compile-time constant)unsafe.Alignof(v): Alignment ofvin bytesunsafe.Offsetof(s.f): Byte offset of fieldfwithin structsunsafe.Add(ptr, offset)(Go 1.17): Pointer arithmeticunsafe.Slice(ptr, len)(Go 1.17): Create a slice from a pointer and lengthunsafe.String(ptr, len)(Go 1.20): Create a string from a pointer and length
- Syscalls and cgo: Converting Go pointers to system call arguments
- High-performance serialization: Zero-copy conversion between
[]byteandstring:*(*string)(unsafe.Pointer(&bytes))(avoids allocation) - Accessing unexported fields (in testing/debugging): Through pointer arithmetic
- Memory-mapped I/O: Converting raw memory addresses to Go types
- Implementing
sync.Pool,atomic.Value, etc.: The runtime itself usesunsafeextensively
unsafe.Pointer patterns):- Only 6 conversion patterns are valid. Violating them causes undefined behavior
uintptris an integer, NOT a pointer. The GC does not track it. Auintptrcan become invalid if the GC moves the object. Never storeuintptrvalues across function callsgo vetchecks for commonunsafe.Pointermisuses
- Breaks Go’s memory safety guarantees
- Not portable across architectures
- Can cause silent memory corruption, segfaults, or security vulnerabilities
- Code using
unsafemay break with new Go versions (no compatibility guarantee)
unsafe.Pointer ↔ uintptr conversions.Red flag answer: “I use unsafe to make things faster” — this should be an extreme last resort after profiling proves it necessary and safer alternatives are insufficient.Follow-up:- Why is
uintptrdangerous to store across function calls? (Answer:uintptris an integer. The GC does not know it points to an object. If the GC moves the object (stack growth, compaction), theuintptrbecomes a dangling pointer. Only useuintptrin a single expression:unsafe.Pointer(uintptr(p) + offset)) - How would you do a zero-copy
stringto[]byteconversion? (Answer:*(*[]byte)(unsafe.Pointer(&s))— but the resulting byte slice MUST NOT be modified, as Go strings are immutable. Mutation would violate string immutability and cause undefined behavior) - Is
unsafecode covered by Go’s compatibility guarantee? (Answer: No. The Go 1 compatibility promise explicitly excludesunsafe. Code usingunsafemay break with any Go release)
60. Go Assembly
60. Go Assembly
math/bits: Bit manipulation using CPU-specific instructions (POPCNT, CLZ)crypto/aes,crypto/sha256: Hardware-accelerated encryption (AES-NI, SHA extensions)sync/atomic: CPU-level atomic operations (LOCK CMPXCHG)runtime: Scheduler, stack switching, signal handlingmath: Fastmath.Sqrt,math.Logusing FPU instructions
- Uses pseudo-registers:
FP(frame pointer),SP(stack pointer),SB(static base),PC(program counter) - Function signatures are declared in
.gofiles, implementations in.sfiles - The assembler handles some platform differences, but you still need per-architecture files for SIMD/specialized instructions
- Go 1.17+ register-based ABI complicates hand-written assembly (must follow the new calling convention)
- SIMD vectorization (SSE, AVX, NEON) — the Go compiler does not auto-vectorize
- Hardware-specific instructions (AES-NI, CRC32)
- Extremely hot inner loops where the compiler generates suboptimal code
- Almost never in application code — only in performance-critical library code
go tool compile -S— see the assembly output of Go codego tool objdump— disassemble a compiled binarygithub.com/minio/asm2plan9s— convert x86 assembly to Plan 9 syntaxgithub.com/gorse-io/goat— Go assembly toolkit
- Why doesn’t the Go compiler auto-vectorize like GCC/Clang? (Answer: Go prioritizes compilation speed and code simplicity. Auto-vectorization is complex and can increase compilation time significantly. For SIMD, Go expects manual assembly implementations)
- How do you write a Go function in assembly? (Answer: Declare the function signature in a
.gofile with no body:func FastHash(data []byte) uint64. Implement it in a.sfile with the same package. The linker connects them) - What is the performance difference between Go code and hand-written assembly for a SHA-256 hash? (Answer: Hardware-accelerated SHA-256 assembly (using SHA-NI extensions) can be 3-10x faster than pure Go implementation. This is why the
crypto/sha256package includes assembly implementations for amd64 and arm64)
7. Cross-Cutting Interview Questions
61. Goroutine Leak Detection and Prevention
61. Goroutine Leak Detection and Prevention
- Unbuffered channel with no reader:
go func() { ch <- val }()— if nobody readsch, the goroutine blocks forever - Missing context cancellation: A goroutine waiting on
<-ctx.Done()wherecancel()is never called - Infinite loops without exit conditions:
for { select { case <-ch: ... } }wherechis never closed - Leaked HTTP connections: Forgetting to read and close
resp.Bodyin HTTP clients
- In tests: Use Uber’s
goleakpackage:defer goleak.VerifyNone(t)at the top of each test - In production: Monitor
runtime.NumGoroutine()over time. Alert if it grows monotonically. Export as a Prometheus metric - Profiling:
go tool pprof http://localhost:6060/debug/pprof/goroutine— shows all goroutine stacks grouped by state
- Always pass
context.Contextto goroutines and checkctx.Done() - Always close channels when the sender is done
- Always read and close
resp.Bodyin HTTP clients (even on error responses) - Use
errgroupwhich handles context cancellation automatically
- You notice
runtime.NumGoroutine()growing by 100/minute in production. How do you diagnose it? (Answer: Take twopprofgoroutine snapshots minutes apart. Diff them to find accumulating goroutines. The stack traces show where they are blocked) - How does
goleakdetect goroutine leaks in tests? (Answer: It snapshotsruntime.Stack()before and after the test. New goroutines that appear after the test (excluding known background goroutines like GC) are reported as leaks) - Is there a runtime limit on the number of goroutines? (Answer: No hard limit — but practical limits exist. Each goroutine uses at least 2KB of stack memory. At 1M goroutines, that is 2GB. The real limit is usually the downstream resources goroutines hold: file descriptors, DB connections, network sockets)
62. Go Generics in Practice (Type Constraints & Type Sets)
62. Go Generics in Practice (Type Constraints & Type Sets)
any: No constraint — accepts all types (alias forinterface{})comparable: Types that support==and!=(needed for map keys)- Custom constraints using type sets:
- The
~tilde operator matches types with the specified underlying type:type MyInt inthas underlying typeint, so~intmatches bothintandMyInt
- For pointer types: shared code with a dictionary (type metadata) passed at runtime
- For value types: may generate specialized (stenciled) code for each type
- This balances binary size (pure stenciling = code bloat) vs runtime overhead (pure dictionaries = slower)
- Generics: When the algorithm is the same but the type varies. Data structures (Stack, Queue, Tree), utility functions (Map, Filter, Reduce)
- Interfaces: When behavior varies. Different implementations of the same operation (different storage backends, different auth providers)
- What does the
~tilde mean in type constraints? (Answer: It matches the underlying type, not just the exact type.~intmatchesint,type MyInt int,type Age int, etc. Without~, only the exact typeintmatches) - Can you have generic methods (not just generic functions and types)? (Answer: No. Go does not support type parameters on methods. You can have methods on generic types, but you cannot add new type parameters to a method that the type does not already have. This is a known limitation)
- How does the Go generics implementation avoid C++‘s compilation speed problem? (Answer: C++ instantiates templates in every translation unit, causing massive code duplication. Go uses dictionaries for most instantiations (shared code + runtime type info), only stenciling when performance demands it. This keeps binary sizes and compilation times manageable)
63. Profiling Go Applications (pprof)
63. Profiling Go Applications (pprof)
pprof tool. Zero external dependencies, production-safe, and deeply integrated with the runtime.Profile types:- CPU profile: Where the program spends CPU time. Samples the call stack every 10ms (configurable)
- Heap profile: Current heap allocations (in-use) and cumulative allocations (alloc). Shows what is consuming memory
- Goroutine profile: Stack traces of all goroutines, grouped by state. Essential for deadlock/leak debugging
- Block profile: Where goroutines block on synchronization (channels, mutexes). Shows contention
- Mutex profile: How long goroutines wait to acquire mutexes. Shows lock contention
- Threadcreate profile: Stack traces that led to OS thread creation
- Expose pprof on a separate port (not the public API port): restrict access via firewall/auth
- CPU profiling has ~5% overhead — safe for production, but avoid continuous profiling unless you use a sampling service
- Heap profiling is nearly free — keep it always enabled
- Use continuous profiling services (Pyroscope, Datadog Continuous Profiler, Google Cloud Profiler) for always-on insights
- How do you find a memory leak in a Go service? (Answer: Take heap profiles at regular intervals. Compare them with
go tool pprof -diff_base=before.prof after.prof. Look for types whose allocation count grows over time. Theinuse_objectsview shows what is currently alive) - What is the overhead of leaving pprof enabled in production? (Answer: The HTTP endpoints have near-zero overhead when not being queried. CPU profiling has ~5% overhead when active. Heap profiling sampling rate can be tuned with
runtime.MemProfileRate) - How do you profile a Go program that crashes before you can collect a profile? (Answer: Use
GOTRACEBACK=crashto get a core dump on crash. Or useruntime/pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)in a signal handler to dump goroutine stacks before exit)
64. `cgo`: Calling C from Go
64. `cgo`: Calling C from Go
cgo enables Go programs to call C functions and use C types. It is the bridge between Go and the C ecosystem (system libraries, legacy code, hardware interfaces).Basic usage:- Function call overhead: ~100-200ns per cgo call (vs ~2ns for a Go function call). The runtime must save/restore Go state, switch stacks, and handle the Go/C ABI boundary
- No goroutine multiplexing: During a cgo call, the goroutine is pinned to an OS thread. The P is detached. If you have 100 goroutines in cgo calls, you need 100 OS threads
- GC interaction: The GC cannot scan C memory. Go pointers passed to C must follow the cgo pointer passing rules (Go 1.6+)
- Calling system libraries (OpenSSL, SQLite, GPU drivers)
- Using hardware interfaces (serial ports, USB, custom hardware)
- Integrating with large C/C++ codebases during migration
- Accessing SIMD intrinsics or CPU-specific features
- “cgo is not Go” (Dave Cheney) — cgo programs lose cross-compilation, static linking (usually), and Go’s memory safety
- Compilation is slower (invokes the C compiler)
- Debugging is harder (mixed Go/C stacks)
- If a pure Go alternative exists (e.g.,
crypto/tlsinstead of OpenSSL,modernc.org/sqliteinstead of C SQLite), prefer it
strlen or memcpy is an anti-pattern.Follow-up:- Why is cgo call overhead 100ns instead of 2ns like a normal Go call? (Answer: The runtime must: (1) save Go registers, (2) switch from Go stack to C stack, (3) lock the goroutine to the OS thread, (4) detach the P so other goroutines can run, (5) call the C function, (6) reverse all of this. Each step involves syscalls or atomic operations)
- What are the cgo pointer passing rules? (Answer: Go pointers passed to C must not contain other Go pointers — the GC might move the pointed-to objects. C code must not store Go pointers after the call returns.
runtime.Pinner(Go 1.21) can pin objects in memory to allow longer-lived C references) - How does
modernc.org/sqlitework without cgo? (Answer: It transpiles the C SQLite source code to pure Go using a C-to-Go transpiler (ccgo). The result is pure Go — no cgo, full cross-compilation, static linking. ~10-20% slower than C SQLite but much easier to deploy)
65. Dependency Injection and Testing in Go
65. Dependency Injection and Testing in Go
- Implicit interfaces eliminate boilerplate interface declarations
- The
main()function is the composition root — wire everything there - For larger apps, use wire (
github.com/google/wire) for compile-time dependency injection code generation — no runtime overhead
- Interface mocking: Define a narrow interface, implement a mock (manually or with
mockgen) - Table-driven tests: Multiple test cases exercising different mock behaviors
- Integration tests: Use
testcontainers-goto spin up real databases in Docker for integration testing httptest:httptest.NewRecorder()for unit testing HTTP handlers,httptest.NewServer()for integration tests
- How do you decide how narrow an interface should be? (Answer: An interface should have only the methods the consumer actually calls. If a handler only reads users, define
type UserReader interface { GetUser(ctx, id) (*User, error) }— not the full CRUD interface. This is the Interface Segregation Principle applied naturally through Go’s implicit interfaces) - When would you use
mockgenvs hand-written mocks? (Answer: Hand-written mocks for simple interfaces (1-3 methods) — they are more readable and maintainable.mockgenfor large interfaces or when you need sophisticated matching/verification. But first ask: if the interface is large, should it be split?) - How does Google’s
wiretool work? (Answer: You define provider functions and injector functions.wire generategenerates the code that calls providers in dependency order. It is a compile-time tool — no runtime reflection, no performance overhead. The generated code is plain Go that you can read and debug)
66. Memory Alignment and Struct Padding
66. Memory Alignment and Struct Padding
- Fields are aligned to their natural alignment (int64 must start at an 8-byte boundary, int32 at 4-byte, etc.)
- The struct itself is aligned to the alignment of its largest field
- Padding is inserted between fields and at the end of the struct
- In a
[]Badwith 1M elements: 24MB vs 16MB for[]Good— 33% memory waste - More memory = more cache misses = slower iteration
- The
fieldalignmentanalyzer fromgo vetdetects suboptimal field ordering:
unsafe.Sizeof(T{}): Shows the total size including paddingunsafe.Alignof(T{}): Shows alignment requirementunsafe.Offsetof(T{}.field): Shows the byte offset of each fieldgithub.com/dominikh/go-tools(staticcheck): Includes thefieldalignmentcheck
- Why does alignment matter for performance? (Answer: Misaligned access may require two memory reads instead of one (crossing a cache line boundary). On some architectures, misaligned 64-bit access causes a hardware exception. Even on x86 where misaligned access works, it is slower)
- Does the Go compiler ever reorder struct fields for better alignment? (Answer: No. Go guarantees struct fields are laid out in declaration order. The programmer must order fields manually for optimal packing. This is a deliberate design choice — predictable layout is important for unsafe code, cgo, and binary serialization)
- When should you NOT optimize struct field ordering? (Answer: When readability matters more than memory savings. Group related fields together for code clarity. Only optimize when profiling shows memory/cache pressure from this specific struct)
67. `errgroup` and Structured Concurrency
67. `errgroup` and Structured Concurrency
golang.org/x/sync/errgroup combines WaitGroup + error propagation + context cancellation into a single primitive. It is the go-to tool for structured concurrent work in Go.Basic usage:g.Wait()blocks until all goroutines complete, returns the first non-nil error- When any goroutine returns an error, the associated context is cancelled — signaling other goroutines to stop
- Unlike bare
WaitGroup, you do not need to manageAdd/Donemanually
SetLimit makes errgroup a bounded worker pool with error handling — no need to implement one from scratch.Comparison with alternatives:sync.WaitGroup: No error propagation, no cancellation. Use for fire-and-forget goroutineserrgroup: First-error propagation + context cancellation. Use for work that can fail- Manual channels: Maximum control. Use when you need per-goroutine error handling or partial results
x/sync ecosystem and preference for structured concurrency over ad-hoc goroutine management.Red flag answer: Implementing manual WaitGroup + channel + context cancellation when errgroup does it all in 3 lines.Follow-up:- What happens if two goroutines in an errgroup return errors simultaneously? (Answer: Only the first error is captured by
Wait(). The second error is lost. If you need all errors, useerrors.Joinor collect errors into a slice with a mutex) - How does
SetLimitwork internally? (Answer: It uses a semaphore (buffered channel).Goacquires the semaphore before starting the goroutine, releases it when the goroutine finishes. If the semaphore is full,Goblocks until a slot is available) - What is “structured concurrency” and how does errgroup relate? (Answer: Structured concurrency means every goroutine has a clear owner and lifetime — it starts and ends within a well-defined scope.
errgroupenforces this: all goroutines must complete beforeWaitreturns. This prevents goroutine leaks and makes concurrent code easier to reason about)
68. Channel Patterns: Done, Or-Done, Tee, Bridge
68. Channel Patterns: Done, Or-Done, Tee, Bridge
- Or-Done: Any time you read from a channel provided by external code and need timeout/cancellation
- Tee: Sending the same event stream to two consumers (e.g., logging + processing)
- Bridge: Consuming paginated results where each page returns its own channel
- Why does the Tee pattern need nil channel assignment? (Answer: To ensure both outputs receive the value. After sending to one output, set it to nil so the next
selectiteration sends to the other. Nil channels are never selected, so this forces theselectto pick the remaining output) - How would you implement a “first-of-N” pattern where you want the result from whichever channel produces first? (Answer:
selecton all channels. Return the first value received. Cancel the context to signal other producers to stop. This is the basis of speculative execution / hedged requests) - What is the performance cost of these patterns vs using mutexes? (Answer: Each channel hop adds ~50-100ns of latency. For data flowing through 5 stages, that is 250-500ns of overhead. Mutex-based approaches are faster for in-process coordination but harder to compose. Channels are better for pipeline architectures where clarity outweighs raw performance)