Blog · 2026-01-12 · 21 min read

Go Interview Questions

The Go questions that come up repeatedly, goroutines and channels, sync primitives, the race detector, interfaces, slices, errors, context, JSON decoding and profiling.

Go interviews reward precision because the language is small. There is no framework to memorise and no decorator to recall, so the interviewer spends the time on concurrency, interfaces, and error handling, which is where production bugs actually live. Answers below target current Go releases; where behaviour changed by version, the version is named.

What is the difference between a goroutine and an OS thread?

A goroutine is a stack plus a few bookkeeping fields managed by the Go runtime. It starts with a small growable stack, a couple of kilobytes, that the runtime copies to a larger stack on demand. An OS thread is a kernel scheduling entity with a fixed, much larger stack and a system call to create and destroy.

Go uses an M:N scheduler: M goroutines are multiplexed onto N OS threads, where N is roughly the number of CPUs and is bounded by GOMAXPROCS. The runtime holds per-P (per-processor) work-stealing queues, so an idle P can steal work from a busy one instead of the scheduler round-robining globally.

The practical consequence is that "one goroutine per request" or "one goroutine per connection" is affordable. Creating one costs on the order of a couple of kilobytes of stack, which is why servers routinely hold hundreds of thousands. The rule of thumb people quote is that you can have roughly a goroutine per two kilobytes of memory, but do not treat that as a hard figure. The real limits are your memory budget and whether the goroutines do blocking work without handing the P off.

Because goroutines are cheap, blocking is expensive in a different way. A goroutine blocked on a channel parks and its P is released to run something else, which is exactly the behaviour you want. A goroutine spinning without yielding, such as a tight loop over a huge map inside a goroutine that never checks the scheduler, holds its P and causes a latency spike elsewhere in the process.

Channels: direction, closing, and select

A channel is a synchronisation and data-transfer primitive. The arrow in its type declares the permitted direction: chan<- int can only send, <-chan int can only receive. Using directional channel types in function signatures documents intent and lets the compiler catch a mistake that would otherwise show up as a runtime deadlock.

Closing is where interviews usually find people. Three rules:

  1. The sender closes, the receiver observes. Closing notifies receivers that no more values are coming.
  2. Only one goroutine may close. Closing an already-closed channel panics.
  3. Sending on a closed channel panics. Receiving from a closed channel is not a panic; it returns immediately with the zero value and ok == false.
func producer(out chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 3; i++ {
		out <- i
	}
}

func run() {
	ch := make(chan int)
	var wg sync.WaitGroup
	wg.Add(3)
	for i := 0; i < 3; i++ {
		go producer(ch, &wg)
	}
	go func() {
		wg.Wait() // every sender has returned
		close(ch) // so this goroutine owns the close
	}()

	for v := range ch { // range exits when ch is closed and drained
		fmt.Println(v)
	}
}

range ch is the correct receive loop precisely because it handles both termination cases. If you write v := <-ch in a for loop, a closed channel hands you a zero value forever and you spin at full CPU. When you need the closed signal, use v, ok := <-ch.

select blocks until one case is ready, picks pseudo-randomly among ready cases, and can include a default for a non-blocking attempt. The two idioms worth being able to write:

// Cancellation-aware worker.
select {
case <-ctx.Done():
	return ctx.Err()
case job, ok := <-jobs:
	if !ok {
		return nil
	}
	return process(job)
}

// Non-blocking send, used to avoid blocking a producer on a slow consumer.
select {
case out <- v:
default:
	// drop, or count it, or hand it to a queue
}

An unbuffered channel is a synchronous handoff. A buffered channel of size n lets a sender proceed until the buffer is full, which decouples producer and consumer and gives you bounded backpressure. Sizing the buffer is a real decision: too small and you serialise the pipeline, too large and you have hidden latency because work sits in the buffer unseen. If a buffer exists only to stop a sender blocking forever, the design usually wants a context instead.

WaitGroup, Mutex, RWMutex, Once, and errgroup

