Free Handbook · Every example compiled & verified

Interview Questions

Sixty Go interview questions — 25 junior, 25 mid-level, 10 senior — with model answers, what each is really testing, a coding round and a take-home checklist.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 15 · what you'll be able to do

  • Answer the 25 junior questions on types, slices, maps, structs, interfaces, errors and goroutines that every first screen draws from
  • Explain slice and map internals, the nil-interface trap, channels, select, context, generics and the GC at mid-level depth
  • Reason through senior questions on service layout, graceful shutdown, observability, profiling and API design
  • Run a live coding round in Go with a repeatable script, and hand in a take-home that reviewers approve
01

How to use this module

Go interviews follow a stable pattern: a screen on the language itself (types, slices, maps, interfaces, errors), a technical round that goes deep on concurrency — goroutines, channels, select, context, races — and, for experienced roles, a conversation about running Go services in production. The questions below are grouped the same way, and each answer links back to the module that teaches it.

  • Say the answer out loud before opening it. Recognising an answer when you read it is not the same as producing it under pressure.
  • Read the "what they are really testing" line. It tells you where the follow-up question will go, which is where most candidates lose points.
  • Back every claim with code you have run. The modules this draws on — Arrays, Slices & Maps, Interfaces, Goroutines & Channels — have examples you can paste into main.go.
Version matters in Go interviews too
Go changes slowly but it does change: per-iteration loop variables (1.22), range-over-func iterators (1.23), Swiss-table maps and b.Loop (1.24), container-aware GOMAXPROCS and WaitGroup.Go (1.25), errors.AsType and new(expr) (1.26), methods with their own type parameters (1.27). Say which version a behaviour arrived in — many production services are a few releases behind, and it shows you know the difference.
02

Junior: language basics and types — 9 questions

Asked in nearly every first-round Go screen. Short questions with precise answers; a vague answer here ends the interview early.

JuniorWhat kind of language is Go, and why do teams pick it?

Go is a statically typed, compiled language with garbage collection, created at Google and released in 2009. go build produces a single native binary with no runtime to install, it compiles in seconds, and concurrency is built into the language with goroutines and channels. The language is deliberately small — one loop keyword, no inheritance, no exceptions — so large teams read each other's code easily, and gofmt ends style arguments. That combination is why cloud infrastructure (Docker, Kubernetes, Terraform, Prometheus) and many backend services are written in it.

What they are really testing: Whether they can name concrete reasons (static binary, fast builds, goroutines, simplicity) rather than "it is fast".

JuniorWhat is the difference between var x int = 1, var x = 1 and x := 1?

All three declare an int named x. var with a type is explicit; var without one infers the type; := is the short form that declares and assigns with inference. := only works inside functions, and it needs at least one new variable on the left. The trap is shadowing: x, err := f() inside an if or loop block creates a new x that hides the outer one, so the outer variable is never updated. var is still used at package level and when you want the zero value (var buf bytes.Buffer).

What they are really testing: That they know := is function-scope only and can explain the shadowing bug it causes.

JuniorWhat is a zero value, and why does Go have them?

Every variable in Go is initialised: 0 for numbers, "" for strings, false for booleans, and nil for pointers, slices, maps, channels, functions and interfaces. There is no "uninitialised memory" bug class. Good Go types are designed so the zero value is useful: a zero sync.Mutex is unlocked, a zero bytes.Buffer is empty and ready, a nil slice can be appended to. The exception to remember: reading a nil map returns zero values, but writing to a nil map panics — it must be created with make or a literal first.

What they are really testing: "Make the zero value useful" is a Go proverb; the nil-map write panic is the follow-up.

JuniorArrays or slices — what is the difference?

An array has a fixed length that is part of its type: [3]int and [4]int are different types, and assigning or passing an array copies every element. A slice ([]int) is a small descriptor — a pointer to an underlying array, a length and a capacity — that can grow with append. Passing a slice copies only the descriptor, so the callee sees the same elements. Almost all Go code uses slices; arrays appear for fixed-size data such as a SHA-256 digest ([32]byte) or as map keys. See Module 04.

What they are really testing: Value semantics of arrays versus the shared backing array of slices.

JuniorWhat are string, byte and rune, and what does len("héllo") return?

A string is an immutable sequence of bytes, conventionally UTF-8. A byte is an alias for uint8; a rune is an alias for int32 and holds one Unicode code point. len("héllo") is 6, because é takes two bytes in UTF-8. Indexing s[i] gives a byte; for i, r := range s decodes runes (and i jumps by 2 at the é). To count characters use utf8.RuneCountInString(s); to change characters convert to []rune and back.

What they are really testing: The single most common Go string bug is treating bytes as characters. They want the number 6 and the reason.

JuniorHow does Go decide what is public and private?

By the first letter of the name. An identifier starting with an upper-case letter (User, ParseConfig, a field Name) is exported and visible to other packages; lower-case (user, parse) is visible only inside its own package. There are no public/private keywords and no per-class privacy — the unit of encapsulation is the package. A directory named internal/ adds one more level: packages under it can only be imported by code rooted at its parent. See Module 09.

What they are really testing: That privacy is per package, not per type — a common surprise for Java and C# developers.

JuniorWhat does defer do, and in what order do deferred calls run?

defer f() schedules f to run when the surrounding function returns — normally or by panic. Multiple defers run in last-in, first-out order, like a stack. The arguments are evaluated at the moment of the defer statement, not when the call runs:

for i := range 3 {
    defer fmt.Print(i)
}
// prints 210
It is the standard way to pair cleanup with acquisition: f, err := os.Open(p) then defer f.Close(); mu.Lock() then defer mu.Unlock(). A defer inside a long loop only runs when the function ends, so move the loop body into its own function. See Module 03.

What they are really testing: LIFO order, argument evaluation time, and the defer-in-a-loop resource leak.

JuniorWhy do Go functions return multiple values, and what is the "comma ok" idiom?

Go has no exceptions for ordinary failures, so a function that can fail returns its result and an error: n, err := strconv.Atoi(s). The caller checks err != nil right away. The comma-ok form is the same idea for "was it there?": v, ok := m[key] for maps, v, ok := x.(T) for type assertions and v, ok := <-ch for channel receives (ok is false once the channel is closed and drained). It distinguishes "missing" from "present with the zero value".

