Skip to content
Languages

Top 100 Go Interview Questions and Answers

These 100 Go interview questions cover the language first—slices, maps, interfaces, goroutines, channels, context, runtime behavior, errors, and tests—then extend into the production backend concerns Go roles commonly own.

103 min read100 detailed answersReviewed Aug 24, 2026
What to remember

A strong Go answer makes ownership explicit: who can mutate the value, who closes the channel, who cancels the work, who observes failure, and how every goroutine exits.

Question set

100 detailed answers

01

How does a slice differ from an array, and what does a slice look like under the hood?

Short answer: An array is a fixed-size value type whose length is baked into the type itself ([3]int and [4]int are different types), and it is copied wholesale on assignment and when passed around. A slice is a lightweight descriptor over an array: a header of three fields {pointer to backing array, len, cap}. Only the header is copied; the data stays shared.

In depth:

  1. Array[N]T, length is part of the type, the data sits as one contiguous block right where it's declared (on the stack, in a struct, or on the heap) and is copied byte for byte. Pass it to a function and you get a copy of every element.
  2. Slice[]T, three machine words: ptr to an element of the backing array, len (how much is visible) and cap (how much fits before a reallocation).
  3. Slicingarr[1:3] copies no data; it builds a new header pointing into the same array.
s := arr[1:3]

  slice header          backing array [5]int
 ┌──────────┐          ┌───┬───┬───┬───┬───┐
 │ ptr  ────┼────────► │ 0 │ 1 │ 2 │ 3 │ 4 │
 │ len = 2  │              ▲       ▲
 │ cap = 4  │              └─ ptr  └─ len
 └──────────┘

⚠️ Common mistake: thinking s2 := s1 gives you an independent copy. Only the 24-byte header is copied — both slices point at the same backing array, and writing s2[0] = x is visible through s1.

02

What does append do when capacity is exhausted, and why can two slices unexpectedly share one backing array?

Short answer: As long as spare cap remains, append writes into the same backing array and just bumps len — so another slice pointing into that array will see the mutation (aliasing). When cap is exhausted, the runtime allocates a new array, copies the data over, and the slices diverge — the old one points at the previous memory, the new one at the fresh block.

In depth:

  1. Room left (len < cap) — in-place write, shared backing array. The classic bug: b := a[:2]; b = append(b, x) clobbers a[2].
  2. No room (len == cap) — new array, copy, old references never see the new elements.
  3. Growth — for cap < 256 capacity doubles; beyond that a formula smoothly decays from ~2x toward ~1.25x (Go 1.18+, an implementation detail, not a language guarantee).
Writing to a slice with spare cap:

 before: a ─► [1,2,3,_]  cap=4    b := a[:2]  ─► sees [1,2]
 append(b, 9)
 after:  [1,2,9,_]                a[2] became 9 — surprise!

Writing when cap is exhausted:

 before: a ─► [1,2,3]    cap=3
 append(a, 9)
 after:  a ─► [1,2,3,9]  ← NEW array, the old one lives separately

⚠️ Common mistake: passing a sub-slice s[:k] into a function that does append and assuming the original is untouched. While cap remains, append overwrites the original's tail. The guard is the full slice expression s[:k:k], which trims cap down to len.

03

How do you copy a slice so that changes to the copy don't affect the original?

Short answer: Assignment b := a copies only the header — the data stays shared. To get an independent copy you must copy the elements themselves: copy(dst, src) into a pre-allocated slice, append([]T(nil), src...), or slices.Clone(src) (Go 1.21+).

In depth:

  1. copy(dst, src) — copies min(len(dst), len(src)) elements; dst must be pre-allocated with make, otherwise 0 elements are copied.
  2. append([]T(nil), src...) — the idiom for a copy sized exactly right, no manual make.
  3. slices.Clone(src) — the most readable option since Go 1.21.
  4. A related trick — the full slice expression s[a:b:c] — copies no data but trims cap to c-a: someone else's append will move to a new array and leave the original untouched.
// NOT a copy — shared backing array:
b := a

// Copies (changes to b are invisible in a):
b := make([]int, len(a))
copy(b, a)

b := append([]int(nil), a...)

b := slices.Clone(a) // Go 1.21+

⚠️ Common mistake: all of these are shallow copies. If T is a slice, a map, or a struct holding a pointer, the nested data is still shared; you need a manual deep copy.

04

What's the difference between a nil slice and an empty slice, and where does it bite?

Short answer: Both have len == 0 and cap == 0, both can be ranged over and appended to. The difference shows in two places: a nil slice equals nil (a header with a null pointer), an empty one does not; and in encoding/json a nil slice marshals to null, an empty one to [].

In depth:

  1. nil slicevar s []T. Pointer is nil, s == nil is true.
  2. Empty slices := []T{} or make([]T, 0). Pointer to an empty (non-nil) array, s == nil is false.
  3. Practical difference — almost always none: len, range, append behave identically. It bites in JSON contracts and in explicit == nil checks.
nil slice empty slice
Declaration var s []T []T{}
len / cap 0 / 0 0 / 0
s == nil true false
append works yes yes
JSON null []

⚠️ Common mistake: returning a nil slice from an API handler where the frontend expects an array — you get null in JSON instead of [], and the client crashes on .map(...). Initialize with make([]T, 0) when the contract demands an array.

05

Does Go pass arguments by value or by reference? What happens when you pass a slice or a map to a function?

Short answer: In Go everything is passed strictly by value — there are no references. It's just that a slice's "value" is the header {ptr, len, cap}, and a map's is a pointer to the internal hmap structure. That descriptor is copied, but it points at the same data, so element mutations are visible on the outside.

In depth:

  1. The descriptor is copied — the function gets a copy of the slice header / a copy of the map pointer, not the data itself.
  2. Element mutations are visibles[i] = x or m[k] = v change the shared data, and the caller sees them.
  3. Reassigning the header is not visibleappend with a reallocation changes only the local copy of the header; to return the result you reassign the slice: s = append(s, x).
func grow(s []int) {
    s = append(s, 99) // changes the LOCAL header
}
func set(s []int) {
    s[0] = 99         // changes the SHARED data
}

a := []int{1, 2, 3}
grow(a) // a is still [1 2 3]
set(a)  // a became [99 2 3]

⚠️ Common mistake: saying "slices and maps are passed by reference." To an interviewer that's a red flag. The correct framing: the descriptor is copied by value, and it refers to shared data.

06

How is a Go map built internally: buckets, load factor, evacuation?

Short answer: The classic (pre-Go 1.24) implementation is a hash table of buckets holding 8 key/value pairs each; collisions chain into overflow buckets. When average occupancy passes a load factor of ~6.5 elements per bucket, the map doubles in size with incremental evacuation — old buckets migrate into new ones not all at once but in chunks on each write. In Go 1.24 the map was rewritten on top of Swiss Tables.

In depth (classic, pre-1.24):

  1. Bucket — an array of 8 slots; stores the top byte of the hash (tophash) for fast filtering, then keys, then values.
  2. Collisions — if all 8 slots are full, an overflow bucket is chained onto it.
  3. Growth — when count / buckets > 6.5 the bucket count doubles; evacuation is incremental to avoid a long pause.

What changed in Go 1.24 (Swiss Tables):

  1. Groups of 8 with a 64-bit control word (one byte per slot, holding the low 7 bits of the hash) — fast SIMD-like lookup within a group.
  2. Load factor 7/8 (~87.5%) instead of 6.5 — denser, less memory.
  3. No overflow buckets — on collision other groups are probed via quadratic probing; large maps are split across several tables of ≤128 groups.
Classic bucket (pre-1.24):
┌────────────── bucket ──────────────┐    overflow
│ tophash[8] │ keys[8] │ vals[8]     │ ─► ┌───────┐
└────────────────────────────────────┘    │  ...  │
                                          └───────┘

⚠️ Common mistake: quoting the load factor as "exactly 6.5" as an eternal truth — as of Go 1.24 it's 7/8 and a different structure. Be explicit about which Go version you mean.

07

What happens when you read from a nil map? And when you write to it?

Short answer: Reading from a nil map is safe — you get the value type's zero value, and in the comma-ok form ok == false. Writing to a nil map panics: panic: assignment to entry in nil map.

In depth:

  1. Readingv := m[k] returns the zero value, v, ok := m[k] gives ok == false. Ranging over a nil map is zero iterations, also panic-free.
  2. Writingm[k] = v on a nil map crashes the runtime with a panic. The map must be initialized first: m := make(map[K]V) or a literal map[K]V{}.
  3. Contrast with a sliceappend works on a nil slice and returns a new one, but you cannot write directly into a nil map.
var m map[string]int // nil map

n := m["x"]      // 0, no panic
n, ok := m["x"]  // 0, false
for range m {}   // 0 iterations

m["x"] = 1       // panic: assignment to entry in nil map

⚠️ Common mistake: declaring var m map[string]int and writing to it immediately. It reads like working code but crashes at runtime — you need make.

08

Why can't you take the address of a map value (&m[key])?

Short answer: Map values are not addressable: on growth and evacuation the buckets are reallocated and elements physically move to different memory. A pointer to the old spot would dangle, so &m[key] is a compile error: invalid operation: cannot take address of m[key].

In depth:

  1. The reason — the runtime is free to move key/value pairs on resize/evacuation; an address taken earlier would point into the void. The language forbids it at compile time.
  2. Workaround via a pointer value — store map[K]*V; the pointer itself is stable, and V lives separately on the heap.
  3. Workaround via read-modify-write — read the value, mutate the copy, put it back: v := m[k]; v.N++; m[k] = v.
type Counter struct{ N int }
m := map[string]Counter{"a": {}}

// m["a"].N++            // compile error: not addressable
// p := &m["a"]          // compile error

v := m["a"]; v.N++; m["a"] = v   // read-modify-write

mp := map[string]*Counter{"a": {}}
mp["a"].N++              // ok: the pointer is addressable

⚠️ Common mistake: trying m[key].field = x on a struct value. The compiler refuses; either store pointers or reassign the whole struct.

09

Which types can be map keys, and why can't a slice be one?

Short answer: A key can be any comparable type — one for which == is defined. Slices, maps and functions are not comparable (they have no ==, only comparison to nil), so they can't be keys. Structs and arrays qualify as long as all their fields/elements are comparable too.

In depth:

  1. Allowed — numbers, strings, bool, pointers, channels, interfaces, plus structs and arrays of comparable fields.
  2. Not allowed — slice, map, function: they have no ==, only a nil check.
  3. Why — a key must be hashable and equality-comparable; for a slice equality is ambiguous (by pointer? element-wise?), so the language forbids it.
Type Key?
int, string, bool yes
pointer, channel yes
array [N]T (T comparable) yes
struct of comparable fields yes
slice []T no
map, function no

⚠️ Common mistake: using interface{} (or any) as the key type and putting a slice in it. It compiles but panics at runtime: panic: runtime error: hash of unhashable type.

10

What happens on concurrent map writes from multiple goroutines without synchronization?

Short answer: The runtime detects simultaneous access and brings the whole process down: fatal error: concurrent map writes. This is a fatal error, not a panic — it can't be caught with recover, and the program crashes entirely.

In depth:

  1. Not a silent race — the runtime deliberately tracks concurrent writes (a flag in hmap) and intentionally crashes rather than silently corrupt the data structure.
  2. Fatal, not a panicrecover won't save you, defer won't run normally. The -race detector will also catch the data race.
  3. Fixes — a sync.Mutex/sync.RWMutex around access, or sync.Map for "many readers, occasional writes" scenarios.
// Crashes: fatal error: concurrent map writes
m := map[int]int{}
for i := 0; i < 8; i++ {
    go func() { m[i] = i }() // race
}

// Safe:
var mu sync.Mutex
mu.Lock(); m[i] = i; mu.Unlock()

⚠️ Common mistake: saying "you'll just get a wrong value" or that "recover will save you." The interviewer wants to hear "fatal error, the process dies, recover doesn't help."

11

Why is Go map iteration order random?

Short answer: The randomization is intentional: on every range the runtime picks a random starting position. This keeps code from depending on an order that isn't guaranteed anyway (buckets and evacuations change the physical layout).

In depth:

  1. Done deliberately — otherwise developers would implicitly rely on a "stable" order that would then change with a Go version bump or a change in map size.
  2. Order is undefined regardless — insertion doesn't preserve sequence; evacuation on growth reshuffles elements.
  3. When you need a stable order — collect the keys into a slice and sort them.
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Println(k, m[k]) // deterministic order
}

⚠️ Common mistake: writing a test that expects a specific map output order. It will be flaky — failing intermittently. Sort the keys or compare as a set.

12

What does len() return for a Cyrillic string, and how do you count characters correctly?

Short answer: A Go string is an immutable sequence of bytes in UTF-8, and len() counts bytes, not characters. A Cyrillic letter takes 2 bytes, so len("привет") == 12, not 6. Characters (runes) are counted with utf8.RuneCountInString or len([]rune(s)).

In depth:

  1. len(s) — the number of bytes. For ASCII it matches the character count; for multi-byte text it doesn't.
  2. Counting runesutf8.RuneCountInString(s) (no allocation) or len([]rune(s)) (allocates a rune slice).
  3. range over a string — iterates by runes, but the index i is a byte offset (the start position of the rune), not the ordinal character number.
s := "привет"
len(s)                       // 12 — bytes
utf8.RuneCountInString(s)    // 6  — runes
len([]rune(s))               // 6  — runes

for i, r := range s {        // i is a byte index
    fmt.Printf("%d:%c ", i, r)
}

⚠️ Common mistake: s[0] = 'x' won't compile — strings are immutable. And s[0] returns a byte (the first byte), not the first character; for Cyrillic that's half a rune.

13

What's the difference between make and new?

Short answer: new(T) allocates zeroed memory for any type and returns a pointer *T. make works only for slice, map and chan — it doesn't just allocate memory, it initializes the internal structures and returns a ready-to-use value (not a pointer).

In depth:

  1. new(T) — universal, yields *T to a zero value. new(int)*int pointing at 0.
  2. make(T, ...) — slice/map/chan only; sets up the slice header / hmap / hchan. Returns the T itself, not a pointer.
  3. Why — a slice, map or chan is useless in its "zero" form without internal initialization, so they get a dedicated make.
new(T) make(T, ...)
Types any slice, map, chan
Returns *T T
Initialization zeroing internal structures
Example new(int)*int make([]int, 3)[]int

⚠️ Common mistake: new(map[string]int) returns *map[string]int pointing at a nil map. Dereference and write to it and you get panic: assignment to entry in nil map. Maps need make.

14

What are the zero values of a slice, map, channel, pointer, and interface — which are usable without initialization?

