Go interview questions – practice quiz for developers

Reviewed by Mark Dickie · Last updated

Go is a statically typed, compiled language built at Google and released in 2009, designed around fast compilation, straightforward concurrency, and a small surface area that keeps large codebases readable. For a Go interview at any level, you should be comfortable with goroutines and channels, the interface system (implicit satisfaction, no implements keyword), how the garbage collector and escape analysis affect allocation, and the standard patterns for error handling — since Go has no exceptions. Knowing why Go made certain trade-offs (no generics until 1.18, no inheritance, explicit error returns) matters as much as knowing the syntax itself.

What does a Go interview actually test?

Interviewers split Go questions into roughly four concerns:

  1. Language mechanics — zero values, slice vs. array, map internals, value vs. pointer receivers, defer execution order.
  2. Concurrency — goroutine scheduling (the M:N model via the Go runtime), channel direction, select, sync.Mutex vs. sync/atomic, common data-race patterns.
  3. Tooling and ecosystemgo test, benchmarks (-bench), the race detector (-race), modules and go.sum, build tags.
  4. Design and idioms — table-driven tests, error wrapping with fmt.Errorf("%w", ...) and errors.Is/errors.As, context propagation, and interface composition.

Mid-to-senior roles add questions on the scheduler (GOMAXPROCS, preemption since Go 1.14), pprof profiling, and generic constraints introduced in Go 1.18.

How do Go versions change what you need to know?

| Go version | Key addition worth knowing for interviews | |---|---| | 1.11 | Modules (go.mod) replace GOPATH for dependency management | | 1.13 | errors.Is / errors.As / fmt.Errorf("%w") for error wrapping | | 1.14 | Goroutines become asynchronously preemptible (affects scheduler questions) | | 1.18 | Generics land — type parameters, constraints, comparable | | 1.21 | slices, maps, cmp packages added to stdlib; min/max builtins | | 1.22 | Loop variable capture semantics fixed — a classic interview gotcha changes behaviour |

If a job description mentions a specific Go version, check the release notes for that version; interviewers sometimes ask about exactly the kind of subtle behaviour changes shown above.

Where do candidates lose points in a Go interview?

Three areas account for most mistakes:

  1. Slice aliasing. Candidates describe slices as copies when they share the underlying array until a append triggers a reallocation. Being able to draw the (pointer, length, capacity) header wins points.
  2. Goroutine leaks. Writing a goroutine that blocks on a channel with no reader — and not knowing how to detect it with runtime.NumGoroutine() or pprof — is a common gap.
  3. Interface nil traps. A typed nil stored in an interface value is not equal to an untyped nil. Candidates who can explain this with a concrete example stand out.

Practise writing code by hand for at least a few sessions, since many Go interviews include a short live-coding segment without IDE support.

At a glance

Questions15
Difficulty2–5 of 5
FormatsMultiple choice, Multiple answer, True / false, Code output, Find the bug, Short answer, Flashcard, Fill in the blank, Design exercise

What you'll review

  1. maps
  2. defer
  3. channels
  4. error wrapping
  5. zero values
  6. closures go
  7. slices
  8. goroutines
  9. waitgroup
  10. interfaces
  11. context cancellation
  12. mutex sync
  13. select statement

Practice questions

Go/go-types/maps

You declare var m map[string]int and never call make. What happens when you execute m["a"] = 1?

Options

  • It panics at runtime with "assignment to entry in nil map"
  • It silently creates the map and stores the value
  • It is a compile error — the map must be initialized first
  • It stores nothing but does not panic
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.

Why:

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/defer

When exactly are the arguments to a deferred call evaluated? Consider x := 1; defer fmt.Println(x); x = 2.

Options

  • Immediately when the defer statement runs, so it prints 1
  • When the surrounding function returns, so it prints 2
  • When the surrounding function returns, re-reading x live, so it prints 2
  • Lazily, only if the deferred function actually executes
Show answer

The arguments to a deferred call are evaluated immediately, when the defer statement runs — only the call is postponed until the function returns. So defer fmt.Println(x) captures x as 1, and a later x = 2 is ignored, printing 1. To read the value at return time instead, defer a closure: defer func() { fmt.Println(x) }().