What they are really testing: Whether they know why ok exists: a map lookup for a missing key returns 0, which may be a real value.

JuniorWhat do go run, go build, go fmt, go vet and go test do?

go run main.go compiles to a temporary binary and runs it — for quick experiments. go build produces the binary you ship. go fmt (really gofmt) rewrites code into the one standard layout. go vet runs static checks for likely bugs that still compile: wrong Printf verbs, copied mutexes, unreachable code, a discarded context cancel function. go test ./... runs every _test.go file in the module; add -race to find data races and -cover for coverage. All of it ships with the toolchain — no plugins needed.

What they are really testing: Fluency with the built-in toolchain, which Go teams rely on instead of third-party build tools.

03

Junior: slices and maps — 5 questions

Slices and maps are most of the data handling in any Go program, and each has one behaviour that surprises newcomers. Interviewers go straight for it.

JuniorWhat are len and cap of a slice, and what does append do when capacity runs out?

len is how many elements the slice currently shows; cap is how many the underlying array can hold from the slice's start. append writes into spare capacity if there is some. If there is none, it allocates a new, larger array, copies the elements, and returns a slice pointing at it — which is why you must always write s = append(s, x). When you know the final size, preallocate: make([]T, 0, n) avoids repeated copying. See Module 04.

What they are really testing: That append may return a different backing array, so ignoring its result is a bug.

JuniorWhat is the difference between make and new?

new(T) allocates a zeroed T and returns a *T; it works for any type. Since Go 1.26 it also accepts an expression, so new(42) is an *int pointing at 42. make is only for the three built-in reference types — slices, maps and channels — and returns an initialised value (not a pointer) that is ready to use: make([]int, 0, 10), make(map[string]int), make(chan int, 5). In practice new is rare; a composite literal like &User{Name: "a"} is the normal way to get a pointer to a new struct.

What they are really testing: That make initialises the internal structure (a map must be made before writing) while new just zeroes memory.

JuniorWhat happens when you read a missing key from a map, and how do you tell it apart from a stored zero?

A lookup of a missing key returns the value type's zero value — 0, "", nil — and never panics. To know whether the key was present, use the two-value form:

age, ok := ages["zoe"]
if !ok {
    // not in the map
}
The zero-value behaviour is also a feature: counts[word]++ works on a new word because the missing entry reads as 0. Writing to a nil map, though, panics with assignment to entry in nil map.

What they are really testing: The comma-ok lookup, and whether they have hit the nil-map panic.

JuniorIs iterating over a Go map ordered?

No. The order is unspecified, and the runtime deliberately randomises it so that code cannot come to depend on it — the same program can print a map in a different order on each run. When order matters, collect and sort the keys: for _, k := range slices.Sorted(maps.Keys(m)) { … } (Go 1.23+). fmt.Println(m) prints maps with sorted keys, which hides the problem in quick experiments but not in a range loop. See Module 04.

What they are really testing: A classic source of flaky tests; they want "randomised on purpose" and the sort-the-keys fix.

JuniorCan you delete from a map while ranging over it?

Yes. The specification allows delete(m, k) during a range over m: an entry removed before it is reached will not be produced. Adding entries during iteration is also allowed, but whether the new entries are visited is unspecified. clear(m) (Go 1.21+) removes everything at once. What is not allowed is modifying a map from several goroutines without a lock — the runtime detects it and aborts with fatal error: concurrent map writes.

