- Go interviews are concurrency interviews. Expect goroutines and the GMP scheduler, channel semantics (nil, closed, buffered), select, sync and context, plus a live coding task like a worker pool.
- Memorise the channel table: send on closed panics, receive on closed returns zero value, ok=false, nil channel blocks forever, double close panics.
- The most common trick question: a typed nil pointer inside an interface is not nil.
- The most common real bug: goroutine leaks from sends or receives that never complete. Always give goroutines a way to exit (context, close, buffered result channel).
- Know the version changes: per-iteration loop variables (Go 1.22), generics (1.18),
GOMEMLIMIT(1.19).
Go has become a mainstream backend language in India: infrastructure and platform teams, fintechs, and companies such as Zerodha, Razorpay and many SaaS startups use it for high-concurrency services. Its interviews have a distinctive shape. The syntax is small enough that interviewers spend little time on it and a lot of time on concurrency semantics, where small misunderstandings produce deadlocks, leaks and races. This guide covers 50 questions with code and the exact output you should expect.
If you are coming from Java, it helps to compare with Java multithreading interview questions: Go replaces most explicit locking with communication over channels, but mutexes are still everywhere in real code.
Where and How Go Gets Tested
Language and runtime Q&A
Slices, maps, interfaces, scheduler, GC. Often rapid-fire with "what does this print" snippets.
Concurrency coding
Worker pool, rate limiter, fan-in/fan-out pipeline, concurrent crawler with a depth limit. Graded on correctness under cancellation.
Service design
An HTTP service with timeouts, graceful shutdown and bounded concurrency. Go's standard library makes this a natural follow-up.
Go Language Basics: Slices, Maps, Defer, Errors (Q1-10)
Q1. Why was Go designed, and where does it fit?
Go was created at Google for large networked services: fast compilation, a static single binary, simple syntax, built-in concurrency, garbage collection and a strong standard library (HTTP, JSON, crypto). It trades expressiveness (no inheritance, limited generics until 1.18, explicit error handling) for readability. It fits network services, CLIs and infrastructure (Docker, Kubernetes and Terraform are written in Go); it fits less well where you need manual memory control or hard real-time latency.
Q2. Array vs slice?
An array has a fixed length that is part of its type ([4]int and [5]int are different types) and is copied by value. A slice is a small header of pointer, length and capacity referencing an underlying array. Passing a slice copies the header, not the data, so callee modifications to elements are visible to the caller.
Q3. What does this print? (slice aliasing)
a := []int{1, 2, 3, 4}
b := a[:2] // len 2, cap 4, shares a's array
b = append(b, 99) // fits in capacity: writes into a[2]
fmt.Println(a, b) // [1 2 99 4] [1 2 99]
append writes in place when capacity allows, silently overwriting a[2]. When capacity runs out, it allocates a new array and the slices stop sharing. Defensive fix: a full slice expression a[:2:2] caps capacity so the append must copy.
Q4. How does append grow a slice?
When capacity is exceeded, the runtime allocates a larger array and copies. Growth is roughly doubling for small slices and a gentler factor for large ones (the exact formula is a runtime detail that has changed across versions). Amortised append is O(1). If you know the size, preallocate: make([]T, 0, n).
Q5. Map facts interviewers test.
- Iteration order is deliberately randomised; never depend on it.
- Reading a missing key returns the zero value; use
v, ok := m[k]to distinguish. - Writing to a nil map panics; reading from one is fine.
- Maps are not safe for concurrent writes: the runtime detects it and crashes with
fatal error: concurrent map writes, which cannot be recovered. - Map elements are not addressable:
m[k].field = 1does not compile for struct values. Store pointers or reassign the whole struct.
Q6. What does defer do, and in what order?
defer schedules a call to run when the surrounding function returns, in LIFO order. Arguments are evaluated when the defer statement executes, not when the call runs.
for i := 0; i < 3; i++ {
defer fmt.Println(i)
}
// prints 2, 1, 0
Q7. Can defer change a function's return value?
func f() (result int) {
defer func() { result++ }()
return 0
}
// f() returns 1
With a named result, return 0 assigns result = 0, then deferred functions run and can modify it. This is how deferred code annotates errors: defer func() { if err != nil { err = fmt.Errorf("saveOrder: %w", err) } }().
Q8. How does error handling work, and what are wrapping, errors.Is and errors.As?
var ErrNotFound = errors.New("not found")
func getUser(id int) (*User, error) {
u, err := db.find(id)
if err != nil {
return nil, fmt.Errorf("getUser %d: %w", id, err) // wrap with context
}
return u, nil
}
if errors.Is(err, ErrNotFound) { /* 404 */ } // matches anywhere in the chain
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { /* unique violation */ }
Errors are values. %w wraps while keeping the original inspectable; errors.Is compares against sentinel values through the chain; errors.As extracts a specific error type.
Q9. panic vs error, and how does recover work?
Return errors for expected failures. panic is for programmer bugs or truly unrecoverable states. recover() only works inside a deferred function, and only catches panics in the same goroutine. HTTP servers recover per request so one bad handler does not crash the process, but a panic in a goroutine you spawned from that handler will still crash everything unless that goroutine has its own recover.
Q10. Value vs pointer receivers?
Use a pointer receiver when the method mutates the receiver, when the struct is large, or when it contains a sync.Mutex (which must not be copied). Use value receivers for small immutable types. Be consistent per type: mixing them causes surprising interface satisfaction rules (Q13).
Go Interfaces Interview Questions (Q11-17)
Q11. How are interfaces implemented in Go?
Implicitly. A type satisfies an interface by having its methods; there is no implements keyword. That lets consumers define small interfaces for exactly what they need ("accept interfaces, return structs"), and lets you mock a third-party client by declaring an interface with the two methods you use.
Q12. What is an interface value internally?
A pair: the dynamic type and a pointer to the dynamic value (for non-empty interfaces, an itab holding the type and method table). An interface is nil only when both are nil.
Q13. The nil interface trap: what does this print?
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }
func validate(ok bool) error {
var e *MyErr // typed nil pointer
if !ok {
e = &MyErr{}
}
return e // always a non-nil interface!
}
func main() {
err := validate(true)
fmt.Println(err == nil) // false
}
The returned interface holds type *MyErr with a nil value, so it is not equal to nil and callers treat a success as a failure. Fix: return nil explicitly on the success path. This is probably the single most-asked Go trick question.
Q14. Why doesn't a value type satisfy my interface?
type Shape interface{ Area() float64 }
type Square struct{ s float64 }
func (q *Square) Area() float64 { return q.s * q.s }
var _ Shape = &Square{2} // OK
var _ Shape = Square{2} // compile error: Area has pointer receiver
The method set of T includes only value-receiver methods; *T includes both. A value stored in an interface is not addressable, so Go cannot call a pointer method on it.
Q15. Type assertion vs type switch?
s, ok := v.(string) // comma-ok form never panics
switch x := v.(type) {
case int: fmt.Println("int", x+1)
case string: fmt.Println("string", len(x))
case nil: fmt.Println("nil")
default: fmt.Printf("other %T\n", x)
}
The single-value form v.(string) panics if the type is wrong.
Q16. What is the empty interface, and when should you use generics instead?
interface{} (alias any) holds any value, at the cost of type safety and boxing. Since Go 1.18, generics express "works for any T" with compile-time checking:
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
type Number interface{ ~int | ~int64 | ~float64 }
func Sum[T Number](xs []T) T {
var s T
for _, x := range xs { s += x }
return s
}
Use generics for containers and algorithms; keep interfaces for behaviour.
Q17. Struct embedding vs inheritance?
Embedding promotes the embedded type's fields and methods to the outer type, which looks like inheritance but is composition: there is no polymorphic override, and the embedded method's receiver is still the inner value. Embedding an interface in a struct is a common way to partially implement a large interface in tests.
Goroutines and the Go Scheduler (Q18-23)
Q18. Goroutine vs OS thread?
| Goroutine | OS thread | |
|---|---|---|
| Initial stack | A few KB, grows and shrinks dynamically | Typically around 1-8 MB reserved, fixed |
| Scheduled by | Go runtime, in user space | OS kernel |
| Switch cost | Cheap: save a few registers | Kernel context switch |
| Practical count | Hundreds of thousands are feasible | Thousands before memory and scheduling hurt |
| Identity | No exposed ID (deliberately) | TID |
Q19. Explain the GMP model.
Each P has a local run queue of goroutines; an M must hold a P to run Go code. Idle Ps steal work from others. When a goroutine blocks in a system call, its M is detached and the P moves to another thread so other goroutines keep running. Network I/O does not block threads at all: the netpoller (epoll/kqueue/IOCP) parks the goroutine and wakes it when the socket is ready.
Q20. What is GOMAXPROCS?
The number of Ps, so the maximum number of goroutines executing Go code simultaneously. It defaults to the number of available CPUs. In containers, older Go versions read the host's core count and ignored the cgroup CPU limit, causing heavy throttling; libraries like automaxprocs fixed that, and Go 1.25 made the default container-aware. Check which version your service uses.
Q21. Is goroutine scheduling preemptive?
Yes. Originally preemption happened only at function calls, so a tight loop without calls could hog a P. Since Go 1.14 the runtime uses asynchronous preemption via signals, so long-running loops can be interrupted.
Q22. What does this print?
func main() {
go fmt.Println("hello from goroutine")
}
Usually nothing. When main returns, the program exits without waiting for other goroutines. Use a sync.WaitGroup or a channel to wait.
Q23. Is concurrency the same as parallelism?
No. Concurrency is structuring a program as independently executing tasks; parallelism is executing them simultaneously on multiple cores. A Go program with GOMAXPROCS=1 is still concurrent (it interleaves goroutines) but not parallel. Rob Pike's talk "Concurrency is not Parallelism" is the canonical reference.
Go Channels and Select Interview Questions (Q24-33)
Q24. Unbuffered vs buffered channels?
An unbuffered channel is a synchronisation point: a send blocks until a receiver takes the value, and vice versa. A buffered channel of capacity n lets sends proceed until n values are queued; receives block only when it is empty. Buffers absorb bursts; they do not fix a consumer that is permanently slower than the producer.
Q25. The channel behaviour table.
| Operation | nil channel | Open channel | Closed channel |
|---|---|---|---|
| Send | Blocks forever | Blocks until received / buffer space | Panic |
| Receive | Blocks forever | Blocks until a value is available | Remaining buffered values, then zero value with ok == false, never blocks |
| Close | Panic | Succeeds | Panic |
Rule of thumb: only the sender closes, and only when no more sends will happen. Closing is a broadcast to receivers ("no more values"), not a resource cleanup; channels you never close are garbage collected normally.
Q26. Why does this deadlock?
func main() {
ch := make(chan int)
ch <- 1 // blocks: nobody is receiving
fmt.Println(<-ch)
}
// fatal error: all goroutines are asleep - deadlock!
The send on an unbuffered channel waits for a receiver that can never arrive, since the only goroutine is the one blocked. A buffer of 1, or sending from a separate goroutine, fixes it. The runtime detects only global deadlock; if any other goroutine (for example an HTTP server) is alive, a stuck goroutine just leaks silently.
Q27. How does range over a channel end?
for v := range ch receives until the channel is closed and drained. If the producer never closes it, the loop blocks forever, a common leak.
Q28. How does select choose a case?
It blocks until at least one case can proceed. If several are ready, it chooses uniformly at random, so no case starves. With a default case, select never blocks.
select {
case msg := <-orders:
handle(msg)
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
return errors.New("timed out waiting for order")
}
A follow-up: in a long-running loop, prefer one time.NewTimer or time.Ticker you reset or stop rather than calling time.After every iteration. Before Go 1.23, unstopped timers from time.After were not collected until they fired, which could pile up memory in hot loops.
Q29. How do you do a non-blocking send?
select {
case events <- e:
default:
droppedEvents.Inc() // buffer full: drop instead of blocking the request path
}
Q30. How can a nil channel be useful?
Because a nil channel is never ready, setting a channel variable to nil disables its case in a select. Merging two channels until both are closed:
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok { a = nil; continue } // a closed: stop selecting it
out <- v
case v, ok := <-b:
if !ok { b = nil; continue }
out <- v
}
}
}()
return out
}
Q31. Write a worker pool with cancellation.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Job struct{ ID int }
type Result struct {
JobID int
Out string
}
func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return // no more jobs
}
r := Result{JobID: j.ID, Out: fmt.Sprintf("worker %d did job %d", id, j.ID)}
select {
case results <- r:
case <-ctx.Done():
return // never block forever on a send
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(ctx, i, jobs, results, &wg)
}
go func() { // producer: the only sender, so it closes jobs
defer close(jobs)
for i := 1; i <= 10; i++ {
select {
case jobs <- Job{ID: i}:
case <-ctx.Done():
return
}
}
}()
go func() { // close results only after every worker has exited
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r.Out)
}
}
What the interviewer checks: the producer closes jobs; a separate goroutine closes results after wg.Wait() (closing it from a worker would panic other workers); every blocking operation also listens on ctx.Done(); wg.Add happens before go, not inside the goroutine.
Q32. Build a pipeline (generator, square, sink).
func gen(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// for v := range square(ctx, gen(ctx, 1, 2, 3)) { fmt.Println(v) } // 1 4 9
Each stage owns and closes its output channel. Fan-out means running several square stages reading the same input; fan-in means merging their outputs (Q30's pattern generalised with a WaitGroup).
Q33. Implement a simple rate limiter.
limiter := time.NewTicker(100 * time.Millisecond) // 10 requests/second
defer limiter.Stop()
for _, req := range requests {
<-limiter.C // wait for the next tick
go call(req)
}
For bursts, use a buffered channel as a token bucket, or the production-grade golang.org/x/time/rate limiter (rate.NewLimiter(10, 20), then limiter.Wait(ctx)).
The sync Package and context (Q34-40)
Q34. Channels or mutexes?
The Go proverb: "Don't communicate by sharing memory; share memory by communicating." In practice: use channels to transfer ownership of data or coordinate stages; use a mutex to protect shared state such as a cache or counter. A mutex around a map is simpler and faster than a goroutine-owned map with request channels for most cases.
Q35. Mutex vs RWMutex?
sync.Mutex allows one holder. sync.RWMutex allows many concurrent readers or one writer; it helps when reads dominate and critical sections are non-trivial. For tiny critical sections, a plain Mutex is often faster because RWMutex has more bookkeeping. Never copy a struct containing a mutex after first use; go vet catches this.
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(k string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[k]
return v, ok
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[k] = v
}
Q36. What are WaitGroup, Once and errgroup for?
WaitGroup waits for a set of goroutines (call Add before starting them; Go 1.25 adds a wg.Go(func) helper that does Add and Done for you). sync.Once runs initialisation exactly once, safely, across goroutines. errgroup.Group (from golang.org/x/sync) runs goroutines, returns the first error, cancels a shared context when one fails, and with SetLimit bounds concurrency: the idiomatic way to call five downstream services in parallel.
Q37. When is sync.Map appropriate?
The docs describe two cases: keys written once and read many times (caches that only grow), or goroutines working on disjoint key sets. For general read-write maps, a regular map with a Mutex is usually simpler and as fast or faster, with type safety.
Q38. What does sync/atomic offer?
Lock-free operations on single values: atomic.Int64 with Add, Load, Store, CompareAndSwap. Ideal for counters and flags. Anything involving more than one variable that must change together needs a mutex.
Q39. How does context propagate cancellation?
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel() // release resources even on success
order, err := h.repo.Find(ctx, r.PathValue("id")) // DB driver honours ctx
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "timeout", http.StatusGatewayTimeout)
return
}
// ...
}
Contexts form a tree: cancelling a parent cancels all children. r.Context() is cancelled when the client disconnects, so a well-plumbed service stops wasting work on abandoned requests. Always defer cancel(); go vet warns if you drop it.
Q40. Context rules and anti-patterns?
- Pass
ctxas the first parameter; do not store it in structs. - Never pass a nil context; use
context.TODO()if unsure. - Use
WithValueonly for request-scoped metadata (trace IDs, auth claims), with unexported key types to avoid collisions, never for optional function parameters. - Check
ctx.Err()in long loops so CPU-bound work also stops.
Common Go Concurrency Bugs Interviewers Ask About (Q41-46)
Q41. What is a goroutine leak? Show one and fix it.
// LEAKY: on timeout, the goroutine blocks forever on the unbuffered send
func fetchWithTimeout(url string) (string, error) {
ch := make(chan string)
go func() { ch <- slowFetch(url) }()
select {
case body := <-ch:
return body, nil
case <-time.After(time.Second):
return "", errors.New("timeout") // nobody will ever receive from ch
}
}
// FIXED: buffer of 1 lets the goroutine finish and be collected
ch := make(chan string, 1)
Better still, pass a context into slowFetch so the work itself stops. Detect leaks by watching runtime.NumGoroutine() or the /debug/pprof/goroutine profile grow over time, and in tests with go.uber.org/goleak.
Q42. What is a data race and how do you find it?
Two goroutines access the same memory concurrently, at least one writes, and there is no synchronisation. Results are undefined, not just "slightly wrong". Find races with go test -race ./...; it reports both stack traces. Run it in CI.
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); count++ }() // RACE: read-modify-write
}
wg.Wait()
fmt.Println(count) // often less than 1000; -race flags it
// fix: atomic.Int64, or a mutex around count++
Q43. The loop variable capture bug: did Go 1.22 fix it?
for _, v := range []string{"a", "b", "c"} {
go func() { fmt.Println(v) }()
}
Before Go 1.22, all goroutines shared one v variable and often printed "c c c". Since Go 1.22 (for modules declaring go 1.22 or later in go.mod), each iteration has its own variable, so it prints a, b and c in some order. In an interview, mention both behaviours and the old fix (v := v or passing v as an argument).
Q44. What's wrong with calling wg.Add inside the goroutine?
wg.Wait() may run before any goroutine has called Add, see a zero counter and return immediately. Call Add in the parent before the go statement.
Q45. Spawning a goroutine per request without limits: what breaks?
Goroutines are cheap, not free. A burst of 100,000 requests each launching goroutines that call a database exhausts the connection pool, memory, or the downstream service. Bound concurrency with a worker pool, a semaphore (a buffered channel of size N, or golang.org/x/sync/semaphore), or errgroup.SetLimit.
Q46. A mutex deadlock example?
Two goroutines lock A then B and B then A respectively: each holds one and waits forever for the other. Prevent with a consistent lock order, shorter critical sections, or not calling unknown code (callbacks) while holding a lock. Another classic: Go mutexes are not reentrant, so a method that holds the lock and calls another method that locks the same mutex deadlocks itself.
Go Garbage Collector, Memory and Performance (Q47-50)
Q47. How does Go's garbage collector work?
A concurrent, tri-colour mark-and-sweep collector. It is non-generational and non-compacting. Marking runs concurrently with your goroutines using a write barrier, with brief stop-the-world phases typically well under a millisecond. Goroutines that allocate heavily during a cycle are drafted into "mark assist", which is how GC cost shows up as latency in allocation-heavy code.
Q48. What do GOGC and GOMEMLIMIT do?
GOGC (default 100) sets the next GC target relative to the live heap: with 100 MB live, the next cycle starts around 200 MB. Higher values mean fewer GCs and more memory. GOMEMLIMIT (Go 1.19+) is a soft total memory limit: the GC works harder as the process approaches it, which is ideal in containers. A common setup is GOMEMLIMIT at roughly 80-90% of the container limit so the GC acts before the OOM killer does.
Q49. What is escape analysis?
The compiler decides whether a value can live on the goroutine's stack (cheap, freed automatically) or must "escape" to the heap (GC-managed). Returning a pointer to a local, storing it in an interface, or capturing it in a long-lived closure usually causes escape. Inspect decisions with go build -gcflags=-m. Fewer heap allocations means less GC work.
Q50. How do you profile a slow Go service?
import _ "net/http/pprof" // registers /debug/pprof/ handlers on DefaultServeMux
// then:
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 (CPU)
// go tool pprof http://localhost:6060/debug/pprof/heap (memory)
// curl http://localhost:6060/debug/pprof/goroutine?debug=2 (all stacks)
Write benchmarks with testing.B and run go test -bench . -benchmem to see allocations per operation. Typical wins: preallocating slices and maps, reusing buffers with sync.Pool, avoiding needless []byte/string conversions, and using strings.Builder. Expose the pprof endpoints only on an internal port.
How to Prepare for a Go Interview
- Write the concurrency patterns from memory. Worker pool, pipeline, fan-in merge, bounded parallel fetch with errgroup, rate limiter. Each should handle cancellation and close channels correctly.
- Run everything with
-race. Deliberately write the racy counter, the leaky timeout and thewg.Add-inside-goroutine bug, and watch the tooling catch them. - Build one real service. An HTTP API with
net/http(Go 1.22's router supports methods and path parameters), context timeouts on DB calls, structured logging withlog/slog, graceful shutdown withserver.Shutdown(ctx)on SIGTERM, and pprof. - Read the source of truth. The Go Memory Model and Effective Go are short and answer most "why" questions.
time.Sleep to "wait for goroutines"; ignoring errors with _; launching goroutines with no exit path; claiming maps are thread-safe for reads and writes; and treating channels as a replacement for every mutex.Go roles almost always include a service-design discussion, so pair this with REST API design questions and microservices interview questions. Many Go services ship as containers on Kubernetes, which is why Docker and Kubernetes questions often follow in the same loop.
Frequently Asked Questions
Prepare for Go Backend Interviews
Prepflix's interview program pairs language-level depth like Go concurrency with DSA, system design and mock interviews for Indian product companies.
Explore the Prepflix Course →