Go interview questions — practice with real interview problems
Reviewed by Mark Dickie · Last updated
Go is a statically typed, compiled language designed at Google for simple, reliable, and fast systems programming. For interviews, you should be solid on goroutines and channels, the interface and type system, escape analysis and the garbage collector, and the standard library patterns that show up repeatedly in coding rounds. Knowing how the scheduler works and why Go avoids lock-free data structures by default will separate a passing answer from a strong one.
What does a Go interview typically test?
Most Go interviews split into coding and systems discussion. The coding side checks whether you can write idiomatic Go under time pressure; the systems side probes whether you understand what the runtime does for you.
| Area | What gets asked | Common follow-up |
|---|---|---|
| Concurrency | Goroutine lifecycle, channel direction, select semantics | Race conditions, deadlock detection |
| Interfaces | Implicit satisfaction, empty interface, type assertions | Why compile-time safety matters |
| Memory | Escape analysis, make vs new, pointer vs value receivers | GC pause behavior, pprof |
| Error handling | error interface, wrapping with %w, errors.Is / errors.As | Custom error types |
| Data structures | Slices vs arrays, map internals, sync package | Slice growth, map bucket layout |
How should you prepare for Go interview questions?
- Write small programs that use goroutines with buffered and unbuffered channels until the synchronization model is second nature.
- Study the memory model: read about escape analysis, run
go build -gcflags="-m", and understand why a value moves to the heap. - Practice slice and map internals so you can explain capacity, growth factors, and the difference between
appendon a nil slice and a zero-length slice. - Drill the
contextpackage — cancellation propagation, timeouts, and howcontext.Backgrounddiffers fromcontext.TODOin real code. - Review
sync.Mutex,sync.RWMutex, andsync.WaitGroupenough that you can spot a race condition by reading code.
The quiz below pulls from these topic areas so you can find gaps before the real interview.
Key facts
- Tarmac has 92 Go interview questions on this topic, 10 of them on this page, at difficulty 1–4 of 5.
- Tarmac last reviewed these Go interview questions on 23 August 2026.
At a glance
| Questions | 10 shown · 92 in the bank |
|---|---|
| Difficulty | 1–4 of 5 |
| Formats | Ordering, Multiple choice, Multiple answer, Fill in the blank, True / false, Short answer, Flashcard, Code output, Find the bug, Design exercise |
What you'll review
- goroutines
- maps
- zero values
- mutex sync
- closures go
- context cancellation
- slices
- waitgroup
- select statement
Practice questions
Go/go-concurrency/goroutines
Order the three stages of a single goroutine's lifecycle from first to last.#
Put these in order
Show answer
A goroutine's lifecycle proceeds in three stages: the go statement creates the new goroutine, that goroutine then executes the function body, and finally the goroutine terminates when its function returns. Each stage must complete before the next can begin.
A goroutine begins life when the go statement executes and the runtime creates it. It then runs the function body. When that function returns (or the goroutine panics), the goroutine terminates. These three stages are strictly sequential: creation precedes execution, and execution precedes termination.
Go/go-types/maps
You declare var m map[string]int and never call make. What happens when you execute m["a"] = 1?#
Options
Show answer
Writing to a nil map panics at runtime with "assignment to entry in nil map". A map's zero value is nil with no backing storage, so there is nowhere to put the entry. Reads are different: indexing a nil map safely returns the value type's zero value without panicking. Always initialize with make(map[K]V) or a map literal before assigning keys.
A map's zero value is nil, and a nil map has no backing storage allocated, so writing to it panics at runtime with "assignment to entry in nil map". Reading is asymmetric and safe — m["a"] on a nil map returns the value type's zero value (0 here) without panicking — which is exactly why the write panic surprises people. Go does not auto-allocate on first write; you must call make(map[string]int) (or use a literal) before assigning. The compiler can't catch this because nilness is a runtime property.
Go/go-language/zero-values
In Go, a variable declared with var but no initializer takes its type's zero value. Which of these zero values are nil? Select all that apply.#
Options
Pick every one that applies.
Show answer
Slices, maps, and pointers all zero-value to nil (as do channels, functions, and interfaces). An int zero-values to 0, and a struct like time.Time zero-values to a struct with every field at its own zero value — a struct is never nil and cannot be compared to nil. Note a nil slice is safe to append to, but a nil map panics on write.
Slices, maps, pointers, channels, functions, and interfaces are the reference-like types whose zero value is nil. So var s []int, var m map[string]int, and var p *int are all nil. An int zero-values to 0, not nil, and a struct zero-values to a struct with every field set to its zero value (so var t time.Time is a usable zero Time, never nil — you cannot compare a struct to nil and it won't compile). The practical trap: a nil slice is safe to range and append to, but a nil map panics on write — same nil, very different usability — which is why people get burned reaching for the wrong one.
Go/go-concurrency/mutex-sync
The idiomatic way to protect a critical section with a sync.Mutex named mu is to call mu._____() and then immediately defer mu._____() so the lock is released even if the function panics or returns early.#
Show answer
The idiomatic way to protect a critical section with a sync.Mutex named mu is to call mu.**Lock**() and then immediately defer mu.**Unlock**() so the lock is released even if the function panics or returns early.
mu.Lock() acquires the mutex and mu.Unlock() releases it. Pairing Lock() with an immediate defer Unlock() is the standard Go idiom because the deferred call runs on every return path — including a panic — so the lock can't be leaked. A forgotten or skipped Unlock causes every other goroutine waiting on that mutex to block forever, a classic deadlock. Note sync.Mutex is not reentrant: locking it twice from the same goroutine deadlocks.
Go/go-language/closures-go
In a module declaring go 1.22 (or later), each iteration of a for i := 0; i < n; i++ loop gets a fresh copy of i, so a closure or goroutine that captures i sees that iteration's value rather than the loop's final value.#
Options
Show answer
True in Go 1.22 and later. The loop variable now has per-iteration scope — it is effectively re-declared each iteration — so a closure or goroutine capturing i sees that iteration's value. Before 1.22 a single shared variable meant captured closures observed the loop's final value, the classic capture bug. The behavior is gated on the go version declared in go.mod.
True for Go 1.22+. The loop variable was redefined to have per-iteration scope: the variable is effectively re-declared each iteration, so a captured i holds the value from its own iteration. Before Go 1.22 there was a single shared i for the whole loop, and goroutines/closures launched in the loop notoriously all observed the final value — the decade-old 'loop variable capture' gotcha. The behavior is gated on the go directive in go.mod, so the same source can behave differently depending on the declared language version. This is why the version must be pinned when reasoning about loop-capture code.
Go/go-concurrency/goroutines
How does a goroutine differ from an OS thread, and why can a Go program run hundreds of thousands of goroutines but not hundreds of thousands of OS threads?#
Show answer
A goroutine is a lightweight, user-space unit of execution managed by the Go runtime's scheduler, not the operating system. The runtime multiplexes many goroutines onto a small pool of OS threads (the M:N or G-M-P model), so they don't map one-to-one to kernel threads. Goroutines start with a tiny stack (around 2KB) that grows and shrinks on demand, whereas an OS thread reserves a large fixed stack (often 1-8MB) and each costs kernel resources and expensive context switches. Because goroutines are cheap to create and switch between in user space, and blocking one (e.g. on I/O) lets the runtime park it and run another on the same thread, you can have hundreds of thousands of them; the same number of OS threads would exhaust memory and overwhelm the kernel scheduler.
The key distinctions: goroutines are scheduled by the Go runtime in user space (not the kernel), are multiplexed M:N onto a small pool of OS threads, and start with a tiny growable stack (~2KB) versus a thread's large fixed stack. That's what makes them cheap enough to spawn by the hundreds of thousands. A strong answer names the small/growable stack and the runtime scheduler; weak answers just say 'goroutines are lighter' without the mechanism. This matters at work when deciding concurrency strategy — goroutines make per-request or per-connection concurrency practical where a thread-per-connection model would not scale.
Go/go-concurrency/context-cancellation
What does context.Context provide for goroutines, and how does a goroutine learn it should stop?#
Show answer
A Context carries cancellation signals, deadlines/timeouts, and request-scoped values across API boundaries and goroutines. Cancellation propagates down a tree of derived contexts (created with WithCancel, WithTimeout, or WithDeadline). A goroutine watches for cancellation by selecting on the context's Done() channel — which is closed when the context is cancelled or its deadline passes — and then returns promptly, checking ctx.Err() to see why (Canceled or DeadlineExceeded). The caller must call the cancel function (usually defer cancel()) to release resources even if the work finishes normally.
Context is Go's standard mechanism for cancellation, deadlines, and request-scoped data. The crucial mechanic is that ctx.Done() returns a channel closed on cancellation, so goroutines select on it to exit cleanly — this is how you avoid leaking goroutines that keep running after their work is no longer needed. Forgetting defer cancel() is a common resource leak. Passing Context as the first parameter and respecting Done() is core to writing well-behaved Go services.
Go/go-types/slices
What does this Go program print?#
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4}
b := a[:2]
b = append(b, 99)
fmt.Println(a)
fmt.Println(b)
}Options
Show answer
[1 2 99 4]
[1 2 99]
b := a[:2] makes a slice of length 2 but capacity 4 that shares a's backing array. Because there is spare capacity, append(b, 99) writes into the existing array at index 2 instead of allocating a new one — so it overwrites a[2] (the 3) with 99. a becomes [1 2 99 4] and b is [1 2 99]. The trap (option b) is assuming append always copies; it only reallocates when capacity is exhausted. This aliasing bug bites in production when a subslice handed to one function silently mutates the caller's data — the fix is a full copy or the three-index slice a[:2:2] to cap the capacity and force a reallocation on append.
Go/go-concurrency/waitgroup
This code is supposed to wait for all workers to finish, but Wait() often returns before they do (and sometimes panics). What's the root cause?#
func run(tasks []Task) {
var wg sync.WaitGroup
for _, t := range tasks {
go func(t Task) {
wg.Add(1)
defer wg.Done()
process(t)
}(t)
}
wg.Wait()
}Options
Show answer
wg.Add(1) is called inside the goroutine; wg.Wait() can run before any goroutine has started and added, so it sees a zero counter and returns immediately. Add must be called before launching the goroutine.
wg.Add(1) runs inside each goroutine, so there is a race between the main goroutine reaching wg.Wait() and the workers getting scheduled to call Add. If Wait() runs first, the counter is still 0 and it returns immediately without waiting; it can also panic with 'WaitGroup is reused before previous Wait has returned' or a negative counter under bad interleavings. The fix is to call wg.Add(1) in the loop before go func(...), so the count is registered before any goroutine can finish or Wait can be reached. Option b is wrong — defer works fine inside goroutines. Option c is wrong here because the closure captures the outer wg by reference already (it isn't a parameter). This 'Add inside the goroutine' mistake is one of the most common Go concurrency bugs and produces flaky, schedule-dependent failures.
Go/go-concurrency/select-statement
Design a concurrent worker pool in Go that processes a stream of jobs with bounded parallelism.#
Show answer
Shape. A fixed pool of N worker goroutines, a jobs channel they all range over, and a results channel they send to. The producer sends jobs then close(jobs); each worker's for job := range jobs loop exits naturally when the channel drains and closes.
Bounding. Concurrency is capped at N simply by launching N workers — no matter how many jobs arrive, only N run at once. (An alternative is a buffered-channel semaphore or errgroup.SetLimit(N).)
Cancellation. A context.Context is passed to each worker. The work loop selects on both the job source and ctx.Done(): select { case job, ok := <-jobs: ...; case <-ctx.Done(): return }, and when sending a result it also selects on ctx.Done() so a cancelled run never blocks forever on a send. ctx.Err() tells the caller it was cancelled vs timed out.
Clean shutdown. A sync.WaitGroup counts the N workers; a dedicated closer goroutine does wg.Wait(); close(results) so results is closed exactly once, only after every sender has exited. The caller ranges over results until it closes. Nobody sends after close, and the WaitGroup guarantees no worker is left running.
Hazards. Deadlock: avoided by closing jobs (workers' range terminates) and by having a drainer range results to completion. Goroutine leak on cancel: avoided because both the receive and the send select on ctx.Done(). Send-on-closed-channel panic: avoided because only the closer goroutine closes results, and only after wg.Wait(). Results aren't written to a shared slice without synchronization — they flow over the channel.
Idiomatic shortcut. errgroup.Group with WithContext + SetLimit(N) gives bounded concurrency, first-error propagation, context cancellation, and a single Wait() — replacing most of the hand-rolled WaitGroup/closer plumbing for the common case.
A strong answer treats 'worker pool' as concrete channel/goroutine plumbing: N workers ranging a closed-when-done jobs channel to bound concurrency, results flowing back over a channel (not a racy shared slice), a context selected on for prompt cancellation, and a WaitGroup-plus-closer so the results channel is closed exactly once after all senders exit. The recurring interview signal is whether the candidate can answer 'who closes the channel and how do you avoid send-on-closed and leaks on cancel' — the questions that separate someone who has shipped Go concurrency from someone who has only read about goroutines.
Related interview questions
The other 82 questions
This page shows 10. A free account opens the rest and marks your answers, which a page of answers can't do. Miss one and it returns a few days later, then a week after that, until you stop missing it.
Free · the whole bank · 100 graded answers per 30 days · written feedback and job-ad quizzes on the paid plan