What they are really testing: Knowing the language rule (unlike Java's ConcurrentModificationException) and the separate concurrency rule.

04

Junior: structs, interfaces and errors — 8 questions

Go has no classes, no inheritance and no exceptions. These questions check that you use what it has instead — structs, methods, implicit interfaces and errors as values — rather than imitating another language.

JuniorGo has no classes. How do you model an object?

With a struct for the data and methods declared on it: func (a *Account) Deposit(n int). Methods can be attached to any named type in the same package, not only structs (type Celsius float64 can have a String() method). A constructor is just a function by convention named NewAccount. There is no inheritance; reuse comes from composition (embedding one struct in another) and polymorphism from interfaces. See Module 05.

What they are really testing: That they reach for composition and interfaces, not a Go imitation of class hierarchies.

JuniorWhen do you use a value receiver and when a pointer receiver?

Use a pointer receiver (func (c *Counter) Inc()) when the method must modify the receiver, when the struct is large enough that copying it on each call is wasteful, or when it contains something that must not be copied such as a sync.Mutex. Use a value receiver for small, immutable-style types (time.Time, a Point). A value receiver works on a copy, so a "setter" with a value receiver silently changes nothing. Keep a type consistent: if any method needs a pointer receiver, give them all one. See Module 05.

What they are really testing: The silent no-op setter bug, and the consistency rule.

JuniorHow does a type implement an interface in Go?

Implicitly. If a type has all the methods an interface lists, it satisfies it — there is no implements keyword. os.File, bytes.Buffer and strings.Builder all satisfy io.Writer because each has Write([]byte) (int, error). This means the consumer can define a small interface for just what it needs, and existing types from other packages satisfy it without changes. To assert at compile time that a type implements one, write var _ io.Writer = (*MyType)(nil). See Module 06.

What they are really testing: Implicit satisfaction and the idea that interfaces belong to the consumer.

JuniorWhat is any, and how do you get a concrete value back out of it?

any is an alias for interface{}, the interface with no methods, so every type satisfies it. To use the value you must recover its type. A type assertion n, ok := v.(int) checks one type (without ok, a wrong guess panics). A type switch handles several:

switch x := v.(type) {
case int:    fmt.Println("int", x+1)
case string: fmt.Println("string", len(x))
default:     fmt.Println("other")
}
Overusing any throws away compile-time checking; since Go 1.18, generics are usually the better tool for "works with many types".

What they are really testing: The comma-ok assertion versus the panicking one, and knowing when generics replace any.

JuniorWhat is struct embedding, and how is it different from inheritance?

Embedding puts a type inside a struct without a field name: type Admin struct { User; Level int }. The fields and methods of User are promoted, so a.Name and a.Greet() work. But an Admin is not a User: you cannot pass an Admin where a User is expected, and when a promoted method runs, its receiver is the inner User — there is no virtual dispatch back to Admin. It is composition with convenient syntax. See Module 05.

What they are really testing: Whether they understand there is no "is-a" relationship and no overriding.

JuniorHow does Go handle errors, and why no exceptions?

Errors are ordinary values of the built-in error interface (one method, Error() string), returned as the last result and checked immediately: if err != nil { return fmt.Errorf("load config: %w", err) }. The reasoning is that failure is a normal outcome of I/O and parsing, so it should be visible in the code path rather than jumping invisibly up the stack. The cost is verbosity; the benefit is that every function call that can fail is visibly handled, and adding context at each level produces readable messages like load config: open app.yaml: no such file or directory. See Module 07.

What they are really testing: Not whether they like it — whether they add context when returning an error instead of just "return err".

JuniorWhen should code panic instead of returning an error?

Almost never in library or request-handling code. A panic is for programmer errors and impossible states — an index out of range, a nil dereference, a switch that reached a case that cannot happen — and for failures during start-up where continuing makes no sense (a regexp.MustCompile on a constant pattern, a missing required config). Anything a caller could reasonably handle — bad input, a missing file, a timeout — is an error. An unrecovered panic in any goroutine crashes the whole program. See Module 07.

What they are really testing: Judgement: panic for bugs, errors for expected failures.

JuniorWhat is the difference between errors.New and fmt.Errorf with %w?

errors.New("not found") creates a fixed error, often stored in a package variable as a sentinel (var ErrNotFound = errors.New("not found")). fmt.Errorf("get user %d: %w", id, err) creates a new error with context and wraps the original, so callers can still find it with errors.Is(err, ErrNotFound) or errors.As. Using %v instead of %w keeps the text but breaks the chain. See Module 07.

What they are really testing: That %w preserves the error for errors.Is/As and %v does not.

05

Junior: goroutines and channels — 3 questions

Concurrency is the reason many teams chose Go, so even junior screens ask about it. At this level they want the basic model and the most common mistake.

JuniorWhat is a goroutine, and how is it different from an OS thread?

A goroutine is a function running concurrently, started with go f(). It is managed by the Go runtime, not the OS: it starts with a stack of a few kilobytes that grows as needed, and the scheduler multiplexes many goroutines onto a small number of OS threads (about GOMAXPROCS, which defaults to the number of CPUs available — respecting container CPU limits since Go 1.25). Creating one costs roughly a microsecond, so a server can run hundreds of thousands. There is no handle to kill a goroutine from outside; it stops when its function returns, which is why cancellation goes through context.

What they are really testing: Cheap, runtime-scheduled, growable stacks — and that goroutines cannot be killed externally.

JuniorWhat is a channel, and what is the difference between buffered and unbuffered?

A channel is a typed pipe between goroutines: ch <- v sends, v := <-ch receives. An unbuffered channel (make(chan int)) makes the sender wait until a receiver takes the value — a handoff that also synchronises the two goroutines. A buffered channel (make(chan int, 10)) lets up to 10 sends complete without a receiver; the sender blocks only when the buffer is full. Buffers smooth bursts; they do not fix a design where nobody receives. If every goroutine is blocked, the runtime aborts with fatal error: all goroutines are asleep - deadlock!. See Module 08.

What they are really testing: The synchronisation property of unbuffered channels and when a buffer actually helps.

JuniorWhat happens to running goroutines when main returns, and how do you wait for them?

When main returns, the program exits immediately and every other goroutine is killed mid-work — no cleanup, no deferred calls. To wait, use a sync.WaitGroup:

var wg sync.WaitGroup
for _, url := range urls {
    wg.Go(func() { fetch(url) }) // Go 1.25+: Add(1) + go + Done() in one call
}
wg.Wait()
On older versions it is wg.Add(1) before the go statement and defer wg.Done() inside. Alternatively, collect exactly N results from a channel. time.Sleep is never a correct way to wait. See Module 08.

What they are really testing: That they have seen output disappear because main exited, and know WaitGroup (and its newer Go method).

06

Mid-level: slice, map and string internals — 5 questions

For roles with two to five years of experience. The interviewer wants to know how the built-in types work inside, because that is what lets you predict their failure modes.

Mid-levelExplain the slice header, and why appending to a sub-slice can corrupt the original.

A slice value is three words: a pointer into an array, a length and a capacity. Reslicing (b := a[:2]) creates a new header over the same array, and b inherits the remaining capacity. So append(b, 99) has room and writes into a[2]:

a := []int{1, 2, 3, 4}
b := a[:2]
b = append(b, 99)
fmt.Println(a) // [1 2 99 4]
The fix is the full slice expression a[:2:2], which caps b's capacity so append must allocate, or an explicit copy with slices.Clone. The same aliasing is behind "every result is the same" bugs in backtracking. See Module 04.

What they are really testing: Whether they can predict aliasing from the header model rather than memorising symptoms. Mentioning a[lo:hi:max] is a strong signal.

Mid-levelHow does append grow a slice, and why does it matter?

When capacity runs out, the runtime allocates a bigger array: roughly double for small slices, tapering towards about 1.25× growth for large ones, then rounded up to a memory size class. The exact formula is an implementation detail and has changed between releases, so never depend on specific capacities. It matters because each growth copies every element and leaves the old array for the GC. Appending n items one by one is still amortised O(1) per append, but preallocating with make([]T, 0, n) when n is known removes the copies and the garbage — a common easy win in a hot path that a profile shows allocating.

What they are really testing: Amortised cost reasoning, and that they would not hard-code capacities in tests.

Mid-levelHow is a Go map implemented, and what are its limits?

A map is a hash table. Since Go 1.24 the runtime uses a Swiss-table design (groups of slots with a small control word per slot, probed in bulk), which made lookups and inserts faster and lowered memory use; before that it was buckets of eight entries with overflow chains. Consequences you can rely on: average O(1) operations, iteration order randomised, keys must be comparable (no slices, maps or functions as keys), you cannot take the address of an element (&m[k] is a compile error, because entries move when the table grows), and a map is not safe for concurrent writes — the runtime aborts with fatal error: concurrent map writes, which cannot be recovered. Use a sync.Mutex around it, or sync.Map for append-mostly caches with disjoint keys.

What they are really testing: Knowing the concurrency rule is fatal (not a panic), and why &m[k] is illegal. The Swiss-table detail shows they follow releases.

Mid-levelWhat changed about for loop variables in Go 1.22?

Before Go 1.22, a for loop had one variable reused across all iterations, so closures and goroutines that captured it all saw the final value:

for _, v := range []string{"a", "b", "c"} {
    go func() { fmt.Println(v) }() // pre-1.22: often "c c c"
}
Since Go 1.22 each iteration gets a fresh variable, so each closure captures its own v and the old v := v workaround is unnecessary. The new semantics apply per module according to the go line in go.mod, so an old module built with a new toolchain keeps the old behaviour until the line is raised. Go 1.22 also added for i := range 10. See Module 03.

What they are really testing: That they know the bug, the fix, and that go.mod's go directive controls it — reviewers still see v := v in older code.

Mid-levelStrings are immutable. How do you build one efficiently, and what do conversions cost?

Concatenating with += in a loop allocates a new string every time: O(n²) bytes copied overall. Use strings.Builder (b.WriteString, then b.String(), which does not copy), or strings.Join for a slice. Converting []byte(s) or string(b) normally copies, because one side is mutable and the other must not change; the compiler removes the copy in some safe cases, such as a map lookup m[string(b)] or a comparison. When profiling shows conversions in a hot path, keep data as []byte end to end (the bytes package mirrors strings).

What they are really testing: Understanding of immutability costs, and knowing strings.Builder by name.

07

Mid-level: interfaces, method sets and errors — 6 questions

Questions about the parts of the type system that produce confusing bugs — and about how you design error handling that other packages depend on.

Mid-levelWhy can a function that returns a nil pointer produce an error that is not nil?

An interface value is a pair: (dynamic type, value). It is nil only when both are nil. Returning a typed nil pointer fills in the type:

func find() error {
    var e *NotFoundError = nil
    return e // interface = (*NotFoundError, nil)
}
fmt.Println(find() == nil) // false
The caller sees a non-nil error and may even call its Error() method on a nil receiver. The rule: functions that return error return a literal nil on success, never a typed pointer variable that happens to be nil. See Module 06.

What they are really testing: The most famous Go gotcha. They want the (type, value) explanation, not just "it is a known bug".

Mid-levelWhat does "accept interfaces, return structs" mean?

Functions should take the smallest interface they need — io.Reader rather than *os.File — so callers can pass a file, a network connection, a bytes.Buffer or a test fake. They should return concrete types, so callers get every method and field, and adding a method later does not break anyone. Interfaces are defined by the consumer, where they are used, and kept to one to three methods; a 20-method interface defined next to its only implementation is a Java habit that makes testing harder, not easier. See Module 06.

What they are really testing: Design taste. Strong candidates mention consumer-side interfaces and small method sets.

Mid-levelWhy does *T satisfy an interface when T does not?

Because of method sets. The method set of T contains only value-receiver methods; the method set of *T contains both value- and pointer-receiver methods. If Save has a pointer receiver, a T stored in an interface could not be modified in place — the interface holds a copy — so the compiler refuses: T does not implement Saver (method Save has pointer receiver). Calling t.Save() directly on an addressable variable still works because Go takes &t for you; that shortcut does not exist for values inside interfaces or maps.

What they are really testing: Whether they understand the reason (copies in interfaces) behind a compile error they have almost certainly seen.

Mid-levelWhen do you use errors.Is, errors.As and errors.AsType?

errors.Is(err, target) walks the wrap chain comparing against a sentinel value: errors.Is(err, fs.ErrNotExist), errors.Is(err, context.DeadlineExceeded). errors.As(err, &target) walks the chain looking for an error of a type and fills the variable so you can read its fields: var pe *fs.PathError; if errors.As(err, &pe) { … pe.Path … }. Go 1.26 added the generic errors.AsType[*fs.PathError](err), which returns (pe, ok) without the pre-declared variable. Never compare with == or match on err.Error() text; both break as soon as someone adds context by wrapping. See Module 07.

What they are really testing: Sentinel versus typed errors, and knowing the current API.

Mid-levelWhen should you wrap an error with %w, and when deliberately not?

Wrap when callers may legitimately need to react to the cause — a not-found, a timeout, a constraint violation. Wrapping makes the inner error part of your API: once callers write errors.Is(err, sql.ErrNoRows) against your function, you can no longer switch databases without breaking them. So at a package boundary, translate implementation errors into your own sentinel (return ErrUserNotFound) or use %v to keep the text but hide the type. errors.Join (Go 1.20+) combines several errors into one that Is/As can search — useful for validation or closing several resources.

What they are really testing: A senior-leaning judgement question: wrapping leaks implementation details.

Mid-levelHow do panic and recover interact with goroutines?

recover() stops a panic only when called directly inside a deferred function in the same goroutine that is panicking. A panic in a goroutine you started cannot be caught by a recover in main or in the parent — it crashes the whole process. That is why long-lived worker goroutines that run untrusted or plugin code wrap their body:

go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("worker panic: %v", r)
        }
    }()
    work()
}()
net/http already recovers panics per request so one bad handler does not take the server down. Fatal runtime errors (concurrent map writes, deadlock, out of memory) cannot be recovered at all.