Short answer: The zero value of all of these is nil, but they behave differently. A nil slice is usable (len, append), a nil map can be read but not written, a nil channel blocks forever, dereferencing a nil pointer panics, and calling a method on a nil interface panics.

In depth:

  1. Slicenil; len, range, append all work. The friendliest nil.
  2. Mapnil; reads return the zero value, writes panic.
  3. Channelnil; send and receive block forever (used deliberately to "disable" a branch in a select).
  4. Pointernil; dereferencing *ppanic: nil pointer dereference.
  5. Interfacenil; comparing to nil is fine, calling a method panics.
Type zero value Without init
slice nil len/append ok
map nil read ok, write panics
channel nil blocks forever
pointer nil dereference panics
interface nil method call panics

⚠️ Common mistake: lumping it all into "nil is nil." The key nuance: a nil map reads but doesn't write, and a nil channel doesn't panic — it blocks.

15

What is iota and how do you build enums with it?

Short answer: iota is an auto-incrementing counter inside a const block: it's 0 on the block's first line and increases by 1 with each subsequent ConstSpec line. It resets in every new const block. It's the basis for enum-like sets of constants.

In depth:

  1. Basic enum — list the constants and iota fills in 0, 1, 2… automatically.
  2. Skipping a value_ = iota skips 0 (often so the zero value means "unset").
  3. Bit flags1 << iota yields powers of two for masks.
type Weekday int
const (
    Sunday Weekday = iota // 0
    Monday                // 1
    Tuesday               // 2
)

type Perm uint
const (
    Read  Perm = 1 << iota // 1
    Write                  // 2
    Exec                   // 4
)

const (
    _  = iota            // skip 0
    KB = 1 << (10 * iota) // 1024
    MB                    // 1048576
)

⚠️ Common mistake: thinking iota counts constants. It counts ConstSpec lines in the block: if a single line declares several constants with commas, iota is the same for all of them, and the increment happens per line.

16

Why is an interface holding a typed nil pointer not equal to nil?

Short answer: Under the hood an interface is a (type, value) pair, and it equals nil only when both halves are empty. Store a typed nil pointer in it ((*MyErr)(nil)) and the "type" half is set, so iface == nil returns false even though the data inside is nil.

In depth:

  1. The (type, value) pairi == nil is true only when both the type and the data pointer are zero.
  2. Typed nil — the variable var e *MyErr is itself nil, but returning it through error carries its type *MyErr into the interface. Type is set → the interface is not nil.
  3. A classic production bug — a function returns error, the caller writes if err != nil, and the error branch fires on a "success".
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }

func do() error {
    var e *MyErr = nil // typed nil
    return e           // goes into the error interface with its type
}

func main() {
    err := do()
    fmt.Println(err == nil) // false — the trap!
}
clean error(nil)          error with (*MyErr)(nil)
┌──────┬───────┐          ┌─────────┬─────────┐
│ type │ value │          │  *MyErr │   nil   │
│ nil  │  nil  │          │  (set)  │  (data) │
└──────┴───────┘          └─────────┴─────────┘
    == nil ✔                    != nil ✘

⚠️ Common mistake: declaring the error variable with a concrete type (var e *MyErr) and returning it from a function whose signature says error. Keep the variable as var err error or return nil explicitly — a typed nil pointer must never leak into the interface.

17

How are iface and eface represented in the runtime?

Short answer: The empty interface (interface{}/any) is represented by the eface struct of two pointers: to a type descriptor (*_type) and to the data. An interface with methods is iface: a pointer to an itab (type + method table) and a pointer to the data. The itab is built once and cached by the runtime.

In depth:

  • eface{*_type, data unsafe.Pointer}. Knows the concrete type of the value but carries no method table.
  • iface{*itab, data unsafe.Pointer}. Here itab = {inter (interface type), _type (concrete type), hash, fun[...] — method pointers}.
  • itab cache — the runtime builds the itab for an (interface, type) pair once and stores it in a global hash; later assignments reuse it.
  • data — a pointer to the value; a value is usually boxed (a copy on the heap).
eface (any)                iface (interface with methods)
┌────────────┐             ┌────────────┐
│ *_type     │─► type      │ *itab      │─► ┌───────────────┐
├────────────┤             ├────────────┤   │ inter (I type)│
│ data       │─► data      │ data       │   │ _type (T type)│
│ unsafe.Ptr │             │ unsafe.Ptr │   │ hash          │
└────────────┘             └────────────┘   │ fun[0..n] ────│─► methods
                                            └───────────────┘

⚠️ Common mistake: treating any/interface{} as free. Boxing a value into an interface often triggers an allocation and blocks inlining — on a hot path it shows up.

18

How does Go achieve polymorphism without classes and inheritance?

Short answer: Polymorphism comes from implicit (structural) interface satisfaction: a type satisfies an interface automatically if it has the required method set — no implements. Instead of inheritance there is composition via embedding, and encapsulation is driven by the first letter's case.

In depth:

  1. Structural typing (duck typing) — "if it quacks like a duck". Type and interface are never explicitly linked; the link is checked by method signatures at compile time.
  2. Composition over inheritance — embedding promotes the embedded type's methods outward, but that is has-a, not is-a.
  3. Encapsulation — an uppercase name is exported, a lowercase one is private to the package.
type Stringer interface{ String() string }

type Point struct{ X, Y int }

func (p Point) String() string { // Point implements Stringer
    return fmt.Sprintf("(%d,%d)", p.X, p.Y)
} // no "implements Stringer" — matching the method is enough

⚠️ Common mistake: calling embedding "inheritance". There are no subtypes and no virtual dispatch: the outer type does not convert to the embedded one, and an embedded method is not "overridden" and cannot see the outer type's fields.

19

How do you do a type assertion without risking a panic?

Short answer: Use the comma-ok form v, ok := i.(T): on a type mismatch ok == false and v gets the zero value — no panic. To handle several cases, use a type switch. The single-value form i.(T) panics on a mismatch.

In depth:

  1. Single-value form i.(T) — panics if the dynamic type is not T. Only fine when the type is guaranteed.
  2. Comma-ok v, ok := i.(T) — safe; always check ok before using v.
  3. Type switch switch v := i.(type) — the idiom for several types at once.
var i any = "hello"

s := i.(string)     // ok; but i.(int) → panic

s, ok := i.(string) // ok == true,  s == "hello"
n, ok := i.(int)    // ok == false, n == 0 (no panic)

switch v := i.(type) {
case string: fmt.Println("string", v)
case int:    fmt.Println("int", v)
default:     fmt.Println("other type")
}

⚠️ Common mistake: using the single-value v := i.(T) on data whose type is not guaranteed (requests, JSON, plugins). One unexpected type takes the service down with a panic — use comma-ok or a type switch.

20

Value receiver vs pointer receiver: what's the difference, and how does it affect interface satisfaction?

Short answer: A pointer receiver (*T) can mutate the original and avoids copying the struct on each call; a value receiver (T) works on a copy. For interfaces the key point: the method set of *T includes both value and pointer methods, while T's method set holds only value methods. So if a method is declared on *T, a value of type T does not satisfy the interface.

In depth:

  1. Mutation — a pointer method mutates the receiver; a value method edits a copy and the changes are lost.
  2. Copying — a value receiver copies the whole struct on every call; for large structs that is costly.
  3. Method set — decides what satisfies an interface:
Method receiver In T method set In *T method set
func (t T) M()
func (t *T) M()

Hence: if M is declared on *T, then var _ I = T{} won't compile — you need var _ I = &T{}. On a direct call t.M() over an addressable variable Go takes &t for you, but that magic does not apply to interface satisfaction.

⚠️ Common mistake: mixing value and pointer receivers on one type, then wondering why T{} doesn't fit the interface. Pick one receiver kind per type; if there's even one pointer method, people usually make all of them pointer.

21

How does struct embedding work, and what happens when two embedded types have the same method?

Short answer: Embedding includes a type in a struct without a field name; its methods and fields are promoted onto the outer struct. An outer method shadows an embedded one of the same name. If two embedded types carry a same-named method at the same depth, the selector becomes ambiguous, and the compiler complains on any call without an explicit path.

In depth:

  1. Promotiona.Method() calls the embedded type's method when the outer type has none of its own.
  2. Shadowing — an outer-level method/field overrides the embedded one; the shallowest depth wins.
  3. Ambiguous selector — two candidates at the same depth → a compile error; resolve it with an explicit path a.Inner.Method().
type A struct{}
func (A) Hello() string { return "A" }

type B struct{}
func (B) Hello() string { return "B" }

type C struct{ A; B } // both embedded at the same depth

func main() {
    c := C{}
    // c.Hello()     // compile error: ambiguous selector
    _ = c.A.Hello()  // "A" — explicit path
    _ = c.B.Hello()  // "B"
}

⚠️ Common mistake: expecting embedding to give OOP-style overriding. There is no virtual dispatch: if a method of A calls Hello() internally, it calls A.Hello() — not an "overridden" version at the C level.

22

How does a goroutine differ from an OS thread?

Short answer: A goroutine is a lightweight unit of execution managed by the Go runtime, not the OS kernel. Its stack starts at ~2 KB and grows dynamically, while an OS thread reserves megabytes of fixed stack. The runtime multiplexes thousands of goroutines onto a small number of threads in an M:N model, so a single process comfortably hosts hundreds of thousands of goroutines.

In depth:

  1. Stack — goroutine: ~2 KB, grows/shrinks on demand; OS thread: a fixed 1–8 MB reserved up front.
  2. Scheduler — goroutines are scheduled by the Go runtime (the GMP model: G — goroutine, M — OS thread, P — processor context) in user space; threads are scheduled by the kernel.
  3. Switch cost — a goroutine switch never enters the kernel and is orders of magnitude cheaper than a thread context switch.
  4. Scale — threads realistically number in the thousands, goroutines in the hundreds of thousands and beyond.
Criterion Goroutine OS thread
Stack ~2 KB, grows 1–8 MB, fixed
Scheduled by Go runtime (M:N) OS kernel
Switch user space, cheap syscall, expensive
Per process hundreds of thousands thousands

⚠️ Common mistake: answering "it's a lightweight thread" and stopping there. Without mentioning the runtime scheduler and the M:N model the answer sounds junior — the interviewer wants the mechanics.

23

What's the difference between buffered and unbuffered channels?

Short answer: An unbuffered channel hands a value over synchronously: send blocks until another goroutine does a receive, and vice versa. A buffered channel lets you send without a waiting receiver as long as the buffer has room, and blocks send only when the buffer is full (and receive when it's empty).

In depth:

  1. Unbuffered (make(chan T)) — a synchronization point: send and receive meet at the same instant, each side acting as a barrier for the other.
  2. Buffered (make(chan T, N)) — a queue of N elements: the sender can run ahead of the receiver by the buffer size.
  3. When a buffer helps — smoothing load spikes, decoupling producer and consumer pace; but a buffer sized "by feel" hides backpressure problems.
func main() {
    ch := make(chan int) // unbuffered
    ch <- 1              // blocks forever: no receiver
    fmt.Println(<-ch)    // never reached
}
// fatal error: all goroutines are asleep - deadlock!

⚠️ Common mistake: sending to an unbuffered channel in the same goroutine that will later read it. The send blocks until a receiver appears, but the receiver is the next line of the same goroutine, which control never reaches → deadlock.

24

What happens when you send to a closed channel, receive from it, or close it twice?

Short answer: Sending to a closed channel panics. Receiving from a closed channel first returns any values still in the buffer, then the type's zero value with ok == false. Closing the same channel a second time also panics.

In depth:

  1. Send to closedpanic: send on closed channel. The channel should therefore be closed by the goroutine that owns the sending lifecycle and can prove no more values will be sent; a receiver cannot know that safely.
  2. Receive from closed — the buffer drains as usual; once empty, receives immediately return the zero value with ok == false and never block.
  3. Second closepanic: close of closed channel; a double close is a classic symptom of missing single ownership.
  4. Close of a nil channel — also panics (close of nil channel).
Channel operation Open Closed
ch <- v ok / block panic
<-ch ok / block drain buffer, then zero + ok=false
close(ch) closes panic

⚠️ Common mistake: assuming a receive from a closed channel blocks. On the contrary — it returns instantly, so a closed channel in a select loop fires on every iteration unless you nil out its variable.

25

What happens on send/receive with a nil channel, and how is that used in select?

Short answer: Both send and receive on a nil channel block forever. That's not a bug but a tool: by assigning nil to a channel variable you "switch off" its select case — it stops being ready and is never chosen again.

In depth:

  1. Nil-channel semantics<-nilCh and nilCh <- v block forever; close(nilCh) panics.
  2. Why it matters in select — a closed channel is instantly ready in select and fires the zero value endlessly in a loop. To disable an exhausted input, you nil its variable — then the case is never ready.
  3. Fan-in pattern — merge two channels into one, switching off each source's case as it closes.
for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok { a = nil; continue } // disable case a
        out <- v
    case v, ok := <-b:
        if !ok { b = nil; continue } // disable case b
        out <- v
    }
}
close(out)

⚠️ Common mistake: leaving a closed channel in select as is. The case <-closedCh fires on every iteration and spins a busy-loop at 100% CPU instead of serving the remaining sources.

26

How do you detect that a channel is closed when receiving?

Short answer: Use the v, ok := <-ch form: after the channel is closed and the buffer fully drained, ok becomes false. Or use for v := range ch — the loop ends on its own once the channel is closed and empty.

In depth:

  1. Comma-okv, ok := <-ch: while the channel is open or the buffer holds data, ok == true; after close and drain, v is the type's zero value and ok == false.
  2. rangefor v := range ch reads until close and exits with no extra code; the idiomatic consumer form.
  3. Why ok specifically — distinguishing "a genuine zero value was sent" (e.g. 0 or "") from "the channel is closed" is only possible via ok; the value itself is indistinguishable.
// form 1: comma-ok
v, ok := <-ch
if !ok {
    // channel is closed and empty
}

// form 2: range — exits on its own at close(ch)
for v := range ch {
    process(v)
}

⚠️ Common mistake: testing "is the channel closed?" by comparing the value to zero (if v == 0). A real 0 sent down the channel is indistinguishable from the zero value of a closed channel — the only reliable signal is ok.

27

Who should close a channel — the sender or the receiver? Why?

Short answer: The sender should close the channel, and only the sender. close is the signal "no more data is coming," and only the writer has the right to say it. If the receiver closes the channel, the sender gets panic: send on closed channel on its next send.