sync.WaitGroup counts outstanding work. Add before you start, Done in a defer so a panic path still decrements, and Wait where you need the work finished.

func fetchAll(ctx context.Context, ids []int) ([]Item, error) {
	var wg sync.WaitGroup
	items := make([]Item, len(ids))
	errs := make([]error, len(ids))

	for i, id := range ids {
		wg.Add(1)
		go func() {
			defer wg.Done()
			items[i], errs[i] = fetch(ctx, id)
		}()
	}
	wg.Wait()

	for _, err := range errs {
		if err != nil {
			return nil, err
		}
	}
	return items, nil
}

Go 1.22 gave each loop iteration its own variables, so the closure capture above is safe on a modern toolchain. On an older module you would still pass i and id as parameters. Go 1.25 added wg.Go(f) which combines Add and Done, though you cannot take the address of the WaitGroup to pass to a callee that only wants to signal completion.

sync.Mutex protects an invariant over shared state. sync.RWMutex allows concurrent readers but blocks writers, and it is worth saying out loud that for short critical sections an RWMutex is usually slower than a plain Mutex, because every operation pays extra bookkeeping and readers still contend on the same cache lines. Reach for it when read sections are long or genuinely parallel, or when you have measured it.

sync.Once runs a function exactly once, safely, no matter how many goroutines call it. Use it for expensive, idempotent initialisation that has no natural ordering:

var (
	once     sync.Once
	db       *sql.DB
	initErr  error
)

func DB() (*sql.DB, error) {
	once.Do(func() {
		db, initErr = sql.Open("pgx", os.Getenv("DATABASE_URL"))
	})
	return db, initErr
}

If initialisation can fail, capture the error in a package variable and return it on subsequent calls, because Do takes a func() and gives you no return value. Skipping that and discarding the error inside Do means the first caller gets a nil error and a broken handle, and every later caller gets the same broken handle. Go 1.21 added sync.OnceFunc and sync.OnceValue for the common cases.

golang.org/x/sync/errgroup is what you want when a group of goroutines should share a cancellation and stop at the first error. The context is cancelled when any goroutine returns a non-nil error, and Wait returns the first one.

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // bound concurrency; Go 1.20+

for _, url := range urls {
	g.Go(func() error {
		body, err := get(ctx, url)
		if err != nil {
			return fmt.Errorf("get %s: %w", url, err)
		}
		return store(ctx, url, body)
	})
}
if err := g.Wait(); err != nil {
	return fmt.Errorf("fan-out failed: %w", err)
}

SetLimit is the feature people miss: an unbounded fan-out to a thousand URLs will exhaust file descriptors or trip rate limits long before it exhausts CPU.

What does the race detector actually find?

go test -race builds with instrumentation that records every memory access and the happens-before edges between them. If two accesses to the same address conflict, at least one is a write, and there is no happens-before edge between them, it reports a data race. That is a genuinely strong tool, and it should be in CI on every project. It is cheap enough relative to the cost of a production race that skipping it is not a reasonable trade.

What it finds: unsynchronised reads and writes, most concurrent map misuse where the runtime does not fatal first, check-then-act races, and races that the Go memory model says are undefined behaviour.

What it does not find: races in code paths your tests do not exercise, because it is a dynamic analysis and only observes accesses that actually happen; logical errors that are correctly synchronised but semantically wrong, such as two locks taken in opposite orders in different functions, which it will report only as a deadlock if you also run with a short timeout; and races that only appear under a timing window so narrow your test misses them. A clean -race run is evidence, not proof.

Two operational details. The instrumented binary is slower and uses more memory, so never benchmark under -race. And race reporting needs real concurrency to be useful; a test that runs everything sequentially will pass -race while your production code races. Using t.Parallel() in table-driven tests materially improves coverage here.

Memory model, and what sync/atomic does and does not buy you