What they are really testing: The per-goroutine rule is what they are after; many candidates think recover in main protects everything.

08

Mid-level: concurrency, sync and context — 8 questions

The heart of most mid-level Go interviews. Expect follow-ups that ask you to write a worker pool or find the bug in a snippet that leaks goroutines.

Mid-levelHow does select choose a case, and what are default and nil channels for?

select blocks until one of its cases can proceed. If several are ready it picks one at random, so no case starves and you must not rely on order. A default case runs immediately when nothing is ready, turning the select into a non-blocking try-send or try-receive. A case on a nil channel is never ready, which is the idiom for switching a case off — for example, set in = nil once an input channel is closed so the loop keeps serving the others. Timeouts are a case on time.After(d) or, better, ctx.Done(). See Module 08.

What they are really testing: Random choice among ready cases and the nil-channel trick separate users from readers of the tour.

Mid-levelWho should close a channel, and what happens on send or receive after close?

The sender closes, and only when it is sure no more sends will happen; with several senders, a coordinator closes after a WaitGroup says they have all finished. Sending on a closed channel panics; closing twice panics. Receiving from a closed channel never blocks: it drains any buffered values, then returns the zero value with ok == false, which is what ends a for v := range ch loop. Closing is a broadcast signal, not a resource cleanup — a channel nobody closes is garbage-collected normally. See Module 08.

