Go Buffered vs Unbuffered Channels: Interview Guide
When a Go channel send blocks, what buffering changes, who closes a channel, and how select, nil channels and timeouts fit together.
Go for backend interviews: goroutines and channels, the scheduler, interfaces, error handling, slices and maps, context cancellation, generics and testing.
Official reference: Effective Go
When a Go channel send blocks, what buffering changes, who closes a channel, and how select, nil channels and timeouts fit together.
How context cancellation propagates through a Go call tree, why cancel must always be called, and how to stop goroutines on a deadline.
How fmt.Errorf with %w builds an error chain in Go, when to use errors.Is versus errors.As, and how to design sentinel and typed errors.
How goroutines differ from OS threads, how the GMP scheduler maps them onto CPUs, and what GOMAXPROCS, preemption and syscalls change.
What counts as a data race in Go, how sync.Mutex and WaitGroup fix it, and how go test -race finds the race before production does.
Why a nil pointer returned as an error makes err != nil true in Go, how interface values store type and value, and how to avoid it.
How a Go slice header works, when append reallocates, why two slices can overwrite each other, and how to copy safely with Clone.
How to bound concurrency in Go with a worker pool or errgroup, why early returns leak goroutines, and how to find leaks with pprof.
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:
context or a closed channel.Likely follow-up: What happens to running goroutines when main returns? · How many goroutines is too many?
GOMAXPROCS control?hardThe 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.
Likely follow-up: Why can a Go process have more threads than GOMAXPROCS? · What problem did container-aware GOMAXPROCS solve?
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:
Likely follow-up: When would you use a buffer of exactly 1?
Closing a channel means "no more values will be sent". The rules:
ok == false.for v := range ch ends when the channel is closed and drained.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.
Likely follow-up: How do you signal "stop" to many goroutines at once?
select work, and how do you use it for timeouts and non-blocking operations?midselect 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:
time.After(d) or ctx.Done().select alongside <-ctx.Done().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.
Likely follow-up: How would you give one channel priority over another?
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.*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.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.
Likely follow-up: How would you collect results from those goroutines safely?
The Go proverb is "share memory by communicating", but it is guidance, not a ban on locks. A simple rule:
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.
Likely follow-up: What happens if a goroutine locks the same mutex twice?
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.
Likely follow-up: Is reading a bool flag from another goroutine without a lock safe?
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:
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.
Likely follow-up: How would you find which line leaked 50,000 goroutines in production?
Bound the concurrency instead of starting 10,000 goroutines that all hit the network at once. Two common shapes:
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.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.
Likely follow-up: How would you preserve the input order of results?
context.Context for, and how does cancellation propagate?midA 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().
Likely follow-up: What does context.WithoutCancel do and when would you use it?
context.WithValue?midContext 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:
type ctxKey struct{}) so packages cannot collide on plain string keys.any), so wrap access in small typed helpers such as RequestID(ctx) (string, bool).Likely follow-up: Why is storing a context in a struct discouraged?
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:
fmt.Errorf("load config %s: %w", path, err).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.
Likely follow-up: When is it acceptable to panic in library code?
%w, and the difference between errors.Is and errors.As.midfmt.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.
Likely follow-up: How would you make a custom error type match a sentinel with errors.Is?
panic and recover work, and when is recovering appropriate?midpanic 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.
Likely follow-up: Why must every goroutine you start recover its own panics?
defer work? Explain evaluation order and common pitfalls.easydefer 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:
defer statement; only the call is delayed. defer fmt.Println(x) prints the value x had then.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.
Likely follow-up: How would you return the error from Close in a function that writes a file?
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:
io.Reader, fmt.Stringer). "The bigger the interface, the weaker the abstraction."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.
Likely follow-up: What is the empty interface, and why is any an alias for it?
error produce err != nil?hardAn 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
}Likely follow-up: How could you detect a typed nil inside an interface at run time?
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.
Likely follow-up: Why can you not call a pointer method on a map element like m[k].Save()?
append?midA 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:
s = append(s, v).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.slices.Clone or copy to release it.Likely follow-up: What is the difference between a nil slice and an empty slice?
A map is a reference to a runtime hash table (Swiss-table based since Go 1.24). The points interviewers probe:
slices.Sorted(maps.Keys(m)) in Go 1.23) when output order matters.make or a literal.v, ok := m[k] to tell a missing key from a zero value.m[k].Count++ on a struct value does not compile. Store pointers or read, modify and write back.recover cannot catch. Guard with a sync.Mutex or sync.RWMutex; sync.Map fits only append-mostly caches or disjoint key sets per goroutine.Likely follow-up: When is sync.Map faster than a map with a mutex?
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:
Server cannot be passed where a *log.Logger is expected; you pass s.Logger.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.
Likely follow-up: What happens when two embedded types promote the same method name?
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.~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.
Likely follow-up: Why does Go not allow generic methods?
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.
Likely follow-up: How would you find the biggest allocation source in a service?
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.
Likely follow-up: Does a buffered channel give the same guarantee as an unbuffered one?
for loop variables in Go 1.22, and why did it matter?midBefore 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:
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.for i := range 10.Likely follow-up: What did Go 1.23 add to range loops?
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.BenchmarkXxx, and b.Loop since Go 1.24) and native fuzzing (FuzzXxx, since Go 1.18).go test -race -cover in CI.Likely follow-up: How would you test code that depends on time.Now?
go.mod and go.sum for?midgo.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.
Likely follow-up: Why does Go not need a separate lock file?
Run the server in a goroutine, wait for a signal, then call Shutdown with a deadline.
signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) to get a context that is cancelled on the signal.srv.ListenAndServe() in a goroutine and treat http.ErrServerClosed as a normal exit.<-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.
Likely follow-up: What is the difference between Shutdown and Close?
len returns.easyA 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:
s[:n] can cut a multi-byte character in half.string and []byte copies, because strings are immutable.+= is quadratic; use strings.Builder.Likely follow-up: How would you reverse a string that contains non-ASCII text?
No questions match that filter.
Prefer multiple choice? All 20 Go MCQs with answers →