Interview prep
Go interview questions: goroutines, channels, interfaces, slices and errors
The Go questions interviewers keep asking, answered with short programs we ran on Go 1.24 with the race detector on, plus the follow-up each one invites.
The HIDEA AI team
20 min read

The Go interview questions that come up most are goroutines versus threads, buffered and unbuffered channels, select, sync.WaitGroup and Mutex, the race detector, context cancellation, interfaces and the nil-interface trap, slice capacity, concurrent map access, defer, error wrapping and generics. A strong answer states the rule, shows a short program and names the trade-off. We ran every sample below on Go 1.24.
A version note first. Every program here was run with go run, and the concurrent ones with go run -race as well. The Go 1 compatibility promise means they behave the same on newer releases. Two older changes still shape interview answers: generics arrived in Go 1.18, and since Go 1.22 each loop iteration gets its own copy of the loop variable when the module's go.mod says go 1.22 or later. If you are asked about an older codebase, say which behaviour you are assuming.
What is the difference between a goroutine and an OS thread?
A goroutine is a function running concurrently under the Go runtime rather than directly under the operating system. The runtime multiplexes many goroutines onto a smaller number of OS threads, which is called M:N scheduling. GOMAXPROCS sets how many threads can execute Go code at the same moment, and it defaults to the number of CPUs available to the process.
Why that matters: a goroutine starts with a small stack of a few kilobytes that grows and shrinks as needed, while an OS thread usually reserves a fixed stack of a megabyte or more. Starting a goroutine is a cheap runtime operation rather than a system call, and switching between goroutines happens in user space. That is why a Go server can comfortably run one goroutine per connection with tens of thousands alive at once, where one OS thread per connection would strain the machine.
The follow-ups. Goroutines have no ID you can access and no way to be killed from outside. You ask one to stop through a channel or a context, and it has to cooperate. Since Go 1.14 the scheduler can preempt a long-running goroutine asynchronously, so a tight loop no longer starves the others. And a goroutine blocked forever, say on a channel nobody will ever send to, is a goroutine leak: it holds its stack and everything it references until the process exits.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
squares := make([]int, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
squares[i] = i * i // each goroutine writes its own index
}()
}
wg.Wait()
fmt.Println(squares) // [0 1 4 9 16]
}This program starts five goroutines and waits for them with a sync.WaitGroup. Call Add before starting the goroutine, not inside it, or Wait can return before the counter goes up. Defer Done so it runs however the goroutine returns. Each goroutine writes its own slice index, so there is no data race, and the race detector stayed quiet. The closure uses i directly, which is safe since Go 1.22 because each iteration has its own i. Before 1.22 all five goroutines shared one variable, and the standard fix was to pass i as an argument. Go 1.25 added a wg.Go method that wraps Add and Done for you; we did not run that one, since our toolchain was 1.24.
What is the difference between buffered and unbuffered channels?
A channel passes values between goroutines. An unbuffered channel has no storage. A send blocks until a receiver takes the value, so the send and the receive happen together, which makes the channel a synchronisation point as well as a pipe. A buffered channel has a fixed capacity. Sends block only when the buffer is full, and receives block only when it is empty.
package main
import "fmt"
func main() {
buf := make(chan int, 2) // buffered: sends block only when full
buf <- 1
buf <- 2
fmt.Println(len(buf), cap(buf)) // 2 2
ch := make(chan int) // unbuffered: each send waits for a receiver
go func() {
defer close(ch) // the sender closes, never the receiver
for i := 1; i <= 3; i++ {
ch <- i
}
}()
for v := range ch { // ends when ch is closed and drained
fmt.Print(v, " ")
}
v, ok := <-ch
fmt.Println("\nafter close:", v, ok) // after close: 0 false
}Two sends into the buffered channel succeed with nobody receiving, and len and cap both report 2. A third send in main would block forever, and because no other goroutine could ever receive, the runtime would stop the program with fatal error: all goroutines are asleep - deadlock!. The unbuffered half shows the standard producer pattern. The sender closes the channel when it is done, and range on the receiving side ends after the last value. A receive from a closed, drained channel returns the zero value at once, and the second result, ok, is false.
The channel rules interviewers check, in one list:
- Sending on a closed channel panics. Closing an already closed channel panics. Closing a nil channel panics.
- Receiving from a closed channel never blocks. It returns the zero value and ok is false.
- Sending to or receiving from a nil channel blocks forever. Inside a select that simply disables the case, which is a useful trick for switching cases off.
- Only the sender should close a channel, and only when receivers need to know no more values are coming. A channel that is never closed is still garbage-collected once nothing references it.
The trade-off: a buffer is not a performance fix by default. It decouples a fast producer from a bursty consumer, or lets a goroutine send one result and exit without waiting for anyone. Picking a buffer size just to make a deadlock go away usually moves the bug somewhere harder to see, where it waits for a change in load.
How does select work in Go?
select waits on several channel operations and runs whichever case is ready first. If several are ready at the same moment, it picks one at random, so you cannot use case order as a priority. A default case runs immediately when nothing else is ready, which turns select into a non-blocking check. An empty select, written select {}, blocks forever.
package main
import (
"fmt"
"time"
)
func after(d time.Duration, msg string) <-chan string {
out := make(chan string, 1) // buffered, so the sender never blocks forever
go func() { time.Sleep(d); out <- msg }()
return out
}
func main() {
select {
case msg := <-after(10*time.Millisecond, "fast reply"):
fmt.Println(msg) // fast reply
case <-time.After(50 * time.Millisecond):
fmt.Println("timed out")
}
select {
case msg := <-after(time.Second, "slow reply"):
fmt.Println(msg)
default:
fmt.Println("nothing ready") // default makes select non-blocking
}
}The first select races a reply against a 50 millisecond timer and prints the fast reply. The second has a default, so it moves on at once instead of waiting a full second. Notice the buffer of one inside after. If the timeout wins, the helper goroutine can still send its value into the buffer and exit. With an unbuffered channel it would block forever on that send, which is a goroutine leak, and spotting that is the follow-up interviewers like to ask.
A related detail: time.After creates a new timer each time it is called. Before Go 1.23, a timer was not garbage-collected until it fired, so long timeouts inside a hot loop held memory. Since Go 1.23, timers nothing references can be collected right away. Either way, for a deadline that applies to a whole operation, a context or one reused time.Timer states the intent more clearly.
When do you use sync.Mutex, and what does the race detector do?
A data race happens when two goroutines access the same memory at the same time, at least one of them writes, and nothing orders the two accesses. The result is undefined: lost updates, torn values or crashes. The program below increments a shared counter from many goroutines with no synchronisation at all.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
count := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // read, add, write: not atomic
}()
}
wg.Wait()
fmt.Println(count) // often less than 1000
}count++ is a read, an add and a write. Two goroutines can read the same value and both write back the same result, so increments get lost. Running it with go run -race printed WARNING: DATA RACE with stack traces for both conflicting accesses, and the process exited with status 66. The race detector instruments memory accesses while the program runs. It reports only races that actually happen during that run, so it finds nothing in code your tests never exercise, and it makes the program several times slower and hungrier for memory. Teams therefore run it in tests and CI with go test -race rather than in production.
The fix is a mutex around the shared state:
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func main() {
var c Counter // pass *Counter around: copying a Mutex breaks it
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); c.Inc() }()
}
wg.Wait()
fmt.Println(c.n) // 1000, and go run -race stays quiet
}c.mu.Lock() followed by defer c.mu.Unlock() is the idiom, because the lock is released however the function returns. Keep the mutex next to the data it guards, and never copy a struct that contains one, since the copy gets its own lock that guards nothing. The copylocks check in go vet catches that. sync.RWMutex allows many readers or one writer, and helps when reads far outnumber writes and hold the lock long enough to matter. For a single counter, the sync/atomic package is simpler still: an atomic.Int64 with Add and Load needs no lock.
Follow-up: should you use a mutex or a channel?
The Go proverb share memory by communicating is often heard as always use channels. A more useful rule is this. Use a channel when you pass ownership of data or signal between goroutines, such as handing work to workers or broadcasting a shutdown. Use a mutex when several goroutines touch one piece of state, such as a cache or a counter. A mutex around a map is usually shorter, faster and easier to review than a goroutine that owns the map and serves requests over channels.
How does context cancellation work?
A context.Context carries a cancellation signal, a deadline and request-scoped values across API boundaries. When a context is cancelled or its deadline passes, its Done channel closes, and every goroutine selecting on Done sees it at once. Contexts form a tree, so cancelling a parent cancels every child derived from it.
package main
import (
"context"
"errors"
"fmt"
"time"
)
func produce(ctx context.Context, out chan<- int) error {
for i := 0; ; i++ {
select {
case <-ctx.Done():
return ctx.Err() // stop promptly and report why
case out <- i:
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel() // always call cancel, even on the happy path
out := make(chan int) // nobody reads this, so the first send blocks
err := produce(ctx, out)
fmt.Println(err, errors.Is(err, context.DeadlineExceeded))
// context deadline exceeded true
}produce tries to send on a channel that nobody reads, so without a context it would block forever. With a 30 millisecond timeout, Done closes, select takes that case, and the function returns ctx.Err(), which is context.DeadlineExceeded, as errors.Is confirms. Calling cancel yourself would produce context.Canceled instead.
The conventions interviewers listen for:
- Pass the context as the first parameter, named ctx. Do not store it in a struct.
- Always call the cancel function, usually with defer, or the context's resources are held until its timer fires or its parent is cancelled. The lostcancel check in go vet flags a forgotten cancel.
- Cancellation is cooperative. A goroutine stops only if it checks ctx.Done() or calls something that does, such as an HTTP request built with http.NewRequestWithContext or a database call that takes ctx.
- Use context values only for request-scoped data such as a trace ID, never for optional function parameters.
- If asked what is newer: Go 1.20 added context.WithCancelCause, and Go 1.21 added context.AfterFunc and context.WithoutCancel.
How do interfaces work in Go, and what is implicit satisfaction?
An interface is a set of method signatures. A type satisfies an interface just by having those methods, and there is no implements keyword. That lets you define an interface where it is used, in the consuming package, and have existing types satisfy it without touching them. The common advice is to accept interfaces and return structs, and to keep interfaces small. io.Reader has a single method.
package main
import "fmt"
type Shape interface {
Area() float64
}
type Rect struct{ W, H float64 }
func (r Rect) Area() float64 { return r.W * r.H } // value receiver
type Square struct{ S float64 }
func (s *Square) Area() float64 { return s.S * s.S } // pointer receiver
// Compile-time checks. There is no "implements" keyword.
var _ Shape = Rect{}
var _ Shape = (*Square)(nil) // Square{} alone would not compile
func main() {
for _, s := range []Shape{Rect{W: 2, H: 3}, &Square{S: 4}} {
fmt.Printf("%T %.1f\n", s, s.Area())
}
// main.Rect 6.0
// *main.Square 16.0
}Rect has a value receiver, so both Rect and *Rect satisfy Shape. Square has a pointer receiver, so only *Square does, and assigning a plain Square{} to a Shape fails to compile. That is the method-set rule: the method set of a type T holds its value-receiver methods, and the method set of *T holds both kinds. The var _ Shape lines are compile-time assertions, a common way to make implicit satisfaction explicit and catch a changed method signature at build time.
The follow-ups. The empty interface, spelled any since Go 1.18, holds a value of any type, and you get the concrete type back with a type assertion, v, ok := x.(int), or with a type switch. An assertion without the ok form panics when it fails. Interfaces can embed other interfaces, as io.ReadWriter embeds Reader and Writer.
Why is an interface holding a nil pointer not nil?
An interface value has two parts, a dynamic type and a value. It equals nil only when both are unset. If you store a nil *ValidationErr in an error, the type part is set to *ValidationErr, so the interface is not nil even though the pointer inside it is.
package main
import "fmt"
type ValidationErr struct{ Field string }
func (e *ValidationErr) Error() string { return "invalid " + e.Field }
func validate(name string) error {
var err *ValidationErr // typed nil pointer
if name == "" {
err = &ValidationErr{Field: "name"}
}
return err // BUG: a nil *ValidationErr inside a non-nil interface
}
func main() {
err := validate("ana")
fmt.Println(err == nil) // false
fmt.Printf("%T\n", err) // *main.ValidationErr
}validate("ana") is a valid input, yet the caller sees a non-nil error and treats it as a failure. Printing the type with %T shows that the interface still carries *main.ValidationErr. The fix is to return a literal nil on the success path and never return a typed pointer variable through an error result. The bug shows up most often when a helper returns *MyError instead of error and the caller stores the result in an error variable.
If the interviewer pushes further: calling a method on the typed nil still works when the method does not dereference its receiver, which is why this bug can travel a long way before anything crashes. Fix it at the source, in the function signature, rather than adding special checks in every caller.
What is the difference between an array and a slice in Go?
An array has a fixed length that is part of its type, so [3]int and [4]int are different types. Arrays are values, so assigning or passing one copies every element. A slice is a small header with three fields: a pointer to a backing array, a length and a capacity. Copying a slice copies the header, not the elements, so two slices can share one backing array.
package main
import "fmt"
func main() {
arr := [3]int{1, 2, 3}
cp := arr // arrays are values: this copies all three elements
cp[0] = 9
fmt.Println(arr, cp) // [1 2 3] [9 2 3]
s := make([]int, 3, 4)
a := append(s, 10) // fits in capacity: shares s's backing array
b := append(s, 20) // writes the same slot a is using
fmt.Println(a, b) // [0 0 0 20] [0 0 0 20]
c := append(s[:3:3], 30) // full slice expression caps capacity at 3
c[0] = 7 // c got a new array, so s is untouched
fmt.Println(s, c) // [0 0 0] [7 0 0 30]
}Copying arr gives an independent array. The slice part is the classic trap. s has length 3 and capacity 4, so append(s, 10) writes into the spare slot of the shared backing array and returns a slice that points at it. append(s, 20) writes into the same slot, and both a and b end up as [0 0 0 20]. When append must grow past capacity, it allocates a new array and copies, and from then on the slices are independent. Whether two slices alias therefore depends on spare capacity, which is why this bug comes and goes with input size.
The fixes: assign append's result back to the same variable, as in s = append(s, x), and treat the old slice as stale; use a full slice expression, s[low:high:max], to cap the capacity so the next append must copy, as c does above; or copy explicitly with copy or slices.Clone, which arrived in Go 1.21. Other slice facts worth knowing: a nil slice has length 0 and works with len, append and range. Append is amortised O(1), though the exact growth factor is a runtime detail that has changed between versions. And slicing a small piece out of a huge slice keeps the whole backing array alive until you copy the piece out.
Are Go maps safe for concurrent use?
No. Concurrent reads are fine, but a write at the same time as any other read or write is a data race. The runtime detects some of these cases and stops the program with fatal error: concurrent map writes, which, unlike a panic, cannot be recovered. We saw exactly that message when eight goroutines wrote to one map without a lock. The detection is best-effort, so a run without a crash proves nothing, and the race detector is the real check.
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex
counts = map[string]int{}
wg sync.WaitGroup
)
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counts["hits"]++ // without the lock: fatal error: concurrent map writes
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counts["hits"]) // 50
}A mutex around the map is the default fix, as above, and sync.RWMutex suits read-heavy maps. sync.Map is designed for two specific cases, according to its documentation: keys written once and then read many times, and goroutines working on disjoint sets of keys. For general use, a plain map with a mutex is clearer and keeps type safety, since sync.Map stores keys and values as any. Under heavy contention, the next step is sharding: several maps, each with its own lock, chosen by a hash of the key.
Map follow-ups: iteration order is deliberately randomised, so never depend on it, and sort the keys when you need stable output, for example with slices.Sorted(maps.Keys(m)) on Go 1.23 or later. Reading from a nil map returns zero values, but writing to one panics. Map elements are not addressable, so m[k].field = 1 does not compile when the value is a struct. Store pointers, or read the struct, change it and write it back.
In what order do deferred calls run?
Deferred calls run when the surrounding function returns, in last-in, first-out order. The arguments to a deferred call are evaluated when the defer statement executes, not when the call finally runs.
package main
import "fmt"
func double() (n int) {
defer func() { n *= 2 }() // runs after return sets n to 3
return 3
}
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println("deferred", i) // i is evaluated now
}
fmt.Println("body done, double() =", double())
}The output is body done, double() = 6, followed by deferred 2, deferred 1 and deferred 0. Each Println captured i at the moment its defer statement ran, and the calls unwound in reverse order. double shows the other follow-up: a deferred closure can read and change named result parameters after the return statement has set them, so return 3 becomes 6. That is how people add context to an error on the way out of a function, or turn a panic into an error with recover.
Two practical warnings. A defer inside a loop does not run until the function returns, so opening thousands of files in a loop with defer f.Close() keeps them all open; move the loop body into its own function. And recover only stops a panic when it is called directly inside a deferred function in the goroutine that is panicking. Defer has been cheap since Go 1.14, so performance is no longer a good reason to avoid it.
How do you wrap errors, and when do you use errors.Is versus errors.As?
Errors in Go are ordinary values that implement the error interface. To add context while keeping the original error, wrap it with fmt.Errorf and the %w verb. errors.Is walks the chain looking for a specific value, usually a sentinel such as io.EOF or sql.ErrNoRows. errors.As walks the chain looking for a specific type, and when it finds one it assigns it to your variable so you can read its fields.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid " + e.Field }
func load(id int) error {
if id <= 0 {
return &ValidationError{Field: "id"}
}
return fmt.Errorf("load user %d: %w", id, ErrNotFound)
}
func main() {
err := fmt.Errorf("handler: %w", load(7))
fmt.Println(err) // handler: load user 7: not found
fmt.Println(errors.Is(err, ErrNotFound)) // true
var ve *ValidationError
err = fmt.Errorf("handler: %w", load(0))
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field) // bad field: id
}
}The message gathers context at each layer: handler: load user 7: not found. errors.Is still finds ErrNotFound under two layers of wrapping. errors.As finds the *ValidationError inside the second chain and hands us its Field. Comparing with == would fail in both cases, because the outer error is a different value, which is the reason to use errors.Is for sentinels once any wrapping is involved.
The follow-ups. The %v verb formats the inner error into the message but does not wrap it, so errors.Is cannot see through it. Use %v on purpose when you do not want callers to depend on an internal error. Since Go 1.20, fmt.Errorf accepts several %w verbs and errors.Join combines errors, and Is and As search every branch. Custom error types can define their own Is or Unwrap methods to control matching. The sentinel-versus-type debate comes down to one question: use a sentinel when callers only need to know which error happened, and a type when they need data from it.
How do generics work in Go?
Since Go 1.18, functions and types can take type parameters in square brackets. A constraint is an interface that limits which types are allowed. A constraint can list methods, as ordinary interfaces do, or a type set such as ~int | ~float64, where the tilde means any type whose underlying type is int or float64.
package main
import "fmt"
// A type set: any type whose underlying type is one of these.
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T // the zero value of whatever T is
for _, x := range xs {
total += x
}
return total
}
type Cents int64 // allowed because of the ~ in ~int64
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{0.5, 0.25})) // 0.75
fmt.Println(Sum([]Cents{250, 199})) // 449
}Sum works for int, float64 and Cents, a named type that is allowed because of the tilde in ~int64. The compiler infers T from the argument, so you rarely need to write Sum[int]. The standard library now covers most of what people used to write by hand: the Ordered constraint in the cmp package (Go 1.21), the slices and maps packages, and the built-in min and max functions.
The trade-offs worth stating. Generics fit containers and algorithms that work identically for every type, such as Map, Filter, a set or a heap. When behaviour differs per type, an ordinary interface with methods is still the better tool. Methods cannot declare their own type parameters; only functions and types can. And an interface that contains a type set, such as Number, can only be used as a constraint, not as the type of a variable.
Worked example: a worker pool in Go
This is one of the most common Go live coding tasks: process a stream of jobs with a fixed number of workers and collect the results. Here is an invented example of how a candidate might narrate it, followed by the code.
Example narration (invented): "I'll use an unbuffered jobs channel and three workers that range over it, so each worker exits when jobs is closed. The producer closes jobs after its last send. Results need closing too, but only after every worker has finished, so a separate goroutine waits on the WaitGroup and then closes results. That lets main just range over results. The squares from 1 to 9 add up to 285, so that is what I expect to see."
package main
import (
"fmt"
"sync"
)
func main() {
jobs, results := make(chan int), make(chan int)
var wg sync.WaitGroup
for w := 0; w < 3; w++ { // a fixed number of workers
wg.Add(1)
go func() {
defer wg.Done()
for n := range jobs { // exits when jobs is closed
results <- n * n
}
}()
}
go func() { // producer: send work, then close
defer close(jobs)
for n := 1; n <= 9; n++ {
jobs <- n
}
}()
go func() { wg.Wait(); close(results) }() // close results once all workers finish
sum := 0
for r := range results {
sum += r
}
fmt.Println(sum) // 285
}It printed 285, and the race detector stayed quiet. Three pieces of coordination do the work, and interviewers check each one. The producer closes jobs, which tells the workers to stop. Only the closer goroutine closes results, after wg.Wait returns, because closing it inside a worker would panic as soon as another worker sent. And main reads results while the workers run. If main called wg.Wait before reading, the workers would block sending results nobody reads, and the program would deadlock.
Expect the follow-up about stopping early on the first error. Add a context: each worker selects on ctx.Done() alongside its send, and the first failure calls cancel. The errgroup package in golang.org/x/sync wraps exactly this pattern and can cap the number of goroutines with SetLimit. It is not part of the standard library, so say so if you use it. If the order of results matters, send each job's index along with its result and write into a slice sized in advance.
Common mistakes in Go interviews
- Calling wg.Add inside the goroutine instead of before starting it.
- Closing a channel from the receiving side, or from more than one sender.
- Starting a goroutine with no way for it to stop, then leaking it when the caller gives up.
- Returning a typed nil pointer through an error result.
- Appending to a slice that shares a backing array with another and assuming the two are independent.
- Writing to a map from several goroutines without a lock, and taking the absence of a crash as proof that it is safe.
- Comparing wrapped errors with == instead of errors.Is.
- Copying a struct that contains a sync.Mutex.
- Skipping the race detector, or claiming code is race-free because go test -race passed on paths the tests never reached.
A practice loop for Go rounds
- Write it, then race it.. Run every concurrent solution with go run -race or go test -race before you call it finished.
- State ownership out loud.. For each channel, say who sends, who closes and who receives. For each shared variable, say what guards it.
- Name every exit path.. For each goroutine you start, say how it ends. If you cannot, you have found a leak.
- Change one thing at a time.. Rerun the worker pool with a buffered results channel, with an early error, and with ordered output. Each variant is a likely follow-up.
- Explain it in under a minute.. Practise the two-sentence version of each answer in this guide, then the follow-up, out loud and against a timer.
Pair this loop with the structure in narrating a live coding problem under a timer, and use the two-week technical interview plan to fit Go practice around algorithms and system design.
How HIDEA AI fits Go interview practice
HIDEA AI is a desktop app for macOS and Windows with four separate surfaces: Verbal, Live coding, System design and Project. Go is one of the 18 languages the Live coding surface supports. You capture the problem from your screen, and the response leads with a plain-English approach, then Go code, then time and space complexity. You keep typing in the interviewer's editor, and you speak or type every answer yourself.
The Project surface answers questions about your own code from a local folder you bind at setup, which helps when an interviewer asks you to walk through a Go service you built. Mock interviews are verbal-only, run for 20 or 30 minutes, and are on Pro and Pro+, which suits concept questions such as goroutines versus threads. The Report tab scores a saved session. Fifteen session minutes a day is free, with no credit card; see the pricing page for plan details.
Before using any tool in a live round, check the employer's rules. What some employers publish is a good place to start.
Where to go next
- Read how to use an interview copilot in a live coding round.
- The companion guide in this series: SQL interview questions.
- Other language guides: Java interview questions and Python interview questions.
- See the use cases for the four surfaces, or download the app.
Frequently asked questions
What Go interview questions are asked most often?
The most common are goroutines versus threads, buffered versus unbuffered channels, select, sync.WaitGroup and Mutex, data races and the race detector, context cancellation, interfaces and the nil-interface trap, slice capacity and append aliasing, concurrent map access, defer order, error wrapping with errors.Is and errors.As, and generics. A worker pool is the most common coding task.
What is the difference between a goroutine and a thread?
A goroutine is scheduled by the Go runtime, which multiplexes many goroutines onto a smaller number of OS threads. Goroutines start with a small stack of a few kilobytes that grows as needed, and starting or switching between them is far cheaper than with OS threads, so programs can run tens of thousands of them at once.
When should I use a channel instead of a mutex in Go?
Use a channel to hand data or work from one goroutine to another, or to signal events such as shutdown. Use a mutex when several goroutines read and write one piece of shared state, such as a map or a counter. A mutex around a map is usually simpler and faster than a goroutine that owns the map behind channels.
Why is my Go error not nil when I returned a nil pointer?
An interface is nil only when both its type and value are unset. Returning a nil *MyError through an error result sets the type, so the error compares as non-nil. Return a literal nil on the success path, and avoid declaring helper functions that return a concrete error pointer type.
Which Go version should I prepare for?
Prepare for Go 1.22 or later, where each loop iteration has its own loop variable, and know generics, which arrived in 1.18. Be ready to explain how loop-variable capture worked before 1.22, since older codebases and interview questions still assume it. The samples in this guide were run on Go 1.24.