What they are really testing: Ownership of close, and the asymmetry between send-after-close (panic) and receive-after-close (zero, false).

Mid-levelMutex or channel — how do you decide?

"Share memory by communicating" is the default advice, but it is a guideline, not a rule. Use channels to pass ownership of data or to coordinate stages — a pipeline, a worker pool, a stop signal. Use a mutex to protect shared state that many goroutines read and update in place — a cache, a counter, a connection registry; sync.RWMutex when reads vastly outnumber writes. Use sync/atomic types (atomic.Int64) for single counters and flags. A channel used as a lock, or a mutex held while sending on a channel, are both signs the wrong tool was picked. See Module 08.

What they are really testing: Pragmatism. Dogmatic "always channels" answers score lower than a clear rule of thumb.

Mid-levelWhat is a data race, and how do you find one?

A data race is two goroutines accessing the same memory at the same time, at least one of them writing, with no synchronisation (lock, channel operation, atomic) ordering them. The Go memory model gives racy programs no guarantees: you can see torn values, stale reads, or a map corruption crash. Find them with the race detector: go test -race ./... or go run -race. It instruments memory accesses and reports both stacks when a race actually happens during the run, so it needs tests that exercise the concurrent paths; it costs several times the CPU and memory, which is why it runs in CI rather than production. See Module 11.

What they are really testing: That they use -race routinely and know it only finds races on executed paths.

Mid-levelHow do you limit how many goroutines do work at once?

Two standard shapes. A worker pool: start N goroutines that range over a jobs channel and send to a results channel; close jobs when done, and close results after a WaitGroup of the workers finishes. Or a semaphore: a buffered channel of capacity N, where each task does sem <- struct{}{} before working and <-sem after. Either way the goroutine count is bounded, which protects downstream systems (a database with 20 connections) and memory. Add a context so the whole batch can be cancelled, and decide what one failure does to the rest. The golang.org/x/sync/errgroup package packages "run N tasks, cancel on first error, with SetLimit" — it is outside the standard library but very common. See Module 08.

What they are really testing: Bounded concurrency and backpressure. Unbounded "go per item" over a million items is the wrong answer.

Mid-levelWhat is a goroutine leak, and how do you find and prevent one?

A goroutine that can never finish — usually blocked forever sending on a channel nobody reads, receiving from one nobody closes, or waiting on a lock — so its stack and everything it references stay in memory. Symptoms: memory and goroutine count climbing slowly over days. Find it with the goroutine profile (/debug/pprof/goroutine?debug=1 groups goroutines by stack, so thousands parked on the same line stand out) or by checking runtime.NumGoroutine() in tests. Prevent it by giving every goroutine a way out: a select on ctx.Done() next to each blocking send or receive, a buffered result channel of size 1 when the reader may give up early, and a clear owner who closes channels. See Module 08.

What they are really testing: Production experience: they want a diagnosis method (goroutine profile) and a structural fix (context), not "be careful".

Mid-levelWhat is context.Context for, and what are the rules for using it?

It carries a cancellation signal, a deadline and request-scoped values across API boundaries and goroutines. When a client disconnects or a timeout fires, every function holding the context sees ctx.Done() close and can stop work and release resources. The rules: pass it as the first parameter, named ctx; never store it in a struct; never pass nil (use context.TODO()); always defer cancel() after WithCancel/WithTimeout; derive child contexts rather than creating new roots inside a request. In an HTTP handler start from r.Context(). See Module 08.

What they are really testing: Whether they treat context as plumbing for cancellation, and know the conventions reviewers enforce.

Mid-levelWhat should and should not go into context.WithValue?

Only request-scoped data that crosses API boundaries and that intermediate functions do not need to know about: a request ID, a trace span, the authenticated user. Not optional function parameters, not a database handle, not configuration — those belong in explicit parameters or struct fields, where the compiler can check them. Keys must be of an unexported type defined in your package (type ctxKey struct{}) so no other package can collide with them, and you expose typed helpers like UserFrom(ctx) (User, bool) rather than making callers do type assertions.

What they are really testing: Restraint: context values are a well-known source of hidden dependencies.

09

Mid-level: generics, testing, modules and memory — 6 questions

You do not need to tune the garbage collector for a mid-level job, but you need a working model of where values live, how dependencies resolve, and how Go code is tested.

Mid-levelWhen do you use generics, and when an interface?

Use generics when the code is the same for every type and only the element type changes: containers (Set[K comparable]), algorithms (slices.Sort, Map/Filter), and functions that must return the same type they were given. Use an interface when the behaviour differs per type — each shape computes its own Area(). Constraints say what a type parameter must support: any, comparable (usable with == and as a map key), cmp.Ordered (usable with <), or a union with ~ such as ~int | ~float64, where ~int also admits named types like type Score int. If you are writing [T any] and then type-switching on T, you wanted an interface. See Module 10.

What they are really testing: That generics are for algorithms over types, not a replacement for polymorphism, and that they know what ~ means.

Mid-levelCan a Go method declare its own type parameters?

For most of Go's generic history, no: methods could only use the type parameters of their receiver's type, so func (b Box[T]) Map[U any](f func(T) U) Box[U] was rejected and you wrote a top-level Map[T, U any](b Box[T], …) function instead. Go 1.27 accepts methods with their own type parameters. Two limits remain: an interface method still cannot have type parameters (interface method must have no type parameters), and a generic method therefore cannot satisfy a non-generic interface method. So in library APIs that must support older Go versions, or that need interface satisfaction, top-level generic functions are still the portable choice. See Module 10.

What they are really testing: Current knowledge of the language, and whether they know the interface limitation that remains.

Mid-levelHow do you structure tests in Go?

Tests live in _test.go files next to the code, as func TestXxx(t *testing.T). The idiomatic shape is table-driven: a slice of cases, each run as a subtest with t.Run(tc.name, …), so failures name the case and one case can be run with -run. t.Helper() makes failures point at the caller; t.Parallel() runs independent subtests together; t.Cleanup and t.TempDir() handle teardown. Benchmarks use for b.Loop() { … } (Go 1.24+). HTTP handlers are tested with httptest.NewRecorder or httptest.NewServer; dependencies are replaced with small fakes that satisfy a consumer-side interface rather than a mocking framework. Run everything with go test -race -cover ./.... See Module 09.