Why:

A deferred call's arguments are evaluated immediately, at the point the defer statement executes — only the call itself is postponed until the function returns. So defer fmt.Println(x) snapshots x as 1, and the later x = 2 has no effect on what is printed. To defer evaluation of the value until return time, wrap it in a closure: defer func() { fmt.Println(x) }(), which reads x when the closure runs. Misremembering this is a common source of bugs when deferring cleanup that logs a variable expected to be mutated later.

Go/go-concurrency/channels

A channel is created with ch := make(chan int, 2) and nothing ever receives from it. On which send does the sending goroutine first block?

Options

  • The third send — the buffer holds 2 values, then sends block until a receive frees space
  • The first send — every send blocks until a receiver is ready
  • The second send — the buffer holds 1 value
  • It never blocks; sends to a buffered channel are always asynchronous
Show answer

With make(chan int, 2), the first two sends succeed and fill the buffer; the third send blocks until a receiver frees a slot. A buffered channel's capacity is how many values it can hold before a send must wait. An unbuffered channel (capacity 0) blocks on the very first send until a receiver is ready. Overrunning an undrained buffer is a common deadlock cause.

Why:

A buffered channel with capacity 2 accepts two sends without a receiver — they fill the buffer. The third send blocks because the buffer is full and there is no receiver to free a slot. Capacity is the number of values the channel can hold before a send must wait. An unbuffered channel (capacity 0) is the case where every send blocks until a receiver is ready (option b). Misjudging buffer capacity is a classic cause of deadlocks (fatal error: all goroutines are asleep - deadlock!) when a producer overruns a buffer no one is draining.

Go/go-errors/error-wrapping

A function returns fmt.Errorf("loading config: %w", os.ErrNotExist). A caller wants to detect the underlying os.ErrNotExist. What is the correct check?

Options

  • errors.Is(err, os.ErrNotExist) — it unwraps the chain and compares each layer
  • err == os.ErrNotExist — direct equality on the returned error
  • err.Error() == os.ErrNotExist.Error() — compare the message strings
  • errors.As(err, os.ErrNotExist) — extract the sentinel value
Show answer

Use errors.Is(err, os.ErrNotExist). Because the error was wrapped with %w, it forms a chain, and errors.Is unwraps and compares each layer against the target, matching the wrapped sentinel. Direct == fails — err is the new wrapper, not the sentinel. errors.As is for extracting a concrete error type into a variable, not for matching a sentinel value.

Why:

The %w verb wraps the original error, building a chain that errors.Is walks layer by layer, comparing each against the target — so errors.Is(err, os.ErrNotExist) correctly matches even though err is the wrapping error, not the sentinel itself. Direct == (b) fails because err is the new *fmt.wrapError, not os.ErrNotExist. String comparison (c) is brittle and breaks the moment a message changes. errors.As (d) is for extracting a concrete error type into a variable via a pointer, not for matching a sentinel value, and its second argument must be a pointer to a target — passing the sentinel directly won't compile. Using == on wrapped errors is the bug that makes "not found" handling silently stop working after someone adds context with %w.

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

  • A slice: var s []int
  • A map: var m map[string]int
  • A pointer: var p *int
  • An int: var n int
  • A struct: var t time.Time
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.

Why:

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-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.

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.

Why:

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-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

  • [1 2 99 4] [1 2 99]
  • [1 2 3 4] [1 2 99]
  • [1 2 3 4] [1 2 3 99]
  • [1 2 99 4] [1 2 99 4]
Show answer
[1 2 99 4]
[1 2 99]
Why:

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/goroutines

Assume the module declares go 1.22. What does this program print? (Each goroutine writes a distinct index, so there is no data race.)

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	out := make([]int, 3)
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			out[i] = i * i
		}()
	}
	wg.Wait()
	fmt.Println(out)
}

Options

  • [0 1 4]
  • [4 4 4]
  • [0 1 2]
  • panic: index out of range [3]
Show answer
[0 1 4]
Why:

In Go 1.22+ the loop variable i is per-iteration, so each goroutine's closure captures its own i. wg.Wait() guarantees all three goroutines finish before the print, and because each writes a different index there is no race, making the output deterministic: out[0]=0*0=0, out[1]=1*1=1, out[2]=2*2=4, i.e. [0 1 4]. Option b ([4 4 4]) is the pre-1.22 result, where a single shared i had already reached its final value 2 by the time the goroutines ran (and 2*2=4). The version pinned in go.mod decides which behavior you get, so the same source can produce either output across Go versions — a real hazard when upgrading or copying old code.

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

  • 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.
  • defer wg.Done() should be wg.Done() without defer, because deferred calls don't run in goroutines
  • The WaitGroup must be passed as a pointer to the goroutine or each goroutine gets its own copy
  • process(t) should run on the main goroutine, not in a separate goroutine
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.

Why:

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-types/interfaces

Explain why a function returning a *MyError that is nil, declared to return the error interface, can make if err != nil unexpectedly true. How do you avoid it?

Show answer

An interface value is a (type, value) pair, and it equals nil only when BOTH the type and the value are nil. When you assign a nil *MyError pointer to an error return, the interface captures the concrete type *MyError with a nil value — so the interface itself is non-nil (its type word is set), even though the pointer inside is nil. The caller's err != nil is therefore true, and they may then call a method that dereferences the nil pointer and panics. This is the classic 'typed nil' trap. The fix is to return a literal nil (the untyped nil) on the success path rather than returning a nil-valued concrete pointer: declare the function to return error and return nil directly, or check the pointer and return a bare nil instead of the typed nil pointer.

Why:

An interface value holds a (type, value) pair and is nil only when both halves are nil. Returning a nil-valued *MyError into an error sets the type half, so the interface is non-nil despite the nil pointer — err != nil is true and a later method call can panic. The fix is to return the untyped nil on success, never a typed nil pointer. This 'typed nil' bug is a senior-level favorite because the code looks obviously correct; it surfaces in production as panics in error-handling paths that 'should never run'.

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.

Why:

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-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.

Why:

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-concurrency/select-statement

Design a concurrent worker pool in Go that processes a stream of jobs with bounded parallelism. Requirements Process up to N jobs concurrently (a fixed number of workers), never more. Collect each job's result (or error) back on the caller's side. Support cancellation: if the caller's context.Context is cancelled, in-flight work stops promptly and no goroutines leak. The whole thing must terminate cleanly — no deadlocks, no goroutines left running. Walk through the channels and goroutines you'd use, how you bound concurrency, how results flow back, how cancellation propagates, and how you guarantee every goroutine exits. Call out the concurrency hazards (deadlock, goroutine leak, sending on a closed channel) and how your design avoids each.

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.

Why:

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.

Go/go-language/defer

What does this Go program print, one value per line?

package main

import "fmt"

func main() {
	for i := 0; i < 3; i++ {
		defer fmt.Println(i)
	}
	fmt.Println("done")
}

Options

  • done 2 1 0
  • done 0 1 2
  • 0 1 2 done
  • done 3 3 3
Show answer
done
2
1
0
Why:

Two rules combine here. First, deferred-call arguments are evaluated immediately, so each defer fmt.Println(i) snapshots the current i — capturing 0, 1, then 2. Second, deferred calls run in last-in-first-out order when main returns, so they fire as 2, 1, 0 — after "done", which prints during normal execution. The result is done, 2, 1, 0. Option d (3 3 3) is the answer you'd get if defer captured the variable by reference and ran after the loop — but argument snapshotting prevents that, and in Go 1.22+ each i is per-iteration anyway. LIFO ordering matters in real code when stacking defer file.Close() / defer mu.Unlock() calls that must unwind in reverse.

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.

Why:

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.

Related interview questions

Job market

See go salaries and hiring demand from live job postings.

Practice this for real

CodePrep turns your target job description into an adaptive quiz from a bank of tagged questions, scores your answers, and resurfaces the topics you miss.

New topics and job-market signal, in your inbox

Occasional updates — new question topics, launch news, and what the developer job market is hiring for. Confirm your email to join, and unsubscribe anytime.