Top 40 Go Interview Questions and Answers (2026)
Go is the language that runs the cloud. Kubernetes, Docker, Terraform, Prometheus, etcd, and a large share of the microservices behind modern SaaS are written in it. If you are interviewing for a backend, platform, infrastructure, or SRE role in 2026, there is a strong chance Go is on the table - and interviewers expect more than syntax recall. They want to know whether you understand concurrency, the memory model, and the trade-offs the language forces you to make explicit.
This guide collects 40 questions that mirror what real teams ask, grouped from fundamentals up to system design. Each answer is the version a senior engineer would actually give: direct, accurate to the current runtime, and honest about trade-offs. Work through them, then pressure-test yourself with timed coding challenges and AI mock interviews so you can say these answers out loud under interview conditions. If you are prepping across languages, the same series covers the top 50 Java interview questions and the top 50 Python interview questions.
Fundamentals
1. What makes Go different from other backend languages?
Go was designed for large engineering teams building networked services. It is statically typed and compiled to a single self-contained binary, has a garbage collector tuned for low latency, and bakes concurrency into the language with goroutines and channels rather than leaving it to libraries. The deliberate design choices are minimalism (one obvious way to do things), fast compilation, and a tiny surface area you can hold in your head.
The practical payoff in interviews: Go forces you to handle errors explicitly, makes concurrency cheap, and produces binaries you can drop into a scratch container. Teams pick it when they want predictable performance and code that stays readable as the org scales.
2. What is the zero value, and why does it matter?
Every variable in Go has a well-defined zero value when declared without initialization: 0 for numerics, false for booleans, "" for strings, and nil for pointers, slices, maps, channels, functions, and interfaces. There is no concept of an uninitialized variable holding garbage.
This is a design feature, not an accident. It lets you write types whose zero value is immediately useful - sync.Mutex, bytes.Buffer, and sync.WaitGroup all work without a constructor. A good interview answer points out that a nil slice is safe to append to and to range over, whereas a nil map is safe to read but panics on write.
var mu sync.Mutex // ready to use, no init needed
mu.Lock()
defer mu.Unlock()
var s []int // nil slice
s = append(s, 1) // fine
var m map[string]int // nil map
_ = m["missing"] // fine, returns 0
m["x"] = 1 // panic: assignment to entry in nil map
3. What is the difference between an array and a slice?
An array has a fixed length that is part of its type: [3]int and [4]int are different types. Arrays are value types, so assigning or passing one copies all its elements. You rarely use them directly.
A slice is a lightweight descriptor over a backing array: a pointer to the start, a length, and a capacity. Slices are the workhorse. Passing a slice copies the three-word header but not the underlying data, so the callee sees the same elements. This is why a function can mutate a slice's contents but cannot grow the caller's slice - append may allocate a new backing array and the caller still points at the old one.
func mutate(s []int) { s[0] = 99 } // visible to caller
func grow(s []int) { s = append(s, 1) } // NOT visible to caller
4. How does append work and when does it reallocate?
append adds elements to a slice and returns the (possibly new) slice header. If the backing array has spare capacity, it writes in place and returns a slice pointing at the same array. If capacity is exhausted, it allocates a larger array (typically growing by a factor that shrinks as the slice gets big), copies the elements over, and returns a slice pointing at the new array.
The classic gotcha: two slices can share a backing array, and an append that fits within capacity will silently overwrite the other slice's data. When you need an independent copy, use make plus copy, or a full three-index slice expression s[low:high:max] to cap capacity.
5. Explain Go's error handling model.
Go treats errors as ordinary values. Functions that can fail return an error as their last result, and the caller checks it explicitly. There are no checked exceptions; the explicitness is the point - the failure path is visible in the code, not hidden in a stack unwind.
Since Go 1.13, errors support wrapping with fmt.Errorf("...: %w", err), and you inspect chains with errors.Is (sentinel comparison) and errors.As (type assertion into the chain). panic/recover exists but is reserved for truly unrecoverable situations, not control flow.
if err := doWork(); err != nil {
return fmt.Errorf("doWork failed: %w", err)
}
// Caller inspects the chain:
if errors.Is(err, io.EOF) { ... }
var perr *os.PathError
if errors.As(err, &perr) { ... }
6. What is the difference between new and make?
new(T) allocates zeroed storage for a T and returns a *T. It works for any type. make is special-cased and only applies to slices, maps, and channels - it initializes their internal data structures and returns the type itself (not a pointer), because those types are already reference-like headers.
You almost never need new; &T{} is more idiomatic. You almost always need make to create a usable map or channel, since their zero value (nil) is not writable.
7. How do defer, panic, and recover work?
defer schedules a function call to run when the surrounding function returns, in last-in-first-out order. It is the idiomatic way to release resources - close files, unlock mutexes, decrement wait groups - because the cleanup sits right next to the acquisition and runs even if the function returns early or panics.
panic unwinds the stack, running deferred calls as it goes. recover, called inside a deferred function, stops the unwinding and returns the panic value. The standard pattern is to recover at a boundary (an HTTP handler, a worker loop) so one bad request does not crash the process. Deferred functions can also modify named return values, which is how you convert a recovered panic into a returned error.
func safeProcess() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return risky()
}
8. What are Go modules and how does dependency management work?
A module is a collection of packages versioned together, defined by a go.mod file at its root that declares the module path, the Go version, and its dependencies. go.sum records cryptographic checksums so builds are reproducible and tamper-evident. Modules replaced the old GOPATH model and made versioning explicit.
Versions follow semantic import versioning: major version 2 and above appear in the import path (example.com/lib/v2). The minimal version selection algorithm picks the lowest version that satisfies all requirements, which makes builds deterministic. In an interview, mention go mod tidy to prune and add what is actually used, and the module proxy plus checksum database for supply-chain integrity.
Concurrency
9. What is a goroutine and how does it differ from an OS thread?
A goroutine is a lightweight, runtime-managed unit of concurrent execution. It starts with a small stack (a couple of kilobytes) that grows and shrinks on demand, whereas an OS thread reserves a fixed, large stack. The Go runtime multiplexes many goroutines onto a small number of OS threads, so you can run hundreds of thousands of goroutines on a machine that could never host that many threads.
You launch one with the go keyword. The key interview point is cost and scheduling: goroutines are cheap to create and switch between because the scheduling happens in user space, not via expensive kernel context switches.
go func() {
fmt.Println("runs concurrently")
}()
10. Explain the Go scheduler (G-M-P model).
The scheduler maps goroutines (G) onto OS threads (M) using logical processors (P). A P is a scheduling context that holds a run queue of runnable goroutines; the number of Ps defaults to GOMAXPROCS, usually the CPU count. An M must hold a P to run Go code, so at most GOMAXPROCS goroutines run truly in parallel.
This design enables work stealing - an idle P pulls goroutines from a busy P's queue - and lets a goroutine that blocks on a syscall hand its P to another thread so the others keep working. Since Go 1.14 the scheduler is asynchronously preemptive, so a tight CPU-bound loop with no function calls can still be interrupted, which fixed an older class of starvation bugs.
11. What is the difference between buffered and unbuffered channels?
An unbuffered channel has no capacity: a send blocks until a receiver is ready, and a receive blocks until a sender is ready. The send and receive happen as a single synchronized rendezvous, which makes unbuffered channels a synchronization primitive as much as a data pipe.
A buffered channel has a fixed capacity. Sends succeed without a receiver until the buffer is full; receives succeed without a sender until it is empty. Use a buffer to decouple producer and consumer rates or to limit concurrency (a semaphore), but remember that a full buffered channel still blocks the sender.
ch := make(chan int) // unbuffered: send blocks until received
buf := make(chan int, 3) // buffered: up to 3 sends before blocking
12. How does the select statement work?
select lets a goroutine wait on multiple channel operations at once. It blocks until one of its cases can proceed; if several are ready, it picks one at random to avoid starvation. A default case makes the select non-blocking - it runs immediately if no other case is ready.
select is the backbone of cancellation, timeouts, and fan-in. The canonical pattern combines a work channel with a ctx.Done() channel or a time.After so a goroutine never blocks forever.
select {
case msg := <-work:
handle(msg)
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return errTimeout
}
13. What is the context package for, and when do you use it?
context carries cancellation signals, deadlines, and request-scoped values across API boundaries and goroutines. When a request is cancelled or times out, every goroutine doing work for it can observe ctx.Done() and stop, which prevents leaked goroutines and wasted work. It is mandatory hygiene in any server: the first parameter of a request-handling function should be ctx context.Context.
You create derived contexts with context.WithCancel, WithTimeout, or WithDeadline, and you must call the returned cancel function (usually via defer) to release resources. Avoid stuffing business data into context values - it is for cancellation and a small set of cross-cutting request metadata, not a general-purpose bag.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
result, err := db.QueryContext(ctx, query)
14. What is a data race and how do you detect one?
A data race occurs when two goroutines access the same memory concurrently, at least one access is a write, and there is no synchronization ordering the accesses. The result is undefined behavior - corrupted values, torn reads, or crashes that appear only under load.
Go ships a race detector: run tests or the program with the -race flag and it instruments memory accesses to report races at runtime with both stack traces. It only finds races on code paths that actually execute, so it complements but does not replace careful design. The fix is to add synchronization: a channel, a sync.Mutex, or sync/atomic operations.
15. When should you use a channel versus a mutex?
The Go proverb is "share memory by communicating," but mature engineers use both. Reach for a channel when you are transferring ownership of data, distributing work, or coordinating the lifecycle of goroutines - the channel models the flow of data through your program. Reach for a sync.Mutex when you are guarding shared mutable state that several goroutines read and write, like a cache or a counter, where a channel would be awkward overhead.
A strong answer: use a mutex when the problem is "protect this field," and a channel when the problem is "hand this off." Choosing a channel for a simple counter is over-engineering; choosing a mutex to build a pipeline is fighting the language.
16. Explain sync.WaitGroup and a common misuse.
sync.WaitGroup waits for a collection of goroutines to finish. You Add the count before launching, each goroutine calls Done (typically deferred) when it completes, and the coordinator blocks on Wait. It is the standard fan-out/join primitive.
The classic bug is calling Add inside the goroutine instead of before it, which races against Wait and can let Wait return before all goroutines start. Always Add in the launching goroutine, and never copy a WaitGroup after use.
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1) // before the goroutine
go func(j Job) {
defer wg.Done()
process(j)
}(job)
}
wg.Wait()
17. How do you implement a worker pool?
A worker pool bounds concurrency by launching a fixed number of goroutines that all read from a shared jobs channel and write to a results channel. This caps resource use - you do not want to spawn a goroutine per item when items number in the millions - and gives you natural backpressure.
func pool(jobs <-chan int, results chan<- int, workers int) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { // exits when jobs is closed
results <- j * j
}
}()
}
wg.Wait()
close(results)
}
The interview follow-up is usually about clean shutdown: close the jobs channel to signal "no more work," let the range loops drain and exit, then close results after Wait returns so no goroutine writes to a closed channel.
18. What happens when you close a channel, and what are the rules?
Closing a channel signals that no more values will be sent. Receivers can keep draining buffered values, and a receive on a closed, empty channel returns the zero value immediately with the comma-ok flag set to false. A for range over a channel exits cleanly when the channel is closed and drained.
The rules that trip people up: sending on a closed channel panics, closing a closed channel panics, and closing a nil channel panics. By convention only the sender closes a channel, never the receiver, because only the sender knows when the stream is finished. With multiple senders you need separate coordination (a WaitGroup plus a single closer) to decide when it is safe to close.
19. What are common ways to leak a goroutine?
A goroutine leaks when it blocks forever and never returns, holding its stack and any referenced memory. The two big causes are blocking on a channel that no one will ever send to or receive from, and blocking on a receive without honoring a cancellation signal. Leaked goroutines accumulate silently until the process exhausts memory.
The defenses: always provide a way out with select plus ctx.Done(), make sure every channel has a guaranteed sender-or-closer, and use buffered channels or timeouts when a producer might outlive its consumer. In tests, tools like goleak can assert that a test left no goroutines behind.
20. What does sync.Once do?
sync.Once guarantees a function runs exactly once, even across many goroutines calling it concurrently. The first caller runs the function while others block until it completes, then all subsequent calls are no-ops. It is the idiomatic way to do lazy, thread-safe initialization of a singleton or expensive resource.
var (
once sync.Once
client *http.Client
)
func getClient() *http.Client {
once.Do(func() { client = &http.Client{} })
return client
}
21. What is the difference between sync.Mutex and sync.RWMutex?
sync.Mutex provides mutual exclusion: one goroutine holds the lock at a time, full stop. sync.RWMutex distinguishes readers from writers - any number of readers can hold the read lock simultaneously, but a write lock is exclusive and blocks all readers and writers.
Use an RWMutex when reads vastly outnumber writes and the critical section is non-trivial, such as a configuration cache that is read constantly and updated rarely. For short critical sections or balanced read/write ratios, a plain Mutex is often faster because RWMutex carries more bookkeeping overhead. Never copy either after first use - vet will flag it.
22. What is sync/atomic and when do you reach for it?
The sync/atomic package provides lock-free atomic operations on integers and pointers - add, load, store, swap, compare-and-swap. They are the cheapest way to maintain a concurrently-updated counter or flag, because they map to single CPU instructions and avoid the overhead of acquiring a mutex.
Modern Go exposes typed wrappers like atomic.Int64 and atomic.Bool that are harder to misuse than the raw functions. Reach for atomics for simple shared scalars (metrics counters, a "closed" flag); reach for a mutex the moment you need to update more than one value together, since atomics cannot make a multi-field update appear as one operation.
Interfaces and Types
23. How do interfaces work in Go?
A Go interface defines a set of method signatures. Any type that implements those methods satisfies the interface implicitly - there is no implements keyword. This structural typing decouples consumers from concrete implementations: you define small interfaces where you use them, and any type with the right methods fits.
Under the hood, an interface value is a two-word pair: a pointer to a type descriptor (the dynamic type and its method table) and a pointer to the underlying data. This is why an interface can be nil only when both words are nil, a subtlety behind the famous typed-nil bug.
type Stringer interface {
String() string
}
// Any type with String() string satisfies it, no declaration needed.
24. What is the empty interface, and what replaced most uses of it?
The empty interface, written interface{} or its alias any (added in Go 1.18), has no methods, so every type satisfies it. Historically it was how you wrote code that handled values of unknown type - container libraries, JSON decoding, fmt.Println.
Generics replaced most legitimate uses of any for containers and algorithms, because they give you type safety and avoid the runtime cost and verbosity of type assertions. Today, prefer a concrete type or a generic type parameter, and reserve any for genuinely heterogeneous data like decoded JSON.
25. Explain the typed-nil interface gotcha.
An interface value holds both a type and a value. If you assign a nil concrete pointer to an interface, the interface is not nil - it carries a non-nil type and a nil value - so comparing it to nil returns false. This bites people who return a concrete error pointer that happens to be nil.
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }
func bad() error {
var e *MyErr = nil
return e // interface is non-nil! type is *MyErr, value is nil
}
// bad() == nil is FALSE
The fix is to return a literal nil on the success path, never a typed nil pointer. This is a favorite trap because the code looks obviously correct.
26. When should you use a pointer receiver versus a value receiver?
Use a pointer receiver when the method needs to mutate the receiver, or when the struct is large and copying it on every call would be wasteful. Use a value receiver for small, immutable-by-convention types where copying is cheap and you want callers to be able to use both values and pointers freely.
The consistency rule matters in interviews: if any method on a type needs a pointer receiver, give all of them pointer receivers, so the method set is uniform. Note that a value of type T does not satisfy an interface whose methods have pointer receivers, because the method set of T excludes pointer-receiver methods - only *T satisfies it.
27. What are generics in Go and what are their limits?
Generics, added in Go 1.18, let you write functions and types parameterized by type, constrained by interfaces that may include type sets. They eliminated a whole category of interface{}-and-reflection code for containers, and let you write things like a type-safe Map or Filter once.
type Number interface { ~int | ~int64 | ~float64 }
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
Know the limits: Go has no generic methods (you cannot add type parameters to a method beyond the type's own), no covariance, and constraints are nominal type sets rather than arbitrary duck typing. The ~ token means "any type whose underlying type is this," which is how constraints accept named types like a custom type Celsius float64.
28. What is a type assertion versus a type switch?
A type assertion extracts the concrete type out of an interface value: v, ok := i.(string). Use the comma-ok form so a wrong type yields ok == false instead of a panic. A type switch handles several possible dynamic types in one construct, which is cleaner than a chain of assertions.
switch v := i.(type) {
case int:
fmt.Println("int", v)
case string:
fmt.Println("string", v)
default:
fmt.Println("other")
}
Reach for these when decoding heterogeneous data or implementing something like a custom serializer. If you find yourself type-switching over your own types frequently, that is often a sign a method on an interface would model the problem better.
29. How do struct embedding and composition work?
Go has no inheritance; it has composition through embedding. When you embed a type in a struct without a field name, the outer struct promotes the embedded type's fields and methods, so you can call them directly. This gives you method reuse and a form of interface satisfaction without a class hierarchy.
type Logger struct{ prefix string }
func (l Logger) Log(s string) { fmt.Println(l.prefix, s) }
type Server struct {
Logger // embedded
addr string
}
// s.Log("up") works - Log is promoted from Logger.
Embedding an interface in a struct is also common for wrapping and decoration - you embed the interface, override the one method you care about, and delegate the rest. The honest framing: embedding is composition with syntactic sugar, not inheritance, and there is no virtual dispatch on the outer type.
Memory and Performance
30. How does Go's garbage collector work?
Go uses a concurrent, tri-color mark-and-sweep collector tuned to keep stop-the-world pauses sub-millisecond. It runs mostly concurrently with your program: it marks reachable objects while the application keeps executing, using a write barrier to track pointers that change during marking, then sweeps unreachable memory. The trade-off it chooses is low latency over maximum throughput.
The GC is paced by the GOGC setting (default 100, meaning it triggers when the heap doubles since the last collection) and, since Go 1.19, an optional soft memory limit via GOMEMLIMIT that helps prevent out-of-memory kills in containers. A good answer mentions that the way to help the GC is to allocate less, not to fiddle with knobs.
31. What is escape analysis and why does it matter?
Escape analysis is a compile-time pass that decides whether a value can live on the goroutine's stack or must be allocated on the heap. If a value's lifetime cannot be proven to end when the function returns - because a pointer to it escapes via a return value, a closure, or an interface - it escapes to the heap, which means GC pressure later.
It matters because stack allocation is essentially free and heap allocation is not. You can inspect decisions with go build -gcflags='-m'. Common causes of escapes: returning a pointer to a local, storing a value in an interface, capturing variables in goroutine closures. Reducing escapes is one of the highest-leverage performance optimizations in Go.
32. How do maps work internally, and are they safe for concurrent use?
A Go map is a hash table built from buckets, each holding up to eight key/value pairs plus an overflow pointer for collisions. As the map grows, it incrementally rehashes into a larger bucket array. Map iteration order is intentionally randomized so you never accidentally depend on it.
Maps are not safe for concurrent use: concurrent read/write or write/write panics with a "concurrent map writes" runtime error by design, to surface the bug loudly. For concurrent access, guard the map with a mutex, or use sync.Map for the specific case of many goroutines with disjoint keys or read-heavy stable key sets. You also cannot take the address of a map element, which is why you sometimes store pointers as values.
33. What is the difference between a slice's length and capacity?
Length is the number of elements the slice currently exposes; capacity is the number of elements its backing array can hold from the slice's start before a reallocation is needed. len(s) and cap(s) report them. Understanding the gap is the key to predictable performance.
Preallocate with make([]T, 0, n) when you know the rough final size, so append does not repeatedly reallocate and copy. Slicing within capacity (s[:cap(s)]) reuses the same backing array, which is both a performance tool and a source of aliasing bugs. The three-index slice s[low:high:max] lets you cap the capacity you hand out, protecting callers from accidental overwrites.
34. How do you profile a Go program?
Go ships first-class profiling through runtime/pprof and net/http/pprof. You capture CPU, heap, goroutine, mutex, and block profiles, then analyze them with go tool pprof, which gives you flame graphs and top-cost call lists. For services, importing net/http/pprof exposes profiles over an HTTP endpoint you can scrape live.
The disciplined workflow is benchmark first with the testing package (go test -bench plus -benchmem for allocations), find the hotspot with a profile, optimize, then re-benchmark to confirm. The interview signal here is that you measure before you optimize rather than guessing.
Standard Library and Idioms
35. How do you write idiomatic table-driven tests?
Table-driven tests are the Go convention: define a slice of test cases as structs, then loop over them, running each as a subtest with t.Run. This makes adding a case trivial and gives you per-case names in the output. Subtests can run in parallel with t.Parallel() and isolate failures.
func TestAbs(t *testing.T) {
cases := []struct {
name string
in int
want int
}{
{"positive", 3, 3},
{"negative", -3, 3},
{"zero", 0, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Abs(c.in); got != c.want {
t.Errorf("Abs(%d) = %d, want %d", c.in, got, c.want)
}
})
}
}
36. How does the net/http server handle concurrency?
The standard net/http server runs each incoming request in its own goroutine. That is what makes Go web services scale so cleanly - you write straight-line handler code and the runtime handles the concurrency. The consequence is that any state shared across requests (caches, counters, connection pools) must be safe for concurrent access, because many handler goroutines touch it at once.
Production-grade answers mention setting timeouts (ReadTimeout, WriteTimeout, IdleTimeout) so slow clients cannot exhaust resources, threading r.Context() through downstream calls so a disconnected client cancels its work, and using middleware for cross-cutting concerns. Knowing that the per-request goroutine is the unit of concurrency is the core point.
37. What is io.Reader and io.Writer, and why are they everywhere?
io.Reader and io.Writer are single-method interfaces: Read([]byte) (int, error) and Write([]byte) (int, error). They are the universal abstraction for streaming bytes, and because they are so small, almost everything implements them - files, network connections, buffers, HTTP bodies, gzip streams, encryption layers.
This composability is the payoff: you can chain a file through a gzip writer through a hash through the network without any component knowing about the others. io.Copy, bufio, and the encoding/* packages all speak these interfaces. The lesson interviewers want to hear is that small interfaces compose, which is a core Go design philosophy worth carrying into your own system design answers.
38. How do you handle JSON encoding and decoding?
The encoding/json package marshals Go values to JSON and back using struct tags to control field names and behavior. json.Marshal and json.Unmarshal work on whole values; json.Encoder/Decoder stream to and from an io.Writer/Reader. Tags like json:"name,omitempty" rename fields and drop empty ones.
type User struct {
ID int `json:"id"`
Email string `json:"email,omitempty"`
pw string `json:"-"` // unexported, never serialized
}
Watch the gotchas: only exported fields are encoded, unknown JSON fields are silently ignored unless you call DisallowUnknownFields, and numbers decode into float64 when the target is any. For high-throughput services people sometimes reach for code-generated or third-party encoders, but the standard library is the default and the right interview answer.
System Design with Go
39. How would you design a high-throughput rate limiter in Go?
Start with the algorithm: a token-bucket gives you smooth limiting with bursts, which is usually what you want. The standard library's golang.org/x/time/rate implements exactly this and is the idiomatic first answer - one Limiter per client, Allow or Wait per request. For a single process this is lock-efficient and battle-tested.
The interesting part is distributed limiting across many instances. You move the counter to a shared store - Redis with an atomic Lua script for a sliding window or token bucket - and accept the network round trip, or you use a gossip/approximate approach where each node enforces a local share of the global budget. Discuss the trade-offs out loud: accuracy versus latency, a hot shared store versus eventual consistency, and what happens when the limiter backend is down (fail open or fail closed). Per-client state eviction (an LRU or TTL map) keeps memory bounded.
40. How would you design a concurrent pipeline for processing a large data stream?
Model it as stages connected by channels, where each stage is a function that reads from an input channel, does work, and writes to an output channel. This is the Go pipeline pattern: a generator stage produces items, one or more middle stages transform them, and a sink consumes them. Fan-out by running several goroutines on a slow stage reading from the same channel, and fan-in by merging their outputs back into one channel.
func gen(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 r := range square(gen(1, 2, 3)) { ... }
The senior-level details: every stage must propagate cancellation via a shared context.Context so the whole pipeline tears down when a consumer stops early, otherwise upstream stages leak goroutines blocked on sends. Bound memory with buffered channels sized to your throughput, apply backpressure naturally by letting slow consumers block fast producers, and decide how a single item's failure is handled - skip, retry, or abort the stream. Being able to draw this and talk about leaks and backpressure is exactly the systems judgment infrastructure teams are screening for.
How to actually prepare
Reading answers is not the same as being able to produce them under pressure with a stranger watching. The engineers who pass Go interviews have written concurrent code until channels, select, and context are muscle memory, and they can explain a trade-off without hedging.
A practical plan:
- Build something genuinely concurrent - a worker pool, a rate limiter, a small pipeline - and run it under the
-racedetector until it is clean. - Drill timed coding challenges so you can write correct Go fast, then work through targeted practice questions on the topics above to find your weak spots.
- Read standard library source for
sync,context, andnet/http. It is short, idiomatic, and exactly the code interviewers learned from. - Rehearse out loud in AI mock interviews so you practice the part that actually gets scored - reasoning through trade-offs in real time - and extend the same prep to system design for the architecture rounds.
Go rewards focused preparation more than almost any other language because its surface area is small and its hard parts - concurrency and the memory model - are concentrated and learnable. Put in a few deliberate weeks on the questions above, practice them live, and you will walk into the room able to answer not just what Go does, but why. Start practicing on gitGood and turn these answers into instinct.