The Go memory model was made formal in Go 1.19, on top of C11's foundation. The whole of it is: if one goroutine happens before another, the effects of the first are visible to the second; otherwise there is no ordering guarantee whatsoever, and the compiler is free to reorder, cache, or eliminate accesses. Atomic operations in Go are sequentially consistent, so an atomic store that a reader observes through an atomic load is ordered before the reads that follow it.

What this buys you: atomic.Int64.Add is a safe counter across goroutines without a mutex.

var hits atomic.Int64

func record() { hits.Add(1) }

What it does not buy you: an invariant spanning several fields. If you have atomic.Pointer[Config] and you build a new Config, write its fields, then store the pointer atomically, a reader that loads the pointer atomically is guaranteed to see a fully constructed value. That is the correct way to publish immutable data. What you must not do is store the pointer atomically and update a separate plain field non-atomically, or read a plain field while another goroutine writes it, expecting atomicity on the neighbouring field to help. It does not.

Use sync.Mutex when the invariant involves more than one value, and reserve sync/atomic for single-word counters, flags, and pointer publication. The race detector treats atomics as synchronisation, so correct atomic code does not produce false positives.

Interfaces

A type satisfies an interface implicitly. There is no implements clause; the only requirement is that the type's method set contains the interface's methods. Interfaces are satisfied structurally, which means you can define an interface in your own package for a type from someone else's, and the third-party package does not need to know about it.

Keep them small. An interface with one method is trivially implementable by a test double, which is the entire point of having it. io.Reader is the canonical shape:

type Reader interface {
	Read(p []byte) (n int, err error)
}

One method, so anything can be a reader: a file, a buffer, an HTTP body, or a five-line fake in your test. io.ReadWriteCloser with three methods is still reasonable. A dozen methods is not an interface, it is a class you failed to declare.

Follow "accept interfaces, return structs". Accepting an interface lets the caller choose the implementation and lets you substitute a fake in tests. Returning an interface hides the concrete type's capabilities from the caller, prevents you from adding a method to that struct later without breaking every consumer, and pushes error handling onto the caller because an interface cannot express a required method like MustMarshal well.

Define interfaces where they are consumed, not where they are implemented. The database/sql package does this: it accepts interfaces defined in database/sql, and each driver implements them without importing anything.

One trap that comes up repeatedly: an interface value is a pair of type and value, and it is nil only when both are nil. A typed nil pointer inside an interface is not a nil interface.

var buf *bytes.Buffer // nil
var r io.Reader = buf
r == nil // false

This is why a function that returns an interface should never return a nil pointer on the error path; return the untyped nil literal instead.

Slices and the append aliasing trap

A slice header is three words: a pointer to the backing array, a length, and a capacity. An array is a fixed-length value that is copied on assignment and embedded in whatever struct holds it. Slicing an array or a slice creates a new header that shares the same backing array, which is the source of the classic bug.

func main() {
	a := make([]int, 3, 5) // len 3, cap 5
	a[0], a[1], a[2] = 1, 2, 3

	b := append(a, 4) // fits in the spare capacity: shares a's array
	c := append(a, 5) // also fits: overwrites the slot b just used

	fmt.Println(a, b, c) // [1 2 3] [1 2 3 5] [1 2 3 5]
}

b was [1 2 3 4]. Then c's append wrote 5 into index 3 of the shared array, and b changed underneath the caller. This is not a Go bug, it is documented append semantics, but it produces real data corruption when the slices escape into a struct or a cache.

Fixes: allocate the exact capacity you need, use the three-index form a[:3:3] to cap capacity so any append is forced to copy, or clone explicitly. Go 1.21 added the slices package, so slices.Clone(a) and slices.Concat replace most hand-written copy loops.

Remember too that passing a slice to a function passes the header by value. The callee can mutate existing elements and you will see it; if the callee appends, you see the result only when the backing array had spare capacity.

Maps

A map is a reference to a runtime hash table. Reading from a nil map is legal and returns the zero value, range over a nil map does nothing, and delete on a nil map is a no-op. Writing to a nil map panics with "assignment to entry in nil map", which is why you allocate with make before filling, or read into a variable first.

