Top Go Interview Questions & Answers

Go interviews focus on its concurrency model — goroutines, channels — and its deliberately minimal approach to error handling and typing. These are the questions that come up most for backend and infrastructure roles.

Take Practice Interview Now
Improve your chances of getting selected — rehearse these answers out loud with an AI interviewer and get a scored report in minutes.
Start Free →

1. What is a goroutine, and how does it differ from an OS thread?

A goroutine is a lightweight, user-space unit of concurrency managed by the Go runtime, not directly by the OS kernel. It starts with a tiny stack, around 2KB, that grows and shrinks dynamically, whereas an OS thread typically reserves a fixed, much larger stack, often a megabyte or more, which is why you can comfortably run hundreds of thousands of goroutines but not nearly that many OS threads. The Go runtime uses an M:N scheduler that multiplexes many goroutines onto a smaller number of OS threads, with `GOMAXPROCS` roughly controlling how many run truly in parallel. Because scheduling decisions happen in user space at function calls, channel operations, and other safe points, context switches between goroutines are far cheaper than kernel-level thread switches. This is why idiomatic Go code spawns goroutines freely with `go func(){}()` instead of managing a thread pool by hand.

2. What's the difference between buffered and unbuffered channels?

An unbuffered channel has zero capacity, so a send blocks until another goroutine is ready to receive, and vice versa — it acts as a synchronization point, not just a data pipe. A buffered channel, made with `make(chan T, n)`, holds up to `n` values, so sends only block once the buffer fills and receives only block once it's empty. Unbuffered channels give you a strong happens-before guarantee between sender and receiver, which is useful for signaling that work is truly done. Buffered channels are better for decoupling producers and consumers when some amount of queuing is acceptable, but relying on buffer size to avoid a deadlock usually just postpones the deadlock rather than fixing the design. A good default is to start unbuffered and only add buffering when you have a measured reason to.

3. What does the `select` statement do, and when would you use it?

`select` lets a goroutine wait on multiple channel operations at once and runs whichever case becomes ready first, similar in spirit to a `switch` but for channels instead of values. If several cases are ready at the same time, Go picks one at random to avoid starving any particular channel. Adding a `default` case makes the select non-blocking, immediately falling through if nothing is ready, which is useful for polling without stalling a goroutine. It's also the standard way to implement timeouts and cancellation, typically pairing a work channel with `time.After(...)` or a `context.Done()` channel in the same select. Without it, coordinating multiple channels would require extra goroutines or busy-waiting, so select is central to writing responsive concurrent Go code.

4. What is `sync.Mutex`, and when do you actually need one?

`sync.Mutex` provides mutual exclusion: only one goroutine at a time can hold the lock between a `Lock()` and `Unlock()` call, protecting a critical section from concurrent access. You reach for it when multiple goroutines read and write shared state, like a map, counter, or cache, that isn't naturally synchronized through channels. Go also offers `sync.RWMutex`, which allows any number of concurrent readers but requires exclusive access for a writer, which pays off when reads vastly outnumber writes. A common and important idiom is `defer mu.Unlock()` immediately after locking, so the mutex still releases even if the function panics or returns early. The rule of thumb often quoted is that channels are for communicating data between goroutines while mutexes are for guarding shared state, and real code uses both depending on the situation.

5. What is `sync.WaitGroup` used for?

`sync.WaitGroup` lets one goroutine wait for a set of other goroutines to finish before continuing. You call `Add(n)` to record how many goroutines you're tracking, each one calls `Done()` when it finishes, typically via `defer`, and the waiting goroutine calls `Wait()`, which blocks until the internal counter hits zero. It's the standard fan-out pattern: launch several goroutines to process items concurrently, then wait for all of them before aggregating results. A frequent bug is calling `Add` inside the spawned goroutine rather than before it starts, which creates a race where `Wait` can return before every goroutine has even registered itself. It also doesn't return values on its own, so it's usually combined with channels or a mutex-protected slice to actually collect the work's results.

6. How does Go handle errors, and why doesn't it use exceptions?

Go treats errors as ordinary values instead of using exceptions: a function that can fail returns an `error` as its last result, and callers check it explicitly with `if err != nil`. This is a deliberate design choice meant to keep failure handling visible in the normal control flow rather than hidden in stack-unwinding logic, at the cost of some repetitive `if err != nil` blocks. The `error` type is just an interface with one method, `Error() string`, and Go 1.13 added `errors.Is`, `errors.As`, and `%w` wrapping so you can build chains of errors and inspect underlying causes without losing information. `panic` and `recover` do exist, but they're reserved for genuinely unrecoverable situations, like a programming bug or corrupted invariant, not for routine error signaling. This is a sharp contrast to languages like Java or Python where try/catch is the default, and it's one of the more debated parts of Go's design.