What they are really testing: Fluency with the standard testing package; heavy mocking frameworks are a mild red flag in Go teams.

Mid-levelHow do Go modules resolve dependency versions?

A module is defined by go.mod: its path, the go language version, and require lines. go.sum records cryptographic hashes so builds are verifiable. Versions are chosen by Minimal Version Selection: for each dependency, Go uses the highest of the minimum versions that any module in the graph requires — never "latest", so builds are reproducible without a lock file. Major versions 2+ change the import path (example.com/lib/v2), so two majors can coexist. go mod tidy syncs requirements with imports; replace points at a local fork; go get [email protected] upgrades one dependency. See Module 09.

What they are really testing: MVS and the /v2 import-path rule are what distinguish real module experience.

Mid-levelWhat is escape analysis, and how do you see its decisions?

The compiler decides whether each value can live on the goroutine's stack (freed for free when the function returns) or must escape to the heap (managed by the GC). A value escapes when it outlives the function — you return a pointer to it, store it in a global or a heap object, capture it in a closure that escapes, or its size is unknown at compile time; converting to an interface often causes it too. See the decisions with go build -gcflags=-m: lines like moved to heap: u or &User{...} escapes to heap. You do not fight escape analysis everywhere — only in hot paths that a CPU or allocation profile has already pointed at.

What they are really testing: The mechanism plus the discipline to measure before optimising.

Mid-levelHow does Go's garbage collector work, and what can you tune?

It is a concurrent, tri-colour mark-and-sweep collector: most marking runs alongside your goroutines, with short stop-the-world pauses typically well under a millisecond. It is non-moving and non-generational. Since Go 1.26 the default marking algorithm is the "Green Tea" design, which scans small objects page by page for better memory locality. Two knobs: GOGC (default 100) sets how much the heap may grow over the live heap before the next cycle — higher means fewer collections and more memory; GOMEMLIMIT (Go 1.19+) sets a soft memory ceiling so the GC works harder as the process nears it, which is what you want in a container with a hard limit. The biggest lever is still allocating less.

What they are really testing: A correct mental model, GOMEMLIMIT for containers, and "reduce allocations" as the first answer.

10

Senior: service design and production — 10 questions

Senior Go interviews assume you have run Go services in production: containers, Kubernetes, Postgres, queues and dashboards. There is no single right answer; the model answers show the shape of a strong one — what you would ask first, what you would measure, and which trade-off you would accept.

SeniorHow would you lay out a production Go HTTP service?

cmd/api/main.go only wires things together: load config from the environment, build dependencies, start the server, handle shutdown. Business code lives under internal/ so no other module can import it, organised by domain (internal/orders, internal/billing) rather than by layer. Handlers are thin: decode and validate, call a service, map errors to status codes. Services depend on small interfaces they define (type OrderStore interface { Get(ctx, id) … }), implemented by a Postgres package and by in-memory fakes in tests. Since Go 1.22 the standard http.ServeMux supports methods and wildcards (mux.HandleFunc("GET /orders/{id}", h), r.PathValue("id")), so many services need no router framework. No package-level mutable state, dependencies passed explicitly, context everywhere.

What they are really testing: Whether their structure keeps business logic testable without HTTP or a database, and whether they avoid framework-first design.

SeniorWalk me through graceful shutdown for a Go service in Kubernetes.

Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds (30 s by default), then SIGKILLs. The service should:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()                      // signal received
ready.Store(false)                // readiness probe now fails
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)      // stop accepting, finish in-flight requests
workers.Wait()                    // drain background jobs
db.Close()
Order matters: fail readiness first so the load balancer stops routing, then Shutdown (which waits for active requests, unlike Close), then stop consumers and flush buffers (Kafka offsets, metrics, logs), then close pools. The shutdown timeout must be shorter than the grace period. Long-lived connections (WebSockets, streaming) are not tracked by Shutdown; register them with RegisterOnShutdown or your own signal. See Module 12.

What they are really testing: The ordering (readiness, drain, flush, close) and the timeout relationship. Dropped requests during deploys are the real-world failure.

SeniorHow do you make a Go service observable?

Three signals, correlated. Logs: structured JSON with log/slog, one line per event, a request ID and trace ID on every line, levels used consistently. Metrics: the RED set per endpoint — rate, errors, duration as a histogram — plus Go runtime metrics (goroutines, heap, GC pauses) and saturation of pools and queues; expose /metrics for Prometheus with labels of bounded cardinality (route templates, not raw URLs or user IDs). Traces: OpenTelemetry spans propagated through context and outgoing HTTP/gRPC headers, so one slow request can be followed across services. Add net/http/pprof on an internal-only port for live profiling. Alert on user-facing symptoms (error rate, p99 latency against an SLO), not on CPU. See Module 12.

What they are really testing: Correlation between signals, cardinality awareness, and symptom-based alerting.

SeniorA Go service's p99 latency doubled after a release. How do you investigate?

Start from data, not guesses. Compare the release diff against dashboards: did the error rate, request mix or a dependency's latency change at the same time? Then profile the running service: a CPU profile (go tool pprof http://host/debug/pprof/profile?seconds=30, then top and the flame graph) for new hot functions; an allocation profile (-sample_index=alloc_space) because more garbage means more GC work and pauses; the mutex and block profiles for contention; and an execution trace (go tool trace) when latency is about scheduling, GC or blocking rather than CPU. Compare with the previous version's profile using pprof -diff_base. Reproduce the hot path in a benchmark with -benchmem, fix, confirm with benchstat. For steady CPU-bound gains, profile-guided optimisation (a default.pgo in the main package) typically gives a few percent for free.

What they are really testing: A method: correlate, profile the right dimension, diff, reproduce, verify. Naming the specific pprof profiles shows real use.

SeniorA Go service in a container keeps getting OOM-killed. What do you look at?