Concurrent use without synchronisation is worse than a race the detector might report. The runtime may detect simultaneous writers and throw fatal error: concurrent map writes, and that fatal error terminates the program. It is not a panic and recover will not catch it. Concurrent read and write is undefined behaviour that may corrupt the hash table instead of failing cleanly.

The fix is a sync.RWMutex around every access, or sync.Map for the narrow cases it is actually tuned for: append-only or disjoint-key workloads with few writes, or a cache that is read far more than written. For a plain counter or configuration map, a mutex is the right answer. For very high write throughput on a general map, a sharded map, one lock per shard keyed by hash, scales better than a single lock.

Defer, panic, and recover

defer schedules a call for when the function returns. Three behaviours to state precisely. Arguments are evaluated when the defer statement executes, not when the function returns, so this captures the value now:

i := 1
defer fmt.Println("deferred i =", i) // prints 1
i = 2

Deferred calls run last-in-first-out. And a defer inside a loop runs when the function returns, not at the end of the iteration, which accumulates open files and locks across iterations. Wrap the body in an immediately invoked closure, or hoist the loop into its own function.

recover only works when called directly from a deferred function of the goroutine that is panicking. It cannot be called from a helper, because the panic unwinds the stack and only deferred frames of the panicking goroutine are candidates. Since Go 1.21, panic(nil) is converted into a non-nil runtime.PanicNilError, so if r := recover(); r != nil behaves as you would expect instead of always seeing nil.

func load(path string) (cfg Config, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("load %s: panic: %v", path, r)
		}
	}()

	cfg = parse(readFile(path))
	return cfg, nil
}

Note that a deferred function can also rewrite named return values, which is how that err gets set. In a server, this belongs in middleware: one recover per request so a panic returns a 500 instead of taking the process down, plus a separate guard in any goroutine you spawn yourself, because middleware cannot see another goroutine's panic.

Errors

The error interface is one method, Error() string. That simplicity is why fmt.Errorf with %w works, and why the standard library can wrap anything:

if err != nil {
	return fmt.Errorf("loading user %d: %w", id, err)
}

%w records the wrapped error so the chain survives. Go 1.20 allows more than one %w in a single call, which produces an error whose Unwrap returns multiple errors.

At the boundary that decides what to do, unwrap and branch:

if errors.Is(err, sql.ErrNoRows) {
	return ErrNotFound
}

var netErr *net.OpError
if errors.As(err, &netErr) && netErr.Timeout() {
	return retryLater(err)
}

errors.Is compares against a chain using equality, and works with sentinel errors. errors.As finds the first error in the chain assignable to your target type and gives you the struct, which is how you reach the fields on a net.OpError, including its Timeout() method, or the byte offset a json.SyntaxError carries. errors.Join combines several errors into one whose Errors() returns them all, and which errors.Is and errors.As search.

Sentinel errors are package-level vars compared by identity. They are cheap and work well for a small closed set of expected conditions. Typed errors carry data and are better when the caller needs the specifics. Use sentinels for "which condition", typed errors for "what happened".

Why if err != nil alone is not enough at a package boundary: a bare equality check tells you only that something failed. Every caller then has to decide what to do, and since it cannot inspect the cause, the only options are string matching, which is fragile and breaks when someone rewords a message, or returning a blanket internal error, which throws away the information needed to map to a status code, decide whether to retry, or alert on a real fault. Wrapping preserves both the machine-readable cause and the human-readable path.

Context

context.Context carries cancellation, deadlines, and request-scoped values. Conventions you should be able to state: pass it as the first parameter, conventionally named ctx; name a derived context parentCtx in the callee; never pass nil, use context.Background() or context.TODO(); and call ctx.Err() or select on ctx.Done() to abandon work early.