7. How do interfaces work in Go, and what does 'structural typing' mean here?

Go interfaces are satisfied implicitly: a type implements an interface simply by having the right method set, with no `implements` keyword or explicit declaration anywhere. This is structural typing — if it has the methods, it satisfies the interface — which decouples where behavior is defined from where it's consumed. Practically, it means you can define a small interface after the fact, and any existing type that happens to match it will satisfy it automatically, which is why idiomatic Go favors small, focused interfaces like `io.Reader` or `io.Writer` over large ones. Internally, an interface value is represented as a pair of a type descriptor and a data pointer, which is also the source of the classic gotcha where a nil concrete pointer stored in an interface is not itself equal to a nil interface. This design encourages consumers of an API to define the interface they need, rather than forcing every implementer to know about it up front.

8. What's the difference between slices and arrays in Go?

An array's length is part of its type, so `[3]int` and `[5]int` are distinct types, and arrays are copied by value whenever they're assigned or passed to a function. A slice is a small header — a pointer, a length, and a capacity — describing a view into an underlying array, and it can grow with `append`, which allocates a new backing array once capacity is exceeded. Because a slice header is copied but the underlying array isn't, passing a slice to a function lets that function mutate the shared elements, though an `append` that triggers reallocation inside the function won't be visible to the caller. In everyday Go code, arrays are rarely used directly; slices are the idiomatic tool for working with sequences of data. Understanding the length-versus-capacity distinction, and exactly when `append` reallocates, is key to avoiding subtle aliasing bugs.

9. When should you use a value receiver versus a pointer receiver?

A method with a value receiver operates on a copy of the struct, so mutations inside it never affect the original, while a pointer receiver operates on the original and can modify it. Pointer receivers make sense when the method needs to mutate the receiver, when the struct is large enough that copying would be wasteful, or when the type embeds something like a mutex that must never be copied. Value receivers fit small, immutable-feeling types where copying is cheap and value semantics are actually desired. A useful rule is consistency: once any method on a type needs a pointer receiver, it's usually best to make them all pointer receivers, since mixing the two can create confusing situations around interface satisfaction. It's also worth remembering that a plain value of type `T` only has access to value-receiver methods unless it's addressable, so a type only satisfies an interface requiring pointer-receiver methods through `*T`, not `T` itself.

10. Explain `defer`, `panic`, and `recover`.

`defer` schedules a function call to run when the enclosing function returns, whether it returns normally or via a panic, and multiple deferred calls run in LIFO order, which makes it the natural tool for cleanup like closing a file or releasing a lock. `panic` immediately stops normal execution of the current goroutine and begins unwinding the stack, running deferred calls along the way, conceptually similar to throwing an unhandled exception. `recover` only has an effect when called directly inside a deferred function; if a panic is in progress, `recover` stops the unwinding and lets the enclosing function return normally instead of crashing the program. Idiomatic Go uses panic and recover sparingly, mainly at boundaries like a top-level HTTP middleware that prevents one bad request from taking down the whole server, rather than as everyday control flow. A common mistake is calling `recover()` somewhere other than directly in a deferred function, where it simply returns nil and does nothing.

11. How does Go's garbage collector work, at a high level?

Go uses a concurrent, tri-color mark-and-sweep collector that runs alongside your program rather than pausing it for long stretches. It's non-generational and non-compacting: it marks reachable objects starting from roots like goroutine stacks and globals, and it uses a write barrier so pointer updates made by your program during marking are tracked correctly and nothing reachable gets missed. The design deliberately favors low, predictable latency over raw throughput, aiming for stop-the-world pauses in the sub-millisecond range that only occur briefly for setup and cleanup phases, not for the bulk of marking and sweeping. You can influence its behavior with the `GOGC` environment variable, which controls how much the heap is allowed to grow before triggering the next collection, and more recently `GOMEMLIMIT` for setting a soft memory ceiling. Most Go programs never need manual GC tuning, and when performance issues show up, the real culprit is usually excessive allocation pressure in the code rather than the collector itself.

12. What is the empty interface, and how do type assertions and type switches work?

`interface{}`, aliased as `any` since Go 1.18, has zero methods, so every type in Go automatically satisfies it, making it the way to express 'a value of any type' before generics existed. To recover a concrete type from it, you use a type assertion like `v, ok := x.(string)`, where the comma-ok form checks success without panicking if the assertion is wrong, instead just setting `ok` to false. A type switch, written as `switch v := x.(type) { case int: ...; case string: ... }`, lets you branch cleanly over several possible underlying types in one statement. Overusing `interface{}` throws away compile-time type safety and is often a sign that generics or a narrower, purpose-built interface would fit better. It still shows up legitimately in APIs like `encoding/json` or generic-feeling containers written before Go had real generics, where the type genuinely can't be known ahead of time.

