Ch. 25

Go interview questions & answers

Go for backend interviews: goroutines and channels, the scheduler, interfaces, error handling, slices and maps, context cancellation, generics and testing.

30 interview questions20 quiz questions8 notes
your progress0%

Notes in this chapter

Filter all notes →

30 Go interview questions study by subtopic

30 questions
  1. 1.What is a goroutine, and how is it different from an OS thread?easy

    A goroutine is a function running concurrently, managed by the Go runtime rather than the operating system. You start one with the go keyword.

    The differences that matter:

    • Cost: a goroutine starts with a small stack (about 2 KB) that grows and shrinks as needed, while an OS thread usually reserves a fixed stack of 1 MB or more. Running hundreds of thousands of goroutines is normal.
    • Scheduling: the runtime multiplexes many goroutines onto a few OS threads (M:N scheduling). Switching between goroutines happens in user space, so it is much cheaper than a kernel context switch.
    • Blocking: when a goroutine waits on a channel, a mutex or the network, the runtime parks it and runs another one on the same thread.
    • No identity: goroutines have no public ID and cannot be killed from outside; you stop them cooperatively, usually with a context or a closed channel.
    What interviewers listen for
    • Managed by the Go runtime, not the OS
    • Small growable stack, cheap to create
    • M:N scheduling onto OS threads
    • Stopped cooperatively, never killed from outside

    Likely follow-up: What happens to running goroutines when main returns? · How many goroutines is too many?

  2. 2.Explain the Go scheduler: what are G, M and P, and what does GOMAXPROCS control?hard

    The scheduler has three entities. G is a goroutine. M is an OS thread (machine). P is a processor: a scheduling context that holds a local run queue of runnable Gs. An M must hold a P to run Go code, so the number of Ps, set by GOMAXPROCS, caps how many goroutines execute Go code in parallel. It defaults to the number of CPUs, and since Go 1.25 it also respects a container's CPU limit on Linux.

    Each P runs Gs from its local queue, checks a global queue periodically, and steals half of another P's queue when idle. When a G makes a blocking syscall, its M blocks with it, so the P is handed to another M to keep running other Gs. Network I/O does not block threads at all: the netpoller (epoll, kqueue) parks the G and readies it when the socket is ready. Since Go 1.14, long-running loops are preempted asynchronously with signals, so one hot goroutine cannot starve the others.

    What interviewers listen for
    • G goroutine, M thread, P scheduling context
    • GOMAXPROCS = number of Ps = parallelism for Go code
    • Local run queues with work stealing
    • Syscall handoff and the netpoller
    • Asynchronous preemption since Go 1.14

    Likely follow-up: Why can a Go process have more threads than GOMAXPROCS? · What problem did container-aware GOMAXPROCS solve?

  3. 3.What is the difference between a buffered and an unbuffered channel?easy

    An unbuffered channel (make(chan T)) has no storage. A send blocks until a receiver takes the value, so the two goroutines meet at that point: it is a synchronisation as much as a data transfer.

    A buffered channel (make(chan T, n)) holds up to n values. A send blocks only when the buffer is full, and a receive blocks only when it is empty. That decouples producer and consumer for short bursts.

    Choosing between them:

    • Use unbuffered when you need a handoff guarantee, such as "the worker has really received this job".
    • Use a small buffer to absorb bursts or to let a goroutine send a single result and exit even if nobody reads it yet, which avoids a leak.
    • A buffer is not a fix for a deadlock or a slow consumer; it only delays the moment the sender blocks.
    What interviewers listen for
    • Unbuffered send blocks until a receiver is ready
    • Buffered send blocks only when full
    • Unbuffered gives a synchronisation point
    • Buffers smooth bursts, they do not fix slow consumers

    Likely follow-up: When would you use a buffer of exactly 1?

  4. 4.What are the rules for closing a channel, and who should close it?mid

    Closing a channel means "no more values will be sent". The rules:

    • Receiving from a closed channel never blocks: it returns remaining buffered values, then the zero value with ok == false.
    • for v := range ch ends when the channel is closed and drained.
    • Sending on a closed channel panics, closing an already closed channel panics, and closing a nil channel panics.

    So the sender closes, never the receiver, because only the sender knows when it has finished. With several senders, none of them can close safely on its own; a coordinator waits for all of them (usually with a sync.WaitGroup) and then closes once.

    You do not have to close every channel. Garbage collection reclaims an unreachable channel either way. Close only when receivers need to learn that the stream ended, such as when they range over it.

    What interviewers listen for
    • Close signals no more sends
    • Send on closed, double close and close of nil all panic
    • Receivers get zero value and ok == false after drain
    • The sender, or a coordinator for many senders, closes

    Likely follow-up: How do you signal "stop" to many goroutines at once?

  5. 5.How does select work, and how do you use it for timeouts and non-blocking operations?mid

    select waits on several channel operations and runs the case for whichever is ready first. If several are ready at once, it picks one pseudo-randomly, which prevents starvation but means you cannot rely on case order for priority. A default case runs immediately when nothing is ready, which makes the operation non-blocking.

    Common patterns:

    • Timeout: one case receives the result, another receives from time.After(d) or ctx.Done().
    • Cancellation: every blocking send or receive in a worker sits in a select alongside <-ctx.Done().
    • Try-send: select { case ch <- v: default: drop() } for best-effort metrics or logs.

    An empty select {} blocks forever. Cases on a nil channel are never ready, which lets you switch a case off dynamically by setting its channel variable to nil.

    What interviewers listen for
    • Runs whichever ready case comes first
    • Random choice among several ready cases
    • default makes it non-blocking
    • Timeouts via time.After or ctx.Done()

    Likely follow-up: How would you give one channel priority over another?

  6. 6.How do you wait for a group of goroutines to finish?easy

    Use sync.WaitGroup. Call wg.Add(1) before starting each goroutine, defer wg.Done() inside it, and wg.Wait() where you need all of them finished.

    Rules that interviewers check:

    • Add must happen before the goroutine starts. Calling it inside the goroutine races with Wait, which may return early.
    • Never copy a WaitGroup: pass *sync.WaitGroup or capture it in a closure. A copy has its own counter, so Wait on the original never returns. go vet reports the copy.
    • A negative counter panics.

    Since Go 1.25, wg.Go(func() { ... }) does the Add and Done for you. If the goroutines can fail and you want the first error plus cancellation of the rest, errgroup.Group from golang.org/x/sync/errgroup is usually the better tool.

    What interviewers listen for
    • Add before go, Done via defer, then Wait
    • Never copy a WaitGroup
    • wg.Go in Go 1.25
    • errgroup when errors and cancellation matter

    Likely follow-up: How would you collect results from those goroutines safely?

  7. 7.When would you use a mutex instead of a channel?mid

    The Go proverb is "share memory by communicating", but it is guidance, not a ban on locks. A simple rule:

    • Mutex when several goroutines touch shared state that stays in one place: a cache, a counter, a map of sessions. The lock protects an invariant for a short critical section, and the code stays straightforward.
    • Channel when you are transferring ownership of data or coordinating work: handing jobs to workers, streaming results, signalling completion or cancellation.

    Mutexes are usually faster for guarding a small piece of state, and a channel used as a lock is harder to read. Channels shine when the shape of the program is a pipeline. Use sync.RWMutex only when reads clearly dominate and critical sections are not tiny, and sync/atomic types for single counters or flags.

    Keep critical sections short, never call unknown code while holding a lock, and remember Go mutexes are not reentrant.

    What interviewers listen for
    • Mutex for shared state, channels for ownership transfer
    • Do not force channels where a lock is clearer
    • RWMutex only for read-heavy, non-trivial sections
    • Mutexes are not reentrant

    Likely follow-up: What happens if a goroutine locks the same mutex twice?

  8. 8.What is a data race in Go, and how do you find one?mid

    A data race happens when two goroutines access the same memory concurrently, at least one access is a write, and nothing orders them (no mutex, channel operation or atomic). The Go memory model says a racy program can observe torn or stale values, so the result is undefined in practice.

    Find races with the race detector: go test -race ./... or go run -race. It instruments memory accesses and prints WARNING: DATA RACE with both stack traces when two accesses conflict at run time. It only reports races that actually execute, so it needs tests that exercise concurrent paths, and it slows code down several times, so it is for tests and staging rather than production.

    Maps are a special case: concurrent writes, or a write during iteration, can crash the process with fatal error: concurrent map writes, which recover cannot catch. Fix races with a mutex, a channel, sync/atomic types or by not sharing the data.

    What interviewers listen for
    • Concurrent access, at least one write, no ordering
    • go test -race reports both stacks
    • Only finds races that run
    • Concurrent map writes are a fatal error

    Likely follow-up: Is reading a bool flag from another goroutine without a lock safe?

  9. 9.What is a goroutine leak, and how do you detect and prevent one?hard

    A goroutine leak is a goroutine that stays blocked forever, usually on a channel send or receive that nobody will ever complete. It is never collected, so it holds its stack and everything it references. Leaks show up as steadily growing memory and goroutine counts.

    Typical causes:

    • A function returns early (timeout or error) and a worker is still trying to send its result on an unbuffered channel.
    • A consumer stops reading and producers block on send.
    • A loop waits on a channel that is never closed.

    Detect with runtime.NumGoroutine() in metrics, the goroutine profile from net/http/pprof (/debug/pprof/goroutine?debug=1 groups identical stacks with counts), and go.uber.org/goleak in tests.

    Prevent by giving every goroutine an exit path: pass a context and select on ctx.Done() around every blocking operation, give single-result channels a buffer of 1, and make sure the owner closes input channels.

    What interviewers listen for
    • Blocked forever, never collected
    • Early returns leave senders stuck
    • Detect with pprof goroutine profile and goleak
    • Every goroutine needs an exit path

    Likely follow-up: How would you find which line leaked 50,000 goroutines in production?

  10. 10.How would you process 10,000 URLs concurrently but with at most 20 in flight?mid

    Bound the concurrency instead of starting 10,000 goroutines that all hit the network at once. Two common shapes:

    • Worker pool: start 20 workers that range over a jobs channel, a producer that sends URLs and then closes the channel, and a sync.WaitGroup that closes the results channel once all workers return.
    • Semaphore: start one goroutine per URL but acquire a slot from a buffered channel of size 20 (or errgroup.Group with SetLimit(20)) before doing the work.

    Whichever you pick, pass a context so a failure or a client disconnect stops the work, make every send select on ctx.Done(), and decide the error policy: fail fast (errgroup.WithContext) or collect all errors. Also set an HTTP client timeout, because a bounded pool of hung requests is still hung.

    What interviewers listen for
    • Bound concurrency, do not spawn unbounded goroutines
    • Worker pool or semaphore pattern
    • errgroup with SetLimit
    • Context for cancellation and a clear error policy

    Likely follow-up: How would you preserve the input order of results?

  11. 11.What is context.Context for, and how does cancellation propagate?mid

    A context.Context carries a cancellation signal, an optional deadline and request-scoped values across API boundaries. It is the first parameter of any function that does I/O or may block, by convention named ctx.

    You derive children with context.WithCancel, WithTimeout or WithDeadline. They form a tree: cancelling a parent cancels every descendant, but cancelling a child never affects the parent. Each derive returns a cancel function that you must call (usually defer cancel()) to release the timer and the link to the parent; go vet warns when you drop it.

    Cancellation is cooperative. ctx.Done() returns a channel that closes on cancellation, and ctx.Err() then returns context.Canceled or context.DeadlineExceeded. Library code such as net/http and database/sql watches it for you; your own loops must select on ctx.Done() or check ctx.Err().

    What interviewers listen for
    • Cancellation, deadline and request-scoped values
    • Tree: parent cancel reaches children, not the reverse
    • Always call cancel
    • Cooperative: code must watch Done()

    Likely follow-up: What does context.WithoutCancel do and when would you use it?

  12. 12.What should and should not go into context.WithValue?mid

    Context values are for request-scoped data that crosses API boundaries and that intermediate layers do not need to understand: a request ID, a trace span, the authenticated principal.

    They should not carry optional function parameters, dependencies (database handles, loggers configured per service) or anything a function needs to work correctly. Those belong in explicit parameters or struct fields, where the compiler checks them.

    Mechanics to mention:

    • Use an unexported key type (type ctxKey struct{}) so packages cannot collide on plain string keys.
    • Lookups walk up the parent chain, so they are linear in depth; fine for a few values, not a general map.
    • Values are untyped (any), so wrap access in small typed helpers such as RequestID(ctx) (string, bool).
    • Never store a context in a struct; pass it through calls.
    What interviewers listen for
    • Request-scoped, cross-boundary data only
    • Not for dependencies or optional params
    • Unexported key type to avoid collisions
    • Typed accessor helpers

    Likely follow-up: Why is storing a context in a struct discouraged?

  13. 13.How does error handling work in Go, and why doesn’t Go use exceptions?easy

    In Go an error is an ordinary value of the built-in interface type error, which has one method, Error() string. Functions that can fail return it as the last result, and the caller checks if err != nil straight away.

    The design choice is that failure paths are visible in the code and handled where they happen, instead of jumping up an unknown number of frames. That makes control flow explicit and reviewable, at the cost of some repetition.

    Good practice:

    • Handle an error once: either log it or return it, not both.
    • Add context while returning: fmt.Errorf("load config %s: %w", path, err).
    • Compare with errors.Is and extract types with errors.As, never by matching strings.

    panic exists, but it is reserved for programmer errors and truly unrecoverable states, not for expected failures like a missing file.

    What interviewers listen for
    • error is an interface value returned last
    • Explicit checks keep failure paths visible
    • Wrap with context, handle once
    • panic only for programmer errors

    Likely follow-up: When is it acceptable to panic in library code?

  14. 14.Explain error wrapping with %w, and the difference between errors.Is and errors.As.mid

    fmt.Errorf("query user %d: %w", id, err) returns a new error whose message adds context and whose Unwrap() returns err. Using %v instead formats the message but drops the link, so callers can no longer inspect the cause.

    • errors.Is(err, target) walks the chain and reports whether any error equals target (or has an Is method that says so). Use it for sentinel values like sql.ErrNoRows or fs.ErrNotExist.
    • errors.As(err, &target) walks the chain looking for an error assignable to the target's type and, if found, stores it in target. Use it to read fields, such as *fs.PathError or your own *ValidationError.

    Since Go 1.20, errors.Join and multiple %w verbs create errors that wrap several causes, and Is/As search all branches. Wrapping makes the cause part of your API, so wrap deliberately: use %v when you do not want callers depending on an internal error.

    What interviewers listen for
    • %w keeps the chain, %v flattens it
    • Is compares values, As matches types
    • errors.Join and multiple %w since Go 1.20
    • Wrapping exposes the cause as API

    Likely follow-up: How would you make a custom error type match a sentinel with errors.Is?

  15. 15.How do panic and recover work, and when is recovering appropriate?mid

    panic stops normal execution of the current goroutine and starts unwinding its stack, running deferred calls as it goes. If nothing recovers, the program prints the panic value and stack trace and exits with status 2.

    recover stops the unwinding, but only when called directly by a deferred function while that goroutine is panicking; anywhere else it returns nil. A panic in one goroutine cannot be recovered by another, so an unrecovered panic in any goroutine crashes the whole process.

    Recover at boundaries where one bad request should not kill the server: net/http already recovers panics in handlers and logs them, and worker pools often wrap each job. Convert the panic into an error, log the stack, and keep going only if state is not corrupted. Some failures, such as fatal error: concurrent map writes or running out of memory, are not panics and cannot be recovered at all.

    What interviewers listen for
    • panic unwinds and runs defers
    • recover only works directly in a deferred function
    • Per goroutine: one unrecovered panic kills the process
    • Recover at request or job boundaries

    Likely follow-up: Why must every goroutine you start recover its own panics?

  16. 16.How does defer work? Explain evaluation order and common pitfalls.easy

    defer schedules a function call to run when the surrounding function returns, whether it returns normally or by panicking. It is the idiomatic way to release resources right after acquiring them: f, err := os.Open(p), check err, then defer f.Close().

    Three rules:

    • Deferred calls run in LIFO order.
    • The function value and its arguments are evaluated immediately at the defer statement; only the call is delayed. defer fmt.Println(x) prints the value x had then.
    • A deferred closure can read and modify named results, which is how you annotate a returned error.

    Pitfalls: deferring inside a long loop piles up calls (and open files) until the function returns, so move the loop body into its own function. Ignoring the error from Close on a file you wrote can hide a failed flush. Defer costs almost nothing since Go 1.14, so performance is rarely a reason to avoid it.

    What interviewers listen for
    • Runs when the function returns, LIFO
    • Arguments evaluated at the defer statement
    • Can modify named results
    • Avoid defer in long loops

    Likely follow-up: How would you return the error from Close in a function that writes a file?

  17. 17.How do interfaces work in Go, and what does "satisfied implicitly" mean?easy

    An interface type lists method signatures. Any type whose method set contains those methods satisfies the interface automatically; there is no implements keyword. *os.File satisfies io.Reader because it has Read([]byte) (int, error), without knowing io.Reader exists.

    Consequences that shape Go design:

    • Define interfaces where they are used, in the consumer package, and keep them small (io.Reader, fmt.Stringer). "The bigger the interface, the weaker the abstraction."
    • Accept interfaces, return concrete types, so callers get the full type and can choose their own abstraction.
    • You can introduce an interface later to decouple or to fake a dependency in tests, without touching the original type.

    To assert at compile time that a type satisfies an interface, write var _ io.Reader = (*MyReader)(nil). At run time, a type assertion r.(io.WriterTo) or a type switch checks for optional capabilities.

    What interviewers listen for
    • Satisfaction is implicit via method sets
    • Small interfaces defined by the consumer
    • Accept interfaces, return structs
    • Compile-time check with var _ I = (*T)(nil)

    Likely follow-up: What is the empty interface, and why is any an alias for it?

  18. 18.Why can a function that returns a nil pointer as an error produce err != nil?hard

    An interface value is a pair: a dynamic type and a dynamic value. It equals nil only when both are unset.

    If a function declares var e *MyError (a nil pointer) and returns it as error, the interface holds type *MyError with a nil value. The type half is set, so err != nil is true, and callers take the failure path for a call that actually succeeded.

    The fix is to return the literal nil on success and only convert a concrete pointer to error when it is non-nil. Avoid declaring concrete error types as return types (func f() *MyError) when callers will store the result in an error variable.

    The same trap appears with any interface: a nil *bytes.Buffer stored in an io.Writer is not a nil writer. Methods with pointer receivers can still be called on it, so it may even appear to work until one dereferences the pointer.

    type MyError struct{}
    
    func (*MyError) Error() string { return "failed" }
    
    func check() error {
    	var e *MyError // nil pointer
    	return e       // non-nil interface: (type=*MyError, value=nil)
    }
    
    func main() {
    	fmt.Println(check() == nil) // false
    }
    What interviewers listen for
    • Interface = (type, value) pair
    • nil only when both halves are nil
    • Typed nil pointer makes a non-nil interface
    • Return literal nil on success

    Likely follow-up: How could you detect a typed nil inside an interface at run time?

  19. 19.When should a method have a pointer receiver instead of a value receiver?mid

    Use a pointer receiver when the method must modify the receiver, when the struct is large enough that copying it on every call matters, or when it contains something that must not be copied, such as a sync.Mutex. Use a value receiver for small, immutable types (a Point, a time.Time-like value) where a copy is cheap and safe.

    Be consistent: if any method needs a pointer receiver, make them all pointers, so the type behaves one way.

    Method sets matter for interfaces. The method set of T contains only value-receiver methods; the method set of *T contains both. So if Save has a pointer receiver, *Doc satisfies Saver but Doc does not, and var s Saver = Doc{} fails to compile with "method Save has pointer receiver". Calling d.Save() on an addressable variable still works because Go takes &d for you; that convenience does not extend to interface satisfaction.

    What interviewers listen for
    • Pointer receiver to mutate, avoid copies, or hold locks
    • Value receiver for small immutable types
    • Do not mix receiver kinds on one type
    • Method set of T excludes pointer-receiver methods

    Likely follow-up: Why can you not call a pointer method on a map element like m[k].Save()?

  20. 20.How is a slice represented, and what can go wrong with append?mid

    A slice is a small header of three words: a pointer to an underlying array, a length and a capacity. Slicing (s[1:3]) creates a new header over the same array, and passing a slice to a function copies only the header.

    append writes in place when len < cap; otherwise it allocates a larger array (roughly doubling for small slices, growing more slowly for large ones), copies, and returns a header pointing to the new array. Hence the rules:

    • Always use the result: s = append(s, v).
    • Two slices over one array can overwrite each other: appending to a[:2] writes into a[2] if capacity allows. A full slice expression a[:2:2] caps capacity and forces a copy on the next append.
    • A function that appends to a parameter does not change the caller's length; return the new slice.
    • A small slice of a huge array keeps the whole array alive; use slices.Clone or copy to release it.
    What interviewers listen for
    • Header: pointer, len, cap
    • append may or may not reallocate
    • Shared arrays cause silent overwrites
    • Full slice expression or Clone to isolate

    Likely follow-up: What is the difference between a nil slice and an empty slice?

  21. 21.What should you know about Go maps: iteration order, nil maps and concurrent access?mid

    A map is a reference to a runtime hash table (Swiss-table based since Go 1.24). The points interviewers probe:

    • Iteration order is unspecified and deliberately varied between runs, so sort keys (slices.Sorted(maps.Keys(m)) in Go 1.23) when output order matters.
    • A nil map can be read (missing keys give the zero value) and ranged over, but writing to it panics with "assignment to entry in nil map". Create maps with make or a literal.
    • Use the comma-ok form v, ok := m[k] to tell a missing key from a zero value.
    • Map elements are not addressable: m[k].Count++ on a struct value does not compile. Store pointers or read, modify and write back.
    • Maps are not safe for concurrent use. Concurrent writes can crash with a fatal error that recover cannot catch. Guard with a sync.Mutex or sync.RWMutex; sync.Map fits only append-mostly caches or disjoint key sets per goroutine.
    What interviewers listen for
    • Random iteration order
    • Nil map: reads fine, writes panic
    • Elements are not addressable
    • Not concurrency safe: mutex or sync.Map

    Likely follow-up: When is sync.Map faster than a map with a mutex?

  22. 22.Go has no inheritance. How does struct embedding work, and how is it different from inheritance?mid

    Embedding puts a type in a struct without a field name, for example type Server struct { *log.Logger; addr string }. The embedded type's fields and methods are promoted, so s.Println(...) works and Server satisfies any interface the promoted methods satisfy.

    It is composition, not inheritance:

    • There is no "is-a" relationship: a Server cannot be passed where a *log.Logger is expected; you pass s.Logger.
    • There is no virtual dispatch. When a promoted method runs, its receiver is the embedded value, so it cannot call an "overridden" method on the outer type.
    • The outer type can shadow a promoted method by defining its own with the same name; the embedded one stays reachable as s.Logger.Println.

    Be careful embedding in exported structs: every promoted method becomes part of your API, and embedding a sync.Mutex exposes Lock and Unlock to callers. A named field is often the clearer choice.

    What interviewers listen for
    • Fields and methods are promoted
    • Composition, no is-a, no virtual dispatch
    • Outer methods shadow promoted ones
    • Embedding leaks methods into the API

    Likely follow-up: What happens when two embedded types promote the same method name?

  23. 23.How do generics work in Go, and what are type constraints?mid

    Since Go 1.18, functions and types can take type parameters: func Map[T, U any](s []T, f func(T) U) []U. Each type parameter has a constraint, which is an interface describing what the type must support.

    • any allows every type but permits only operations valid for all types.
    • comparable allows == and !=, needed for map keys.
    • Union and approximation elements, such as ~int | ~float64, allow operators like + and <; the ~ includes named types whose underlying type matches. cmp.Ordered (Go 1.21) covers the ordered built-ins.

    Type arguments are usually inferred from the call. Limits worth knowing: methods cannot declare their own type parameters, and a constraint with a type union can only be used as a constraint, not as an ordinary variable type.

    Use generics for containers and algorithms that are truly type-agnostic (slices, maps, a typed cache). When behaviour differs per type, an ordinary interface is usually simpler.

    What interviewers listen for
    • Type parameters with interface constraints
    • any, comparable, unions with ~
    • Type inference at call sites
    • No type parameters on methods

    Likely follow-up: Why does Go not allow generic methods?

  24. 24.How does Go decide whether a value lives on the stack or the heap, and how does the garbage collector work?hard

    The compiler runs escape analysis. A value stays on the goroutine's stack if the compiler can prove it does not outlive the function; it escapes to the heap if, for example, its address is returned, stored in a long-lived structure, captured by a closure that outlives the call, or converted to an interface in a way the compiler cannot see through. go build -gcflags=-m prints these decisions. Returning a pointer to a local is legal in Go; it simply moves the value to the heap.

    Heap memory is reclaimed by a concurrent, tri-colour mark-and-sweep collector. It is non-generational and non-compacting, runs mostly alongside your goroutines with short stop-the-world phases, and uses a write barrier during marking. GOGC (default 100) sets how much the heap may grow relative to live data before the next cycle; GOMEMLIMIT (Go 1.19) adds a soft memory ceiling, useful in containers.

    To reduce GC cost, reduce allocations: preallocate slices, reuse buffers, use sync.Pool for hot temporary objects, and profile with pprof before guessing.

    What interviewers listen for
    • Escape analysis picks stack or heap
    • go build -gcflags=-m shows decisions
    • Concurrent tri-colour mark-sweep, non-moving
    • GOGC and GOMEMLIMIT tune it

    Likely follow-up: How would you find the biggest allocation source in a service?

  25. 25.What does the Go memory model guarantee, and why can’t you just use a plain boolean flag between goroutines?hard

    The memory model defines when a write in one goroutine is guaranteed to be visible to a read in another. The guarantee comes only from a synchronized-before relationship created by synchronisation: a channel send is synchronized before the matching receive completes, closing a channel before a receive that sees the close, Unlock before the next Lock, sync.Once and sync/atomic operations, and WaitGroup.Done before Wait returns.

    Without that edge, a program has a data race. A loop like for !done {} reading a plain bool set by another goroutine may never see the update, because the compiler is free to hoist the read out of the loop, and writes to multi-word values such as strings or interfaces can be observed half-written.

    The fix is to use atomic.Bool, a mutex or a channel. The model's own advice is direct: if you have to read it carefully to reason about your program, you are being too clever; synchronise explicitly.

    What interviewers listen for
    • Visibility only through synchronized-before edges
    • Channels, mutexes, atomics, Once, WaitGroup
    • Plain flags are data races
    • Use atomic.Bool or a channel

    Likely follow-up: Does a buffered channel give the same guarantee as an unbuffered one?

  26. 26.What changed about for loop variables in Go 1.22, and why did it matter?mid

    Before Go 1.22, a for loop declared its variables once, and each iteration updated the same variable. Closures and goroutines that captured it all saw the final value, so code like for _, v := range items { go func() { use(v) }() } often processed the last item many times. The workaround was v := v inside the loop.

    Since Go 1.22, each iteration gets a fresh variable, for both three-clause and range loops, so captured values behave as most people expect.

    Details worth stating:

    • The new behaviour depends on the go line in the module's go.mod being 1.22 or later, so old modules keep their old semantics when compiled with a new toolchain.
    • v := v copies are now redundant, and the tc := tc line in parallel table tests can go.
    • Go 1.22 also added ranging over an integer: for i := range 10.
    What interviewers listen for
    • Old: one variable shared across iterations
    • Go 1.22: fresh variable per iteration
    • Gated by the go line in go.mod
    • v := v no longer needed

    Likely follow-up: What did Go 1.23 add to range loops?

  27. 27.How do you write tests in Go? Describe table-driven tests and subtests.easy

    Tests live in _test.go files next to the code, as functions func TestXxx(t *testing.T), and run with go test ./.... There is no assertion library in the standard library: you compare and call t.Errorf (keep going) or t.Fatalf (stop this test).

    A table-driven test puts cases in a slice of structs (name, input, expected output) and loops over them, calling t.Run(tc.name, ...) for each. Each case becomes a named subtest that can be run alone with go test -run 'TestParse/empty_input' and reported separately.

    Useful extras:

    • t.Parallel() to run subtests concurrently, t.Cleanup for teardown, t.Helper() in assertion helpers for correct line numbers.
    • net/http/httptest for handlers and fake servers.
    • Benchmarks (BenchmarkXxx, and b.Loop since Go 1.24) and native fuzzing (FuzzXxx, since Go 1.18).
    • go test -race -cover in CI.
    What interviewers listen for
    • TestXxx in _test.go, run with go test
    • Cases as a slice of structs
    • t.Run subtests, run individually with -run
    • httptest, benchmarks, fuzzing, -race

    Likely follow-up: How would you test code that depends on time.Now?

  28. 28.How do Go modules choose dependency versions, and what are go.mod and go.sum for?mid

    go.mod declares the module path, the minimum Go version (the go line, enforced since Go 1.21, with an optional toolchain line), and the minimum version required of each dependency. go.sum records cryptographic hashes of each module version so a build fails if downloaded code changes; it is checked against the public checksum database by default.

    Go uses minimal version selection (MVS). Each module states the minimum version it needs; the build uses, for every dependency, the highest of those minimums, not the latest release. Builds are therefore reproducible without a lock file, and upgrading is always an explicit act (go get example.com/lib@v1.4.0).

    Other points: a major version 2 or above changes the import path (/v2), go mod tidy adds missing and removes unused requirements, replace points a dependency at a fork or a local path, and go work workspaces let you develop several modules together.

    What interviewers listen for
    • go.mod: requirements and Go version
    • go.sum: hashes, verified against sumdb
    • MVS picks the highest required minimum
    • Major versions change the import path

    Likely follow-up: Why does Go not need a separate lock file?

  29. 29.How do you shut down a Go HTTP server gracefully on SIGTERM?hard

    Run the server in a goroutine, wait for a signal, then call Shutdown with a deadline.

    • Use signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) to get a context that is cancelled on the signal.
    • Start srv.ListenAndServe() in a goroutine and treat http.ErrServerClosed as a normal exit.
    • After <-ctx.Done(), call srv.Shutdown(shutdownCtx) where shutdownCtx has a timeout shorter than the orchestrator's grace period (Kubernetes defaults to 30 seconds).

    Shutdown closes listeners, closes idle connections and waits for active requests to finish, or returns the context's error when the deadline passes. It does not wait for hijacked connections such as WebSockets, or for background goroutines you started: stop those through your own context and a WaitGroup, then close database pools and flush telemetry last. In Kubernetes, fail the readiness probe first so traffic drains before the listener closes.

    What interviewers listen for
    • signal.NotifyContext for SIGTERM
    • ErrServerClosed is expected
    • Shutdown with a bounded context
    • Stop your own goroutines and close resources after

    Likely follow-up: What is the difference between Shutdown and Close?

  30. 30.How are strings represented in Go? Explain bytes, runes and what len returns.easy

    A Go string is an immutable sequence of bytes, usually holding UTF-8 text. len(s) returns the number of bytes, not characters, and s[i] returns a byte.

    A rune is an alias for int32 and represents one Unicode code point. for i, r := range s decodes UTF-8 and yields each rune with its byte offset, so offsets jump by 2 to 4 for non-ASCII characters. utf8.RuneCountInString(s) counts code points, and []rune(s) converts to a slice of code points when you need indexing by character.

    Practical consequences:

    • Slicing s[:n] can cut a multi-byte character in half.
    • Converting between string and []byte copies, because strings are immutable.
    • Building strings in a loop with += is quadratic; use strings.Builder.
    • A code point is not always a user-visible character: some emoji and accented letters are several code points.
    What interviewers listen for
    • Strings are immutable bytes, usually UTF-8
    • len counts bytes
    • range yields runes with byte offsets
    • strings.Builder for concatenation

    Likely follow-up: How would you reverse a string that contains non-ASCII text?

Prefer multiple choice? All 20 Go MCQs with answers →

esc