Go is the language of cloud infrastructure — powering Kubernetes, Docker, Terraform, and most modern backend services at companies like Google, Uber, Cloudflare, and Stripe. Go interview questions focus on concurrency (goroutines/channels), interfaces, error handling philosophy, and the design decisions that make Go uniquely suited for distributed systems. This guide covers what you need for senior Go engineering interviews.
Goroutines and Concurrency
Goroutines are lightweight threads managed by the Go runtime. Creating one: go func() { doWork() }(). Cost: ~2 KB initial stack (grows dynamically). You can run millions of goroutines. The Go runtime multiplexes goroutines onto OS threads (M:N scheduling). Key concurrency patterns: (1) Fan-out/fan-in — spawn N goroutines (fan-out) to process work in parallel, collect results into one channel (fan-in). (2) Worker pool — a fixed number of goroutines consume from a shared work channel. Limits concurrency while processing a large number of tasks. (3) Pipeline — chain goroutines via channels: stage1 -> channel -> stage2 -> channel -> stage3. Each stage processes and forwards data. (4) Select — multiplex on multiple channels: select { case msg := <-ch1: handle(msg); case <-ctx.Done(): return; case <-time.After(5*time.Second): timeout(); }. Select blocks until one case is ready. Race conditions: goroutines sharing mutable state without synchronization cause data races. Detection: go run -race or go test -race. Fix with: channels (share by communicating, not by sharing memory), sync.Mutex (lock shared state), or sync/atomic (atomic operations on primitives). Go proverb: "Do not communicate by sharing memory; share memory by communicating." Channels are the idiomatic synchronization mechanism. Mutexes are for protecting shared state when channels are awkward.
Channels
Channels are typed conduits for passing data between goroutines. ch := make(chan int) (unbuffered) or make(chan int, 10) (buffered with capacity 10). Unbuffered: the sender blocks until a receiver is ready (synchronization point). Buffered: the sender blocks only when the buffer is full; the receiver blocks when empty. Direction: chan<- int (send-only), <-chan int (receive-only). Use in function signatures to enforce communication direction. Closing: close(ch) signals no more values will be sent. Receivers: for msg := range ch reads until the channel is closed. Reading from a closed channel returns the zero value immediately (use the two-value form: val, ok := <-ch; if !ok { /* closed */ }). Common patterns: (1) Done channel — signal goroutines to stop: done := make(chan struct{}). The goroutine selects on done and exits when it is closed. close(done) broadcasts to all listeners. (2) Semaphore — buffered channel as a counting semaphore: sem := make(chan struct{}, 10). Acquire: sem <- struct{}{}. Release: <-sem. Limits concurrency to 10. (3) Timeout — select with time.After: select { case res := <-ch: use(res); case <-time.After(3*time.Second): return ErrTimeout; }. Interview question: "What happens if you send on a closed channel?" Answer: panic. Always: only the sender should close a channel. Never close from the receiver side. "What happens if you send on a nil channel?" Answer: blocks forever.
Interfaces and Composition
Go interfaces are implicit — a type implements an interface by implementing its methods (no “implements” keyword). type Writer interface { Write([]byte) (int, error) }. Any type with a Write method satisfies io.Writer. This enables: (1) Polymorphism without inheritance — pass any Writer (file, buffer, HTTP response, network connection) to functions accepting io.Writer. (2) Small, focused interfaces — io.Reader (one method), io.Writer (one method), io.ReadWriter (both). Compose small interfaces into larger ones. Go proverb: “The bigger the interface, the weaker the abstraction.” (3) Mock-friendly testing — define an interface for your dependency. In tests, pass a mock implementation. No mocking framework needed. Type assertions: var w Writer = getWriter(); if f, ok := w.(*os.File); ok { /* f is a *os.File */ }. Safe type assertion with the two-value form (panics without ok). Type switch: switch v := w.(type) { case *os.File: …; case *bytes.Buffer: …; }. Empty interface: interface{} (or any in Go 1.18+) accepts any type. Use sparingly — you lose type safety. Prefer generics (Go 1.18+) for type-safe parameterized code. Embedding: type ReadWriter struct { Reader; Writer } — ReadWriter has all methods of Reader and Writer. This is composition, not inheritance. Go does not have classes or inheritance — composition via embedding and interface satisfaction is the design philosophy.
Error Handling
Go uses explicit error values instead of exceptions. Functions return (result, error). The caller checks: if err != nil { return fmt.Errorf(“failed to open file: %w”, err) }. The %w verb wraps errors — callers can unwrap with errors.Is() and errors.As(). Why no exceptions: explicit errors are visible in the function signature. You cannot accidentally ignore an error (the compiler warns about unused variables). Error handling is part of the normal control flow, not a separate exception path. Error wrapping: add context as errors propagate up the call stack. Each layer wraps with additional information: “failed to process order: failed to charge card: connection timeout.” The final error message tells you the full chain. errors.Is(err, ErrNotFound) checks if any error in the chain is ErrNotFound. errors.As(err, &target) extracts a specific error type. Sentinel errors: var ErrNotFound = errors.New(“not found”). Package-level error values that callers check with errors.Is. Custom error types: type ValidationError struct { Field string; Message string }. Implement the Error() string method. Callers extract with errors.As. panic/recover: for truly unrecoverable situations (programming errors, not runtime errors). Panic unwinds the stack. Recover catches the panic (in a deferred function). Do NOT use panic for normal error handling — this is a strong Go convention. Libraries should almost never panic; they should return errors.
Context, Generics, and Performance
context.Context: carries deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines. ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second); defer cancel(). Pass ctx to all downstream calls. Functions check ctx.Done() to detect cancellation. HTTP handlers receive context from the request: r.Context(). Database queries accept context: db.QueryContext(ctx, query). Rule: context is always the first parameter: func DoWork(ctx context.Context, args …) error. Generics (Go 1.18+): func Map[T, U any](slice []T, f func(T) U) []U. Type parameters enable type-safe reusable code without interface{}/reflection. Constraints: [T comparable] (supports ==), [T constraints.Ordered] (supports ). Use for: generic data structures (linked list, stack, queue), utility functions (map, filter, reduce), and type-safe containers. Do not overuse — Go philosophy favors simplicity over abstraction. Performance: Go compiles to native code (no JVM, no interpreter). Fast startup (no warm-up), low memory (no runtime overhead), and predictable latency (simple GC with sub-millisecond pauses). Profiling: go tool pprof for CPU and memory profiling. go test -bench for benchmarking. go tool trace for execution tracing. Common optimizations: avoid allocations in hot paths (use sync.Pool for object reuse), prefer value types over pointers for small structs (reduces GC pressure), and use buffered I/O (bufio.Reader/Writer).
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do goroutines and channels work together in Go?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Goroutines are lightweight threads (~2 KB each, millions possible). Channels are typed conduits between goroutines. Go philosophy: share memory by communicating (channels), not communicate by sharing memory (locks). Key patterns: (1) Fan-out/fan-in: spawn N goroutines (fan-out), collect results in one channel (fan-in). (2) Worker pool: fixed goroutines consume from a shared channel. (3) Pipeline: chain goroutines via channels (stage1 -> ch -> stage2 -> ch -> stage3). (4) Select: multiplex on multiple channels. Blocks until one is ready. Unbuffered channels synchronize sender and receiver. Buffered channels allow N items without blocking. Closing a channel signals no more values — receivers using range automatically stop. Key rules: only senders close channels (receiving panic on send-to-closed). Sending on nil channel blocks forever. The go run -race flag detects data races at runtime.”}},{“@type”:”Question”,”name”:”Why does Go use explicit error handling instead of exceptions?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Go returns (result, error) from functions. The caller checks: if err != nil { return fmt.Errorf(context: %w, err) }. Benefits over exceptions: (1) Errors are visible in the function signature — you know which functions can fail. (2) Cannot accidentally ignore errors (compiler warns about unused variables). (3) Error handling is normal control flow, not a separate exception path — easier to reason about. Error wrapping with %w enables: errors.Is(err, ErrNotFound) to check the error chain, and errors.As(err, &target) to extract specific error types. Sentinel errors (var ErrNotFound = errors.New) provide package-level error values. panic/recover exists for unrecoverable situations (programming bugs) but is NOT used for normal error handling — this is a strong Go convention.”}}]}