13. How do Go modules and dependency management work?

Go modules, introduced in Go 1.11, replaced the older GOPATH-based workflow as the standard way to manage dependencies. A module is defined by a `go.mod` file at its root, which records the module's import path, the Go version it targets, and its direct dependencies with specific versions, while `go.sum` stores cryptographic checksums of every dependency to make builds reproducible and tamper-evident. Go resolves versions using minimal version selection, choosing the lowest version that satisfies every requirement in the module graph rather than always grabbing the newest compatible one. Commands like `go get` to add or update a dependency, `go mod tidy` to clean up unused ones, and `go mod why` to explain why something is present cover most day-to-day dependency work, and modules are typically fetched through a proxy such as `proxy.golang.org` by default. This whole system fixed a real pain point from the GOPATH era, where reproducing the exact same dependency set across machines was surprisingly hard.

14. Why didn't Go have generics until version 1.18, and what actually changed?

Go's designers deliberately held off on generics for years because they were wary of the compile-time cost, runtime overhead, and cognitive complexity generics had introduced in languages like C++ and Java, and they wanted a design that stayed true to Go's emphasis on simplicity and fast builds. Before 1.18, developers worked around this with the empty interface plus type assertions, code generation tools, or simply writing near-duplicate functions for each concrete type. Go 1.18 introduced type parameters, so you can now write something like `func Map[T, U any](s []T, f func(T) U) []U`, along with constraint interfaces that describe sets of allowed types, including approximation elements like `~int` to match any type whose underlying type is `int`. Under the hood, the compiler uses a hybrid strategy, sometimes generating specialized code per type combination and sometimes sharing one implementation across types with the same memory layout via runtime dictionaries, to balance binary size and compile time against execution speed. It took multiple public design drafts over several years precisely because the team wanted to avoid bolting on generics in a way that made everyday Go code harder to read.

15. What causes goroutine leaks, and how do you avoid them?

A goroutine leak happens when a goroutine blocks forever and is never able to finish, so it and everything it references stay alive for the rest of the program's life. The most common cause is a goroutine trying to send on or receive from a channel that no other goroutine will ever read from or write to again, for example a worker still trying to send a result after its receiver has already given up and stopped listening. Another frequent cause is starting a goroutine that's supposed to respect a `context.Context` but never actually selects on `ctx.Done()`, so it keeps running long after the operation that spawned it should have been cancelled. Fan-out patterns without a matching fan-in, or a `range` loop over a channel that's never closed, are also classic sources. The general fix is to always give a goroutine a way out, whether that's a cancellation channel, a context, or a properly sized buffered channel, and to catch leaks early with the goroutine profile in `pprof` or by watching goroutine counts in production.

16. What is the `context` package for, and how is it typically used?

The `context` package gives Go a standard way to carry cancellation signals, deadlines, and a small amount of request-scoped data across API boundaries and between goroutines. A `context.Context` is conventionally passed as the first argument to a function, and you derive new contexts from a parent using `context.WithCancel`, `context.WithTimeout`, or `context.WithDeadline`, each of which returns a cancel function that should always be deferred so its resources get released. When a context is cancelled or its deadline passes, its `Done()` channel closes, and any goroutine that's selecting on that channel can notice and stop its work early instead of running to completion pointlessly. This is the idiomatic way to propagate cancellation through a call chain, for instance stopping a database query or an outbound HTTP request the moment the original client disconnects. `context.Value` also exists for passing things like trace or request IDs, but it's generally discouraged for ordinary function parameters since it bypasses Go's static typing.

17. What's the difference between `make` and `new`?

`new(T)` allocates zeroed memory for a value of type `T` and returns a pointer to it, `*T`, and it works for any type, though in practice `&T{}` is more commonly used for structs since it reads more naturally. `make` is reserved specifically for slices, maps, and channels — the three built-in types that need real internal setup beyond just zeroing bytes, such as establishing a slice's length and capacity or allocating a channel's buffer. `make` returns an initialized, ready-to-use value of the type itself, not a pointer, whereas `new` just gives you a pointer to zeroed memory that may still need further setup before it's actually usable. This distinction matters most with maps: a pointer to a nil map obtained via `new` will panic if you try to write through it, while `make(map[string]int)` immediately gives you an empty, writable map. In short, `make` is only valid for slices, maps, and channels, and everything else uses `new` or a composite literal instead.

Take Practice Interview Now
Improve your chances of getting selected — rehearse these answers out loud with an AI interviewer and get a scored report in minutes.
Start Free →
Related topics
JavaPythonJavaScriptTypeScript