In depth:

  1. Owner = writer — whoever created and writes to the channel is responsible for closing it; the reader just reads to the end.
  2. Multiple senders — none of them closes the channel directly (a double close → panic). You need a coordinator: a WaitGroup waits for all writers, and a dedicated goroutine performs the single close.
  3. Why close at all — to wake up receivers' range/comma-ok; if the receiver already knows when to stop, you can skip closing (GC will reclaim it).
var wg sync.WaitGroup
for _, w := range workers {
    wg.Add(1)
    go func(w Worker) { defer wg.Done(); w.emit(out) }(w)
}
// the single close — after all senders
go func() { wg.Wait(); close(out) }()
for v := range out { process(v) }

⚠️ Common mistake: closing the channel on the receiver side "to free it." That triggers a panic in any writer and breaks the contract — freeing is the garbage collector's job, not close's.

28

How does select choose when several channels are ready, and what does default change?

Short answer: If several cases are ready at once, select picks one of them pseudo-randomly — a guard against starvation, so one busy channel doesn't starve the others. If no case is ready, select blocks until one becomes ready; a default case makes select non-blocking — with nothing ready it falls through to default immediately.

In depth:

  1. Multiple ready — a uniformly random choice among the ready cases (not in code order).
  2. None ready, no default — blocks until the first one is ready.
  3. default — runs immediately if nothing is ready; this is how non-blocking receive/send and poll loops work.
  4. Timeout — the classic pattern via time.After, which delivers a value once the deadline passes.
select {
case res := <-work:
    return res, nil
case <-time.After(2 * time.Second):
    return nil, errors.New("timeout")
case <-ctx.Done():
    return nil, ctx.Err()
}

⚠️ Common mistake: putting time.After inside a hot loop on every iteration. Before Go 1.23 each such Timer lived on the heap until it fired — at high frequency that was a noticeable leak; since Go 1.23 unfired timers are garbage-collected, but the per-iteration allocation remains, so for repeats use time.NewTimer/NewTicker and reuse them.

29

What is a goroutine leak, and how do you find one in a running service?

Short answer: A goroutine leak is a goroutine blocked forever that never finishes: a send to a channel with no reader, a receive from a channel nobody writes to, a wait with no context cancellation. Such goroutines hold their stacks and captured memory, and their count grows monotonically. You find it via the pprof goroutine profile and the runtime.NumGoroutine() metric.

In depth:

  1. Causes — a blocking operation with no exit path: an abandoned channel, a missing ctx.Done(), a forgotten WaitGroup.
  2. Diagnosisnet/http/pprof gives a goroutine profile with stacks (you see exactly where they're stuck); runtime.NumGoroutine() in metrics shows the monotonic climb.
  3. Prevention — every goroutine must have a guaranteed exit path: ctx.Done(), a done channel, or closing the input channel.
// LEAK: receiver left on timeout, sender hangs on send forever
func leak() <-chan int {
    ch := make(chan int) // unbuffered
    go func() { ch <- expensive() }() // nobody to read → blocks forever
    return ch
}

// FIX: a buffer of 1 lets the sender leave even with no receiver
func fixed() <-chan int {
    ch := make(chan int, 1)
    go func() { ch <- expensive() }()
    return ch
}

⚠️ Common mistake: launching a writer goroutine into an unbuffered channel and returning it to a caller who may leave on timeout. The sender hangs on send forever — a classic leak on every such call.

30

Does a panic in one goroutine crash the whole process?

Short answer: Yes. An unhandled panic in any goroutine unwinds its stack to the top and, if nobody catches it with recover, terminates the entire program. A panic is not isolated to a single goroutine.

In depth:

  1. Scope — the panic climbs its own goroutine's stack; reaching the top without recover, it brings down the whole process with a trace.
  2. recover is goroutine-localrecover() works only in a defer inside the same goroutine where the panic occurred. You can't catch a neighboring goroutine's panic — there's no shared stack.
  3. Worker practice — wrap every goroutine that might panic in defer/recover so one faulty task doesn't kill the service.
func safeGo(task func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("recovered: %v", r)
            }
        }()
        task() // a panic here won't crash the process
    }()
}

⚠️ Common mistake: thinking "only this goroutine dies." On the contrary — the whole process goes down; and a recover in the main goroutine won't save you from a panic in a child, you must catch it where it panics.

31

What was the classic loop-variable capture trap in goroutines, and what changed in Go 1.22?

Short answer: Before Go 1.22 the loop variable was shared across all iterations, and goroutines that closed over it saw its shared current value — by the time they ran the loop had usually finished, so they all printed the last value. Go 1.22 changed the semantics: each iteration now gets its own copy of the variable, and the trap is gone.

In depth:

  1. Cause (pre-1.22)i (or v in range) is a single variable reused across iterations; the closure captures it by reference, not by its value at creation time.
  2. Symptom — output like 3 3 3 instead of 0 1 2: the goroutines start after the loop, when the variable already holds its final value.
  3. Go 1.22 — the loop variable became per-iteration; old code "just started working correctly" with go 1.22 in go.mod.
  4. Fix for older versionsi := i (shadowing) or passing it as an argument to the goroutine.
// Pre-Go 1.22 printed 3 3 3; with 1.22 — 0 1 2
for i := 0; i < 3; i++ {
    go func() { fmt.Println(i) }()
}

// Fix that works on any version — pass as an argument:
for i := 0; i < 3; i++ {
    go func(i int) { fmt.Println(i) }(i)
}

⚠️ Common mistake: confidently saying "it prints 0 1 2" in an interview without pinning the Go version. The right answer depends on it: pre-1.22 almost certainly 3 3 3, with 1.22 0 1 2 (in unspecified order).

32

How do you correctly stop running goroutines from the outside?

Short answer: You can't kill a goroutine from the outside — Go has no kill. Stopping is strictly cooperative: the goroutine itself periodically checks a cancellation signal and returns. The idiomatic way is context cancellation; the alternative is closing a shared done channel, which acts as a broadcast to all listeners at once.

In depth:

  1. No forced kill — the runtime won't stop another goroutine; it must cooperate.
  2. context (idiomatic) — the parent calls cancel(), the goroutine catches <-ctx.Done() in its work select and returns; timeouts/deadlines ride along for free.
  3. done channelclose(done) instantly unblocks everyone reading <-done (a broadcast); handy when a context is overkill.
  4. Mandatory drain — on exit, don't leave writers/readers hanging on other channels.
func worker(ctx context.Context, in <-chan Job) {
    for {
        select {
        case <-ctx.Done(): // stop signal from outside
            return
        case job, ok := <-in:
            if !ok { return }
            process(job)
        }
    }
}

⚠️ Common mistake: expecting cancel() to interrupt the goroutine's work immediately. It only closes ctx.Done(); if the loop never checks that channel, the goroutine keeps spinning as if nothing happened.

33

How does net/http handle incoming requests — what's the concurrency model?

Short answer: The net/http server spawns a separate goroutine per incoming connection (and thus per request). So handlers are called concurrently, and any access to shared mutable state from them must be synchronized — with a mutex, atomics, or channels. Client cancellation is visible through r.Context(), which is canceled when the connection drops.

In depth:

  1. Goroutine per connectionServer.Serve loops accepting connections and launches go c.serve(...); handlers for different requests run in parallel.
  2. Handler requirement — they must be safe for concurrent invocation: shared state (a cache, counters, a map) goes under sync.Mutex or sync/atomic.
  3. r.Context() — canceled on client disconnect or deadline; use it to abort expensive operations (a DB query, an upstream call) and stop burning resources on an abandoned request.
// RACE: concurrent writes to a map from many handler goroutines
var cache = map[string]int{}
func bad(w http.ResponseWriter, r *http.Request) {
    cache[r.URL.Path]++ // concurrent map writes → fatal
}

// FIX: guard shared access with a mutex
var mu sync.Mutex
func good(w http.ResponseWriter, r *http.Request) {
    mu.Lock(); cache[r.URL.Path]++; mu.Unlock()
}

⚠️ Common mistake: keeping shared state in a handler without synchronization, assuming "requests come one at a time." They run in parallel; a concurrent write to a map crashes the process with fatal error: concurrent map writes.

34

What's the difference between sync.Mutex and sync.RWMutex, and when is RWMutex actually worth it?

Short answer: Mutex gives exclusive access — one goroutine in the critical section at any moment. RWMutex splits the lock in two: under RLock() readers enter in parallel, while a writer's Lock() is exclusive and waits for all readers to leave. RWMutex only pays off where reads greatly outnumber writes.

In depth:

  1. Mutex — a plain lock: Lock()/Unlock(), always a single owner. The default choice.
  2. RWMutexRLock() for readers (many at once), Lock() for the writer (one, exclusive).
  3. When to reach for RWMutex — only after profiling shows contention on reads and the read:write ratio is high (say 10:1 or more), and the critical section isn't microscopic.
Criterion sync.Mutex sync.RWMutex
Readers one at a time in parallel
Writer exclusive exclusive
Overhead lower higher (more internal state)
When by default reads >> writes

⚠️ Common mistake: using RWMutex "just in case" and warning about writer starvation. Go has none: RWMutex is write-preferring — a waiting Lock() blocks new readers (side effect: a recursive RLock() in one goroutine can deadlock). The real cost is the pricier acquire: for short sections a plain Mutex is often faster.

35

How does sync.WaitGroup work, and what's the classic wg.Add() mistake?

Short answer: A WaitGroup is a goroutine counter: Add(n) raises it, Done() drops it by one, and Wait() blocks until the counter hits zero. The classic mistake is calling Add(1) inside the already-started goroutine: Wait() may see a zero counter and pass through before the goroutine had a chance to increment it.

In depth:

  1. Correct ordering — call Add(1) in the parent goroutine before go func(), and put Done() in a defer inside the goroutine.
  2. Why it races — if Add is inside the goroutine, the scheduler may let Wait() run before the launch — then wg is already "empty" and there's nothing to wait on.
  3. Go 1.25 — added wg.Go(fn): it does Add(1) before start and Done() after, closing this class of bugs. Make sure the project is already on 1.25 before using it.
// WRONG — Add inside the goroutine, races with Wait
for _, u := range users {
    go func(u User) {
        wg.Add(1)          // may not run before Wait()
        defer wg.Done()
        process(u)
    }(u)
}
wg.Wait()

// RIGHT — Add before go, Done via defer
for _, u := range users {
    wg.Add(1)
    go func(u User) {
        defer wg.Done()
        process(u)
    }(u)
}
wg.Wait()

⚠️ Common mistake: forgetting defer wg.Done() on an early return/panic — the counter never reaches zero and Wait() hangs forever (deadlock).

36

What does sync.Once guarantee, and where is it typically used?

Short answer: sync.Once guarantees the function passed to Do(f) runs exactly once for the lifetime of the program, even if Do is called from many goroutines at once. The other callers block until the first finishes f, and only then proceed. The typical use is lazy initialization of a singleton.

In depth:

  1. Guarantee — "exactly once" plus happens-before: after Do returns, f's result is visible to all goroutines without races.
  2. Synchronous — concurrent calls don't slip past; they wait for the first to finish. It's not a best-effort "tried once."
  3. Where it's used — lazily building a config, a connection pool, a client to an external service — when init is expensive and needed once.
  4. Go 1.21+sync.OnceValue(f) and sync.OnceFunc(f) return a function with the same semantics, without hand-declaring a Once and a flag.
var (
    once     sync.Once
    instance *DB
)

func GetDB() *DB {
    once.Do(func() {
        instance = connect() // runs once
    })
    return instance
}

// Go 1.21+: the same, shorter
var GetDB = sync.OnceValue(func() *DB { return connect() })

⚠️ Common mistake: reusing one Once to "re-initialize" — it can't be reset, the second Do simply does nothing. That needs a different mechanism.

37

When is sync/atomic more appropriate than a mutex?

Short answer: sync/atomic fits when you need to atomically change a single machine word — a counter, a flag, a pointer — without locking. These lock-free operations (built on CAS, compare-and-swap) are cheaper than a mutex because they don't park the goroutine or go through the scheduler. The moment the invariant spans several fields at once, atomic no longer helps — you need a Mutex.

In depth:

  1. Single word — incrementing a counter, setting a flag, swapping a config pointer: atomic is faster and non-blocking.
  2. CASCompareAndSwap changes a value only if it still matches the expected one; lock-free algorithms build on it, but they're easy to get subtly wrong.
  3. Compound invariant — if you must change two or more fields consistently (say balance and history), atomic can't make them jointly atomic — only a Mutex can.
  4. Go 1.19+ — typed atomic.Int64, atomic.Bool, atomic.Pointer[T]: safer and more readable than the old atomic.AddInt64(&x, ...) functions.
Situation atomic Mutex
Counter/flag yes, cheap overkill
Pointer swap atomic.Pointer[T] works, pricier
Several fields together no yes
Complex critical section no yes

⚠️ Common mistake: assembling "consistent" state from several atomic variables. Each is atomic on its own, but read together they can be inconsistent — that's a race at the logic level.

38

What is sync.Map for, and why isn't it a drop-in replacement for a mutex-guarded map?

Short answer: sync.Map is a specialized concurrent map optimized for two scenarios: a key is written once and then read many times, or different goroutines work on disjoint sets of keys. Internally it reduces contention across many cores. In the general case, a plain map under an RWMutex is faster, type-safe, and clearer.

In depth:

  1. What it's tuned for — read-heavy caches with stable keys; internally it keeps a read-only copy that's read without locking.
  2. Why it's not universal — under active inserts/deletes of varied keys, sync.Map loses to map+RWMutex due to read-copy misses and rebuilds.
  3. Type safety — the API works with any: every access boxes into an interface and type-asserts back; the compiler can't check key or value.
  4. Selection rule — start with a map under an RWMutex; move to sync.Map only if profiling confirms one of its two patterns.
Criterion map + RWMutex sync.Map
Types static, compiler-checked any, runtime assertions
Writes of varied keys fast slower
Read-heavy, stable keys ok faster
Readability higher lower

⚠️ Common mistake: reaching for sync.Map by default "because concurrent." For most workloads a map under an RWMutex is both simpler and faster.

39

How does the -race flag work, and why doesn't it catch every race?

Short answer: -race enables a dynamic race detector built on ThreadSanitizer: the compiler instruments every memory access, and the runtime builds happens-before, tracking when two accesses to the same location from different goroutines are unordered and at least one is a write. Key limitation: it only sees races that actually occurred on the executed path in that run.

In depth:

  1. How to enablego test -race, go run -race, go build -race; used with tests/CI, not in production.
  2. Dynamic, not static — if the racy code didn't run (wrong input, wrong scheduler timing), the detector stays silent. Hence: run under load and with varied seeds.
  3. Cost — 2–20x CPU slowdown and 5–10x more memory; that's why it's tests only, not live traffic.
  4. Output — on a catch it prints the reader's stack, the writer's stack, and the allocation site, pointing straight at the bug.