func (s *Store) Get(ctx context.Context, id string) (Item, error) {
	rows, err := s.db.QueryContext(ctx, getItemSQL, id)
	if err != nil {
		return Item{}, fmt.Errorf("query item %s: %w", id, err)
	}
	defer rows.Close()

	if !rows.Next() {
		return Item{}, fmt.Errorf("get item %s: %w", id, ErrNotFound)
	}
	return scanItem(rows)
}

Storing a context in a struct field is discouraged. Doing so makes the cancellable scope invisible in the function signature, prevents a type from being used with two different contexts, and is the reason you end up with a struct whose deadline is the oldest request it ever served. The standard exceptions are a request-scoped server type and a test helper, where the context genuinely is the object's whole reason to exist. Context values should carry request-scoped identifiers, never optional parameters and never a logger used for control flow.

Embedding, method sets, and receivers

Embedding promotes fields and methods up one level, which lets a struct satisfy an interface without boilerplate and lets Reader.Read resolve through an embedded io.Reader.

type LoggingReader struct {
	io.Reader
	Name string
}

var _ io.Reader = (*LoggingReader)(nil)

Shadowing rules: a method or field declared directly on the outer type shadows the promoted one at the same depth, and two promoted members of the same name at the same depth are a compile error unless you qualify them explicitly. You can always reach the inner one with l.Reader.Read(p).

Method sets explain several compile errors people hit. The method set of a value type T contains only methods declared with value receivers. The method set of the pointer type to T contains both value-receiver and pointer-receiver methods. So if Count() has a pointer receiver, a plain T does not satisfy your interface and only the pointer to T does, which is why passing a struct value where an interface is expected fails with a non-pointer method.

Choose a receiver style per type and stay consistent, almost always pointer for structs so you do not copy a lock. A method on a nil pointer receiver is perfectly legal, which is a good reason to write methods that check before dereferencing.

Zero values matter

A struct's zero value is fully usable, and Go leans on this heavily. bytes.Buffer{} works, sync.Mutex{} is an unlocked mutex, http.Server{} is a valid server, time.Time{} is the zero instant. This is a deliberate design choice, unlike Java or TypeScript, where you must construct or null-check.

The payoff is that a type can be useful without an initialiser, so var cfg Config behaves sensibly and defaults are zero rather than null. It also means you should avoid designs where the zero value is invalid, such as a slice you must pre-allocate or a mutex you must construct with New, because then every caller must remember something the type system will not enforce.

How would you test Go code?

Table-driven tests with subtests are the default shape:

func TestParseAmount(t *testing.T) {
	tests := []struct {
		name    string
		in      string
		want    int64
		wantErr bool
	}{
		{name: "plain", in: "100", want: 100},
		{name: "negative", in: "-5", want: -5},
		{name: "garbage", in: "10x", wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got, err := parseAmount(tt.in)
			if (err != nil) != tt.wantErr {
				t.Fatalf("parseAmount(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("parseAmount(%q) = %d, want %d", tt.in, got, tt.want)
			}
		})
	}
}

Mark helper functions with t.Helper() so failures report the caller's line. Keep one behaviour per case and name the case after the situation, not the function.

For HTTP handlers, net/http/httptest gives you both levels. httptest.NewRecorder tests a handler without a socket:

req := httptest.NewRequest(http.MethodGet, "/orders?limit=10", nil)
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
	t.Fatalf("status = %d, want 200", rec.Code)
}

httptest.NewServer starts a real server on a random port for tests that need genuine HTTP, including client code and TLS variations.

For benchmarks, report allocations because that is usually the thing that matters:

func BenchmarkParse(b *testing.B) {
	line := []byte(`{"id":1,"amount":100}`)
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		if _, err := parse(line); err != nil {
			b.Fatal(err)
		}
	}
}

b.ReportAllocs() prints allocs and bytes per operation. b.ResetTimer() keeps setup out of the measurement. Go 1.24 added b.Loop(), which handles the loop counter, timer reset, and per-iteration cleanup for you. Use testing.AllocsPerRun when you want an assertion rather than a number to read.