First, is it a leak or a limit problem? Plot go_memstats_heap_inuse_bytes and goroutine count over time. Steady growth that never comes down points to a leak: take two heap profiles (/debug/pprof/heap, -sample_index=inuse_space) an hour apart and diff them, and check the goroutine profile for thousands of goroutines parked on the same line — leaked goroutines hold their buffers. Common causes: unbounded caches or maps, slices that keep a huge backing array alive through a small sub-slice, forgotten timers or tickers, goroutines blocked on channels. If the heap is healthy but the process still exceeds the limit, it is headroom: without GOMEMLIMIT the GC lets the heap grow to twice the live size by default, so set it to roughly 90% of the container limit. Also check GOMAXPROCS — Go 1.25+ respects the container's CPU limit automatically; on older versions a service on a 64-core node thinks it has 64 CPUs.

What they are really testing: Leak versus headroom, heap-profile diffing, and container-aware runtime settings.

SeniorHow do you design a Go package API that other teams will depend on?

Small surface, hard to misuse, easy to evolve. Export as little as possible and keep the rest in internal/. Make the zero value useful, or provide one constructor (New(opts ...Option) with functional options, or a Config struct — options when defaults cover most callers, a struct when there are many required fields). Take context.Context first on anything that does I/O. Return concrete types and let callers define interfaces. Document which errors are part of the contract (exported sentinels or types) and keep everything else opaque. Make types safe for concurrent use or say clearly that they are not. Evolve by adding, never changing: new functions and option types are compatible; changing a signature needs a new major version with a /v2 import path. Write Example tests — they are compiled documentation.

What they are really testing: API stability thinking and Go conventions: context first, concrete returns, semver via import paths.

SeniorHow do you make outbound HTTP calls from Go resilient?

Defaults are dangerous: http.DefaultClient has no timeout, so one hung dependency can pin goroutines forever. Build one shared http.Client per dependency with a Timeout, and pass the request's context (http.NewRequestWithContext) so the caller's deadline propagates. Tune the Transport: MaxIdleConnsPerHost defaults to 2, far too low for a busy service, which causes connection churn. Always drain and close response bodies, or connections cannot be reused. Retry only idempotent requests, with exponential backoff and jitter and an overall budget; honour Retry-After. Protect yourself with concurrency limits or a circuit breaker so a slow dependency degrades one feature instead of exhausting the whole service. Measure dependency latency as its own histogram.

What they are really testing: Knowing the concrete Go defaults that bite (no timeout, 2 idle conns, undrained bodies) plus general resilience patterns.

SeniorYour service consumes a queue and must never fall over under load. How do you design the concurrency?

Make every buffer bounded and every stage able to say no. A fixed pool of workers reads from the queue; the number of in-flight messages is capped (prefetch or a semaphore), so memory is bounded no matter how far behind you fall — the backlog lives in the broker, not in your heap. Each message is processed with a context deadline. Processing is idempotent (dedupe on a message ID or use upserts), because at-least-once delivery will redeliver after crashes and rebalances. Commit offsets or acks only after the work is durable. Failures go to a retry topic with backoff and then a dead-letter queue, never an infinite hot loop. Expose lag, processing duration, and the size of the in-flight set as metrics, and scale on lag. On shutdown: stop fetching, finish in-flight work, commit, exit.

What they are really testing: Backpressure, idempotency and ordered shutdown — the properties that keep a consumer alive in production.

SeniorWhat do you get wrong most often with database/sql in production?

*sql.DB is a connection pool, safe for concurrent use; create one per database at start-up and share it — opening one per request exhausts the database. Set SetMaxOpenConns (the default is unlimited), SetMaxIdleConns and SetConnMaxLifetime so the pool fits the database's connection limit across all replicas of your service and recycles connections behind load balancers. Use the context variants (QueryContext, ExecContext) so cancelled requests stop their queries. Always defer rows.Close() and check rows.Err() after the loop — a forgotten Close leaks a connection until the pool is empty and every request hangs. Keep transactions short and never do network calls inside one. Watch for N+1 query loops. Monitor db.Stats(): WaitCount and WaitDuration growing means the pool is the bottleneck. See Module 12.

What they are really testing: Pool semantics and the rows.Close leak, which is the classic Go database outage.

SeniorREST or gRPC for a new set of Go services?

It depends on who calls them. gRPC suits internal service-to-service traffic: a .proto contract generates typed Go clients and servers, Protobuf is compact and fast, HTTP/2 gives multiplexing and streaming, and deadlines propagate through the call chain automatically. REST with JSON suits public and browser-facing APIs: every client can call it with no code generation, it is easy to debug with curl, and HTTP caching and API gateways understand it. A common answer is both: gRPC inside, a REST or gRPC-Gateway edge outside. Whichever you choose, the harder decisions are the same — versioning and backward compatibility of the contract, error models, pagination, idempotency keys for writes, and timeouts on every hop. I would pick one default for the organisation so teams do not have to decide per service.

What they are really testing: Trade-off reasoning with a recommendation, not a technology preference.

The senior-answer shape
Clarify the goal and constraints, name two options with their costs, pick one and say what would make you change your mind, then say how you would verify it in production. That structure matters more than any single fact.
11

The coding round, walked through

A live coding round is 30–45 minutes on one or two problems in a shared editor, often without autocomplete or gopls. The interviewer grades how you think, communicate and test, not only whether it compiles. Follow the same script every time — the one from Module 14.

  1. 1
    Clarify (2 min)

    Restate the problem. Ask about input size, empty input, duplicates, whether the input is sorted, and what to return when there is no answer. Write the contract as a comment above the function signature.

  2. 2
    Example (1 min)

    Work one small case by hand. It becomes your first line in main.

  3. 3
    Brute force out loud (2 min)

    "For every request, count the same user's requests in the next window — O(n²)." Say it and its cost before improving it.

  4. 4
    Pick the pattern (1 min)

    Map for grouping? Sliding window? Heap? Name it, and why.

  5. 5
    Code (15 min)

    Talk while you type. Use real names, return (value, error) or (value, bool) where it fits, handle the edge cases you listed. Do not fight the language: a slice and a map solve most problems.

  6. 6
    Test (5 min)

    Run your example, then the edges. If you wrote a brute force, keep it and compare the two — finding your own bug scores higher than never having one.

  7. 7
    Complexity and improvements (2 min)

    State time and space, then the better version if there is one.