==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
  main.incr()
      /app/main.go:14 +0x44

Previous read at 0x00c0000b4010 by goroutine 6:
  main.read()
      /app/main.go:9 +0x38
==================

⚠️ Common mistake: treating "passed under -race" as proof there are no races. It's absence of evidence, not evidence of absence — an uncovered path stays unseen.

40

Why must you never copy a sync.Mutex (or a struct containing one), and how does go vet catch it?

Short answer: Copying a mutex duplicates its internal state (the locked flag, the waiter count), giving you two independent locks instead of one — mutual exclusion silently breaks, as different goroutines think they "locked" while working on different copies. go vet, via the copylocks analyzer, flags any pass of such a struct by value.

In depth:

  1. What breaks — after a copy, the original and the copy are not synchronized with each other; protection vanishes with no runtime error.
  2. How vet catches itcopylocks looks at types implementing Lock/Unlock (sync.Locker) and complains about assigning, passing to a function, or returning them by value.
  3. Rule — hold and pass a mutex-containing type only by pointer, and declare methods with a pointer receiver (func (s *Store)), not a value receiver.
type Counter struct {
    mu sync.Mutex
    n  int
}

// BUG: value receiver copies Counter, including mu, on every call
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

// go vet: Inc passes lock by value: Counter contains sync.Mutex

// Correct: pointer receiver — we work on one lock
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

⚠️ Common mistake: declaring a value-receiver method on a struct with a sync.Mutex. The lock is copied on every call, the increment is lost under a race — and go vet in CI flags it immediately.

41

What is context for, and how do WithCancel, WithTimeout, and WithDeadline differ?

Short answer: context carries three things across API and goroutine boundaries: a cancellation signal, a deadline, and request-scoped values. Contexts form a tree — cancelling a parent cancels all descendants. WithCancel gives manual cancellation, WithDeadline cancels at an absolute point in time, and WithTimeout is sugar over WithDeadline (deadline = "now + duration").

In depth:

  1. WithCancel — returns ctx, cancel; you cancel manually when the work is no longer needed (e.g., the first answer among several goroutines already arrived).
  2. WithDeadline — cancellation happens no later than the given time.Time; handy when the deadline is external and absolute.
  3. WithTimeout — same as WithDeadline(now + d); handy for relative request timeouts.
  4. In common — all three return a cancel you must call via defer to release the context's resources; cancellation propagates down the tree.
Constructor Cancel trigger When
WithCancel manual cancel() call cancel on an event/condition
WithDeadline reaching a time.Time external absolute deadline
WithTimeout duration elapses relative timeout

⚠️ Common mistake: not calling defer cancel(). Even if the context cancels on timeout, without cancel() the internal timer and related resources are held until the deadline fires — go vet warns about a lost cancel.

42

What's the difference between context.Background() and context.TODO()?

Short answer: They're functionally identical — both return an empty, non-nil context with no cancellation, deadline, or values. The difference is purely semantic, for the reader of the code: Background() is a deliberate root of the context tree, while TODO() is a marker for "a context is needed here, but which one isn't decided yet."

In depth:

  1. Background — the starting point: main, initialization, the top of an incoming request. You consciously say "this is the root."
  2. TODO — a placeholder during refactoring: a real ctx hasn't been threaded through to this spot yet, and passing nil is forbidden. Linters and reviewers see the debt here.
  3. Technically — both are an emptyCtx under the hood; the choice affects intent read from the code, not behavior.
context.Background() context.TODO()
Behavior empty context empty context
Meaning deliberate root "not decided yet"
Where main, init, request root placeholder during refactor

⚠️ Common mistake: sprinkling TODO() around just to "make it compile." It's for a temporary placeholder; a finished path should carry a real ctx from the caller.

43

Why is context.WithValue an anti-pattern for passing business parameters?

Short answer: WithValue stores values as any under an any key: the dependency becomes invisible — it isn't reflected in the function signature, the compiler checks neither presence nor type, and a missing or mis-asserted value blows up as a panic or nil only at runtime. Business parameters (userID, a limit, a filter) belong in explicit arguments. Only request-scoped metadata legitimately lives in the context.

In depth:

  1. No type safety — on retrieval you do ctx.Value(k).(T); wrong type or key means a panic or nil in production, not a compile error.
  2. Hidden dependency — a function takes ctx, but what it pulls out isn't visible from the signature; it's hard to refactor and test.
  3. What may go in — cross-cutting request metadata: trace/request ID, auth info, locale — things that thread through all layers and aren't business inputs.
  4. Keys — only of your own unexported type, to rule out collisions between packages.
// A key of your own type — guards against collisions
type ctxKey string
const traceIDKey ctxKey = "traceID"

func WithTraceID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, traceIDKey, id)
}

func TraceID(ctx context.Context) (string, bool) {
    id, ok := ctx.Value(traceIDKey).(string)
    return id, ok
}

⚠️ Common mistake: using a string or built-in type as the key (ctx.Value("user")). Two packages with the same key silently overwrite each other's values — the key must be of an unexported type.

44

Why is context passed as the first argument instead of stored in a struct field?

Short answer: A context lives for the span of a single call or request and must flow explicitly down the call chain — so it's passed as the first parameter, ctx context.Context. In a struct field its lifetime blurs: the object outlives the request, and one stored ctx starts to accidentally serve other, unrelated requests, breaking cancellation and deadlines.

In depth:

  1. Explicit flowctx as the first argument makes its scope visible: it's clear this call is subordinate to this context and its cancellation.
  2. The field problem — a long-lived object (a service, a client) with a ctx field binds itself to the first request's context; later requests inherit someone else's deadline or an already-cancelled context.
  3. Convention — stated outright in the context package docs: "Do not store Contexts inside a struct type; instead, pass a Context explicitly."
  4. Exceptions — deliberate: http.Request carries a context inside because the object itself is strictly request-scoped and lives exactly one request.
// Idiomatic: ctx is the first parameter, flows down the chain
func (s *Service) FetchUser(ctx context.Context, id string) (*User, error) {
    return s.repo.Get(ctx, id)
}

// Anti-pattern: ctx hidden in a long-lived service's field
type Service struct {
    ctx context.Context // outlives the request, serves others
}

⚠️ Common mistake: stashing ctx in a field to "avoid passing it everywhere." A long-lived object gets stuck on the first request's context — cancellation and timeout then drift for every later one.

45

Mutex, channel, atomic, or immutability — how do you choose the way to share state between goroutines?

Short answer: Choose by the nature of the problem, not by taste. atomic — for single words (counters, flags, pointers). Mutex — when you must consistently protect a compound invariant across several fields. Channels — for transferring ownership of data and orchestrating goroutines ("share memory by communicating"). Immutability removes the problem entirely: immutable data can be read from any number of goroutines without synchronization.

In depth:

  1. atomic — one cell, a simple operation; as cheap as it gets, but only a whole word.
  2. Mutex — several fields must change and be read as a single unit; the classic critical-section guard.
  3. Channels — data moves between stages/workers, and you need coordination, backpressure, a done signal; ownership is handed off, not shared.
  4. Immutability — build a value once and only read it (or swap a pointer via atomic.Pointer); no races by definition.
Tool Ideal case Not for this
atomic counter, flag, pointer compound invariant
Mutex several fields together ownership handoff
Channel pipeline, workers, signals a plain shared counter
Immutability read-only config/snapshot frequent in-place updates

⚠️ Common mistake: answering "channels are always better." The interviewer is listening for appropriateness: a shared counter behind a channel is over-engineering, while a compound invariant via atomic is a race. Pick the tool to fit the shape of the data.

46

Explain the G-M-P model: how does the Go scheduler distribute goroutines?

Short answer: The Go scheduler is an M:N model over three entities. G is a goroutine (a task with its stack), M is an OS thread (machine), P is a logical processor with a local run queue of runnable goroutines. An M can execute a G only while it holds a P; the number of P is capped by GOMAXPROCS, so exactly that many threads run Go code in parallel.

In depth:

  1. P is the right to run. Each P has its own runqueue (local queue, up to 256 G). An M pulls a G from its P's queue and runs it.
  2. Work stealing. When a P's local queue drains, it steals half the goroutines from another P's queue or pulls from the global queue — load balances with no global lock.
  3. Global queue. Local-queue overflow and freshly woken goroutines land in the shared globrunq; to keep it from starving, a P checks it periodically (every 61st tick).
  4. Decoupling M and P. When an M blocks, its P detaches and goes to another M — goroutines keep running.
   G G G          G G            G G G G
   ┌─────┐        ┌─────┐        ┌─────┐   global runq
   │ P0  │        │ P1  │        │ P2  │   [G G G ...]
   └──┬──┘        └──┬──┘        └──┬──┘        ▲
      │ steal◄───────┘              │           │ (every 61st tick)
   ┌──┴──┐        ┌─────┐        ┌──┴──┐
   │ M0  │        │ M1  │        │ M2  │  ← OS threads
   └─────┘        └─────┘        └─────┘

⚠️ Common mistake: saying "a goroutine is a thread." There can be hundreds of thousands of goroutines but only a handful of threads (M); it's P that multiplexes many G onto a small number of M.

47

What does GOMAXPROCS control, and what's its default?

Short answer: GOMAXPROCS sets the number of P — the maximum count of OS threads executing Go code simultaneously. By default it equals the number of logical CPUs (runtime.NumCPU()) — the case since Go 1.5.

In depth:

  1. What it actually caps. Only parallel execution of Go code. Goroutines blocked on a syscall or network I/O don't count toward the limit — a process can have far more threads than GOMAXPROCS.
  2. How to change it. Via the GOMAXPROCS env var or at runtime with runtime.GOMAXPROCS(n).
  3. The container trap. Historically the default took the whole node's core count and ignored the cgroup CPU limit: on a 64-core node limited to "2 CPU" the runtime spun up 64 P — extra context switches and throttling. The classic fix is uber-go/automaxprocs.
  4. Recent Go. As of Go 1.25 the runtime honors the cgroup CPU limit by default — on Linux the default rounds up from the limit, so check the version before reaching for the workaround.
// GOMAXPROCS=4 ./app — via the environment variable
runtime.GOMAXPROCS(4)      // or at runtime
n := runtime.GOMAXPROCS(0) // 0 — read the current value without changing it

⚠️ Common mistake: on Go < 1.25, relying on the default inside Kubernetes with a CPU limit. Without automaxprocs the runtime sees all of the node's cores, not your allotted quota.

48

Is the Go scheduler cooperative or preemptive? What changed in Go 1.14?

Short answer: Before Go 1.14 the scheduler was cooperative — a goroutine gave up its P only at safepoints (mostly on function calls). Go 1.14 added asynchronous preemption via signals: sysmon sends the thread a SIGURG, and the goroutine is forcibly taken off its P even if it never yields.

In depth:

  1. Cooperative model (pre-1.14). A switch is only possible where the compiler inserted a check — on function calls, allocations, channel ops. A tight loop with no calls left the scheduler no point to preempt.
  2. Symptom. A goroutine running for {} could hold a P forever: other goroutines on that P starve, and garbage collection can't reach its STW because not every goroutine hits a safepoint.
  3. Async preemption (1.14+). The sysmon monitor thread notices a G has run for ~10 ms and sends the thread a SIGURG; the handler safely deschedules the goroutine and returns the P to the pool.
Pre Go 1.14 Go 1.14+
Model cooperative + async preemption
Preempt point safepoints only (calls) anywhere, via signal
Threshold ~10 ms per G
for {} hung P/GC preempted normally

⚠️ Common mistake: assuming preemption is now absolute. The signal can arrive at any moment, but at unsafe points (short spans without stack-scan metadata, runtime internals) descheduling is deferred; for application code you can treat the scheduler as preemptive.

49

What happens to the P and M when a goroutine enters a blocking syscall?

Short answer: On a blocking syscall the M blocks together with its G, but the P detaches (hand-off) and goes to another M — a free one or a newly created one — so the P's other goroutines keep running. Network I/O is a separate story: it goes through the netpoller and doesn't block an M at all.

In depth:

  1. P hand-off. Before the syscall the runtime marks the P as "in syscall." If the call drags on, sysmon detaches the P and hands it to another M; the local queue's goroutines don't idle.
  2. Return from syscall. When the M exits the call it tries to reacquire a P (its own or any free one). If it can't, the G goes to the global queue and the M parks in the pool.
  3. Netpoller. Network operations (sockets) are registered in epoll (Linux) / kqueue (BSD/macOS). The goroutine parks, the M is freed immediately; when the fd is ready the netpoller puts the G back on the run queue. One M serves thousands of connections.
  Blocking syscall (file/disk):          Network I/O:
  ┌─────┐ syscall ┌─────┐                 ┌─────┐   ┌──────────┐
  │  G  │────────►│  M  │(blocked)        │  G  │──►│ netpoller│ epoll
  └─────┘         └─────┘                 └─────┘   │(kqueue)  │
     P ─hand-off─► M2 (keeps running)        M freed  └────┬─────┘
                                              fd ready ─────┘► G to runq

⚠️ Common mistake: assuming a thousand blocking file operations will spin up exactly GOMAXPROCS threads. Each blocking syscall holds its own M, so the OS-thread count can grow well beyond the number of P.

50

How does Go's garbage collector work — what is tricolor mark-and-sweep?

Short answer: Go's GC is a concurrent, non-moving mark-and-sweep with a tricolor abstraction. Objects are white (garbage candidates), grey (reachable but their references not yet scanned), or black (fully scanned). Marking runs concurrently with the application; correctness is held by a write barrier, and the world is fully stopped (STW) only for two short phases.

In depth:

  1. Three sets. Start: everything white. Roots (stacks, globals) are colored grey. Take an object from the grey set, color its references grey and the object itself black. Repeat until no grey remains.
  2. Invariant. Anything still white is unreachable → swept. Sweep is lazy: memory is reclaimed as allocations happen.
  3. Write barrier. While the mutator runs concurrently, it might store a reference to a white object into a black one. The hybrid write barrier (since Go 1.8) recolors that object so a live object isn't swept by mistake.
  4. STW. Only at the boundaries: enabling the barrier (mark start) and finishing marking (mark termination). Both pauses are sub-millisecond.
  ● white   — garbage candidate (not yet reached)
  ◐ grey    — reached, references not yet scanned
  ◉ black   — reached, references scanned

  roots ──► ◉ ──► ◐ ──► ●        the mark wave moves
            (scanned)(in work)(waiting)  left to right

