Go (Golang) Interview Questions and How to Answer Them (45 Questions)
Go has become the lingua franca of cloud infrastructure, microservices, and performance-sensitive backend systems. Companies like Google, Uber, Cloudflare, Dropbox, Docker, and Kubernetes all run on Go. If you are interviewing for a Go role in 2025 or 2026, you will face questions that test not just syntax knowledge but whether you think in Go — concurrency by design, composition over inheritance, explicit error handling, and zero-cost abstractions.
This guide covers 45 questions organized by topic, from fundamentals to advanced runtime internals. Every answer includes real code, the reasoning behind correct patterns, and common wrong answers that interviewers specifically watch for.
Section 1 — Language Fundamentals
1. What is the difference between `var a []int` and `a := []int{}`?
Short answer: The first declares a nil slice; the second declares an empty (non-nil) slice. They behave identically for most operations but differ in JSON serialization and nil checks.
var a []int // nil slice — len 0, cap 0, a == nil is true
b := []int{} // empty slice — len 0, cap 0, b == nil is false
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
// JSON marshaling difference:
// json.Marshal(a) → "null"
// json.Marshal(b) → "[]"When to use which: Prefer var a []int when the slice might stay empty; this avoids a zero-length heap allocation. Use []int{} (or make([]int, 0)) when you need a non-nil slice — for example, when you will JSON-encode it and null would be semantically wrong.
Why interviewers ask this: It tests whether you understand Go's nil semantics and the internal slice header (pointer | length | capacity). A nil slice has a zero pointer; an empty slice has a valid (but unreachable) pointer.
2. Explain the internal structure of a slice.
A slice is not an array. It is a three-field struct in the runtime:
type slice struct {
array unsafe.Pointer // pointer to underlying array
len int // number of elements currently in the slice
cap int // total capacity of the underlying array
}s := make([]int, 3, 5)
fmt.Println(len(s), cap(s)) // 3 5
// Appending within capacity does not allocate
s = append(s, 10)
fmt.Println(len(s), cap(s)) // 4 5
// Appending beyond capacity triggers a new allocation + copy
s = append(s, 20, 30)
fmt.Println(len(s), cap(s)) // 6 10 (capacity doubles, roughly)Slicing a slice shares the backing array:
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b = [2, 3], shares a's array
b[0] = 99 // mutates a too!
fmt.Println(a) // [1 99 3 4 5]Use copy() or the three-index slice a[1:3:3] (limits capacity, prevents accidental writes through the tail) to avoid this.
3. What is the difference between `make` and `new`?
| | new(T) | make(T, ...) |
|---|---|---|
| Returns | *T (pointer to zeroed memory) | T itself (initialized, not a pointer) |
| Works on | Any type | Only slice, map, channel |
| Initialization | Zero value only | Full internal structure ready to use |
p := new(int) // *int pointing to 0
fmt.Println(*p) // 0
s := make([]int, 5) // initialized slice, len=5, cap=5
m := make(map[string]int) // initialized map, ready for writes
ch := make(chan int, 10) // buffered channel with cap 10Common mistake: new([]int) gives you a *[]int pointing to a nil slice — that nil slice is not usable. Always use make for slices, maps, and channels.
4. How do maps work internally, and what are their gotchas?
Go maps are hash tables. Key operations:
// Zero value of a map is nil — reading is safe, writing panics
var m map[string]int
_ = m["key"] // ok, returns 0
m["key"] = 1 // PANIC: assignment to entry in nil map
// Always initialize before writing
m = make(map[string]int)
m["key"] = 1
// Two-value lookup to distinguish missing vs zero
val, ok := m["missing"]
fmt.Println(val, ok) // 0 false
// Maps are not safe for concurrent use
// Use sync.Map or protect with sync.RWMutex for concurrent access
// Iteration order is randomized intentionally
for k, v := range m {
fmt.Println(k, v) // order is not guaranteed
}Gotcha — deleting during range: Deleting a key you have not visited yet during range is safe in Go. The deleted key simply will not appear.
Gotcha — structs as values are not addressable:
type Point struct{ X, Y int }
m := map[string]Point{"a": {1, 2}}
m["a"].X = 10 // COMPILE ERROR: cannot assign to struct field in map
// Fix: read, modify, write back
p := m["a"]
p.X = 10
m["a"] = p5. What are the zero values in Go, and why do they matter?
Every type has a well-defined zero value when declared without initialization:
| Type | Zero value |
|---|---|
| bool | false |
| int, float64, etc. | 0 |
| string | "" |
| pointer, slice, map, channel, function | nil |
| struct | all fields zeroed |
var mu sync.Mutex // zero value is an unlocked mutex — immediately usable
var wg sync.WaitGroup // zero value is usable
var b bytes.Buffer // zero value is an empty buffer — write to it directlyThe design philosophy "make the zero value useful" means idiomatic Go rarely needs constructors. This is why embedding sync.Mutex in a struct gives you working concurrency protection without a New() function.
6. Explain value receivers vs pointer receivers. When do you use each?
type Counter struct{ count int }
// Value receiver — operates on a copy, cannot mutate
func (c Counter) Value() int {
return c.count
}
// Pointer receiver — operates on the original, can mutate
func (c *Counter) Increment() {
c.count++
}Rules:
- 1Use a pointer receiver when the method needs to modify the receiver.
- 2Use a pointer receiver when the struct is large (avoids copying).
- 3Be consistent: if any method has a pointer receiver, give all methods pointer receivers.
Interface satisfaction gotcha:
type Stringer interface{ String() string }
type MyType struct{ name string }
// Only pointer satisfies Stringer if String() has pointer receiver
func (m *MyType) String() string { return m.name }
var s Stringer = &MyType{"hello"} // ok
var s2 Stringer = MyType{"hello"} // COMPILE ERROR7. How does `defer` work, and what are its execution guarantees?
defer schedules a function call to run when the enclosing function returns — regardless of whether it returns normally or via panic.
func readFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close() // guaranteed to run even if the rest panics
return io.ReadAll(f)
}LIFO order: Multiple defers run last-in, first-out.
func main() {
defer fmt.Println("first deferred")
defer fmt.Println("second deferred")
fmt.Println("main body")
}
// Output:
// main body
// second deferred
// first deferredDefer captures arguments at scheduling time:
func example() {
x := 10
defer fmt.Println(x) // captures 10 now
x = 20
fmt.Println(x) // prints 20
}
// Output: 20, then 10Named return values can be modified by defer:
func double(n int) (result int) {
defer func() { result *= 2 }()
result = n
return // deferred func runs before actual return, doubling result
}
fmt.Println(double(5)) // 108. What is the difference between `panic` and `os.Exit`?
// panic unwinds the stack, runs deferred functions, then crashes
func riskyOp() {
defer fmt.Println("cleanup runs")
panic("something went wrong") // deferred functions DO run
}
// os.Exit terminates immediately — deferred functions do NOT run
func main() {
defer fmt.Println("this will NOT print")
os.Exit(1)
}When to use panic:
- Programmer errors that should never happen (index out of bounds, unreachable branches).
- Library initialization failures where continuing would be wrong.
- Never for expected runtime errors (use
errorreturn values instead).
Recover: Can only be called inside a deferred function to catch a panic.
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered panic: %v", r)
}
}()
return a / b, nil // panics if b == 0
}Section 2 — Interfaces and Type System
9. How do interfaces work in Go? What makes them different from other languages?
Go interfaces are implicitly satisfied — no implements keyword. If a type has all the methods an interface requires, it satisfies the interface.
type Writer interface {
Write(p []byte) (n int, err error)
}
type File struct{ /* ... */ }
// File implicitly implements Writer because it has this method:
func (f *File) Write(p []byte) (int, error) { /* ... */ }
// This works without any declaration:
var w Writer = &File{}Interface internals: An interface value is a two-word struct: (type, data). The type is a pointer to type metadata; the data is a pointer to the value.
var w Writer // nil interface: (nil, nil)
var f *File = nil
w = f // non-nil interface: (*File type, nil pointer)
fmt.Println(w == nil) // false! The interface has type info even though f is nilThis is one of Go's most common gotchas. Returning a typed nil from a function that returns an interface gives a non-nil interface:
func getWriter() Writer {
var f *File = nil
return f // returns a non-nil Writer wrapping a nil *File
}
w := getWriter()
fmt.Println(w == nil) // false — surprising!Fix: Return nil explicitly, not a typed nil.
10. What are type assertions and type switches?
// Type assertion — panics if wrong type
f := w.(*os.File)
// Safe type assertion — ok is false instead of panic
f, ok := w.(*os.File)
if !ok {
// w does not hold *os.File
}
// Type switch
switch v := w.(type) {
case *os.File:
fmt.Println("file:", v.Name())
case *bytes.Buffer:
fmt.Println("buffer len:", v.Len())
default:
fmt.Printf("unknown type: %T\n", v)
}When to use: Type switches are the idiomatic way to dispatch on interface types. Excessive type assertions often signal a design problem — the interface may be too broad or the wrong abstraction.
11. What is embedding in Go, and how does it differ from inheritance?
type Animal struct{ Name string }
func (a Animal) Speak() string { return a.Name + " speaks" }
type Dog struct {
Animal // embedding — not inheritance
Breed string
}
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Husky"}
fmt.Println(d.Speak()) // "Rex speaks" — method promoted from Animal
fmt.Println(d.Name) // "Rex" — field promotedEmbedding promotes fields and methods to the outer struct, but there is no subtype relationship. A Dog is not an Animal — you cannot pass Dog where Animal is expected. This is composition, not inheritance.
Interface embedding:
type ReadWriter interface {
io.Reader // embeds Reader interface
io.Writer // embeds Writer interface
}12. How do generics work in Go (1.18+)?
Generics allow type-parameterized functions and types:
// Generic function
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })
// [2, 4, 6]
// Type constraint using interface
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](s []T) T {
var total T
for _, v := range s {
total += v
}
return total
}When to use generics: Container types (trees, stacks, queues), collection utilities (Map, Filter, Reduce), algorithm implementations. Do not overuse — Go's simplicity is a virtue, and generics add cognitive overhead.
Section 3 — Error Handling
13. Why does Go use explicit error returns instead of exceptions?
Go returns errors as values. This has several advantages:
- Errors appear in function signatures — callers know a function can fail
- The compiler enforces handling (unused return values trigger warnings in some linters)
- Error handling integrates with normal control flow — no invisible exception paths
- Performance: no stack unwinding overhead
// Idiomatic Go error handling
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
result, err := divide(10, 0)
if err != nil {
log.Printf("error: %v", err)
return
}
fmt.Println(result)14. How does error wrapping work with `fmt.Errorf` and the `errors` package?
// Wrapping adds context as errors propagate up the call stack
func queryUser(id int) (*User, error) {
u, err := db.Find(id)
if err != nil {
return nil, fmt.Errorf("queryUser %d: %w", id, err)
}
return u, nil
}
// Unwrapping: errors.Is traverses the chain
if errors.Is(err, sql.ErrNoRows) {
// handle not found
}
// errors.As extracts a specific type from the chain
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
fmt.Println("postgres error code:", pgErr.Code)
}Custom error types:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}15. What is the `errors.Is` vs `errors.As` distinction?
errors.Is(err, target): checks iferror any error in its chain equalstarget(by value or via anIs(error) boolmethod). Use for sentinel errors.errors.As(err, &target): checks iferror any error in its chain can be assigned totarget's type. Use for extracting typed errors.
var ErrNotFound = errors.New("not found") // sentinel
// errors.Is for sentinel values
if errors.Is(err, ErrNotFound) { /* ... */ }
// errors.As for typed errors
var netErr *net.OpError
if errors.As(err, &netErr) {
fmt.Println("network op:", netErr.Op)
}Section 4 — Concurrency
16. What are goroutines, and how do they differ from OS threads?
| | Goroutine | OS Thread |
|---|---|---|
| Initial stack | ~2 KB (grows dynamically) | ~1–8 MB (fixed) |
| Scheduling | Go runtime (user space) | OS kernel |
| Creation cost | Microseconds | Milliseconds |
| Context switch | ~100 ns | ~1–10 µs |
| Typical count | Millions | Thousands |
// Starting a goroutine
go func() {
fmt.Println("running concurrently")
}()
// The goroutine runs concurrently — main may exit before it prints
// Use sync.WaitGroup or channels to synchronizeThe Go scheduler uses an M:N model: M goroutines are multiplexed onto N OS threads (where N = GOMAXPROCS, defaulting to CPU count). The scheduler has three entities: G (goroutine), M (OS thread), P (logical processor/scheduling context).
17. What is a goroutine leak, and how do you prevent it?
A goroutine leak occurs when a goroutine is started but never terminates — it stays blocked, wasting memory and CPU.
// LEAK: channel has no sender, goroutine blocks forever
func leaky() {
ch := make(chan int)
go func() {
val := <-ch // blocks forever if nobody sends
fmt.Println(val)
}()
// ch goes out of scope but goroutine is still alive
}
// FIX: use a done channel or context for cancellation
func notLeaky(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // context cancelled, goroutine exits cleanly
}
}()
}Detection: Use runtime.NumGoroutine() in tests, or goleak (uber-go/goleak) to assert no goroutines leak after a test.
18. What is the difference between buffered and unbuffered channels?
// Unbuffered: send blocks until receiver is ready (synchronization point)
ch := make(chan int)
go func() { ch <- 42 }()
val := <-ch // synchronizes here
// Buffered: send blocks only when buffer is full
ch := make(chan int, 3)
ch <- 1 // does not block
ch <- 2 // does not block
ch <- 3 // does not block
ch <- 4 // BLOCKS — buffer fullRule of thumb: Use unbuffered channels for synchronization ("handoff"). Use buffered channels to decouple producers and consumers, or as semaphores.
// Semaphore pattern — limit to 5 concurrent operations
sem := make(chan struct{}, 5)
for _, url := range urls {
sem <- struct{}{} // acquire
go func(u string) {
defer func() { <-sem }() // release
fetch(u)
}(url)
}19. What happens when you send on a closed channel? What about receive?
ch := make(chan int, 1)
ch <- 42
close(ch)
// Receiving from closed channel:
val, ok := <-ch
fmt.Println(val, ok) // 42 true (buffered value still there)
val, ok = <-ch
fmt.Println(val, ok) // 0 false (channel drained and closed)
// Ranging over a closed channel drains it, then stops
for v := range ch { fmt.Println(v) }
// Sending on a closed channel: PANIC
ch <- 1 // panic: send on closed channelGolden rule: Only the sender should close a channel. Never close from the receiver. If multiple goroutines send, coordinate closure (e.g., via sync.WaitGroup + a single closer).
20. Explain the `select` statement and how it handles multiple channels.
select {
case v := <-ch1:
fmt.Println("received from ch1:", v)
case ch2 <- data:
fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready — non-blocking")
}Behavior:
- Blocks until at least one case is ready.
- If multiple cases are ready simultaneously, Go picks one at random (prevents starvation).
defaultmakes it non-blocking.time.Afterimplements timeouts.
Done channel pattern (clean goroutine cancellation):
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case work := <-workCh:
process(work)
}
}
}()
close(done) // signals all goroutines listening on done to stop21. What are common concurrency patterns in Go?
Fan-out / Fan-in:
func fanOut(in <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := range workers {
channels[i] = worker(in)
}
return channels
}
func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
merged <- v
}
}(ch)
}
go func() { wg.Wait(); close(merged) }()
return merged
}Worker Pool:
func workerPool(jobs <-chan Job, results chan<- Result, workers int) {
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
go func() { wg.Wait(); close(results) }()
}Pipeline:
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums { out <- n }
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { out <- n * n }
}()
return out
}
// Usage: for v := range square(generate(1,2,3)) { fmt.Println(v) }22. When should you use `sync.Mutex` vs channels?
The Go proverb: "Do not communicate by sharing memory; share memory by communicating."
Use channels when:
- Passing ownership of data from one goroutine to another.
- Coordinating goroutine start/stop.
- Building pipelines.
Use sync.Mutex when:
- Protecting a small, shared piece of state accessed by many goroutines.
- A channel would require awkward ownership gymnastics.
- Performance profiling shows channel overhead is a bottleneck.
// Mutex example: thread-safe counter
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.RLock() // use RWMutex for read-heavy workloads
defer c.mu.RUnlock()
return c.count
}sync.RWMutex: Allows multiple concurrent readers or one writer. Use when reads vastly outnumber writes.
23. What is `sync.WaitGroup` and how do you use it correctly?
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait() // blocks until all goroutines call Done()Common mistake — calling Add inside the goroutine:
// WRONG: Add may be called after Wait returns if goroutines are slow to start
go func() {
wg.Add(1) // race condition with Wait
defer wg.Done()
}()
// CORRECT: always Add before starting the goroutine
wg.Add(1)
go func() {
defer wg.Done()
}()24. What is `sync.Once`, and when is it useful?
sync.Once ensures a function is executed exactly once, regardless of how many goroutines call it concurrently. It is the idiomatic way to implement lazy initialization.
var (
instance *Database
once sync.Once
)
func GetDatabase() *Database {
once.Do(func() {
instance = &Database{conn: newConn()}
})
return instance
}vs init(): init() runs at package load time (eager). sync.Once is lazy — initialization happens at first use. Prefer sync.Once for expensive resources (DB connections, parsers) to keep startup fast.
25. Explain `sync.Pool` and when to use it.
sync.Pool is a concurrent-safe pool of temporary objects that reduces GC pressure by reusing allocations:
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func processRequest(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
buf.Write(data)
return buf.String()
}Important caveats:
- Objects in a Pool can be reclaimed by the GC at any time.
- Only use for temporary, short-lived objects.
- Always profile before reaching for Pool — premature optimization is harmful.
- Do not store state that must survive across calls.
26. What is the Go memory model, and what is the "happens-before" relationship?
The Go memory model defines when writes to a variable in one goroutine are guaranteed to be observable by another goroutine. The key concept is "happens-before":
- Operations within a single goroutine have sequential happens-before.
gostatement happens-before the goroutine's start.- Channel send happens-before the corresponding receive completes.
sync.Mutex.Unlock()happens-before the nextLock().sync.WaitGroup.Done()happens-beforeWait()returns.
var x int
var wg sync.WaitGroup
wg.Add(1)
go func() {
x = 42 // write
wg.Done() // happens-before wg.Wait() returns
}()
wg.Wait()
fmt.Println(x) // guaranteed to see 42Without this synchronization, reading x from another goroutine after the goroutine writes it is a data race — even if it seems to work in practice.
Section 5 — Memory Management and the Runtime
27. How does Go's garbage collector work?
Go uses a concurrent tri-color mark-and-sweep collector:
- 1Mark phase: Starting from roots (globals, stack variables), the GC marks all reachable objects. Objects are white (unvisited), gray (reachable but children not yet scanned), or black (fully scanned).
- 2Sweep phase: All remaining white objects are unreachable and are freed.
Key design properties:
- Runs mostly concurrently with the application — goroutines are not stopped for the full collection.
- Short "stop-the-world" pauses (sub-millisecond since Go 1.14) for stack scanning.
- The GC is triggered when heap size reaches
GOGC%growth over the last collection (defaultGOGC=100, meaning heap doubles).
Go 1.24+ (Green Tea collector): Reorganizes marking around memory locality, reducing GC overhead 10–40% on typical workloads.
Tuning:
GOGC=200 # collect less frequently (higher memory usage, less CPU overhead)
GOGC=off # disable GC (for batch processing)
GOMEMLIMIT=512MiB # cap total memory, triggers GC to stay under limit (Go 1.19+)28. What is escape analysis? How does it affect performance?
The Go compiler performs escape analysis at compile time to decide whether a variable lives on the stack or the heap:
- Stack: Fast allocation/deallocation (just move stack pointer). No GC involvement.
- Heap: Slower, involves GC.
A variable "escapes" to the heap when:
- A pointer to it is returned from the function.
- It is captured by a closure that outlives the function.
- It is assigned to an interface (type information must be stored).
- Its size is not known at compile time.
// inspect escape analysis:
// go build -gcflags="-m" ./...
func stackAlloc() int {
x := 42 // stays on stack
return x
}
func heapAlloc() *int {
x := 42
return &x // x escapes to heap because pointer is returned
}Performance implication: Reducing heap allocations reduces GC pressure and improves throughput. Use sync.Pool, pre-allocate slices with make([]T, 0, n), and pass pointers rather than returning them where possible.
29. What is `GOMAXPROCS` and how does it affect concurrency?
GOMAXPROCS sets the maximum number of OS threads that can execute Go code simultaneously (the number of logical processors, P, in the scheduler):
import "runtime"
runtime.GOMAXPROCS(4) // use 4 logical processors
fmt.Println(runtime.GOMAXPROCS(0)) // query current value (0 = query only)Default: Since Go 1.5, defaults to runtime.NumCPU().
Effect:
GOMAXPROCS=1: Goroutines are multiplexed onto a single thread — true concurrency is impossible, but race conditions can still occur (cooperative multitasking on preemption points).GOMAXPROCS=N(N > 1): Up to N goroutines truly execute in parallel.
For I/O-bound workloads: Thousands of goroutines can be efficient even with GOMAXPROCS=1 because goroutines blocked on I/O do not consume a P.
30. What is `pprof` and how do you profile a Go application?
import _ "net/http/pprof" // side-effect import registers HTTP handlers
func main() {
go http.ListenAndServe(":6060", nil)
// ... application code
}Access profiles:
# CPU profile (30-second sample)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Heap allocation profile
go tool pprof http://localhost:6060/debug/pprof/heap
# Goroutine dump
curl http://localhost:6060/debug/pprof/goroutine?debug=2Benchmark-driven profiling:
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.outCommon hotspots to look for: excessive allocations (strings, interfaces), lock contention, channel overhead in tight loops, regex compilation in hot paths.
Section 6 — Error Handling, Context, and Patterns
31. How does the `context` package work, and how should you use it?
Context carries deadlines, cancellation signals, and request-scoped values across API boundaries. It is always the first parameter by convention.
// Creating contexts
ctx := context.Background() // root, never cancelled
ctx, cancel := context.WithCancel(ctx) // manually cancellable
defer cancel() // always call cancel to release resources
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ctx, cancel = context.WithDeadline(ctx, time.Now().Add(5*time.Second))
defer cancel()
// Passing values (use sparingly — only for request-scoped data)
type keyType struct{}
ctx = context.WithValue(ctx, keyType{}, "request-id-123")
val := ctx.Value(keyType{}).(string)Checking for cancellation:
func doWork(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // context.Canceled or context.DeadlineExceeded
default:
// do a unit of work
}
}
}Rules:
- 1Never store context in a struct — pass it as a function argument.
- 2Never pass
nilcontext — usecontext.TODO()if unsure. - 3Always call the cancel function returned by
WithCancel/WithTimeout/WithDeadline.
32. What is the options pattern, and why is it idiomatic Go?
The options pattern uses variadic functional options to configure a struct without requiring constructor explosion:
type Server struct {
host string
port int
timeout time.Duration
}
type Option func(*Server)
func WithHost(host string) Option {
return func(s *Server) { s.host = host }
}
func WithPort(port int) Option {
return func(s *Server) { s.port = port }
}
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(opts ...Option) *Server {
s := &Server{
host: "localhost",
port: 8080,
timeout: 30 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
// Usage
srv := NewServer(WithPort(9090), WithTimeout(60*time.Second))Advantages: Backward-compatible (new options do not break existing callers), readable call sites, self-documenting.
33. How do you implement clean shutdown in a Go HTTP server?
func main() {
srv := &http.Server{Addr: ":8080", Handler: handler}
// Start server in background goroutine
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("forced shutdown:", err)
}
log.Println("server stopped gracefully")
}Section 7 — Tooling and Go-Specific Idioms
34. How do Go modules work?
Go modules manage dependencies using go.mod and go.sum files:
module github.com/myorg/myapp
go 1.22
require (
github.com/gorilla/mux v1.8.0
github.com/lib/pq v1.10.7
)Key commands:
go mod init github.com/myorg/myapp # initialize a module
go get github.com/gorilla/mux@v1.8 # add or upgrade a dependency
go mod tidy # remove unused, add missing
go mod vendor # copy deps into vendor/ for reproducible buildsVersioning: Go uses semantic versioning. Major version bumps (v2+) require a module path suffix (github.com/foo/bar/v2), allowing multiple major versions in one build.
35. What is the `init()` function and when should you use it?
package main
var config *Config
func init() {
// Runs automatically before main(), after all var declarations
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
}
config = cfg
}Rules:
- Multiple
init()functions per package are allowed (even in the same file). - They run in source order within a file, file order within a package.
- They run before
main(). - They cannot be called manually.
When to use: Registering database drivers (_ "github.com/lib/pq"), registering codecs, validating configuration. For expensive initialization (DB connections), prefer sync.Once to keep startup fast and make testing easier.
36. What does the blank identifier `_` do?
// Discard a return value
_, err := fmt.Println("hello")
// Import for side effects only (triggers init())
import _ "github.com/lib/pq"
// Silence "declared but not used" for loop index
for _, v := range slice { fmt.Println(v) }
// Compile-time interface satisfaction check
var _ io.Writer = (*MyWriter)(nil) // fails to compile if MyWriter doesn't implement WriterThe last pattern is particularly useful in library code to document and enforce that a type implements an interface.
37. How does Go handle dependency injection?
Go favors explicit constructor injection over service locators or reflection-based frameworks:
type UserService struct {
db Database
mailer Mailer
logger *slog.Logger
}
func NewUserService(db Database, mailer Mailer, logger *slog.Logger) *UserService {
return &UserService{db: db, mailer: mailer, logger: logger}
}Why this works: Interfaces are implicit, so Database and Mailer can be mocked for tests without any framework. The dependency graph is explicit and compile-time checked.
Wire / Fx: For large applications, code-generation tools (Google Wire) or runtime DI frameworks (Uber Fx) manage wiring, but they are optional — most Go services wire dependencies manually in main().
38. What is `slog` and why was it added to the standard library?
log/slog (stable since Go 1.21) provides structured, leveled logging:
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
logger.Info("request processed",
"method", "GET",
"path", "/api/users",
"status", 200,
"latency_ms", 12,
)
// {"time":"...","level":"INFO","msg":"request processed","method":"GET","path":"/api/users","status":200,"latency_ms":12}Before 1.21, teams had to choose between zap, zerolog, logrus, or log. slog standardizes the interface while remaining extensible via custom handlers.
Section 8 — Advanced and System-Level Questions
39. What is the Go scheduler's work-stealing algorithm?
The Go scheduler assigns goroutines (G) to logical processors (P), which run on OS threads (M). When a P's local run queue is empty, it steals goroutines from other P's run queues or the global queue.
Preemption: Since Go 1.14, the scheduler uses asynchronous preemption — a goroutine running for more than 10ms is preempted via a signal (SIGURG on Unix). This prevents a CPU-bound goroutine from starving others.
// This no longer starves other goroutines since Go 1.14:
go func() {
for {
// tight CPU loop — scheduler will preempt after ~10ms
}
}()Network poller: I/O operations are integrated with the scheduler. When a goroutine blocks on a network syscall, the M is parked, and a background goroutine (netpoller) uses epoll/kqueue to wake up goroutines when I/O is ready.
40. How do you detect and prevent data races?
# Run with race detector (10-20x slowdown, use in tests and staging)
go test -race ./...
go run -race main.goThe race detector uses shadow memory to track all memory accesses and reports when two goroutines access the same location concurrently and at least one is a write.
Common race patterns:
// RACE: shared map without synchronization
var cache = make(map[string]string)
go func() { cache["key"] = "value" }() // write
go func() { _ = cache["key"] }() // read — DATA RACE
// FIX option 1: sync.RWMutex
var mu sync.RWMutex
go func() {
mu.Lock()
cache["key"] = "value"
mu.Unlock()
}()
// FIX option 2: sync.Map for read-heavy or dynamic key sets
var syncCache sync.Map
go func() { syncCache.Store("key", "value") }()
go func() { syncCache.Load("key") }()
// RACE: goroutine captures loop variable (pre-1.22)
for _, v := range items {
go func() { fmt.Println(v) }() // all goroutines see last v
}
// FIX (pre-1.22): capture explicitly
for _, v := range items {
v := v // new variable per iteration
go func() { fmt.Println(v) }()
}
// Go 1.22+: loop variable semantics fixed — no workaround needed41. What is `unsafe.Pointer` and when is it acceptable to use it?
unsafe.Pointer bypasses Go's type system — it is like void* in C:
// Convert between pointer types without allocation
func byteSliceToString(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}When it is acceptable:
- Interfacing with C via cgo.
- Performance-critical code where profiling shows allocation is a bottleneck and no safe alternative exists.
- Implementing memory-mapped files or other low-level system interfaces.
Rules for safe use (from the unsafe package docs):
- A
Pointercan be converted to any pointer type. - Any pointer type can be converted to
Pointer. uintptrcan be converted to/fromPointeronly in a single expression (not stored in a variable — GC can move objects).
In almost all application code, reaching for unsafe is the wrong answer. If the interviewer asks why, say: "It bypasses the memory safety guarantees that make Go reliable. I would reach for it only after profiling proves a genuine bottleneck and after exhausting safe alternatives."
42. How do you write table-driven tests in Go?
Table-driven tests are idiomatic Go — they reduce duplication and make it easy to add cases:
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normal division", 10, 2, 5, false},
{"division by zero", 10, 0, 0, true},
{"negative divisor", -10, 2, -5, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := divide(tc.a, tc.b)
if (err != nil) != tc.wantErr {
t.Errorf("divide() error = %v, wantErr %v", err, tc.wantErr)
return
}
if !tc.wantErr && got != tc.want {
t.Errorf("divide() = %v, want %v", got, tc.want)
}
})
}
}t.Run creates subtests, allowing parallel execution (t.Parallel()), individual filtering (go test -run TestDivide/normal), and clearer failure messages.
43. What are build tags and when do you use them?
Build tags conditionally include or exclude files from compilation:
//go:build linux && amd64
package main
// This file only compiles on Linux/amd64
func platformSpecific() string { return "linux-amd64" }Common use cases:
//go:build integration // run only with: go test -tags integration ./...
//go:build !cgo // include when cgo is disabled
//go:build go1.21 // include only for Go 1.21+File naming convention (alternative to tags): Files named *_linux.go, *_darwin.go, *_windows.go, *_amd64.go are automatically included for their target platform.
44. How do `range-over-function iterators` work (Go 1.23+)?
Go 1.23 added range-over-function iterators via the iter package:
// An iterator is a function that calls yield for each element
// yield returns false when the loop should stop
func Integers(start, end int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := start; i < end; i++ {
if !yield(i) {
return
}
}
}
}
// Usage
for v := range Integers(1, 10) {
fmt.Println(v)
}
// Composable: filter without materializing the full sequence
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for v := range seq {
if pred(v) && !yield(v) {
return
}
}
}
}Why it matters for interviews: Shows you understand modern Go. Interviewers at forward-looking companies may ask you to implement a custom iterator or explain how break and return interact with the yield function.
45. What distinguishes a good Go engineer from someone just porting habits from another language?
This is a common closing question. Strong answers include:
- 1Concurrency by design, not as an afterthought. Idiomatic Go uses goroutines and channels from the start, not threads and locks bolted on later.
- 2Small interfaces.
io.Readerhas one method.io.Writerhas one method. If your interface has 10 methods, it is probably a base class from a Java background. Define interfaces at the point of use, not at the point of definition.
- 3Explicit error handling, not exceptions. Never ignore errors. Wrap with context as they propagate. Use sentinel errors or typed errors for callees that need to branch on failure mode.
- 4Composition over inheritance. Struct embedding and interface satisfaction give you all the polymorphism you need. No class hierarchies.
- 5The zero value is your constructor. Design types so
var x MyTypeis immediately useful.
- 6Simple is better than clever. Go code is written once and read many times.
goto,unsafe, and deep reflection are red flags in a PR review unless there is a very good reason.
- 7Profile before optimizing. Go provides
pprof, benchmarks, and the race detector. Use them before reaching forsync.Pool,unsafe, or complex pre-allocation.
Quick Reference Cheat Sheet
| Topic | Key concept | Common pitfall |
|---|---|---|
| Slices | 3-field header (ptr, len, cap) | Shared backing array on sub-slice |
| Maps | Zero value is nil, unordered | Writing to nil map panics |
| Goroutines | 2KB stack, M:N scheduling | Goroutine leaks from blocked channels |
| Channels | Unbuffered = synchronization | Sending on closed channel = panic |
| Interfaces | Implicit, structural typing | Nil interface vs nil value inside interface |
| Errors | Values, wrap with %w | Discarding errors silently |
| defer | LIFO, captures args at schedule | Modifying loop var in deferred closure |
| Context | Carries cancellation and deadlines | Storing context in structs |
| GC | Concurrent tri-color mark-sweep | Premature optimization without profiling |
| Escape analysis | Stack vs heap at compile time | Creating interfaces allocates to heap |
| select | Random when multiple cases ready | Forgetting default makes it blocking |
| sync.Once | Exactly-once initialization | Using for cleanup, not just init |
| Generics (1.18+) | Type parameters with constraints | Overusing — Go values simplicity |
| Loop vars (1.22+) | Fresh var per iteration | Pre-1.22 goroutine closure capture bug |
How Interviewers Actually Score Go Questions
At top tech companies, Go interview rubrics typically measure:
- Correctness: Does the code compile? Does it handle the zero value, nil, and error cases?
- Concurrency safety: Does the candidate reach for a mutex or channel without being prompted? Do they mention
go test -race? - Idiomatic Go: Small interfaces, explicit errors, no exception-style panic propagation, composition over inheritance.
- Runtime awareness: Can the candidate explain what the GC actually does? Do they know the difference between stack and heap allocation? Can they interpret a
pprofflame graph? - Production judgment: Does the candidate mention goroutine leaks, graceful shutdown, context cancellation, and connection pool tuning — or only the happy path?
The candidates who stand out do not just answer the question correctly. They say "and there is a subtle issue here with the nil interface check" or "we should also call cancel() on the context to prevent a goroutine leak." That is the signal that you write Go in production, not just in tutorials.