JSON decoding pitfalls

encoding/json is forgiving in ways that cause production bugs. Unknown fields are ignored by default, so a typo in a field name silently produces a zero value with no error:

func decodeStrict(r io.Reader, v any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	if err := dec.Decode(v); err != nil {
		return fmt.Errorf("decode payload: %w", err)
	}
	return nil
}

Only unexported fields are skipped, which is worth stating because it surprises people who expect an exported field with a mismatched tag to fail loudly. Numbers decode into float64 by default and lose precision on large integers; use json.Decoder.UseNumber() or decode large IDs as strings to avoid that. null and a missing field are indistinguishable for most types, so use pointers when "not supplied" means something different from "zero".

Embedded structs behave differently from most serialisers: an embedded struct with no name tag is treated as inlined, and its fields are promoted. Give it a name tag if you want it nested.

type Envelope struct {
	Event string    `json:"event"`
	At    time.Time `json:"at"`
}

type RawEvent struct {
	Envelope                 // fields promoted to the top level
	Data       json.RawMessage `json:"data"`
	Sequence   int64           `json:"sequence"`
}

json.RawMessage is the escape hatch when you need to decode a field's shape later, or pass it through untouched. It captures the raw bytes and implements json.Marshaler and json.Unmarshaler, so you can defer the decision:

func (r RawEvent) DecodeData() (OrderPayload, error) {
	var p OrderPayload
	if err := json.Unmarshal(r.Data, &p); err != nil {
		return OrderPayload{}, fmt.Errorf("decode data of event %q: %w", r.Event, err)
	}
	return p, nil
}

Go 1.21 added encoding/json/v2 behind a GOEXPERIMENT build tag, which rejects unknown fields by default and is a genuine behaviour change, so check what your toolchain and dependencies actually support before planning a migration.

How would you profile a production problem?

Start by knowing whether it is CPU, memory, or blocking, then take the smallest profile that answers the question. The standard library is already wired up:

import (
	"net/http"
	_ "net/http/pprof"
)

That registers /debug/pprof/profile for a CPU profile, /debug/pprof/heap for memory, /debug/pprof/goroutine for stack traces of every goroutine, and block, mutex, and allocs profiles. Bind that listener to localhost only, behind your admin auth. Then:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof http://localhost:6060/debug/pprof/heap

For CPU, the reading habits that matter are the flat cumulative list to find the expensive function, then the call graph to see who calls it, then the source annotated with per-line costs. For memory there is a distinction people get wrong: inuse_space is memory still live at the sample, which finds leaks, while alloc_space is total allocated since start, which finds allocation churn and GC pressure. A service can have modest live memory and brutal GC pressure, and only alloc_space shows it.

The goroutine profile is the leak detector. Compare goroutine counts over time; a rising count with many goroutines parked in the same place is a leak, and the profile tells you the exact call site to fix.

When the binary has no pprof endpoint, use runtime/pprof to write a CPU profile to a file, and runtime/pprof.Do to attach a label so the profile is sliced by request route rather than aggregated. Benchmarks cover micro-level questions. For scheduling and syscall latency, an execution trace from /debug/pprof/trace opened in go tool trace shows goroutine blocking and preemption that a CPU profile cannot.

If you need a rough allocation assertion inside a test rather than a profile, testing.AllocsPerRun returns the average and you can fail the test on regression. And for containerised services, GOMEMLIMIT plus a memory profile is usually more actionable than tuning GOGC blindly.

Related reading: the system design cheat sheet covers the operational tradeoffs behind these choices, and the SQL window functions post covers what you would build once the query layer needs to report on any of it.


Want the full set?

  • 360 questions, every one with a model answer
  • Concurrency + stdlib cheat sheet
  • Interface & error patterns
  • Testing & profiling

Recommended pack

Go Backend Developer Pack

Concurrency with goroutines and channels, interfaces, error handling and idiomatic service structure.

Get this pack Free Frontend pack