⚠️ Common mistake: calling Go's GC "stop-the-world" or generational. It's concurrent (the world stops only at sub-ms boundaries) and non-generational / non-moving — objects stay put, so pointers held in C code via cgo remain stable.

51

What do GOGC and GOMEMLIMIT tune, and what's the trade-off?

Short answer: GOGC sets how much the heap may grow between collections: at the default GOGC=100 a GC cycle starts when the live heap has doubled. GOMEMLIMIT (Go 1.19+) is a soft cap on the runtime's total memory that makes GC run more often as you approach the limit, saving you from OOM in containers.

In depth:

  1. GOGC — about frequency. GOGC=100 → next collection at +100% over the live heap. A lower value = less peak memory, but GC runs more often and burns more CPU overall; higher = rarer GC but a fatter RSS. GOGC=off disables GC entirely.
  2. GOMEMLIMIT — about the ceiling. A soft limit on total memory (heap + stacks + runtime metadata). As it approaches, the runtime shrinks its heap target and collects more often to avoid crossing the limit.
  3. How to combine. A typical prod recipe: keep GOGC=100 for normal operation and set GOMEMLIMIT a bit below the pod limit — under pressure GC gets more aggressive instead of getting OOM-killed.
Setting What it tunes Lower/tighter → Overshoot risk
GOGC=100 heap growth between GC less RAM, more CPU GC thrashing
GOMEMLIMIT soft memory ceiling OOM protection death spiral: GC burns CPU right at the limit

⚠️ Common mistake: setting GOMEMLIMIT exactly at the container hard limit and turning GOGC fully off. Right at the ceiling the runtime falls into a "GC death spiral" — collection frees almost nothing and eats CPU. Leave headroom.

52

What is escape analysis, and how do you check whether a variable escaped to the heap?

Short answer: Escape analysis is a compiler analysis that decides where to place a variable: on the stack (cheap, freed automatically when the function returns) or on the heap (pressure on the GC). If the compiler can't prove the variable won't outlive the function frame, it "escapes" to the heap. You check with the flag go build -gcflags='-m'.

In depth:

Typical reasons for an escape:

  1. Returning a pointer outward — you returned &local, so the object must outlive the function.
  2. Capture by a closure — the variable is used by a goroutine/closure that lives longer than the frame.
  3. Boxing into an interface — assigning a concrete value into interface{} often forces an allocation (e.g. fmt.Println arguments).
  4. Size unknown at compile time — a slice/array whose size is only known at runtime.
func newUser() *User {
    u := User{Name: "Go"} // -m: moved to heap: u
    return &u             // pointer escapes outward
}
// $ go build -gcflags='-m' ./...
// ./main.go:2:2: moved to heap: u

⚠️ Common mistake: thinking & (taking an address) by itself means the heap. A local pointer that never leaves the function happily lives on the stack — escape analysis decides, not the presence of &.

53

How does a goroutine stack grow, and what happens when it hits the limit?

Short answer: A goroutine starts with a tiny ~2 KB stack. When that's not enough, the runtime allocates a stack twice as large, copies all the data over, and fixes up pointers (contiguous stacks). There's a hard ceiling of ~1 GB on 64-bit platforms; exceeding it gives a fatal error: stack overflow that recover cannot catch.

In depth:

  1. Cheap start. ~2 KB per goroutine — that's why you can keep hundreds of thousands of them, unlike OS threads with megabyte-sized stacks.
  2. Grow by copying. On function entry the prologue checks whether the frame fits. It doesn't → morestack: a new, larger stack is allocated, the old one is copied, and in-stack pointers are rewritten. The stack can both grow and (during GC) shrink.
  3. Hard limit. maxstacksize — ~1 GB on 64-bit (about 250 MB on 32-bit). Hit it and the runtime kills the process.
  4. Fatal, not a panic. stack overflow is a runtime fatal error, not a panic; defer/recover don't catch it — the whole program crashes.
 [2KB] ──overflow──► alloc [4KB] ──► copy old→new ──► fix pointers
   ▲                                                      │
   └──────────── contiguous stack, grows ×2 ──────────────┘

 deep/infinite recursion ──► ~1GB cap ──► fatal: stack overflow

⚠️ Common mistake: counting on recover to "catch" infinite recursion. A stack overflow is a runtime fatal error, not a panic; recover won't save you from it.

54

In what order do multiple defers run, and when are their arguments evaluated?

Short answer: Deferred calls run in LIFO order — the last defer declared fires first, as the function returns. But a defer's arguments are evaluated immediately, when the defer statement executes, not when the call actually runs.

In depth:

  1. A stack, not a queue — each defer is pushed onto the function's stack; on return they pop top to bottom (LIFO). This suits paired operations: Lock/Unlock, Open/Close — you release resources in reverse order of acquisition.
  2. Arguments are captured now — argument expressions are evaluated at the defer point, only the call body is postponed. So defer fmt.Println(i) captures the current i, not the final one.
  3. A closure postpones the readdefer func(){ use(x) }() reads x when the call runs, not when it is declared. Nuance: since Go 1.22 the loop variable is fresh per iteration, so even a closure in the loop prints 2 1 0.
func main() {
    for i := 0; i < 3; i++ {
        defer fmt.Println(i) // argument i evaluated now
    }
}
// Output: 2 1 0  (LIFO + i captured on each iteration)

⚠️ Common mistake: answering 3 3 3, as if defer read a shared i at function exit. The argument is a snapshot taken when the defer statement runs; and since Go 1.22 each iteration has its own i.

55

Can a defer change a function's return value?

Short answer: Yes, but only with named return values. return x first assigns the value to the named variable, then runs the defers, and only then actually hands back the result — so a defer gets a chance to reassign it.

In depth:

  1. How return worksreturn is not atomic: it (1) writes values into the named result variables, (2) runs deferred functions, (3) returns the result. A deferred function sees and can mutate those variables.
  2. Named only — with an anonymous result (func() error) there is nothing to mutate: the defer works on a copy and cannot affect the return.
  3. Practical pattern — turn a panic into an error at an API boundary: recover() inside the defer plus assignment to the named return.
func parse(data []byte) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("parse panic: %v", r)
        }
    }()
    doRiskyParse(data) // panics inside — err leaves the function
    return nil
}

⚠️ Common mistake: expecting the same trick from func() error. Without a named result the defer mutates a local copy, and the caller still gets the original value from return.

56

Where exactly must recover() be called for it to catch a panic?

Short answer: recover() works only when called directly inside a deferred function of the same goroutine where the panic happened. Called outside a defer, or from a function that the deferred function itself calls, it returns nil and catches nothing.

In depth:

  1. Only directly in the defer — a panic is caught if recover() is called in the body of the function that defer scheduled. Not in a function that this deferred function then calls.
  2. Only its own goroutine — a panic in another goroutine is not caught by your recover; each goroutine must recover its own panic, otherwise the whole process crashes.
  3. No panic → nil — with no panic in flight, recover() simply returns nil, so if r := recover(); r != nil is safe.
func catch() { _ = recover() }

defer func() { _ = recover() }() // works
defer catch()                    // also works: catch IS the deferred function
defer func() { catch() }()       // does NOT work: recover is one frame deeper, returns nil

⚠️ Common mistake: calling a helper with recover() from inside a deferred closure — defer func(){ catch() }(). recover sits one frame below the deferred function itself and returns nil. defer catch(), however, works: there catch IS the deferred function.

57

What's the difference between errors.Is and errors.As?

Short answer: errors.Is walks the Unwrap chain and compares the error against a specific sentinel value (e.g. sql.ErrNoRows). errors.As searches the chain for an error of a given type and extracts it into the provided target so you can reach its fields.

In depth:

  1. errors.Is(err, target) — answers "is this error (or any in its chain) equal to this value?". For checking against known sentinels.
  2. errors.As(err, &target) — answers "is there an error of type T in the chain?"; if so, it assigns it into target and you read its fields (.Code, .Field). target must be a pointer.
  3. Both unwrap the %w chain, so wrapping with context doesn't break the checks.
errors.Is errors.As
Looks for a specific value a specific type
Argument a sentinel a pointer to a typed variable
Gives field access no yes
Typical case Is(err, io.EOF) As(err, &pathErr)
if errors.Is(err, sql.ErrNoRows) { /* no row */ }

var perr *os.PathError
if errors.As(err, &perr) { log.Print(perr.Path) }

⚠️ Common mistake: comparing errors with == or err.Error() == "...". After %w wrapping a direct comparison breaks — use errors.Is/As.

58

How do you wrap an error with context while keeping the chain for errors.Is/As?

Short answer: Use fmt.Errorf with the %w verb: fmt.Errorf("read config: %w", err). It's %w that stitches the original error into the Unwrap chain, so errors.Is/errors.As keep seeing it. The verbs %v and %s insert only the text and break the chain.

In depth:

  1. %w preserves the chain — it wraps the error while keeping access to the original via Unwrap. Add short context: what the code was doing, not a restatement of the error.
  2. %v/%s break the chain — use them only when you deliberately hide the underlying error from the caller (an abstraction boundary).
  3. Layered context — each level adds its own prefix, giving a readable trail: open user file: read config: permission denied.
if err != nil {
    return fmt.Errorf("read config %q: %w", path, err)
}
// errors.Is(err, os.ErrPermission) still works

⚠️ Common mistake: handling an error twice — logging it and also returning it up. Each layer either wraps and returns, or (at the very top) logs — not both, or your logs double up.

59

When should a function return an error, and when should it panic?

Short answer: Expected, recoverable failures are always an error as an ordinary return value: file not found, network dropped, invalid input. panic is for programmer errors and broken invariants with no sensible continuation: out-of-bounds access, nil dereference, an "impossible" state.

In depth:

  1. error is the normal path — anything the caller can anticipate and handle is returned as an explicit error. It's part of the function's contract.
  2. panic is a bug, not a scenario — it signals the program is in an inconsistent state and cannot continue. The runtime panics on a nil map write, division by zero, an index out of range.
  3. recover only at a boundary — e.g. so an HTTP server doesn't crash entirely because one handler panicked.
Return error panic
Cause expected failure bug / broken invariant
Examples missing file, timeout, bad input index out of range, nil deref
Whose fault the outside world the programmer
Expectation caller handles it cannot continue

⚠️ Common mistake: using panic as control flow for "not found" or input validation. That's a code smell interviewers deliberately probe for: those cases are a plain error.

60

Sentinel errors vs custom error types — when do you choose which?

Short answer: A sentinel (var ErrNotFound = errors.New(...)) is a simple identifiable signal with no extra data, checked with errors.Is. A custom type with fields is needed when the caller cares about failure details (a code, a field name, a status) — then errors.As extracts the error and gives access to its fields.

In depth:

  1. Sentinel is a flag — one constant value for the whole package. Cheap and readable, but it carries only the fact itself: "not found", "forbidden". Check with errors.Is.
  2. Custom type is a struct with data — when you must pass context: ValidationError{Field, Rule}, APIError{Code}. Check and extract with errors.As.
  3. Both are part of the API — either one becomes a public contract: callers check them by name.
Sentinel Custom type
Carries data no yes (fields)
Check errors.Is errors.As
Cost minimal more code
When simple signal details needed
var ErrNotFound = errors.New("not found")

type ValidationError struct{ Field, Rule string }
func (e *ValidationError) Error() string { return e.Field + ": " + e.Rule }

⚠️ Common mistake: forgetting a sentinel is part of your public API. Users write errors.Is(err, pkg.ErrNotFound), so renaming or removing it breaks their code just like changing a function signature.

61

Implement a worker pool with graceful shutdown via context. What is the interviewer checking?

Short answer: N workers read from a shared jobs channel via range; the producer closes jobs, while results is closed by a separate goroutine after wg.Wait(). Inside each worker a select on ctx.Done() lets it drop work on cancellation. The interviewer watches who closes the channels and whether goroutines leak.

In depth:

  1. Who closes jobs — only the producer (sender). Closing the channel from a consumer worker panics on send/double close.
  2. Who closes results — a dedicated goroutine: go func(){ wg.Wait(); close(results) }(). Otherwise you can't tell when every worker is done.
  3. Cancellation — each worker selects between jobs and ctx.Done(); on cancel the worker returns, wg.Done() in defer.
  4. Leaks — without ctx, a stuck producer or a full results leaves workers blocked forever.
func pool(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
    results := make(chan Result)
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return
                case job, ok := <-jobs:
                    if !ok {
                        return // channel closed by producer
                    }
                    select {
                    case results <- process(job):
                    case <-ctx.Done():
                        return
                    }
                }
            }
        }()
    }
    go func() { wg.Wait(); close(results) }()
    return results
}

⚠️ Common mistake: closing results inside a worker or before wg.Wait() — "send on closed channel". One goroutine closes it, and only after every sender has finished.

62

How do you merge several channels into one (fan-in) without panicking on close?

Short answer: One goroutine per input channel forwards its values into a shared out; a WaitGroup counts those goroutines, and close(out) is called exactly once from a separate goroutine after wg.Wait(). That way the output channel closes only when every input is drained.

In depth:

  1. Goroutine per inputfor v := range in { out <- v }, then wg.Done().
  2. WaitGroupAdd(len(cs)) before launching goroutines so Wait() can't return early.
  3. Single closego func(){ wg.Wait(); close(out) }(). Only the owner of out closes it.
  4. Cancellation (opt.) — for early exit add a select on done in the out send, otherwise goroutines hang on send.