Here is a typical 30-minute problem solved that way: given request logs sorted by time, return the users who made more than limit requests within any window-second span, sorted by name. The brute force stays in the file as a checker for the fast version.

gomain.go
package main

import (
	"fmt"
	"maps"
	"slices"
)

type Req struct {
	User string
	T    int // seconds; reqs are sorted by T
}

// Contract: flag users with more than limit requests in any span of window seconds
// (t - first < window). Returns names sorted; empty input gives an empty result.
func overLimit(reqs []Req, limit, window int) []string {
	recent := map[string][]int{} // user -> their timestamps inside the current window
	flagged := map[string]bool{}
	for _, r := range reqs {
		q := append(recent[r.User], r.T)
		for r.T-q[0] >= window {
			q = q[1:] // slide: drop timestamps that fell out of the window
		}
		recent[r.User] = q
		if len(q) > limit {
			flagged[r.User] = true
		}
	}
	return slices.Sorted(maps.Keys(flagged)) // never rely on map order
}

// Brute force, O(n^2): kept as a checker for the fast version.
func overLimitBrute(reqs []Req, limit, window int) []string {
	flagged := map[string]bool{}
	for _, a := range reqs {
		count := 0
		for _, b := range reqs {
			if b.User == a.User && b.T >= a.T && b.T-a.T < window {
				count++
			}
		}
		if count > limit {
			flagged[a.User] = true
		}
	}
	return slices.Sorted(maps.Keys(flagged))
}

func main() {
	reqs := []Req{{"ana", 1}, {"bo", 1}, {"ana", 2}, {"ana", 3}, {"bo", 5},
		{"ana", 12}, {"cy", 13}, {"bo", 14}, {"bo", 14}, {"bo", 15}}
	fmt.Println(overLimit(reqs, 2, 10))
	fmt.Println(overLimitBrute(reqs, 2, 10))
	fmt.Println(overLimit(nil, 2, 10))  // empty input
	fmt.Println(overLimit(reqs, 0, 10)) // limit 0: anyone who made a request
	fmt.Println(overLimit(reqs, 3, 10)) // nobody has four in one window
}
Outputcompiled & run with real Go
[ana bo]
[ana bo]
[]
[ana bo cy]
[]

The inner for cannot empty q, because the timestamp just appended is always inside its own window. The fast version is O(n) amortised — each timestamp is appended once and dropped at most once — against O(n²) for the checker.

Your turn

An interviewer asks: "what if the logs arrive unsorted?" Add slices.SortStableFunc(reqs, func(a, b Req) int { return cmp.Compare(a.T, b.T) }) at the top of overLimit, say what it does to the complexity (O(n log n)), and point out that it now reorders the caller's slice — then decide whether to sort a copy.

What loses the round

  • Silence for ten minutes, then a wall of code
  • Printing a map with range and getting a different order on each run
  • Ignoring the result of append, or saving a slice that later gets overwritten
  • "It should work" without running an example
  • Reaching for goroutines in a problem that has no concurrency in it

What wins it

  • Narrating your reasoning, including dead ends
  • A written contract and example before code
  • Testing edge cases — nil input, limit 0, ties — and comparing against a brute force
  • Naming the complexity without being asked
  • "I use a map of small slices here because I only need each user's recent timestamps"
12

Take-home assignment checklist

Go take-homes are usually "build a small HTTP API", "write a CLI that processes this file" or "build a concurrent fetcher". Reviewers open the README, run the build and the tests, then read the code. Go makes the first two steps easy, so reviewers expect them to work first time.

  • It builds and tests with the standard toolchain: go build ./..., go vet ./... and go test -race ./... all pass on a clean machine with only Go installed. The go line in go.mod states the version you used.
  • Formatted: every file through gofmt (your editor does it on save). Unformatted Go is noticed immediately. staticcheck or golangci-lint clean is a bonus.
  • README: what it does, how to build, run and test it in three commands, example requests (curl lines) or example CLI invocations, and the decisions you made — including what you deliberately left out.
  • Layout: main.go or cmd/<name>/main.go only wires things together; logic lives in packages (under internal/) that can be tested without starting a server. No package-level mutable state.
  • Tests: table-driven tests for the core logic, httptest for handlers, the edge cases from the spec. A fake that satisfies a small interface beats a mocking framework.
  • Errors on purpose: wrapped with context (fmt.Errorf("parse %s: %w", name, err)), mapped to the right HTTP status or exit code, never swallowed with _. No panic for bad input.
  • Concurrency, if any, is bounded and cancellable: a worker pool or semaphore rather than one goroutine per item, context passed down, everything waited for before exit, clean under -race.
  • A server shuts down gracefully: signal.NotifyContext plus srv.Shutdown, and an http.Server with read and write timeouts rather than a bare http.ListenAndServe.
  • Optional but noticed: a multi-stage Dockerfile producing a small static image, and a docker-compose.yml if there is a database.
  • Commits and time-box: a handful of meaningful commits, and the time spent stated in the README. One finished extra is fine; five half-done extras are a red flag.
The sentence reviewers want to write
"Builds first time, race detector clean, tests cover the edge cases, and it reads like idiomatic Go." Aim every decision at that sentence. The next module, Job Ready, turns the same standards into a portfolio.

Frequently asked questions

What Go topics are asked most in interviews?
At junior level: slices versus arrays, len and cap, maps and the comma-ok idiom, value versus pointer receivers, implicit interfaces, error handling and basic goroutines, channels and WaitGroup. At mid level: slice aliasing, the nil-interface trap, select, closing channels, mutex versus channels, data races, context and generics. Senior rounds add graceful shutdown, observability, profiling with pprof and service design.
Do Go interviews ask about concurrency even for junior roles?
Usually yes. Concurrency is a main reason teams choose Go, so even first screens ask what a goroutine is, how buffered and unbuffered channels differ, and how to wait for goroutines with sync.WaitGroup. Mid-level rounds expect worker pools, select, context cancellation and the race detector.
Which Go version should I prepare for?
Prepare on a current release and know when key behaviours changed: per-iteration loop variables in Go 1.22, range-over-func iterators in 1.23, Swiss-table maps in 1.24, container-aware GOMAXPROCS and WaitGroup.Go in 1.25. Many production services run a release or two behind, so interviewers value knowing the difference.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.