func merge[T any](cs ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup
    wg.Add(len(cs))
    for _, c := range cs {
        go func(c <-chan T) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

⚠️ Common mistake: closing out from every input goroutine, or before all inputs are drained — you get a "close of closed channel" or "send on closed channel" panic. Close strictly once, after Wait().

63

How do you cap concurrency — say, at most 10 simultaneous requests to an external API?

Short answer: A buffered channel semaphore of capacity 10: before a request write a token (acquire), in a defer read it back (release). When the buffer is full the send blocks and no new goroutines start until a slot frees up — that's natural backpressure.

In depth:

  1. Semaphoresem := make(chan struct{}, 10); acquire = sem <- struct{}{}, release = <-sem.
  2. release in defer — mandatory: a panic or early return inside the worker eats a slot, and the pool slowly seizes up.
  3. Ready-madeerrgroup.Group.SetLimit(10) or golang.org/x/sync/semaphore for weighted limits.
  4. Not "a goroutine per request" — the interviewer wants bounded concurrency and backpressure, not 10000 goroutines hammering someone else's API.
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, url := range urls {
    sem <- struct{}{} // acquire: wait for a free slot before spawning
    wg.Add(1)
    go func(url string) {
        defer wg.Done()
        defer func() { <-sem }() // release
        fetch(url)
    }(url)
}
wg.Wait()

⚠️ Common mistake: release without defer. If fetch panics or returns early the token never comes back — free slots dwindle, and eventually the pool grinds to a halt.

64

How do you build a multi-stage channel pipeline and shut it down correctly?

Short answer: Each stage is a function like func(ctx, in <-chan T) <-chan U that spins up its own goroutine, writes to its own out, and does defer close(out). Cancellation is threaded through ctx into every send/receive (select on ctx.Done()), otherwise stages hang if the consumer left early.

In depth:

  1. A stage owns its output — it creates out, writes to it, and closes it; the next stage only reads.
  2. Closing flows downstream — when the input is drained (range ends) the stage closes its out, and the close cascades onward.
  3. Cancellation flows upstream — every send becomes select { case out <- v:; case <-ctx.Done(): return }, so an early consumer exit unblocks upstream stages.
  4. Source — the pattern from the Go blog post "Pipelines and cancellation".
generate ──► square ──► sum
  nums        out       result
   │           │          │
   └──── ctx.Done() cancels every stage ─────┘

⚠️ Common mistake: writing to out without a select on ctx.Done(). If the consumer stops reading, upstream stages block forever on send — the classic pipeline goroutine leak.

65

How do you implement a rate limiter in Go, and how does it differ from a semaphore?

Short answer: A semaphore caps concurrency (how many operations run right now), while a rate limiter caps frequency over time (how many operations per second). For a steady pace use time.Ticker; for bursts use a token bucket from golang.org/x/time/rate.

In depth:

  1. Semaphore ≠ rate limiter — "10 simultaneous requests" and "10 requests per second" are different limits.
  2. Tickert := time.NewTicker(time.Second/10); before each request <-t.C. Exactly 10 rps, no burst.
  3. Token bucketrate.NewLimiter(10, 20): 10 tokens/sec, burst up to 20; limiter.Wait(ctx) blocks until a token. The standard for external APIs.
  4. BackpressureWait waits for a token, Allow says "no" immediately — you choose between waiting and dropping (429).
Criterion Semaphore Rate limiter
Limits concurrency frequency over time
Unit N in parallel N per second
Tool chan struct{} time.Ticker / x/time/rate
Burst no yes (token bucket)

⚠️ Common mistake: a naive time.Sleep between requests instead of a limiter. It allows no burst, accumulates drift, and breaks under parallel workers — each one sleeps on its own.

66

How is an LRU cache built, and how do you make it thread-safe?

Short answer: A map[key]*list.Element on top of a doubly linked list (container/list): the map gives O(1) access, the list keeps usage order. On Get move the element to the front, on Put evict the tail when over capacity. Thread safety is a sync.Mutex around both structures.

In depth:

  1. Two structuresmap[K]*list.Element for O(1) lookup and *list.List (doubly linked) for recency order.
  2. Get — found in map → MoveToFront(el) → return the value.
  3. Put — key exists → update and MoveToFront; missing → PushFront, and when Len() > capacity remove Back() and its key from the map.
  4. Thread safety — one Lock() around the whole operation (map and list change together); under high contention, shard by key hash.
map[key]                doubly linked list (MRU ⇄ LRU)
┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐
│ "a" ─┼──►│  a   │⇄  │  c   │⇄  │  b   │
│ "c" ─┼──►└──────┘   └──────┘   └──────┘
│ "b" ─┼──►  head=MRU            tail=evicted
└──────┘

⚠️ Common mistake: an RWMutex with RLock on Get. Get moves an element in the list — that's a write, not a read; under RLock two goroutines corrupt the list. Get needs a full Lock.

67

How do you add a timeout with select, and what's the catch with time.After in a loop?

Short answer: A select between the work channel and time.After(d): whichever fires first wins. The catch is in a hot loop: previously the timer from time.After wasn't freed until it fired, so a new one piled up each iteration — a leak. As of Go 1.23 the GC collects such timers, but on older versions you use time.NewTimer + Stop.

In depth:

  1. Basic timeoutselect { case v := <-ch: ...; case <-time.After(d): ... }; for cancellation across a call chain prefer context.WithTimeout.
  2. The loop catch — before Go 1.23, time.After in a for created a timer alive for the full d, even if the ch branch fired instantly: thousands of iterations → thousands of live timers.
  3. Go 1.23 — timers and tickers are GC-collected even if Stop wasn't called; timer channels became unbuffered. time.After in a loop no longer leaks.
  4. Older versions — your own time.NewTimer(d) and Stop() as soon as a value arrives from ch.
// Leaks before Go 1.23: a new timer lives ~a second each iteration
for {
    select {
    case v := <-ch:
        handle(v)
    case <-time.After(time.Second):
        return
    }
}

// Safe everywhere: own timer, Stop right after ch
for {
    t := time.NewTimer(time.Second)
    select {
    case v := <-ch:
        t.Stop() // free the timer immediately
        handle(v)
    case <-t.C:
        return
    }
}

⚠️ Common mistake: claiming "time.After always leaks". On Go 1.23+ it doesn't — check the runtime version. But the NewTimer + Stop habit is safer on older versions too.

68

Which bugs are most often hidden in "find the bug in this concurrent code" tasks?

Short answer: The traps are always the same: a race on a shared variable without mutex/atomic, loop-variable capture (before Go 1.22), wg.Add() inside a goroutine, range over a never-closed channel with a deadlock, and a goroutine without recover that takes down the whole process. Keep this list in your head and scan the code against it.

In depth:

  • Data racecounter++ from several goroutines without sync.Mutex/atomic. go test -race catches it instantly.
  • Loop variable (before Go 1.22)go func(){ use(i) }() without a parameter: every goroutine sees the final i. Since Go 1.22 each iteration is a fresh variable.
  • wg.Add inside a goroutineWait() may slip past before Add. Add(1) always before go.
  • Unclosed channelfor v := range ch with no close(ch) anywhere → deadlock, "all goroutines are asleep".
  • No recover — a panic in a separate goroutine isn't caught by the caller and crashes the whole process.
counter++                                   // 1) race: need mutex/atomic
for _, v := range xs { go func(){ use(v) }() } // 2) loop var (before 1.22)
go func(){ wg.Add(1); defer wg.Done() }()   // 3) Add inside goroutine
for v := range ch { _ = v }                 // 4) who calls close(ch)?
go func(){ mightPanic() }()                 // 5) no recover → process dies

⚠️ Common mistake: hunting the bug by eye instead of go test -race and go vet. The race detector and vet find half the traps in seconds — mention them first.

69

What are table-driven tests, and why are they the idiomatic style in Go?

Short answer: A table-driven test is a slice of case structs (input + expected result) that you iterate over, running each case through t.Run(name, ...). You get named subtests, failure isolation, and zero duplication. It's the style of the standard library itself, which is why interviewers expect it.

In depth:

  1. Slice of cases — each case is a struct with a name, input, and expectation; adding a new scenario is one line, not a new function.
  2. t.Run(tc.name, ...) — wraps each case in a subtest: one failure doesn't stop the rest, and the name shows up in the output (TestParse/empty_input), so you immediately see what broke.
  3. One assertion body — the assert logic is written once; cases only vary the data.
  4. Bonus: t.Parallel() inside a subtest runs independent cases in parallel.
func TestAbs(t *testing.T) {
    tests := []struct {
        name string
        in   int
        want int
    }{
        {"positive", 3, 3},
        {"negative", -3, 3},
        {"zero", 0, 0},
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            if got := Abs(tc.in); got != tc.want {
                t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
            }
        })
    }
}

⚠️ Common mistake: capturing the loop variable in a parallel subtest. Before Go 1.22 you needed tc := tc before t.Run; since Go 1.22 the loop variable is per-iteration, and the workaround is no longer needed.

70

How do you profile a Go service with pprof, and what profile types exist?

Short answer: For a live service you add import _ "net/http/pprof" — it mounts endpoints under /debug/pprof/, and you grab a snapshot with go tool pprof http://host/debug/pprof/heap. For offline runs you write a profile to a file via runtime/pprof. The key types are CPU, heap, goroutine, block, and mutex.

In depth:

  1. Collectionnet/http/pprof for a running service (endpoints on a debug port, not a public one!) or runtime/pprof / go test -cpuprofile flags for a local run.
  2. Analysisgo tool pprof interactively: top, list Func, web (graph), and a flame graph in the browser via -http=:8080.
  3. Match the profile to the symptom — the senior signal is knowing which profile to capture for which problem, rather than reaching for CPU every time.
Profile What you look for
cpu where the CPU burns, hot functions
heap allocations and memory usage
goroutine goroutine leaks (a growing count)
block waiting on channels/mutexes
mutex lock contention

The block and mutex profiles are off by default — enable them with runtime.SetBlockProfileRate and runtime.SetMutexProfileFraction.

⚠️ Common mistake: leaving net/http/pprof on a public port. The import registers handlers on the DefaultServeMux, so /debug/pprof/ leaks to the outside — keep it on a separate internal listener.

71

What does go vet catch that the compiler doesn't?

Short answer: go vet catches suspicious but syntactically valid constructs the compiler lets through: printf argument/format mismatches, copied mutexes, malformed struct tags, unreachable code. The compiler checks that the program builds; vet checks that it probably does what you meant.

In depth:

  1. printf formatsfmt.Printf("%d", "str") compiles, but vet reports Printf format %d has arg of wrong type string.
  2. copylocks — assigning or passing a struct containing a sync.Mutex by value copies the mutex along with its internal state and breaks synchronization.
  3. struct tags — a typo in json:"..." (bad quotes, stray spaces) silently breaks marshaling — vet sees it.
  4. unreachable / lostcancel — unreachable code, a dropped cancel from context.WithCancel.
Check Example problem
printf Printf("%d", s) — s is a string
copylocks passing a sync.Mutex by value
structtag typo in a json: tag
lostcancel never calling cancel()

In CI you usually run not bare vet but golangci-lint — an aggregator that layers staticcheck, errcheck, and dozens of other linters on top of vet.

⚠️ Common mistake: assuming go test doesn't run vet. In fact go test runs a subset of vet checks before your tests by default — you're already seeing some of those warnings there.

72

What are go.mod and go.sum for, and how does Minimal Version Selection work?

Short answer: go.mod declares the module path and the required versions of dependencies; go.sum records cryptographic hashes of downloaded modules for integrity verification. Minimal Version Selection (MVS) picks, for each dependency, the minimum version that satisfies every require in the graph — not the newest. This gives reproducible builds without a separate lock file.

In depth:

  1. go.modmodule, the go version, and a require block with direct and indirect dependencies and their versions.
  2. go.sum — hashes per module and its go.mod; on build go checks downloads against them and the checksum database (sum.golang.org).
  3. MVS — takes the maximum of the minimally required versions across the whole graph. If A needs v1.2.0 and B needs v1.3.0, you get v1.3.0; an upstream v1.9.0 release won't move the build until something explicitly requires it.
your module
 ├─ require A v1.2.0 ─► needs lib v1.4.0
 └─ require B v1.5.0 ─► needs lib v1.3.0
         MVS picks lib = max(1.4.0, 1.3.0) = v1.4.0

⚠️ Common mistake: calling go.sum a lock file. It doesn't pin versions — versions are decided by go.mod + MVS; go.sum only stores hashes to verify you downloaded exactly what you expected, and it may even contain hashes for versions that weren't selected.

73

How do Go generics work, and what does the comparable constraint mean?

Short answer: Since Go 1.18, functions and types can take type parameters in square brackets, bounded by a constraint. A constraint is an interface that defines the set of allowed types and the operations on them. comparable is a built-in constraint that permits == and != (e.g. to use a type as a map key or to search with Contains).

In depth:

  1. Type parametersfunc F[T Constraint](x T); the compiler substitutes the concrete type at the call site, with no runtime conversions.
  2. Constraint as an interface — either a regular one (method set) or a type set: [T int | float64] allows only those types and their operators.
  3. comparable — covers types that support ==/!=; needed wherever a value is compared or placed in a map/set.
  4. Versus any — generics give compile-time type safety without type assertions or boxing into interface{}.
func Index[T comparable](s []T, target T) int {
    for i, v := range s {
        if v == target { // == allowed thanks to comparable
            return i
        }
    }
    return -1
}

⚠️ Common mistake: reaching for generics where a plain interface suffices. If the types already share methods, an interface is more idiomatic; generics matter when the concrete type itself matters (slice elements, map keys, arithmetic).

74

How do you mock dependencies in Go tests without monkey-patching?

Short answer: You express the dependency as a small interface declared on the consumer's side and inject the implementation through the constructor (DI). In the test you pass your own fake implementation of that interface — hand-written or generated by mockgen. No monkey-patching needed.

In depth:

  1. A narrow interface at the consumer — the code depends not on a concrete *sql.DB but on an interface with the one or two methods it actually uses.
  2. Constructor injectionNewService(store Store); in production you pass the real implementation, in the test a fake.
  3. Fake or mock — a simple stub is hand-written; for interfaces that assert on calls, generate one with mockgen (gomock, now maintained as go.uber.org/mock) or use testify/mock.
type Store interface {
    Get(id string) (User, error)
}

type fakeStore struct{ u User }
func (f fakeStore) Get(string) (User, error) { return f.u, nil }

func TestService(t *testing.T) {
    svc := NewService(fakeStore{u: User{Name: "Ann"}})
    // ... assert svc's behavior against the fake
}

⚠️ Common mistake: mocking someone else's huge twenty-method interface instead of your own narrow one. The idiom is "accept interfaces, return structs": the consumer declares the interface, sized to its own needs — and then the fake is trivial.

75

How do you write a benchmark in Go, and what does the -benchmem flag show?

Short answer: A benchmark is a func BenchmarkX(b *testing.B) in a _test.go file where the measured code runs in a loop for as many iterations as needed. You run it with go test -bench=. -benchmem. The -benchmem flag adds memory metrics to the timing: B/op (bytes per operation) and allocs/op (allocations per operation).

In depth:

  1. Classic loopfor i := 0; i < b.N; i++; the framework picks b.N itself to gather enough samples.
  2. b.Loop() (Go 1.24)for b.Loop() replaces the manual b.N, excludes setup from the measurement automatically, and keeps the compiler from eliminating the loop body.
  3. Reading the outputns/op is time; B/op and allocs/op are memory; allocations often matter more than nanoseconds because they pressure the GC.
var sink string
func BenchmarkJoin(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = strings.Join([]string{"a", "b", "c"}, ",")
    }
}
// BenchmarkJoin-8   35000000   34.2 ns/op   8 B/op   1 allocs/op

⚠️ Common mistake: the compiler sees the result is unused and eliminates the loop body — the benchmark reports ~0 ns/op. With the classic b.N loop you store the result in a package-level sink variable; b.Loop() (Go 1.24) handles this for you.

76

Saga pattern: orchestration vs choreography — when do you pick which?

Short answer: Orchestration — an explicit coordinator (a state machine) drives the saga and invokes each step; choreography — services react to each other's events with no central brain. Both give only eventual consistency and need compensating transactions — the choice is only about where the complexity lives: in an explicit orchestrator or in an implicit event flow.

In depth:

Orchestration Choreography
Flow explicit state machine in one place implicit: a chain of events
Coupling services know the orchestrator loose: events only
Observability saga status in one place 'who is to blame' — reassembled from traces
Risks orchestrator is a failure point and coupling hub cyclic subscriptions, event cascades
When long sagas, many steps, audit needed 2–3 steps, independent teams
  1. Orchestration — easy to answer 'what state is order #42 in', easy to add timeouts and retries; the price — every business flow converges into one service, and its availability becomes critical.
  2. Choreography — services deploy independently; the price — the flow is written down nowhere: adding a step means 'subscribe over there', and a year later nobody knows the whole chain.

⚠️ Common mistake: presenting choreography as 'more correct because of loose coupling'. The coupling does not disappear — it moves from code into an implicit event graph that is more expensive to debug.

77

Design the compensation flow for the saga 'create order → charge payment → reserve stock' when stock reservation fails.

Short answer: Compensating transactions run in reverse order: refund the payment → cancel the order. Every compensation must itself be idempotent and retryable; until the saga completes, the order lives in PENDING status (a semantic lock) and is never shown as confirmed; a compensation that keeps failing goes to retries + an alert + a manual-review queue — you cannot silently drop it.

In depth:

create order (PENDING) ──► charge payment ──► reserve stock ✗ FAIL

  cancel order (PENDING→CANCELLED) ◄── refund payment ◄──┘
  1. Reverse order — compensate only the steps that succeeded: refund first, then cancel; the reservation itself failed — there is nothing to compensate.
  2. Semantic lock — while the saga is in flight the order stays PENDING: the user never sees it as confirmed, the half-done state does not leak out.
  3. Idempotent compensations — refund with an idempotency key (orderId): a retry after a payment-gateway timeout will not refund twice.
  4. A compensation that fails — the payment gateway is down: retries with backoff, after N attempts — an alert and an entry in the manual-review queue. A customer's money must not get lost in the logs.

⚠️ Common mistake: treating a compensation as a 'transaction rollback'. It is a regular business operation: it can fail, it can be duplicated, and it must be designed as carefully as the forward step.

78

What is the transactional outbox pattern and what problem does it solve?

Short answer: The dual-write problem: a DB commit and a broker publish are two different systems with no atomicity between them. If the service dies in between, the event is lost (or the reverse: the event went out but the row was never committed). Outbox: the event is written into an outbox table in the same local transaction as the business data, and a separate relay publishes it to the broker.

In depth:

┌──── one local transaction ────────┐
│ INSERT INTO orders …              │
│ INSERT INTO outbox (event) …      │
└───────────────────────────────────┘
   outbox ──► relay (poller or CDC/Debezium) ──► broker
  1. Atomicity for free — both writes hit one database: either the order and its event both exist, or neither does.
  2. Relay — a poller (SELECT … FOR UPDATE SKIP LOCKED) or CDC: Debezium tails the WAL — events flow out with no polling load.
  3. Consequence: at-least-once — the relay can crash after publishing but before marking 'sent' → the event goes out twice. So the other side must run an idempotent consumer with deduplication.

⚠️ Common mistake: 'publish before commit — then we never lose events'. That just flips the failure mode: the commit fails but the event already went out — a phantom event about an order that does not exist.

79

How does an idempotent consumer (inbox pattern) work?

Short answer: The consumer keeps a table of processed message ids with a UNIQUE constraint and inserts the id in one local transaction together with the business effect. A redelivered message hits the constraint — it is simply skipped, the effect is never applied twice. Two key conditions: the id is stable across retries, and deduplication is atomic with the effect.

In depth:

BEGIN;
INSERT INTO processed_messages (message_id) VALUES ($1);
-- duplicate → unique_violation → ROLLBACK, just ack the message
UPDATE accounts SET balance = balance - 100 WHERE id = $2;
COMMIT;
  1. One transaction — if you check 'already processed?' separately from applying the effect, a race between two consumers or retries fits between the check and the write.
  2. Stable id — generated by the producer (the outbox event id), not by the broker at delivery time: a retry must arrive with the same id.
  3. TTL/cleanup — the table grows: purge by age, but the retention window must cover the maximum redelivery delay (retries, DLQ).
  4. Effects outside the DB — an HTTP call is not protected by this pattern: the called API needs its own idempotency key.

⚠️ Common mistake: deduplicating in Redis or in memory next to a Postgres transaction — the 'mark' and the effect diverge again; it is the same dual write from a different angle.

80

Two-phase commit: how does it work and why do microservice architectures avoid it?

Short answer: The coordinator sends prepare (every participant votes and holds locks), then commit. The protocol is blocking: if the coordinator crashes between the phases, participants hang in-doubt holding their locks, unable to either commit or roll back. Add latency pegged to the slowest participant and intolerance to partitions — which is why microservices use sagas and the outbox instead of 2PC.

In depth:

coordinator:  prepare? ──► A: yes   B: yes
              💥 crash before sending commit
A, B:         in-doubt — locks held, rows blocked;
              they cannot decide alone: the coordinator
              may already have recorded a commit
  1. Phase 1 (prepare) — participants write redo/undo, vote, and hold locks until the outcome.
  2. Phase 2 (commit/abort) — unanimous yes → commit to everyone; any no or a timeout → abort.
  3. The price — synchronous waiting on all participants: latency = the slowest one; blocking on coordinator failure; under a network partition the protocol simply stalls.
  4. Where it survives — XA transactions inside one JVM/RDBMS world exist, but between services with different databases and brokers — almost never.

⚠️ Common mistake: offering 2PC as an 'honest' alternative to a saga. 2PC buys atomicity at the cost of availability and liveness — in a distributed system that price is usually unacceptable.

81

Does exactly-once delivery exist? What do people actually mean by exactly-once?

Short answer: Exactly-once delivery does not exist (the two generals problem: an acknowledgment can be lost, so the sender must retry). What is achievable is exactly-once processing semantics: at-least-once delivery + an idempotent, deduplicating consumer — the effect is applied exactly once no matter how many times the message arrives.

In depth:

Level Guarantee How
at-most-once no duplicates, possible loss fire-and-forget
at-least-once no loss, possible duplicates ack + retries
exactly-once processing effect applied exactly once at-least-once + idempotency/deduplication
  1. Why delivery is impossible — between 'processed' and 'ack received' there is always a failure window; the broker must redeliver — otherwise you get loss.
  2. Kafka 'exactly-once' — transactions cover the read-process-write cycle inside Kafka: consume and produce are atomic, and read_committed readers never see aborted writes.
  3. THE trap — Kafka transactions do not cover effects outside Kafka: a Postgres write, an HTTP call, sending an email. Those need their own idempotency (inbox pattern, idempotency keys).

⚠️ Common mistake: 'we run Kafka with exactly-once, duplicates are impossible'. The moment the consumer writes to its own DB or calls an external API, the guarantee ended at the Kafka boundary.

82

A distributed lock on Redis via SET key value NX PX — what can go wrong?

Short answer: Three classics: the TTL expires while the holder is still working (GC pause, slow I/O) — a second holder acquires the lock and now there are two; release deletes someone else's lock; a Redis failover loses the lock due to async replication. Hence: the value is the holder's unique token, release is only an atomic check-and-delete in Lua, and for strict correctness a single Redis lock is not enough.

In depth:

t=0   A: SET lock tokenA NX PX 10000 → OK, working
t=10  TTL expired (A stuck in GC / waiting on disk) — A unaware
t=11  B: SET lock tokenB NX → OK — TWO holders now
t=12  A wakes up and writes to the shared resource → conflict
t=13  A: DEL lock → deleted B's lock (DEL without a check)
  1. Unique token — value = the holder's random uuid; release compares and deletes atomically (Lua: if GET == token then DEL), otherwise A wipes B's lock.
  2. TTL is always a tradeoff — short: expires under a live holder; long: after a holder crashes everyone waits for nothing.
  3. Failover — replication is async: the master dies before replicating the SET — the new master knows nothing about the lock, and it gets acquired a second time.

⚠️ Common mistake: acquiring the lock and assuming mutual exclusion is guaranteed. A Redis lock with a TTL is a lease: it can expire underneath you, and without a fencing token the protected resource will not even notice.

83

Why did Kleppmann criticize Redlock, and what is a fencing token?

Short answer: Redlock's safety rests on timing assumptions — bounded clock drift and bounded process pauses — that real systems violate (stop-the-world GC, page faults, network delays). A paused holder whose lock has 'expired' still writes to the resource. The fix is a fencing token: a monotonically increasing number checked by the resource itself, which rejects writes from stale holders.

In depth:

A takes the lock (token 33) ──► GC stop-the-world, 15 s
        TTL expires; B takes the lock (token 34) and writes
A wakes up, 'still holding the lock', writes
  without fencing:  A's write clobbers B's  💥
  with fencing:     storage sees 33 < 34 → reject A
  1. The core critique — a timer-based lock with no shared source of ordering cannot be safe: process pause length and clock drift are unbounded, and a pause is invisible from the inside.
  2. Fencing token — issued with the lock and strictly increasing; the resource must do the check: a conditional UPDATE … WHERE token >= $1 in the DB, CAS in storage. The client cannot police itself.
  3. What to pick — correctness-critical: a consensus-backed lock (ZooKeeper/etcd; zxid/revision is a ready-made fencing token) or fencing at the storage layer. An efficiency-only lock (avoid doing work twice, a duplicate is harmless) — a single Redis is fine.

⚠️ Common mistake: tuning the Redlock TTL 'with headroom' instead of fencing. No TTL survives a pause of unknown length — without a check on the resource side there is no guarantee.

84

Postgres advisory locks: when are they better than a Redis lock?

Short answer: When all contenders already talk to one Postgres. pg_advisory_xact_lock is released automatically with the transaction (or session) — the TTL race does not exist at all: the process dies → the connection closes → the lock is gone. Ideal for a cron singleton, a migration guard, 'one processor per entity at a time'.

In depth:

BEGIN;
SELECT pg_advisory_xact_lock(hashtext('billing-cron'));
-- critical section; released on COMMIT/ROLLBACK/disconnect
COMMIT;
Redis SET NX PX pg advisory lock
Release TTL: 'expired under a live holder' race automatic with the transaction
Holder crash wait out the TTL instant: the connection closed
Fencing needed separately unnecessary within this DB
Scale any services only clients of this Postgres
Cost extra infrastructure holds a connection and a transaction

Honest limitations: does not work across shards or multiple databases; a long critical section = a long transaction (blocks vacuum); session-level variants are incompatible with pgbouncer in transaction mode.

⚠️ Common mistake: dragging Redis in just for a lock in a system that already shares one Postgres. An advisory lock gives a stronger guarantee for free — no TTL, no fencing, no new infrastructure.

85

Linearizability vs eventual consistency: how do you serve read-your-writes on top of async replicas?

Short answer: Linearizability: every operation appears to take effect atomically at some point between its start and end — everyone sees a single timeline. Needed for balances, uniqueness checks, leader election. Eventual: replicas converge 'at some point'. Read-your-writes on top of replicas: read the author's data from the primary (session stickiness), or track the write's LSN/logical timestamp and wait until the replica catches up.

In depth:

Need Model Mechanics
balance, uniqueness check linearizability leader reads / quorum reads
feed, catalog, counters eventual any replica
'I saved it — I see it' read-your-writes primary for the author / wait on LSN
  1. Stick to the primary — for N seconds after a write, serve that user's reads from the primary; simple, but loads the leader and needs session state.
  2. Track the position — remember the commit LSN (pg_current_wal_lsn()) and read from a replica only when pg_last_wal_replay_lsn() >= LSN; more precise, more plumbing.
  3. Quorum — R + W > N gives strong reads at the cost of latency on every request.

⚠️ Common mistake: conflating consistency models with DB isolation levels. Isolation (read committed, serializable) is about concurrent transactions on one node; consistency (linearizability, eventual) is about replicas and distribution. Different axes.

86

Event sourcing and CQRS: when are they justified and what is the price?

Short answer: Event sourcing: state is not stored but derived — state = fold(events); the event log is primary. CQRS: the write model and the read models are separated. You get a full audit trail, temporal queries ('what did the order look like yesterday'), and rebuildable projections. The price is steep: event versioning, snapshots, eventually consistent read models, expensive tooling. It is not a default architecture.

In depth:

events:  OrderCreated → ItemAdded → ItemAdded → OrderPaid
state  = fold(events)   — always derivable from scratch
projections: 'orders per day', 'top items' — separate read
             models, rebuildable from the log at any time
  1. When justified — money movements, ledgers, audit-heavy and compliance-bound domains; 'why did the balance end up like this' is a business question, not a log-digging exercise.
  2. Price #1: versioning — an event lives forever; when the schema changes you must read every old version (upcasters) or migrate the whole log.
  3. Price #2: reads — read models are async: right after a command the projection lags, and the UI, tests, and support must live with that.
  4. CQRS without ES — legitimate and far cheaper: a regular write DB + denormalized read projections.

⚠️ Common mistake: proposing event sourcing for a CRUD app 'for future growth'. If audit is not a business requirement, you pay the full ES price and gain nothing a table plus a change log would not give you.

87

Why can't you rely on wall-clock time across services, and what breaks with last-write-wins?

Short answer: Machine clocks diverge despite NTP — drift, leap smearing, VM pauses add up to milliseconds or even seconds — so 'a later timestamp' ≠ 'actually happened later'. Last-write-wins on such clocks silently drops concurrent writes: the classic lost update across replicas, with not a single error in the logs.

In depth:

A's clock runs 2 s behind
real t=10.0  A: UPDATE profile   (its ts = 8.0)
real t=9.5   B: UPDATE profile   (its ts = 9.5)
LWW: 9.5 > 8.0 → B 'wins',
though A's write physically came LATER — and silently vanished
  1. Wall clock vs monotonic — the wall clock jumps (NTP can step it backwards); intervals are measured with the monotonic clock, but it is not comparable across machines. There is no shared 'now'.
  2. What fixes it — versions + optimistic locking (UPDATE … WHERE version = $1); a monotonic sequence or fencing token from a single writer; vector clocks — conceptually, to tell concurrency apart from ordering; designing operations to be commutative (increment instead of set, CRDT thinking).
  3. Where LWW is tolerable — telemetry, caches, fields where 'last one wins' is honest business semantics and a dropped write is harmless.

⚠️ Common mistake: 'we will just tune NTP tighter'. NTP reduces drift but does not bound it with a guarantee: without TrueTime-grade bounded-clock infrastructure you cannot build correctness on timestamps.

88

Kafka topic anatomy: partitions, replicas, leader, ISR — and which settings make a write durable?

Short answer: A topic is a set of partitions; a partition is an append-only log, and ordering is guaranteed only within it. Each partition has a leader (serves reads and writes) and follower replicas; the ISR are the replicas keeping up with the leader. The durability standard: acks=all + min.insync.replicas=2 with replication.factor=3.

In depth:

topic orders (RF=3)
p0: [0|1|2|3|4] ──► leader: broker1, ISR: {1,2,3}
p1: [0|1|2]     ──► leader: broker2, ISR: {2,3}
p2: [0|1|2|3]   ──► leader: broker3, ISR: {1,3}
  1. acks=all — the leader replies to the producer only after the write is replicated to every ISR member.
  2. min.insync.replicas=2 — with fewer than two live ISR replicas the producer gets NotEnoughReplicas instead of a silent write to a single copy.
  3. Together with RF=3 — one broker going down is survived with no data loss and no write outage: two replicas remain in the ISR.

⚠️ Common mistake: 'Kafka guarantees message ordering.' Only within one partition; there is no global order in a topic.

89

How does a consumer group get partitions assigned, and what happens when there are more consumers than partitions?

Short answer: Within a group, each partition is read by at most one consumer. More consumers than partitions — the extras sit idle: a group's maximum parallelism equals the partition count. Different groups read the same topic independently, each with its own offsets.

In depth:

topic orders: p0  p1  p2  p3
group A (3 consumers):
  c1 ◄─ p0, p1    c2 ◄─ p2    c3 ◄─ p3
group A (6 consumers):
  c1◄p0  c2◄p1  c3◄p2  c4◄p3   c5, c6 — idle
group B: reads the same partitions independently (own offsets)
  1. Within a group — a partition goes to exactly one consumer: this preserves processing order within the partition.
  2. Across groups — independent reads: each group commits its own offsets to __consumer_offsets, so the same data serves both billing and analytics.
  3. Planning — the partition count caps how far the group can scale; provision it with headroom when creating the topic.

⚠️ Common mistake: 'add more consumers and it gets faster.' Beyond the partition count, added consumers simply sit idle.

90

What triggers a consumer-group rebalance, and why can it be dangerous?

Short answer: A rebalance redistributes partitions within the group. Triggers: a consumer joins/leaves/crashes, session.timeout.ms expires, max.poll.interval.ms is exceeded (slow processing in the poll loop — the classic self-inflicted one), the subscription or partition count changes. It is dangerous because the eager protocol halts the entire group.

In depth:

  1. Eager (classic) — stop-the-world: every consumer revokes every partition, and the group's processing freezes until the rebalance ends.
  2. Cooperative / incremental (KIP-429)CooperativeStickyAssignor: only the partitions actually moving are revoked, the rest keep working.
  3. Static membershipgroup.instance.id: on a rolling restart the broker recognizes the returning consumer and skips the rebalance.
  4. Rebalance storm — heavy work inside the poll loop: max.poll.interval.ms exceeded → the consumer is declared dead → rebalance → partitions move → the new consumer cannot keep up either → the cycle repeats.
session.timeout.ms=45000
max.poll.interval.ms=300000
group.instance.id=payments-1
partition.assignment.strategy=CooperativeStickyAssignor

⚠️ Common mistake: curing a 'dying' consumer by endlessly raising timeouts instead of moving heavy processing out of the poll loop.

91

How do you choose a partition key, and what goes wrong with a bad one?

Short answer: The key is your unit of ordering: messages with the same key land in the same partition and are read in order. Pick the business identifier that ordering revolves around (order_id, user_id). A bad key produces skew — hot partitions that no amount of extra consumers can relieve.

In depth:

Key Effect
order_id per-order event ordering; even spread
user_id per-user ordering; risk of hot 'whales'
country 3–5 values → skew, hot partitions
null round-robin/sticky: even, but no ordering
  1. Skew — a partition with a hot key is capped by its single consumer: lag grows on it alone, and scaling the group does not help.
  2. Changing the partition count — hash(key) % partitions changes: old and new messages of the same key end up in different partitions, and per-key ordering breaks. Size the partition count up front.
  3. Null key — fine for events with no owning entity where ordering does not matter.

⚠️ Common mistake: a random key 'for even distribution' where per-entity ordering is needed — there is nowhere to buy the ordering back later.

92

Offset commit strategies: auto vs manual — and how do you get at-least-once right?

Short answer: enable.auto.commit=true commits offsets on a timer (auto.commit.interval.ms), disconnected from whether processing happened: it can commit before you finished processing (loss on crash) or after (duplicates). Correct at-least-once: process first — then commit manually.

In depth:

Commit timing Semantics Risk
before processing at-most-once crash after commit → message lost
after processing at-least-once crash before commit → duplicate
on a timer (auto) unpredictable both of the above
  1. The working patternenable.auto.commit=false; the loop: poll → process the batch → commitAsync() (does not block the poll).
  2. On the way outcommitSync() in the shutdown hook and in the onPartitionsRevoked rebalance listener: the last position is guaranteed to land.
  3. Duplicates remain — at-least-once by definition allows redelivery: deduplication is the consumer's job.

⚠️ Common mistake: auto-commit fires before the batch is fully processed — a crashed consumer has 'eaten' messages and nobody noticed (a silent at-most-once).

93

A consumer processed a message but crashed before committing the offset. What happens?

Short answer: The offset was not committed — as far as Kafka is concerned the message was never processed: after the rebalance the partition goes to another consumer, which receives the same message again. The effect has already been applied once → processing doubles up. This is exactly why Kafka is at-least-once by default and the consumer must be idempotent.

In depth:

c1: poll ──► processed (wrote to DB) ──► 💥 crash
                    offset NOT committed
rebalance ──► c2 reads from the last commit
          ──► the same message is delivered again
  1. A contract, not a bug — the broker cannot know processing finished; the only signal is a committed offset.
  2. The defense — an idempotent consumer: insert the stable message id into an inbox table with a UNIQUE constraint in the same database transaction as the business effect. A redelivery hits the constraint and cannot apply the effect twice.
  3. The reverse order is worse — committing before processing turns the same crash into message loss instead of a duplicate.

⚠️ Common mistake: treating duplicates as an anomaly and skipping deduplication — 'we run Kafka, it's all reliable there.'

94

Consumer lag keeps growing. How do you diagnose and fix it?

Short answer: First measure per-partition lag (kafka-consumer-groups.sh or an exporter into metrics) and figure out what changed: input rate up, or processing slowed down. Then fix in ranked order: consumers up to the partition count, move heavy work out of the poll loop, batching, and only then — more partitions and backpressure.

In depth:

$ kafka-consumer-groups.sh --describe --group billing
TOPIC   PART  CURRENT   LOG-END   LAG
orders  0     91400     91420     20
orders  1     52100     98700     46600  ◄ one partition
orders  2     97800     97810     10
  1. Lag on one partition — a hot key or a 'stuck' message: scaling will not help, fix the key or the processing.
  2. Lag spread evenly — scale consumers up to the partition count; beyond that it is pointless.
  3. Slow processing — move I/O out of the poll loop into a worker pool (carefully: per-key ordering), batch the DB writes.
  4. Systemically — increase the partition count (planned: it remaps keys) or negotiate backpressure with the producers.
  5. While you fix it — watch max.poll.interval.ms: a slow loop will trigger a rebalance and double the lag.

⚠️ Common mistake: adding consumers beyond the partition count as the first move — the new ones simply sit idle.

95

How do you build retries in Kafka without blocking a partition?

Short answer: Retrying in place while blocking the poll is not an option: one poison message stalls the whole partition. The pattern — retries via separate topics with growing delays (orders-retry-5m → orders-retry-30m) and a DLQ after N attempts, carrying failure-reason headers.

In depth:

orders ──✗──► orders-retry-5m ──✗──► orders-retry-30m ──✗──► orders-dlq
   │ ok           │ ok (after delay)       │ ok                 │ alert +
   ▼              ▼                        ▼                    manual review
  1. The main consumer — on failure publishes the message to the retry topic and commits the offset: the partition never blocks.
  2. The retry consumer — waits out the delay (compares the message timestamp with now), processes; on another failure — the next tier.
  3. DLQ — after N attempts; headers carry the reason, stack trace, attempt counter; an alert on a non-empty DLQ is mandatory.
  4. The price — ordering — a message goes into retries while the next one for the same key gets processed first: per-key ordering breaks. Carry a version/sequence in the event and drop stale ones.

⚠️ Common mistake: endless in-place retry of a poison message — the partition stalls, lag grows, and poll timeouts finish the group off with rebalances.

96

Idempotent producer and Kafka transactions: what does each mechanism actually guarantee?

Short answer: enable.idempotence=true: the producer gets a producer id, every message a sequence number, and the broker discards duplicate retries — within a partition and a producer session. Transactions (transactional.id + read_committed) add an atomic consume-transform-produce across Kafka topics. Neither deduplicates business-level duplicates, and neither covers effects outside Kafka.

In depth:

Mechanism What it guarantees Boundaries
idempotent producer a send retry creates no duplicate partition + producer session
transactions consume + produce are atomic Kafka topics only
read_committed aborted writes are invisible consumer side
  1. Idempotence — protection against the client's own network retries; a repeated send() from application code is a brand-new message.
  2. Transactions — offsets are committed inside the same transaction (sendOffsetsToTransaction): the read-process-write cycle happens entirely or not at all.
  3. Boundaries — the application emitting one business event twice → to Kafka these are two different messages; a Postgres write or an HTTP call is not covered by a Kafka transaction.

⚠️ Common mistake: 'we enabled exactly-once — no deduplication needed.' The guarantee ends at the Kafka boundary; an idempotent consumer is still mandatory.

97

Kafka vs RabbitMQ, for real: how do you pick per use case?

Short answer: Kafka — a replicated log with retention and replay: huge throughput, many independent consumer groups, streaming and event sourcing. RabbitMQ — a smart broker: exchange-based routing, per-message ack/requeue, priorities, TTL, and delayed delivery out of the box — the classic task queue.

In depth:

Requirement Kafka RabbitMQ
replay / reread history ✔ retention ✘ deleted after ack
complex routing ✘ topics/keys only ✔ exchanges: topic, fanout, headers
delay / priorities via retry topics, clunky ✔ native
throughput millions of msg/s tens–hundreds of thousands
many independent readers ✔ groups with own offsets must multiply queues
task queue mediocre ✔ per-message ack/requeue
  1. Kafka — data as a log: service integration via events, analytics, stream processing, an event-sourcing backbone.
  2. Rabbit — commands and tasks: 'send this email', retrying one message without touching its neighbors, lower latency at small scale.

⚠️ Common mistake: 'Kafka is always better, it scales more.' For a task queue with priorities and delayed delivery, Rabbit is simpler and semantically a better fit.

98

How do you guarantee processing order for one order's events end-to-end?

Short answer: Ordering holds as a chain: partition key = order_id (all of the order's events in one partition), the partition is read by one consumer in the group, no reordering inside the consumer (per-key serial execution with worker pools), and retries never overtake — the event carries a version, stale ones are dropped.

In depth:

  1. The producer — key=order_id: created, paid, shipped for one order land in one partition in write order; plus the idempotent producer, so a network retry cannot reorder messages.
  2. Broker → group — the partition is assigned to exactly one consumer: read order equals log order.
  3. Inside the consumer — an async worker pool breaks ordering: shard tasks by key — one key always lands on one worker and is processed serially.
  4. Retries — retry topics overtake the main queue: carry a version/sequence in the event, and the consumer drops stale ones (a state machine tolerant of out-of-order).
  5. Across topics — there is NO ordering and never will be: payments and shipments are not synchronized; design a state machine instead of hoping for delivery order.

⚠️ Common mistake: getting the key and the group right, then handing messages to an async pool — ordering quietly breaks inside your own process.

99

Why put a queue between services when HTTP with retries exists?

Short answer: A queue decouples services in time: the consumer can be down while the producer keeps working; peaks are smoothed by the buffer; one event fans out to many consumers; history can be replayed. HTTP with retries keeps the temporal coupling: the receiver must be alive right now.

In depth:

HTTP + retries Queue
receiver is down retries, then failure messages accumulate, processed later
load spike hammers the receiver buffered; consumer works at its own pace
fan-out N calls in code subscriptions; producer unaware of readers
immediate response ✘ correlation id, callback
consistency stronger (sync) eventual
  1. Queue — events, background jobs, spiky load, several independent consumers of one event.
  2. HTTP — an immediate response or strong consistency within the request is required.
  3. The queue's price — eventual consistency, mandatory duplicate handling (at-least-once), lag monitoring, request-response via correlation — more complex and costlier to debug.

⚠️ Common mistake: dragging a queue into a flow where the caller needs a synchronous answer — you get the same coupling plus a broker on top.

100

Cache-aside vs write-through vs write-behind: how does each work and when do you choose it?

Short answer: Cache-aside — the app reads the cache, on a miss goes to the DB and stores the result; on writes it updates the DB and invalidates the key. Write-through — writes go to cache and store synchronously. Write-behind — the cache acks immediately and flushes to the store asynchronously. The default is cache-aside; the other two fit specific workload profiles.

In depth:

Cache-aside Write-through Write-behind
Read miss → DB → cache from cache from cache
Write DB + invalidate the key cache + DB synchronously cache now, DB later
Consistency stale window after a write readers see fresh data weak until the flush
Write latency same as the DB DB + cache: slower cache only: fast
Loss risk none none yes: crash before flush
  1. Cache-aside — the cache is off the write critical path and its outage is survivable (just more misses); the price — your own invalidation protocol on every write.
  2. Write-through — consistent reads out of the box; the price — every write waits on both stores, and you cache things nobody will ever read.
  3. Write-behind — a buffer for write bursts (counters, likes, metrics); without a durable buffer (a queue, AOF) a node crash = lost writes.

⚠️ Common mistake: picking write-behind 'for speed' without answering what happens to unflushed writes when the process dies.

Source notes

References and review policy

RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going

Interview strategy3 min

How to Prepare for a System Design Interview

Prepare for a system design interview with a repeatable framework for requirements, estimates, architecture, data, failure, and trade-offs.

2 quick answers
RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS