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
A recorded source review is available for 100 of 100 answers. Each reviewed answer lists its date and scope.
01How does a slice differ from an array, and what does a slice look like under the hood?
junior
Short answer: An array ([N]T) contains a fixed number of elements; its length is part of its type. Assigning an array copies its elements. A slice ([]T) describes a window into a backing array, so assigning a slice shares that array.
In depth:
- Think of a slice as
{pointer, len, cap}: the pointer identifies its first element,lenis the visible element count, andcapis how far it can grow from that position without needing another array. arr[1:3]includes indexes 1 and 2. The upper bound is excluded. It has length 2 and, for a five-element array, capacity 4.- The standard implementation uses three machine words for a slice descriptor: typically 24 bytes on 64-bit systems and 12 on 32-bit systems. That layout is an implementation detail, not a reason to hard-code its size.
arr := [5]int{0, 1, 2, 3, 4}
s := arr[1:3] // [1 2], len=2, cap=4
copyOfArray := arr
alias := s
alias[0] = 9
fmt.Println(arr, copyOfArray, s)
// [0 9 2 3 4] [0 1 2 3 4] [9 2]
Watch out: Copying an array copies its elements, not the objects that pointer, map, or slice elements refer to. Both array and slice storage placement depends on compiler escape analysis.
Sources: Go reference 1, Go reference 2.
02What does append do when capacity is exhausted, and why can two slices unexpectedly share one backing array?
middle
Short answer: append returns a slice with the appended elements. If len(s) + numberOfNewElements <= cap(s), it reuses the backing array. Otherwise it allocates a larger array and copies the existing elements. Always use the returned slice.
In depth:
- Spare capacity can cause aliasing: appending through a subslice can overwrite elements visible through another slice.
- Growth depends on the total number of appended elements, not just whether
len < capbefore the call. - The language does not promise a capacity growth factor. Compiler/runtime versions and allocation size classes affect the result; do not rely on doubling.
a := []int{1, 2, 3}
b := a[:2]
b = append(b, 9)
fmt.Println(a) // [1 2 9]: the same array
c := a[:2:2] // len=2, cap=2
c = append(c, 8)
fmt.Println(a, c) // [1 2 9] [1 2 8]: new array for c
Watch out: s[:k:k] prevents an append of additional elements from overwriting the original tail. It is not a copy: writing existing elements such as c[0] before that append still changes a[0].
Sources: Go reference 1, Go reference 2.
03How do you copy a slice so that changes to the copy don't affect the original?
junior
Short answer: Use slices.Clone(src) (Go 1.21+) or copy the elements into a separate destination slice. Assignment alone copies the slice descriptor and shares its backing array.
src := []int{1, 2, 3}
// Three independent alternatives:
a := slices.Clone(src) // import "slices"
b := make([]int, len(src))
n := copy(b, src) // n == 3
c := append([]int(nil), src...)
_ = n
_ = c
a[0] = 9
fmt.Println(src, a, b) // [1 2 3] [9 2 3] [1 2 3]
In depth: copy writes min(len(dst), len(src)) elements. The destination can be an existing slice or an array slice; it does not specifically require make. Capacity alone is insufficient: make([]int, 0, len(src)) has no elements for copy to write.
These are shallow copies. With [][]int, []map[string]int, or pointer fields, the nested objects remain shared. Copy those too if your ownership contract requires full isolation.
Watch out: A full slice expression only limits capacity; it does not copy elements. Also, slices.Clone preserves nilness, while append([]T(nil), empty...) returns nil even for a non-nil empty source.
Sources: Go reference 1, Go reference 2.
04What's the difference between a nil slice and an empty slice, and where does it bite?
middle
Short answer: An empty slice means len(s) == 0; it can be nil or non-nil and may have spare capacity. A nil slice specifically has len == 0, cap == 0, and s == nil.
| Declaration | len |
cap |
s == nil |
encoding/json |
|---|---|---|---|---|
var s []int |
0 | 0 | true | null |
s := []int{} |
0 | 0 | false | [] |
s := make([]int, 0, 8) |
0 | 8 | false | [] |
In depth: range runs zero times for all three; append works for all three. Indexing s[0] panics while the length is zero, regardless of capacity. Use len(s) == 0 when you mean “no elements”; s == nil asks a different question.
Where it matters: For an ordinary slice of integers, encoding/json.Marshal emits null for nil and [] for an empty non-nil slice. Field tags such as omitempty can omit either, and []byte has special base64-string encoding. If an API promises an array, ensure a non-nil slice before encoding it.
Sources: Go reference 1, Go reference 2.
05Does Go pass arguments by value or by reference? What happens when you pass a slice or a map to a function?
middle
Short answer: Go passes every argument by value. The copied value can itself refer to shared data: a slice describes a backing array, and a map value refers to shared map storage. A pointer argument is also a copied pointer value.
In depth:
- Mutating
s[i]orm[key]changes the shared data, so the caller sees it. - Reassigning a parameter changes only the local variable. This includes replacing a map and changing a slice's length with
append, even if no allocation occurs. - Return the updated slice and assign it in the caller when its length may change.
func grow(s []int) []int {
return append(s, 99)
}
func set(s []int) {
s[0] = 7
}
func replace(m map[string]int) {
m = make(map[string]int) // only the local map variable
m["x"] = 1
}
func main() {
a := []int{1, 2}
set(a)
a = grow(a)
m := map[string]int{}
replace(m)
fmt.Println(a, len(m)) // [7 2 99] 0
}
Watch out: “Passed by value” does not mean “deep-copied.” Explain which value is copied and which data it continues to share; no knowledge of a version-specific map header is required.
Sources: Go reference 1, Go reference 2.
06What's the difference between make and new?
junior
Short answer: new(T) creates a zero-valued variable of type T and returns *T. make initializes a slice, map, or channel and returns that type itself. Neither spelling guarantees a heap allocation.
| Expression | Result | Initial value |
|---|---|---|
new(int) |
*int |
points to 0 |
new(map[string]int) |
*map[string]int |
points to a nil map |
make(map[string]int) |
map[string]int |
empty map, ready for writes |
make([]int, 3, 8) |
[]int |
three zeros, capacity eight |
make(chan int, 2) |
chan int |
empty channel with room for two values |
In depth: A nil slice is already useful for len, range, and append. A nil map supports reads but not insertion; a nil channel cannot communicate. make supplies the initialized backing storage needed for the operations you intend to use.
Go 1.26+: new also accepts a value expression: p := new(42) creates an *int initialized to 42. The older type form new(int) still creates zero. This new syntax requires a Go 1.26+ language version.
Watch out: p := new(map[string]int); (*p)["x"] = 1 panics because the map is still nil. Use a map literal or make(map[string]int) when you need to insert entries.
Sources: Go reference 1, Go reference 2.
07What are the zero values of a slice, map, channel, pointer, and interface — which are usable without initialization?
concept
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:
- Slice —
nil;len,range,appendall work. The friendliest nil. - Map —
nil; reads return the zero value, writes panic. - Channel —
nil; send and receive block forever (used deliberately to "disable" a branch in aselect). - Pointer —
nil; dereferencing*p→panic: nil pointer dereference. - Interface —
nil; comparing tonilis 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 sending to or receiving from a nil channel blocks, but close(nilChannel) panics. Indexing a nil slice also panics: append returns a usable slice, it does not make s[0] valid before an element exists.
Sources: Go reference 1, Go reference 2.
08What happens when you read from a nil map? And when you write to it?
junior
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:
- Reading —
v := m[k]returns the zero value,v, ok := m[k]givesok == false. Ranging over a nil map is zero iterations, also panic-free. - Writing —
m[k] = von a nil map raises a panic. The map must be initialized first:m := make(map[K]V)or a literalmap[K]V{}. - Contrast with a slice —
appendworks 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 — initialize the map first. delete(m, key) and len(m) are also safe for a nil map with a valid key.
Sources: Go reference 1, Go reference 2.
09Which types can be map keys, and why can't a slice be one?
junior
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:
- Allowed — numbers, strings, bool, pointers, channels, interfaces, plus structs and arrays of comparable fields.
- Not allowed — slice, map, function: they have no
==, only a nil check. - 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.
Example: Use [2]int{x, y} as a coordinate key because arrays of integers are comparable. []int{x, y} cannot be a key. With an interface key, the dynamic value must also be comparable, including values stored in interface fields of a struct key.
Sources: Go reference 1, Go reference 2.
10Why can't you take the address of a map value (&m[key])?
senior
Short answer: A map index expression is not addressable, so &m[key] is illegal. This language rule lets the implementation relocate entries during growth without promising each entry a stable address.
Two useful patterns:
type Counter struct{ N int }
m := map[string]Counter{"a": {}}
// m["a"].N++ // invalid: the struct value is not addressable
// _ = &m["a"] // invalid: cannot take its address
v := m["a"]
v.N++
m["a"] = v // replace the complete value
mp := map[string]*Counter{"a": {}}
mp["a"].N++ // modifies the separately stored Counter
The pointer stored in mp still refers to the same object even if map entries move. The map lookup itself has not become addressable; dereferencing its pointer result gives access to the object.
Watch out: Check that a pointer-valued lookup is non-nil before dereferencing a possibly missing key. Neither pattern makes concurrent access safe: protect map operations and any shared pointed-to state with the appropriate synchronization.
Sources: Go reference 1, Go reference 2.
11Why is Go map iteration order random?
junior
Short answer: The language leaves map iteration order unspecified; it need not change on every iteration and is not a uniformly random shuffle. Runtime 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 (internal growth can change the physical layout).
In depth:
- 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.
- Order is undefined regardless — insertion doesn't preserve sequence; growth can relocate elements.
- 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 particular range order. It can fail on another run or runtime version. This is about iteration; some formatting and serialization packages deliberately sort map keys. Sort the keys or compare as a set.
Sources: Go reference 1, Go reference 2.
12How is a Go map built internally: buckets, load factor, evacuation?
senior
Short answer: A map is a hash table, but buckets and evacuation describe the older implementation. Go 1.24 introduced Swiss Tables. First state the Go version, then explain hashing, collisions, and growth for that implementation.
Current design, introduced in Go 1.24:
- Keys are hashed; groups contain eight key/value slots and a control word. The control bytes track slot state and a seven-bit hash fragment, quickly filtering candidates before full key comparison.
- Collisions use probing across groups instead of overflow-bucket chains.
- A map can contain several tables. A table grows or splits when necessary, so one insertion does not rebuild the entire large map. In this design a table has at most 1,024 slots.
- Normal table occupancy is limited to about 7/8 (87.5%); tiny single-group maps have special handling. These numbers are runtime details, not language guarantees.
Historical design, before Go 1.24: Buckets held eight pairs and linked overflow buckets. Growth usually doubled the bucket count at roughly 6.5 entries per bucket (about 81% occupancy), with incremental evacuation. Excess overflow could also trigger same-size growth.
What to retain: A hash chooses candidate locations; equality identifies the key. Collisions and growth are internal. None of these implementations promises iteration order or safe unsynchronized writes.
Sources: Go reference 1, Go reference 2.
13What happens on concurrent map writes from multiple goroutines without synchronization?
middle
Short answer: Unsynchronized concurrent writes to the same map are a data race, even when the keys differ. The runtime may detect overlapping operations and terminate the process with fatal error: concurrent map writes; detection is not guaranteed on every execution.
In depth:
- A read concurrent with a write also requires synchronization. Concurrent read-only access is safe when no goroutine is changing the map.
- A runtime fatal error cannot be recovered with
recover. An execution that happens not to crash is still not evidence that the program is correct. - Use the same mutex around all access, or assign map ownership to one goroutine.
sync.Mapis a specialized alternative, not the default fix for every map.
m := make(map[int]int)
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
mu.Lock()
m[i] = i
mu.Unlock()
}(i)
}
wg.Wait() // all writers finished before this read
fmt.Println(len(m)) // 8
Verify: Run relevant tests with go test -race. It reports races exercised by those tests; it does not prove every possible execution is race-free.
Sources: Go reference 1, Go reference 2.
14What does len() return for a Cyrillic string, and how do you count characters correctly?
junior
Short answer: len("привет") is 12: len counts bytes. utf8.RuneCountInString("привет") is 6: it counts Unicode code points (runes). A user-perceived character can contain several runes, so first decide what “character” means.
In depth:
- A Go string is an immutable sequence of bytes; it is not required to contain valid UTF-8. Source text and ordinary non-escaped string literals are UTF-8.
s[i]returns a byte.rangedecodes runes and yields each rune's starting byte offset.utf8.RuneCountInStringcounts runes without building a rune slice.len([]rune(s))has the same count; compiler optimizations determine whether that conversion actually allocates.- A combining mark or a multi-rune emoji can make one visible character. Rune counting is not grapheme-cluster counting. Invalid UTF-8 bytes are counted as replacement runes; use
utf8.ValidStringwhen validation matters.
s := "привет"
fmt.Println(len(s), utf8.RuneCountInString(s)) // 12 6
for i, r := range s {
fmt.Printf("%d:%c ", i, r)
}
// 0:п 2:р 4:и 6:в 8:е 10:т
fmt.Println(utf8.RuneCountInString("e\u0301")) // 2: e + accent
Watch out: Strings are immutable, and a byte slice such as s[:1] can split a UTF-8 sequence. Use the unit required by the task: bytes for byte protocols, runes for code points, graphemes for visible text editing.
Sources: Go reference 1, Go reference 2.
15What is iota and how do you build enums with it?
junior
Short answer: iota is an auto-incrementing counter inside a const block: it's 0 on the block's first constant specification and increases by 1 with each subsequent ConstSpec declaration. It resets in every new const block. It's the basis for enum-like sets of constants.
In depth:
- Basic enum — list the constants and
iotafills in 0, 1, 2… automatically. - Skipping a value —
_ = iotaskips 0 (often so the zero value means "unset"). - Bit flags —
1 << iotayields 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
KiB = 1 << (10 * iota) // 1024
MiB // 1048576
)
⚠️ Common mistake: thinking iota counts constants. It counts ConstSpec declarations 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 declaration, not per physical source line. Blank lines and comments do not increment it.
Enum limitation: A defined integer type with constants is not a closed set: Weekday(99) is legal. Validate external values when only named constants are allowed.
Sources: Go reference 1, Go reference 2.
16How does Go achieve polymorphism without classes and inheritance?
junior
Short answer: An interface names a behavior through method signatures. Any type with the required method set implements it implicitly. A function accepting that interface can work with multiple concrete types, and a method call uses the implementation belonging to the stored dynamic value.
type Speaker interface{ Speak() string }
type Cat struct{}
type Dog struct{}
func (Cat) Speak() string { return "meow" }
func (Dog) Speak() string { return "woof" }
func announce(s Speaker) {
fmt.Println(s.Speak())
}
func main() {
announce(Cat{}) // meow
announce(Dog{}) // woof
}
In depth: The signatures must match; no implements declaration is needed. Interfaces are checked statically at assignment/call sites, while calls through interface values can dispatch dynamically. Prefer small interfaces describing what the consumer needs.
Embedding is a separate tool: it composes one type inside another and can promote its methods. It does not make the outer type a subtype of the embedded type or introduce class-style method overriding. Package-level encapsulation is controlled by exported names, which begin with an uppercase Unicode letter.
Watch out: “Go has no class inheritance” does not mean “Go has no dynamic dispatch.” Interface calls provide it. Embedding alone does not make a method on an inner value dispatch to a same-named outer method.
Sources: Go reference 1, Go reference 2.
17Value receiver vs pointer receiver: what's the difference, and how does it affect interface satisfaction?
middle
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:
- Mutation — a pointer method mutates the receiver; assignments to fields in a value receiver change only the copy. But a copied slice, map, or pointer field can still mutate shared underlying objects.
- Copying — a value receiver copies the whole struct on every call; this can matter for large structs, although the compiler may optimize copies. Do not copy a value containing a mutex after its first use.
- 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. Consistent receivers often make the API easier to understand, but mixing them is legal; choose based on mutation, copying, and method-set requirements.
Sources: Go reference 1, Go reference 2.
18How does struct embedding work, and what happens when two embedded types have the same method?
middle
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:
- Promotion —
a.Method()calls the embedded type's method when the outer type has none of its own. - Shadowing — an outer-level method/field shadows the embedded one; the shallowest depth wins.
- 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. Embedding itself adds no virtual dispatch: if a method with receiver a A calls a.Hello(), it calls A.Hello() — not an "overridden" version at the C level.
Sources: Go reference 1, Go reference 2.
19How do you do a type assertion without risking a panic?
junior
Short answer: Use v, ok := x.(T) and check ok. A failed assertion produces the zero value of T and false; the single-value form x.(T) instead panics. Use a type switch when several types are expected.
var x any = "hello"
s, ok := x.(string)
fmt.Println(s, ok) // hello true
n, ok := x.(int)
fmt.Println(n, ok) // 0 false
switch v := x.(type) {
case string:
fmt.Println("text:", v)
case int:
fmt.Println("number:", v)
case nil:
fmt.Println("nil interface")
default:
fmt.Printf("unsupported type %T\n", v)
}
In depth: If T is concrete, the dynamic type must be identical to T; an assertion does not convert between types. If T is itself an interface, the dynamic type must implement it. A nil interface fails every assertion, while an interface containing a typed nil pointer can successfully assert to that pointer type and return a nil pointer with ok == true.
Watch out: After a successful assertion to a pointer type, nil may still be a valid result. Also, a failed single-value assertion raises a panic; whether it terminates the process depends on recovery at the surrounding boundary.
Sources: Go reference 1, Go reference 2.
20Why is an interface holding a typed nil pointer not equal to nil?
senior
Short answer: Conceptually 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:
- The (type, value) pair —
i == nilis true only when the interface has no dynamic type or value. - Typed nil — the variable
var e *MyErris itself nil, but returning it througherrorcarries its type*MyErrinto the interface. Type is set → the interface is not nil. - A classic production bug — a function returns
error, the caller writesif 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.
Check: A method call on an interface holding a nil pointer can succeed if the method handles a nil receiver; the interface is still non-nil. This is different from calling a method on a nil interface.
Sources: Go reference 1, Go reference 2.
21How are iface and eface represented in the runtime?
senior
Short answer: In the standard Go runtime, an empty interface (any, an alias for interface{}) is represented as eface: type metadata plus a data word. A method-bearing interface is iface: an itab pointer plus a data word. These are implementation details; the language-level model is a dynamic type and value.
any / eface interface with methods / iface
(type, data) (itab, data)
| |
concrete type interface type + concrete type
+ method entry points
In depth:
- An
itabdescribes how one concrete type satisfies one interface. Some are emitted by the compiler; others are created and cached by the runtime. - The data word represents the stored value. Depending on its type and compiler decisions, it can hold a pointer-like value directly or point to separately stored data.
- Assigning a value to an interface copies that value. If it contains pointers, a map, or a slice, the objects they refer to can remain shared.
- Interface conversion does not necessarily allocate on the heap. Escape analysis, devirtualization, and inlining affect the cost.
Watch out: Do not cast an interface to a hand-written runtime header in ordinary application code. Use type assertions or reflection. Before optimizing interface use, measure allocations with benchmarks and inspect compiler diagnostics such as -gcflags=-m=2.
Sources: Go reference 1, Go reference 2.
22How does a goroutine differ from an OS thread?
junior
Short answer: A goroutine is a concurrent execution managed by the Go runtime. An OS thread is scheduled by the operating system. Go multiplexes many goroutines over OS threads, and each goroutine has a stack that can grow and shrink.
In depth:
- Start one with
go f(). The caller continues without waiting; add an explicit synchronization point when completion matters. - Goroutine stacks start small (historically around 2 KiB in the standard runtime). The initial size and growth policy are implementation details. OS-thread stack sizes depend on the platform and configuration.
- When a goroutine blocks on a channel or supported network I/O, the runtime can run another goroutine on the available thread. Blocking system calls and cgo can require additional OS threads.
- Runtime scheduling is generally cheaper than creating and managing a dedicated OS thread per task. Exact costs and safe counts depend on the workload; goroutines still consume memory and retain referenced objects.
done := make(chan struct{})
go func() {
fmt.Println("work")
close(done)
}()
<-done // wait for this goroutine's completion
Watch out: Concurrency means tasks can make progress independently; parallelism means they run simultaneously. Goroutines provide concurrency, while available CPUs and GOMAXPROCS constrain parallel Go execution. Returning from main ends the program without waiting for other goroutines.
Sources: Go reference 1, Go reference 2.
23What's the difference between buffered and unbuffered channels?
junior
Short answer: On an open unbuffered channel, a send waits for a matching receive. An open buffered channel can accept values until its buffer is full; a receive waits while the buffer is empty. Buffering changes backpressure, not the need to design synchronization.
unbuffered := make(chan int)
go func() { unbuffered <- 7 }()
fmt.Println(<-unbuffered) // 7: matches the sender
buffered := make(chan int, 1)
buffered <- 9 // no receiver needed yet
fmt.Println(<-buffered) // 9: drains the slot
In depth:
make(chan T)has capacity zero;make(chan T, N)has capacityN.- Sending through an unbuffered channel synchronizes with receipt of that value. It does not mean the receiver has finished processing it. Use a separate acknowledgment when completion matters.
- A buffer absorbs bounded bursts, but if production stays faster than consumption, it eventually fills and applies backpressure.
- Closed channels have different rules: receiving drains buffered values then returns zero with
ok == false; sending panics.
Watch out: In one goroutine, ch <- 1 followed by <-ch on an unbuffered channel cannot progress: execution never reaches the receive. In an otherwise idle program the runtime reports a deadlock. Use another goroutine or a buffer that matches the intended protocol.
Sources: Go reference 1, Go reference 2.
24What happens when you send to a closed channel, receive from it, or close it twice?
middle
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:
- Send to closed —
panic: 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; the key is proving every sender has finished. - Receive from closed — the buffer drains as usual; once empty, receives immediately return the zero value with
ok == falseand never block. - Second close —
panic: close of closed channel; a doublecloseis a classic symptom of missing single ownership. - 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 remains eligible on every iteration and can create a busy loop. On ok == false, exit or nil out its variable; other ready cases can still be selected.
Sources: Go reference 1, Go reference 2.
25How do you detect that a channel is closed when receiving?
junior
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:
- Comma-ok —
v, ok := <-ch: a received value sent by a producer hasok == true; an open, empty channel blocks rather than returning a status; after close and drain,vis the type's zero value andok == false. - range —
for v := range chreads until close and exits with no extra code; the idiomatic consumer form. - Why
okspecifically — distinguishing "a genuine zero value was sent" (e.g.0or"") from "the channel is closed" is only possible viaok; the value itself is indistinguishable.
// form 1: comma-ok
v, ok := <-ch
if !ok {
// channel is closed and empty
}
// form 2: range — exits after close and draining the buffer
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.
Watch out: This is a receive, not a non-consuming “is closed?” query. It can block or consume a value, and checking it cannot make a later send safe against concurrent closure.
Sources: Go reference 1, Go reference 2.
26Who should close a channel — the sender or the receiver? Why?
middle
Short answer: The component that owns the sending lifecycle should close the channel, once it can prove all sends have finished. Usually this is the sole sender; with multiple senders it is often a coordinator. close means “no more values,” not “free this resource.”
In depth:
- A receiver must not close a data channel while producers can still send: the next send would panic.
- With several senders, wait for all of them before a single close. A
sync.WaitGroupis a completion counter:Addregisters work before launch,Donemarks its end, andWaitwaits for zero. - No close is required if the receiver already knows exactly how many values to receive, or stops through an independently owned cancellation signal. Unreachable channels can be garbage-collected; blocked goroutines still need an exit path.
out := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
out <- i
}(i)
}
go func() {
wg.Wait()
close(out) // coordinator knows every sender has finished
}()
for v := range out {
fmt.Println(v) // each of 0, 1, 2; order unspecified
}
Watch out: Ownership, not the goroutine's label or who called make, determines who may close. If a consumer can stop early, producers also need cancellation-aware sends; a wait group alone cannot unblock them.
Sources: Go reference 1, Go reference 2.
27How does select choose when several channels are ready, and what does default change?
middle
Short answer: select makes a uniform pseudo-random choice among communication cases that can proceed. Source-code order gives no priority. If none is ready it blocks, unless a default case is present; then default runs immediately.
select {
case value, ok := <-ch:
if !ok {
fmt.Println("channel closed and empty")
} else {
fmt.Println(value)
}
default:
fmt.Println("nothing available right now")
}
In depth:
defaultdoes not compete with ready channels; it runs only when no communication can proceed.- Random choice avoids a fixed source-order preference, but promises neither round-robin service nor a bounded wait for each case.
- A cancellation case such as
<-ctx.Done()has no automatic priority over a ready work case. - With all channel cases nil and no
default, the select blocks forever. A closed receive channel remains ready, so handleok == falseand exit or disable that channel.
Watch out: A loop with default and no blocking work can spin and consume CPU. Use a blocking select when you mean to wait. For a single timeout, add case <-time.After(d); creating a new timer on every loop iteration resets that wait and may cause avoidable allocations.
Sources: Go reference 1, Go reference 2.
28What happens on send/receive with a nil channel, and how is that used in select?
middle
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:
- Nil-channel semantics —
<-nilChandnilCh <- vblock forever;close(nilCh)panics. - Why it matters in select — a closed channel is instantly ready in
selectand fires the zero value endlessly in a loop. To disable an exhausted input, you nil its variable — then the case is never ready. - Fan-in pattern — merge two channels into one, switching off each source's case as it closes. In this finite example both sources must eventually close, the receiver must drain
out, and this function is the sole sender and closer ofout.
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 stays eligible and can cause a busy loop. It does not gain priority over other ready cases. If a downstream receiver may stop early, protect the out <- v operations with cancellation as well.
Sources: Go reference 1, Go reference 2.
29What was the classic loop-variable capture trap in goroutines, and what changed in Go 1.22?
middle
Short answer: Before the Go 1.22 language semantics, closures could share the loop variable and observe its later values; concurrent access could also race. Go 1.22 gives each iteration its own variable when the variable is declared by the loop (:=). Variables declared outside the loop remain shared.
Version matters: Check the package's language version, normally selected by go in go.mod, not just the installed compiler. The change does not make shared maps, slices, or pointed-to objects race-free, and it does not guarantee goroutine output order.
// Works with old and new loop semantics: copy i as an argument.
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(value int) {
defer wg.Done()
fmt.Println(value)
}(i)
}
wg.Wait() // prints 0, 1, 2, each once, in unspecified order
Still shared in Go 1.22+: var i int; for i = 0; i < 3; i++ { ... } assigns an existing variable. Closures capturing that i can still observe later writes unless properly synchronized.
Watch out: The old buggy example did not guarantee 3 3 3; its result depended on scheduling and could race. Without waiting, returning from main could also end the program before any goroutine printed.
Sources: Go reference 1, Go reference 2.
30How do you correctly stop running goroutines from the outside?
middle
Short answer: Go has no operation that kills another goroutine. Its owner requests cancellation, the goroutine observes it and returns, and the owner waits for completion when needed. Cancellation and completion are separate events.
// process must observe ctx during long work and blocking I/O.
func worker(ctx context.Context, in <-chan Job) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-in:
if !ok {
return nil
}
if err := process(ctx, job); err != nil {
return err
}
}
}
}
In depth: Derive a context with context.WithCancel, call the returned cancel function when the owner is done, and pass the context through the entire operation. If using a separate done channel, one owner closes it to broadcast cancellation. Use a completion channel, wait group, or error-aware group to observe that work has actually stopped.
The outer select cannot interrupt a process call that ignores cancellation. Similarly, potentially blocked sends need their own select on ctx.Done(). A ready work case can win alongside cancellation; cancellation is cooperative and not a priority scheduling guarantee.
Watch out: Draining every channel is not mandatory. Design cancellation or a complete drain protocol so that no producer is stranded, and never close a channel while another goroutine may still send.
Sources: Go reference 1, Go reference 2.
31What is a goroutine leak, and how do you find one in a running service?
middle
Short answer: A goroutine leak is work that outlives its intended lifetime and has no effective way to finish. It may be blocked on a send, receive, lock, or I/O, or keep looping after its owner has gone away. Its stack and referenced objects remain live.
Find it: Compare goroutine counts under similar load, then inspect repeated goroutine profiles for stacks that remain after the corresponding requests have finished. runtime.NumGoroutine() is a clue, not proof: legitimate load also changes the count. A runtime/pprof goroutine profile shows where they are waiting; expose diagnostic endpoints only through your intended diagnostic access path.
// One-result protocol: expensive must eventually return.
func result() <-chan int {
ch := make(chan int, 1)
go func() { ch <- expensive() }()
return ch
}
Why the buffer works here: There is exactly one send and one available slot, so the goroutine can finish even if the caller stops waiting. Closing is unnecessary for this one-result protocol. The buffer does not cancel expensive, solve repeated sends, or help if the computation itself never returns.
General fix: Give the owner cancellation and a way to wait for completion. Make every potentially abandoned blocking operation observe that cancellation, including sends and the underlying I/O. Re-run the failing scenario and verify that the relevant goroutines disappear after it ends.
Sources: Go reference 1, Go reference 2.
32Does a panic in one goroutine crash the whole process?
middle
Short answer: An unrecovered panic in any goroutine terminates the whole program after unwinding that goroutine's deferred calls. Recovery must run directly inside a deferred function on the same goroutine; a parent goroutine cannot recover its child's panic.
func runTask(task func()) (err error) {
defer func() {
if value := recover(); value != nil {
err = fmt.Errorf("task panicked: %v", value)
}
}()
task()
return nil
}
In depth: This helper represents a deliberate task boundary. Its caller receives failure and can log it, report the failed task, and decide whether continuing is safe. Capture a diagnostic stack inside the recovery function with debug.Stack() when needed. If the caller runs it in a goroutine, the error must still be delivered back to the owner and completion must be awaited.
Do not add blanket recovery to every goroutine: continuing after a broken invariant or silently treating a failed task as success can be worse than stopping. Expected operational failures should normally be returned as errors.
Watch out: Recovery belongs to the current stack. If task launches another goroutine that panics, this helper cannot catch it. Likewise, the standard HTTP server's handler recovery does not cover goroutines spawned by a handler. Runtime fatal errors, such as detected concurrent map writes, are not recoverable panics.
Sources: Go reference 1, Go reference 2.
33How does net/http handle incoming requests — what's the concurrency model?
middle
Short answer: net/http can call the same handler concurrently, so shared mutable state must be synchronized. “One connection” and “one request” are not interchangeable: HTTP/1 keep-alive reuses a connection, and HTTP/2 can handle multiple streams concurrently on one connection.
In depth:
Server.Servestarts a service goroutine per accepted connection. In HTTP/1, requests on a connection are ordinarily handled sequentially; different connections can run concurrently. HTTP/2 schedules handlers for streams independently.- A map, cache, or counter shared by handlers needs consistent synchronization for both reads and writes. Unsynchronized map access is a race and can trigger a runtime fatal error.
- The server request context is canceled when the client connection closes, an HTTP/2 request is canceled, or
ServeHTTPreturns. A deadline exists only if the context was given one; socket read/write timeouts are not automatically context deadlines. - Pass
r.Context()to database and upstream calls. AResponseWritermust not be used afterServeHTTPreturns; finish response work before returning.
var (
mu sync.Mutex
visits = make(map[string]int)
)
func count(w http.ResponseWriter, r *http.Request) {
mu.Lock()
visits[r.URL.Path]++
n := visits[r.URL.Path]
mu.Unlock()
fmt.Fprintf(w, "visits: %d\n", n)
}
Watch out: Protecting only writes is insufficient if other handlers read the same map concurrently. Do not hold the map lock while performing slow response or network I/O.
Sources: Go reference 1, Go reference 2.
34What's the difference between sync.Mutex and sync.RWMutex, and when is RWMutex actually worth it?
junior
Short answer: Mutex allows one critical section at a time. RWMutex permits concurrent readers through RLock, while Lock excludes both readers and other writers. Start with the simpler lock; choose RWMutex when measurement shows that parallel reads help.
How to reason about it: All accesses to the protected invariant must use the same lock. Under RLock, only read the shared data: updating an LRU position or lazily initializing a field is a write. An RWMutex cannot be upgraded from RLock to Lock, and recursive read locking can deadlock when a writer is waiting.
A pending writer prevents new readers from acquiring the lock until that writer has acquired and released it. This avoids one source of writer starvation; it does not promise scheduling fairness or a maximum wait.
Example: A configuration lookup that performs substantial read-only work may benefit from concurrent readers. A tiny counter increment often does not. There is no universal read/write ratio or performance ranking—benchmark representative contention.
Further reading: Go documentation
35Why must you never copy a sync.Mutex (or a struct containing one), and how does go vet catch it?
middle
Short answer: A mutex must not be copied after first use. Copies are different locks; copying a struct can leave both copies pointing to the same map while protecting it with different mutexes. Use pointer receivers for such stateful types.
type Cache struct {
mu sync.Mutex
values map[string]int
}
func (c *Cache) Put(key string, value int) {
c.mu.Lock()
defer c.mu.Unlock()
c.values[key] = value
}
// Construct with values: make(map[string]int).
If Put instead uses (c Cache), each call copies the lock and the map descriptor. Concurrent calls then mutate shared map storage under unrelated locks—a real data race. A copied struct containing only an integer counter may instead just increment a discarded copy; that is a lost update even without concurrency.
go vet's copylocks analyzer identifies suspicious assignments, arguments, returns, and value receivers involving lock-containing types. It is conservative, not proof of a race. An untouched zero-value mutex may be copied before use, but designing the containing object to be used through pointers avoids accidental later copies.
Further reading: Go documentation · Go documentation
36How does sync.WaitGroup work, and what's the classic wg.Add() mistake?
middle
Short answer: A WaitGroup waits for registered tasks. In the manual pattern, call Add before launching work and defer Done in each task. Otherwise Wait can see zero and return before the task registers itself.
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(job Job) {
defer wg.Done()
process(job)
}(job)
}
wg.Wait()
Example context: Job, jobs, and process stand for your application's job type, input collection, and processing function. The snippet imports sync.
Go 1.25+: wg.Go(func() { task() }) combines task registration, launch, and completion. Its function must not panic. Call Go before Wait when the counter is empty. The manual pattern is still valid on new versions.
A wait group neither cancels work nor returns task errors. Use an explicit error channel or an error-aware group when failure matters. Never copy a used wait group, decrement below zero, or start a new independent batch before the previous Wait calls return.
Common mistake: Missing Done on an early return leaves Wait blocked. An unrecovered panic normally terminates the process; a wait group is not a panic-handling mechanism.
Further reading: Go documentation · Go documentation
37What does sync.Once guarantee, and where is it typically used?
junior
Short answer: One sync.Once instance invokes the function from its first Do call at most once. Concurrent callers wait for that invocation to finish; initialized data is then published safely to those callers.
var once sync.Once
var config string
func Config() string {
once.Do(func() { config = "production" })
return config
}
Important boundaries: This is once per instance, not once for the whole program. If f panics, Do still considers it complete: a later Do does not retry. Calling Do recursively on the same instance deadlocks. Do not copy an instance after use.
For Go 1.21+, OnceValue caches one return value and OnceValues caches two, such as (Config, error). They also cache an error result—use another mechanism for retryable initialization. Unlike Once.Do, the helper returned by OnceFunc/OnceValue repeats the same panic on subsequent calls if initialization panicked.
Use it for: Lazy construction whose result should remain fixed. Do not use it as a resettable flag or assume a failed connection attempt will be retried.
Further reading: Go documentation · Go documentation
38When is sync/atomic more appropriate than a mutex?
middle
Short answer: Atomics suit a simple shared value such as a counter, flag, or published pointer. A mutex is usually clearer when an operation must preserve a relationship between several mutable fields. Neither choice is universally faster.
// Typed atomics require Go 1.19+.
var completed atomic.Int64
completed.Add(1)
fmt.Println(completed.Load())
Use atomic operations for every concurrent access to that atomic value. Load followed by Store is two operations, not an atomic increment: another goroutine can intervene. Add or a carefully designed compare-and-swap loop makes the update indivisible.
Atomic operations behave as though arranged in one sequentially consistent order, but several atomic variables do not automatically form a consistent snapshot. You can publish a fully built immutable snapshot through atomic.Pointer or atomic.Value; do not mutate its contents after publication.
Common mistake: Calling a logically inconsistent combination of atomic values a data race. The program can be data-race-free and still violate its business invariant. Start with the invariant, then choose and measure the synchronization.
Further reading: Go documentation · Go documentation
39What is sync.Map for, and why isn't it a drop-in replacement for a mutex-guarded map?
middle
Short answer: sync.Map provides concurrent map operations and is especially useful for write-once/read-many entries or goroutines working on disjoint keys. Most code should start with a typed map plus a mutex because it makes types and larger invariants explicit.
What the API guarantees: The zero value is usable. Individual operations are safe concurrently; a separate Load followed by Store is not one atomic update. Use operations such as LoadOrStore or CompareAndSwap when their contract matches the update. Range is not a consistent snapshot.
Version matters: The old implementation used read-only and dirty maps. Go 1.24 replaced it with a concurrent hash-trie implementation, so old claims about promotion, warm-up, or universally slow writes do not describe current Go. Internal structure is not the public contract.
Example: A cache whose entries are immutable after insertion can fit sync.Map. A cache where updating an entry must also update a total size usually needs a common lock around both. any keys and values also mean you must handle their types at the API boundary. Benchmark your own workload before claiming either map design is faster.
Further reading: Go documentation · Go documentation
40What is context for, and how do WithCancel, WithTimeout, and WithDeadline differ?
middle
Short answer: A context carries cancellation, a deadline, and request-scoped values across calls. WithCancel adds manual cancellation, WithDeadline adds an absolute deadline, and WithTimeout adds a duration from now. Parent cancellation also cancels derived contexts.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
return fetch(ctx)
The effective deadline cannot extend an earlier parent deadline. Call the returned cancel function when the operation finishes to release resources promptly—even when a timeout will eventually fire. Cancellation is safe to request repeatedly.
Cancellation is cooperative: Closing ctx.Done() signals that work should stop; it does not kill a goroutine, interrupt arbitrary code, or wait for completion. Pass ctx into context-aware I/O and check it in long loops. Use a wait group or another completion signal when the owner must wait.
ctx.Err() distinguishes context.Canceled from context.DeadlineExceeded. A select with both a ready result and cancellation can choose either: cancellation has no built-in priority. The processing function must observe cancellation too; merely wrapping its result send in a select is insufficient.
Further reading: Go documentation · Go documentation
41What's the difference between context.Background() and context.TODO()?
junior
Short answer: Both return a non-nil context with no deadline, values, or cancellation signal (Done() is nil). Background expresses an intentional root; TODO expresses that the correct parent has not yet been decided.
Use Background() at an application-owned root such as main, then derive a cancelable context for shutdown. Use TODO() temporarily while threading a context through an unfinished API.
For HTTP handlers, use r.Context(). Replacing it with either root drops the client's cancellation and request deadline. The fact that both roots have the same empty behavior does not make them interchangeable with a caller's context, nor should code depend on their internal types or equality.
Check your reasoning: If the client disconnects, should this database query continue? For request-owned work, pass the incoming context into QueryContext; creating a new Background makes that cancellation invisible.
Further reading: Go documentation · Go documentation
42Why is context passed as the first argument instead of stored in a struct field?
middle
Short answer: Passing ctx context.Context first makes the lifetime and cancellation of each call explicit. A long-lived service should receive a fresh caller context per operation, not reuse the context of the request that created it.
type Store interface {
Get(context.Context, string) (string, error)
}
type Service struct { store Store }
func (s *Service) Fetch(ctx context.Context, id string) (string, error) {
return s.store.Get(ctx, id)
}
Failure example: A service stores request A's context in a field. After A times out, request B uses that same canceled context and immediately fails. Keeping dependencies in the service and passing the context per call prevents the mismatch.
This is a strong API convention, not a language restriction. Some established APIs, such as http.Request, carry contexts for compatibility and request-scoped ownership. An intentional exception needs a clear lifecycle. A context can also represent application shutdown or a background job; it is not inherently limited to HTTP requests.
Further reading: Go documentation · Go documentation
43Why is context.WithValue an anti-pattern for passing business parameters?
middle
Short answer: Context values suit request-scoped metadata that crosses API boundaries, such as tracing or authenticated identity. Required business inputs and optional parameters should usually be explicit arguments: hiding them in a context makes the dependency hard to discover and validate.
type traceKey struct{}
func WithTraceID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, traceKey{}, id)
}
func TraceID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(traceKey{}).(string)
return id, ok
}
The key must be comparable. A private key type and typed accessor prevent accidental collisions and unsafe assertions; avoid raw string keys shared between packages. A key need not universally be unexported: the requirement is a collision-safe API.
Example: Authentication middleware may attach verified identity; a domain function LoadOrder(ctx, orderID) should still receive its target order explicitly and perform authorization. Context values do not automatically make mutable objects safe for concurrent use.
Further reading: Go documentation
44Mutex, channel, atomic, or immutability — how do you choose the way to share state between goroutines?
concept
Short answer: Start with the invariant and ownership. Use a mutex for related mutable fields, atomics for simple independent values or snapshot publication, channels for coordination and ownership transfer, and immutable values for shared reads after safe publication.
| Choice | Example | Essential condition |
|---|---|---|
| Mutex | balance plus transaction history | protect the complete invariant |
| Atomic | completed-task counter | use atomic operations consistently |
| Channel | hand a job to one worker | sender stops mutating handed-off data |
| Immutable snapshot | shared configuration | publish safely; never mutate aliases |
Why publication matters: Building a read-only map in one goroutine and assigning its pointer to an unsynchronized global while another reads it is still a race. Publish before starting readers, through a channel, under a lock, or with an atomic pointer.
A channel sends a value copy; it does not deep-copy slices, maps, or pointed-to objects. Ownership transfer is a discipline your code must enforce. Likewise, several atomic fields can be individually race-free while jointly inconsistent. There is no rule that channels or atomics are always faster or more idiomatic than a straightforward lock.
Further reading: Go documentation · Go documentation
45How does the -race flag work, and why doesn't it catch every race?
middle
Short answer: -race enables a dynamic data race detector. It tracks instrumented memory accesses and synchronization, looking for conflicting accesses from different goroutines without the required ordering, where at least one access writes.
Run go test -race ./... for tests or build a service with go build -race and exercise realistic workloads. The report shows the conflicting access stacks and where the participating goroutines were created.
Why passing is not proof: The detector checks executed code and observed synchronization. An untested path can still race, and a logical error involving properly synchronized values can remain undetected. It does not prove freedom from deadlocks or goroutine leaks either.
Instrumentation has substantial, workload-dependent CPU and memory overhead. Use tests and controlled integration/load runs; serving realistic traffic with an instrumented binary can be useful when its cost is acceptable. Supported platforms and native-code coverage are limited—check the toolchain documentation.
Example: Two unsynchronized counter++ calls can race; two separate atomic loads can be race-free while reading an inconsistent pair. Explain the shared location, accesses, and missing synchronization instead of relying only on a clean report.
Further reading: Go documentation
46Explain the G-M-P model: how does the Go scheduler distribute goroutines?
senior
Short answer: In the standard Go runtime, G is a goroutine, M is an operating-system thread, and P is the runtime resource needed to execute Go code. There are GOMAXPROCS Ps; an M must hold a P to run ordinary Go code, so this bounds parallel execution rather than total goroutines or threads.
Follow one task: A runnable G waits in a local or global run queue. An M with a P selects work. When no local work is available, the scheduler can use global work, poll I/O, or steal runnable goroutines from another P. Blocking a G on a channel lets that M/P run another G. A blocking system call can occupy the M while the runtime makes its P available elsewhere.
many runnable Gs → per-P/global queues → M + P → Go execution
blocked Gs → wait for I/O or synchronization → runnable again
Boundary: Queue sizes, stealing fractions, periodic checks, and exact scheduling order are implementation details, not language guarantees. GOMAXPROCS=4 means at most four threads execute Go code simultaneously; it does not guarantee four are always busy or cap blocked syscall threads.
Further reading: Go documentation · Go documentation
47What does GOMAXPROCS control, and what's its default?
junior
Short answer: GOMAXPROCS limits how many operating-system threads can execute Go code simultaneously. It does not limit the number of goroutines, blocked syscall threads, or concurrent I/O operations.
Default by version: Before Go 1.25, the default was the logical CPU count available at startup. Go 1.25+ can also consider CPU affinity and, on Linux, cgroup CPU bandwidth limits, and periodically update the default. Compatibility settings associated with older module language versions can retain the old behavior.
The current runtime normally uses the minimum of CPU count, affinity count, and rounded-up cgroup quota, but does not go below two solely because of the quota. This calculation is an implementation detail: do not equate GOMAXPROCS with Kubernetes CPU requests or simply with runtime.NumCPU().
n := runtime.GOMAXPROCS(0) // query without changing it
fmt.Println(n)
Setting the environment variable or calling runtime.GOMAXPROCS(n) with a positive value overrides automatic updates. Go 1.25+ provides runtime.SetDefaultGOMAXPROCS() to restore the runtime default and updates. Check the toolchain, module version, and GODEBUG settings before diagnosing container throttling.
Further reading: Go documentation · Go documentation
48Is the Go scheduler cooperative or preemptive? What changed in Go 1.14?
senior
Short answer: Modern Go combines cooperative scheduling points with asynchronous preemption. Go 1.14 added asynchronous preemption on supported platforms, making tight loops without function calls much less likely to monopolize execution or delay garbage collection.
Before that change, preemption depended heavily on compiler-inserted checks at safe points such as function calls. A loop with no suitable checks could keep running without yielding. Modern runtimes can request preemption while application code is running; on many Unix systems signals participate in that mechanism.
Do not promise a timeslice: Preemption is only performed where the runtime can safely stop and scan the goroutine. Signal names, monitoring intervals, thresholds, and platform support are implementation details. It is not a real-time scheduler and does not guarantee that every goroutine runs within a fixed number of milliseconds.
Practical consequence: A CPU loop can be preempted and still ignore cancellation forever. To stop it intentionally, check a context or another stop signal at suitable boundaries. Preemption shares processor time; it does not replace cancellation or synchronization.
Further reading: Go documentation · Go documentation
49What happens to the P and M when a goroutine enters a blocking syscall?
senior
Short answer: A blocking system call can keep a goroutine and its OS thread occupied. The runtime can hand off or reclaim that thread's P so another thread can run Go code. A short syscall may return before a handoff is needed.
On return, the thread tries to acquire a P. If none is available, the goroutine becomes runnable and waits to be scheduled. Consequently, total OS threads can exceed GOMAXPROCS when many calls block.
Network I/O differs: Pollable sockets handled by Go's network poller generally park the waiting goroutine without holding one OS thread per connection. The thread can execute other work; readiness makes the goroutine runnable again. This does not mean all network-related activity is nonblocking: resolver paths, cgo, unsupported descriptors, and other calls may block threads.
Example: Ten thousand idle socket connections do not normally need ten thousand waiting threads. Ten thousand simultaneous blocking file or foreign-function calls can pressure the thread limit. Bound expensive blocking work and inspect goroutine/thread profiles instead of assuming GOMAXPROCS caps resource use.
Further reading: Go documentation · Go documentation
50What is escape analysis, and how do you check whether a variable escaped to the heap?
middle
Short answer: Escape analysis helps the compiler decide whether storage can stay on a goroutine stack or must live longer on the heap. Allocation placement is an optimization decision, not a language guarantee. Inspect the actual compiler output for the code and version you ship.
func newNumber() *int {
n := 42
return &n
}
The returned pointer must remain valid after the call. Without inlining, the local storage typically escapes; after inlining, a caller that only reads the value may avoid a heap allocation entirely. Taking an address alone does not force a heap allocation.
Use go build -gcflags='-m=2' ./... to inspect escape and inlining decisions. Captured variables, dynamic-size allocations, and interface conversions can lead to heap allocation, but none is an unconditional rule. Optimizations change between releases; Go 1.26, for example, can keep more small variable-sized slice backing arrays on the stack.
Check the consequence: A compiler escape message is a clue, not proof of a performance problem. Confirm allocation rate with benchmarks (-benchmem) and allocation profiles before changing a clear API to save an allocation.
Further reading: Go documentation · Go documentation
51How does a goroutine stack grow, and what happens when it hits the limit?
middle
Short answer: Goroutine stacks are small and grow as needed. In the standard runtime, growth generally allocates a larger contiguous stack, copies live stack data, and adjusts relevant pointers. Stacks can also shrink; their exact starting size and growth policy are implementation details.
The often-quoted 2 KiB is a historical/common minimum, not a fixed cost or initial size for every goroutine on every platform and version. Total goroutine memory also includes runtime bookkeeping and anything the goroutine keeps reachable.
runtime/debug.SetMaxStack controls the growth limit for a single goroutine. Its documented initial limit is 1 GB on 64-bit systems and 250 MB on 32-bit systems; platform limits can also apply. Exceeding the stack limit while growing crashes the process—it is not an ordinary panic that recover can catch.
Practical check: An unbounded recursive traversal may eventually crash even if it contains a deferred recover. Bound input depth, detect cycles when needed, or use an explicit work stack. Do not solve uncontrolled recursion by merely raising the stack limit.
Further reading: Go documentation · Go documentation
52How does Go's garbage collector work — what is tricolor mark-and-sweep?
senior
Short answer: The standard Go runtime uses a concurrent tracing, non-moving heap collector. In a tricolor model, white objects are unmarked, grey objects are discovered but not fully scanned, and black objects have been scanned. After marking, unmarked heap objects can be reclaimed.
Trace the process: Start from roots such as stacks and global variables. Follow pointers to discover reachable objects. While the application changes pointers concurrently, write barriers preserve the collector's ability to find live objects. Sweeping makes dead storage available again; it is not necessarily returned to the OS immediately.
There are stop-the-world phases as well as concurrent work. Pause duration depends on workload and runtime version: sub-millisecond pauses are not a guarantee. The Green Tea collector became the default in Go 1.26 and changes marking/scanning implementation while preserving the broad tracing model.
Important distinction: Non-moving heap collection does not mean all addresses are permanently stable. Goroutine stacks can move, and cgo has separate pointer and pinning rules. Do not justify arbitrary C-held Go pointers merely by saying the GC does not move heap objects.
Further reading: Go documentation · Go documentation · Go documentation
53What do GOGC and GOMEMLIMIT tune, and what's the trade-off?
middle
Short answer: GOGC trades allocation headroom for collection CPU. GOMEMLIMIT (Go 1.19+) asks the runtime to keep its managed memory below a soft limit; it is not a process RSS cap or protection against every out-of-memory failure.
For modern Go, a useful approximate heap target is:
target heap = live heap + (live heap + GC roots) × GOGC / 100
The collector starts before reaching the target so it can finish on time. GOGC=100 therefore does not literally mean “start when the heap doubles.” Lower values usually spend more CPU to retain less allocation headroom; higher values usually allow more memory between collections.
The memory limit covers Go-managed memory, including stacks and runtime metadata, but excludes some memory such as allocations by C and mappings outside the runtime. Leave headroom below a container limit and measure RSS as well as runtime metrics.
GOGC=off disables the ordinary growth-based trigger; a configured memory limit can still trigger GC. If live data itself approaches the limit, collection cannot remove it. The runtime may exceed the soft limit to avoid spending nearly all CPU collecting. Fix retention or capacity before tightening the limit further.
Further reading: Go documentation · Go documentation
54When should a function return an error, and when should it panic?
junior
Short answer: Return an error for failures callers are expected to handle, such as invalid input, missing files, timeouts, or unavailable dependencies. Reserve panic for programmer errors, violated invariants, or a documented API that intentionally panics.
Examples: A parser returns an error for malformed user input. A MustParse helper may intentionally panic for a fixed developer-controlled value during initialization. The runtime also panics for some invalid operations, such as an out-of-range index.
A panic unwinds deferred calls in the current goroutine. If it reaches that goroutine's top without recovery, the process terminates. Recovery at a deliberate boundary can contain a failure—an HTTP server may recover a handler panic—but it must not pretend the failed operation succeeded or assume shared state is intact.
Do not equate severity with mechanism: An ordinary returned error can still be fatal to an application. A panic does not prove that every piece of process state is corrupt. Choose the API contract and recovery boundary based on what can safely continue; do not add blanket recover to every goroutine or use panic for routine validation.
Further reading: Go documentation · Go documentation
55Sentinel errors vs custom error types — when do you choose which?
middle
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:
- Sentinel is a flag — one shared error value for the package. Cheap and readable, but it carries only the fact itself: "not found", "forbidden". Check with
errors.Is. - Custom type is a struct with data — when you must pass context:
ValidationError{Field, Rule},APIError{Code}. Check and extract witherrors.As. - 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.
Nuance: A sentinel is a shared variable holding an error value, not a Go constant. Custom types can also support errors.Is through Unwrap or an Is method, so the two approaches can be combined. Return a literal nil on success: converting a nil *ValidationError to error produces a non-nil interface.
Further reading: Go documentation · Go documentation
56How do you wrap an error with context while keeping the chain for errors.Is/As?
junior
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:
%wpreserves the chain — it wraps the error while keeping access to the original viaUnwrap. Add short context: what the code was doing, not a restatement of the error.%v/%sbreak the chain — use them only when you deliberately hide the underlying error from the caller (an abstraction boundary).- 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. Usually add context while returning and log at the handling boundary; logging at several layers can duplicate the same failure. Additional logs are useful only if they record distinct information.
Contract check: Exposing a wrapped dependency error makes its identity observable to callers. Wrap only when that is intentional. Go 1.20+ supports several %w operands and errors.Join, so the structure may be a tree; errors.Is/As traverse it, while the single-error errors.Unwrap helper does not unwrap a Join result.
Further reading: Go documentation · Go documentation
57What's the difference between errors.Is and errors.As?
middle
Short answer: errors.Is asks whether an error matches a target condition. errors.As finds an assignable error type and stores it in your target variable so you can inspect its fields. Both traverse wrapped errors, including the tree created by errors.Join (Go 1.20+).
if errors.Is(err, fs.ErrNotExist) {
// Handle the missing-file condition even after wrapping.
}
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Op, pathErr.Path)
}
The As argument is a non-nil pointer to a suitable variable. Above, pathErr is already a pointer, so passing &pathErr supplies a pointer-to-pointer and lets As fill it in. Passing the wrong target can panic.
More than equality: Is may use an error's custom Is(error) bool method; As also supports custom matching. It is not simply text comparison or an exact concrete-type check. Go 1.26+ additionally provides errors.AsType[T](err) for typed extraction; As remains useful for older code.
Use direct == when an API explicitly guarantees an unwrapped sentinel; prefer Is when wrapping is possible. Never depend on an error message's wording for program logic.
Further reading: Go documentation · Go documentation
58In what order do multiple defers run, and when are their arguments evaluated?
middle
Short answer: Deferred calls run in reverse registration order when the surrounding function returns or unwinds a panic. Their function value, receiver, and arguments are evaluated when each defer statement executes; the call itself happens later.
func example() {
n := 1
defer fmt.Println(n) // saves 1 now
defer func() { fmt.Println(n) }() // reads n later
n = 2
}
// Output: 2, then 1, on separate lines.
In a loop, defer fmt.Println(i) saves that iteration's argument regardless of Go version. A closure that reads a loop variable is different: fresh iteration variables apply when the package uses Go 1.22+ language semantics and the variable is declared by the loop. A variable declared outside the loop is still shared.
Resource lifetime: A defer inside a loop runs at function exit, not at the end of the iteration. If each file should close before opening the next one, use a helper function per iteration or close explicitly. os.Exit does not run defers.
Further reading: Go documentation · Go documentation · Go documentation
59Can a defer change a function's return value?
senior
Short answer: A defer can reassign a named result variable before the caller receives it. With an unnamed result it cannot replace the already evaluated return value, but it can still mutate an object reachable through a returned pointer, slice, or map.
func named() (n int) {
defer func() { n++ }()
return 4 // assigns n = 4, then defer changes it to 5
}
func shared() []int {
values := []int{4}
defer func() { values[0]++ }()
return values // caller observes []int{5}, despite unnamed result
}
The return expressions are evaluated and assigned to result storage before defers run. A deferred closure that only reassigns a local scalar does not replace an unnamed result that has already been copied.
Practical use: A named err can record a cleanup failure when it determines the operation's success. Preserve an earlier meaningful error rather than silently overwriting it. Recovering a panic into an error also needs an explicit boundary and a named result; it is not the default way to handle expected errors.
Further reading: Go documentation · Go documentation
60Where exactly must recover() be called for it to catch a panic?
middle
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:
- Only directly in the defer — a panic is caught if
recover()is called in the body of the function thatdeferscheduled. Not in a function that this deferred function then calls. - 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. - No panic →
nil— with no panic in flight,recover()simply returnsnil, soif r := recover(); r != nilis 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.
After recovery: Execution does not resume at the panicking statement. The function that registered the recovering defer returns after its deferred calls finish. Under Go 1.21+ default behavior, even panic(nil) produces a non-nil recovered runtime value; older behavior can be restored with GODEBUG=panicnil=1. Fatal runtime errors and os.Exit are not recoverable panics.
Further reading: Go documentation · Go documentation
61How do you add a timeout with select, and what's the catch with time.After in a loop?
senior
Short answer: Select between a result, cancellation, and a timer. This times out waiting; it does not stop the producer's work unless that work also observes cancellation.
func receive(ctx context.Context, in <-chan int, d time.Duration) (int, error) {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case value, ok := <-in:
if !ok { return 0, io.EOF }
return value, nil
case <-timer.C:
return 0, context.DeadlineExceeded
case <-ctx.Done():
return 0, ctx.Err()
}
}
If multiple cases are ready, select may choose any one; there is no strict “earliest event wins” or timeout priority. The comma-ok receive distinguishes a closed input from a real zero.
Timer lifetime: Before Go 1.23 semantics, an unreferenced time.After timer stayed allocated until it fired. Modern semantics allow unreachable timers and tickers to be collected, though allocating one every hot-loop iteration still has a cost. In Go 1.23–1.26, this depends on the main module's Go version and GODEBUG=asynctimerchan. Go 1.27 removes that override: timer channels are always synchronous and modern timer behavior applies.
Decide whether the timeout covers the whole operation or each idle interval. Creating a timer on every iteration resets the waiting budget. Use one timer outside the loop for an overall deadline, or a context for a deadline shared across calls. This example never reuses its timer, so it needs no stop-and-drain reset protocol.
Further reading: Go documentation · Go documentation · Go documentation · Go documentation
62How do you cap concurrency — say, at most 10 simultaneous requests to an external API?
middle
Short answer: Acquire a semaphore slot before launching work, release it with defer, make acquisition cancellable, and wait for all started work. This bounds active work and avoids creating thousands of goroutines just to wait for a slot.
func runBounded(parent context.Context, jobs []int, limit int,
work func(context.Context, int) error,
) error {
if limit < 1 { return fmt.Errorf("limit must be positive") }
ctx, cancel := context.WithCancel(parent)
defer cancel()
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
var once sync.Once
var firstErr error
launch:
for _, job := range jobs {
if ctx.Err() != nil { break }
select {
case sem <- struct{}{}:
case <-ctx.Done():
break launch
}
wg.Add(1)
go func(job int) {
defer wg.Done()
defer func() { <-sem }()
if err := work(ctx, job); err != nil {
once.Do(func() { firstErr = err; cancel() })
}
}(job)
}
wg.Wait()
if firstErr != nil { return firstErr }
return ctx.Err()
}
work must honor its context. The first observed task error cancels the batch, but all started tasks are joined before return. Cancellation may race with admission, so an admitted task must still check or pass on its context. The limit bounds concurrent work, not requests per second.
Library alternative: golang.org/x/sync/errgroup supports SetLimit and WithContext. Group.Go can block for a slot; that admission wait is not itself a context-select. The derived context is canceled on the first task error or when Wait returns, including success. Use the original parent for unrelated work after Wait, and check task/parent errors explicitly.
Further reading: Go documentation · Go documentation · Go documentation
63Implement a worker pool with graceful shutdown via context. What is the interviewer checking?
senior
Short answer: Use a fixed number of workers, a producer-owned jobs channel, and one coordinator that closes results after every worker exits. Cancellation must reach waiting receives, result sends, the producer, and the processing function itself.
The example processes integer jobs and reports each job's value or error. It rejects a zero/negative worker count. Job errors do not cancel the whole batch; cancellation may discard unfinished jobs and results.
type Result struct {
Value int
Err error
}
func pool(ctx context.Context, jobs <-chan int, n int,
process func(context.Context, int) (int, error),
) (<-chan Result, error) {
if n < 1 {
return nil, fmt.Errorf("worker count must be positive")
}
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
if ctx.Err() != nil { return }
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok { return }
value, err := process(ctx, job)
select {
case out <- Result{Value: value, Err: err}:
case <-ctx.Done():
return
}
}
}
}()
}
go func() { wg.Wait(); close(out) }()
return out, nil
}
Ownership contract: On normal completion the producer closes jobs, workers finish queued work, and the caller drains out. If the caller stops early, it must cancel the shared context; the producer must also select on that context when sending. process must return promptly when canceled and must not leave its own work running.
Do not write case out <- process(job) expecting the select to interrupt processing: Go evaluates the send value before choosing a case. Cancellation is cooperative and has no select priority. Waiting for out to close confirms workers exited, provided processing honors cancellation.
Further reading: Go documentation · Go documentation · Go documentation
64How do you merge several channels into one (fan-in) without panicking on close?
middle
Short answer: One forwarding goroutine per input writes to a common output. A single coordinator waits for all forwarders before closing that output, so no sender can race with close.
func merge[T any](ctx context.Context, inputs ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(inputs))
for _, input := range inputs {
go func(input <-chan T) {
defer wg.Done()
for {
if ctx.Err() != nil { return }
select {
case <-ctx.Done():
return
case value, ok := <-input:
if !ok { return }
select {
case out <- value:
case <-ctx.Done():
return
}
}
}
}(input)
}
go func() { wg.Wait(); close(out) }()
return out
}
This generic example requires Go 1.18+: T keeps every input and the output at the same element type (for example, merge[int]). Values preserve order within each input, but interleaving between inputs is unspecified. With zero inputs, the output closes without producing values. A nil input waits until cancellation.
Stopping early: If the consumer only needs the first result, it must cancel the context. Both the receive and send need cancellation paths; otherwise an idle input or unread output can leave a forwarder blocked. Producers upstream must share that cancellation contract or have another bounded exit path. Closing out signals completion of these forwarders, not necessarily every upstream producer.
Further reading: Go documentation · Go documentation
65How do you build a multi-stage channel pipeline and shut it down correctly?
middle
Short answer: A pipeline connects stages through channels. A single-sender stage owns and closes its output after its input ends or cancellation is observed. Every potentially blocking receive/send needs a completion or cancellation path.
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
if ctx.Err() != nil { return }
select {
case <-ctx.Done():
return
case n, ok := <-in:
if !ok { return }
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}
}()
return out
}
Use this with small integers whose squares fit in int; production arithmetic needs its own overflow contract. The same shared context must reach the source and downstream stages. The consumer that stops early calls cancel, so upstream senders are released. Context cancellation travels from the owner through the context tree; it does not magically propagate backward through channel links.
For a stage with several senders, one coordinator closes the output after all senders finish. For expensive I/O, pass the context into the operation too. Decide explicitly whether a failure cancels the whole pipeline or travels as a result value, and wait for owned workers when shutdown must be complete before returning.
Further reading: Go documentation · Go documentation
66How do you implement a rate limiter in Go, and how does it differ from a semaphore?
middle
Short answer: A semaphore limits operations in flight. A rate limiter limits admissions over time. You may need both: an API can allow 10 requests per second but only three simultaneous requests.
A shared token bucket from golang.org/x/time/rate refills at a configured rate and allows a bounded burst. rate.NewLimiter(10, 20) allows 10 tokens per second with capacity for a burst of 20; the bucket starts full. Wait(ctx) waits for permission and returns an error if it cannot proceed within cancellation/deadline constraints. Always check that error before starting the request. Allow() instead gives an immediate yes/no answer.
Ticker alternative: A shared time.Ticker can provide simple pacing. It does not promise exactly N requests per second: scheduling delays and slow consumers can cause dropped ticks. Select on cancellation while waiting and stop the ticker when its owner finishes. A ticker does not provide the same burst policy as a token bucket.
Common mistake: Giving every worker its own limiter when the API quota is shared. Ten workers each allowed 10 requests per second can admit about 100 per second in total. Share the limiter at the scope of the actual quota.
Further reading: Go documentation · Go documentation
67How is an LRU cache built, and how do you make it thread-safe?
middle
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:
- Two structures —
map[K]*list.Elementfor O(1) lookup and*list.List(doubly linked) for recency order. - Get — found in map →
MoveToFront(el)→ return the value. - Put — key exists → update and
MoveToFront; missing →PushFront, and whenLen() > capacityremoveBack()and its key from the map. - 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.
Define the edges: Choose whether zero capacity means “store nothing” or is invalid, update existing keys without increasing size, and keep eviction and index removal under the same lock. Sharding reduces contention by making several independent LRUs; it no longer preserves one global eviction order. If cached values are mutable pointers, the cache lock alone does not protect callers after Get returns.
Further reading: Go documentation · Go documentation
69What are go.mod and go.sum for, and how does Minimal Version Selection work?
junior
Short answer: go.mod declares a module and its requirements. go.sum records content hashes used to verify downloaded module files. Minimal Version Selection chooses the highest required minimum version for each module path in the dependency graph, rather than automatically choosing the newest release.
app requires A and B
A requires lib v1.4.0
B requires lib v1.3.0
selected lib: v1.4.0, even if v1.9.0 exists
The go directive defines the module's language/minimum Go requirements; a toolchain directive can guide toolchain selection. Dependencies may also be affected by replace, exclude, workspaces, and explicit upgrade commands, so inspect the actual build list with go list -m all.
go.sum is not a lock file: it can contain hashes for versions that are not selected, and does not decide the version graph. The checksum database adds verification for public modules; private-module settings may bypass it. Integrity verification confirms content identity, not that a dependency is free of vulnerabilities.
Check your understanding: Publishing lib v1.9.0 alone does not silently upgrade a build that still selects v1.4.0. A requirement change or an explicit upgrade can change selection.
Further reading: Go documentation · Go documentation
70How do Go generics work, and what does the comparable constraint mean?
middle
Short answer: Go 1.18+ lets functions and types accept type parameters with constraints. Constraints describe permitted types and operations. comparable allows equality/inequality and use as a map key; any imposes no such requirement on arbitrary type parameters.
func Index[T comparable](values []T, target T) int {
for i, value := range values {
if value == target { return i }
}
return -1
}
Index([]int{3, 5}, 5) infers T as int and returns 1. A constraint int | float64 permits exactly those types; ~int | ~float64 also permits defined types with those underlying types.
Interface caveat (Go 1.20+): An ordinary interface type such as any can satisfy comparable. Comparing interface values still panics when their dynamic values are not comparable. For example, Index[any]([]any{[]int{1}}, []int{1}) compiles but panics while comparing slices held in interfaces. A static constraint is not a promise about every possible dynamic value.
Use generics when preserving a relationship between concrete types helps the API. Use an ordinary interface when callers need a shared behavioral contract. Do not promise that generics always eliminate boxing or runtime overhead; generated code and performance depend on the implementation and usage.
Further reading: Go documentation · Go documentation
71What are table-driven tests, and why are they the idiomatic style in Go?
junior
Short answer: Table-driven tests store inputs and expected results as cases, then apply the same assertions to each. They are useful when cases share behavior; separate tests are clearer when setup or assertions differ substantially.
func TestTrimSpace(t *testing.T) {
cases := []struct { name, in, want string }{
{"empty", "", ""},
{"unchanged", "Go", "Go"},
{"surrounding space", " Go ", "Go"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := strings.TrimSpace(tc.in); got != tc.want {
t.Errorf("TrimSpace(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
Place this in a _test.go file and import strings and testing. Named subtests make failures easy to identify and select with go test -run. For your own function, include success, empty/boundary inputs, and expected errors; check returned values and error identity instead of merely calling the function.
Parallelism is optional: Only call t.Parallel() for cases whose fixtures and mutable state are independent. A package using Go 1.22+ semantics gets fresh variables declared by the loop; an older language version needs an iteration-local copy when closures outlive the iteration. Use cleanup hooks for owned resources and perform fatal test assertions in the goroutine running that test.
Further reading: Go documentation · Go documentation · Go documentation
72How do you mock dependencies in Go tests without monkey-patching?
junior
Short answer: Inject a dependency through a small interface or function when the code needs a replaceable boundary. A test can supply a fake that returns controlled results or errors without changing package globals or machine code.
type Store interface {
Name(context.Context, string) (string, error)
}
type fakeStore struct { name string; err error }
func (f fakeStore) Name(context.Context, string) (string, error) {
return f.name, f.err
}
func greeting(ctx context.Context, store Store, id string) (string, error) {
name, err := store.Name(ctx, id)
if err != nil { return "", fmt.Errorf("load name: %w", err) }
return "Hello, " + name, nil
}
func TestGreetingFailure(t *testing.T) {
want := errors.New("store unavailable")
got, err := greeting(context.Background(), fakeStore{err: want}, "42")
if got != "" || !errors.Is(err, want) {
t.Fatalf("got (%q, %v), want empty result and %v", got, err, want)
}
}
Place the fake and test in _test.go; the interface and behavior belong with the consumer. The example needs context, errors, fmt, and testing. The test checks a real contract: failure produces no successful greeting and preserves the underlying error.
Add a success case and verify relevant arguments or cancellation when those matter. A fake does not prove the real database or HTTP integration works, so keep focused integration tests at that boundary. Avoid introducing a huge interface solely for mocking; a function parameter or a real lightweight implementation can be simpler. Assertions on incidental call order tend to make tests fragile.
Further reading: Go documentation · Go documentation · Go documentation
73What does go vet catch that the compiler doesn't?
junior
Short answer: go vet reports suspicious constructs that can compile but are likely mistakes. It complements the compiler and tests; it is not a complete correctness proof.
| Analyzer | Example concern |
|---|---|
| printf | wrong argument type for a formatting verb |
| copylocks | copying a value containing a used lock |
| structtag | malformed reflect-style tag syntax |
| lostcancel | dropping a cancel function instead of releasing resources |
| waitgroup (Go 1.25+) | registering a task from inside its newly launched goroutine |
Run go vet ./... and examine each diagnostic in context. Vet uses heuristics: a report is not automatically a reachable bug, and a clean result does not rule out races, deadlocks, leaks, or incorrect business logic.
go test runs a selected set of vet checks by default; explicit go vet provides a broader analysis pass. Additional linters may be useful if the project has configured them, but no particular aggregator is required. For concurrency, combine relevant tests under -race with a concrete ownership and cancellation review.
Further reading: Go documentation · Go documentation · Go documentation
74How do you write a benchmark in Go, and what does the -benchmem flag show?
middle
Short answer: Put func BenchmarkX(b *testing.B) in a _test.go file and run go test -run='^$' -bench=. -benchmem. The output reports ns/op, allocated bytes per operation (B/op), and allocations per operation (allocs/op); these are not peak memory or retained heap measurements.
func BenchmarkJoin(b *testing.B) {
parts := []string{"go", "build", "test"}
b.ReportAllocs()
for b.Loop() {
strings.Join(parts, "/")
}
}
This example imports strings and testing and requires Go 1.24+. The exact for b.Loop() form manages timing around setup/cleanup and protects measured calls/results from being optimized away under its documented rules. It is not a blanket ban on every compiler optimization.
For older versions, use for i := 0; i < b.N; i++, reset the timer after setup, and ensure a result remains observable when the compiler might eliminate the work. Use either b.Loop or b.N, not both in one benchmark.
Quality check: Benchmark representative inputs, report several samples, and compare under similar machine/load conditions. Do not treat one noisy run as a speedup or remove useful allocation merely to improve a synthetic score. Use profiles to connect the result to the application's real cost.
Further reading: Go documentation · Go documentation
75How do you profile a Go service with pprof, and what profile types exist?
middle
Short answer: Capture a profile that matches the symptom, then inspect it with go tool pprof. Use CPU samples for expensive execution, heap/allocations for memory questions, goroutine stacks for stuck work, and block/mutex profiles for waiting and contention.
| Profile | What it can explain |
|---|---|
| CPU | sampled CPU time, not time asleep on I/O |
Heap (inuse_space) |
sampled live/retained heap allocations |
Allocations (alloc_space) |
cumulative allocation traffic |
| Goroutine | current stacks; repeated blocked-stack growth needs investigation |
| Block / mutex | synchronization waits / lock contention when sampling is enabled |
import _ "net/http/pprof" registers handlers on http.DefaultServeMux; it does not start a server or register them on your custom mux. Serve diagnostics on a deliberately restricted listener or register handlers on a dedicated mux. Avoid unintentionally exposing them through a public default mux.
For a local CPU sample: go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30. Tests can write profiles with go test -cpuprofile=cpu.out or -memprofile=mem.out. Block and mutex sampling need runtime.SetBlockProfileRate and runtime.SetMutexProfileFraction.
Go 1.27+ also has a stable goroutineleak profile (experimental in Go 1.26). It finds some permanently blocked goroutines through reachability analysis, but cannot detect every leak. Measure representative load and compare before/after; a growing goroutine count alone is not proof of a leak.
Further reading: Go documentation · Go documentation · Go documentation
76Saga pattern: orchestration vs choreography — when do you pick which?
middle
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 |
- Orchestration — explicit status, deadlines and retries are easier to inspect. A coordinator must be durable and recoverable; it can be replicated and scoped to one workflow rather than centralizing every business flow in one service.
- 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.
77Design the compensation flow for the saga 'create order → charge payment → reserve stock' when stock reservation fails.
senior
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 ◄──┘
- Compensate completed effects — on a definitive reservation failure, refund then cancel. If reservation timed out, its result is unknown: query/reconcile it and release any successful reservation idempotently before declaring cleanup complete.
- Semantic lock — PENDING means other commands must enforce the workflow rules (for example, prohibit shipping). It does not hide or atomically roll back side effects already visible in other services.
- Idempotent compensation — use a stable refund operation key, such as refund:paymentId:reasonId, distinct from the charge key; retry and reconcile the same operation after timeouts.
- 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.
78What is the transactional outbox pattern and what problem does it solve?
middle
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
- Atomicity for free — both writes hit one database: either the order and its event both exist, or neither does.
- Relay — a poller (
SELECT … FOR UPDATE SKIP LOCKED) or CDC: Debezium tails the WAL — events flow out with no polling load. - 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.
79How does an idempotent consumer (inbox pattern) work?
middle
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;
- 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.
- 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.
- TTL/cleanup — the table grows: purge by age, but the retention window must cover the maximum redelivery delay (retries, DLQ).
- 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.
Deduplication retention must also cover deliberate historical replay and manual DLQ redrive. If those can exceed the retention window, preserve a durable business identity/constraint or define a replay policy that cannot repeat the effect.
80Two-phase commit: how does it work and why do microservice architectures avoid it?
middle
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
- Phase 1 (prepare) — participants write redo/undo, vote, and hold locks until the outcome.
- Decision — any NO vote or prepare-phase timeout lets the coordinator decide ABORT before a durable COMMIT decision. A participant already prepared cannot safely abort merely because its own timer expired: it must recover the authoritative decision.
- The price — synchronous waiting on all participants: latency = the slowest one; blocking on coordinator failure; under a network partition the protocol simply stalls.
- 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.
81Does exactly-once delivery exist? What do people actually mean by exactly-once?
senior
Short answer: Ask what is exactly once: a delivery exposed by a specific API, a committed stream result, or an external business effect. Lost acknowledgements make retries unavoidable, but documented protocols can suppress duplicates within an explicit boundary.
- At-most-once permits loss and avoids redelivery within its scope.
- At-least-once permits duplicate attempts; eventual delivery depends on durable retention, successful retries and the failure model.
- Exactly-once effects combine replay with atomic deduplication or a transaction covering both progress and output.
Kafka transactions atomically commit output records and consumed offsets. Consumers using read_committed hide aborted output. Processing code can execute again after failure even when the committed Kafka result appears once.
A PostgreSQL effect needs its own inbox identity and transaction; an HTTP effect needs the receiving API's idempotency contract. Pub/Sub also documents exactly-once delivery for supported subscriptions and scope, so "exactly-once delivery never exists" is too broad.
⚠️ Gotcha: the guarantee ends at its boundary. A Kafka transaction cannot undo a charge, email or database commit in another system.
82A distributed lock on Redis via SET key value NX PX — what can go wrong?
middle
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)
- 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. - TTL is always a tradeoff — short: expires under a live holder; long: after a holder crashes everyone waits for nothing.
- 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.
83Why did Kleppmann criticize Redlock, and what is a fencing token?
senior
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
- 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.
- Fencing token — the resource atomically remembers the greatest token accepted. For example:
UPDATE resource SET value=$1, last_token=$2 WHERE id=$3 AND last_token <= $2, with last_token initialized and NOT NULL. A smaller incoming token is rejected after a newer one was accepted. Equal tokens permit multiple writes by one lease; duplicate business operations still need idempotency. - What to pick — correctness-critical: a consensus-backed lock plus resource-side fencing (for example, an etcd lock-key revision checked atomically by storage). The lock service alone cannot stop a stale process from writing to an external resource. 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.
84Postgres advisory locks: when are they better than a Redis lock?
middle
Short answer: PostgreSQL advisory locks fit cooperating clients that coordinate work in the same database. A transaction-level lock lasts until that transaction ends; no client-chosen lease TTL is required.
BEGIN;
SELECT pg_advisory_xact_lock(42);
-- Read and change the protected database state on THIS transaction/connection.
COMMIT;
Every participant must derive the same key and acquire the lock: advisory locks do not stop unrelated SQL automatically. Use pg_try_advisory_xact_lock when waiting is undesirable. Transaction-scoped locks work naturally with transaction pooling; session locks must not leak across pooled clients.
If a connection fails, PostgreSQL aborts its transaction and releases locks when it detects the failure. A network partition is not detected instantly: use appropriate connection/deadline settings and avoid long open transactions that retain snapshots.
⚠️ Gotcha: this protects cooperating database work inside the same transaction, not arbitrary external HTTP side effects or independent clusters. A process can continue an external operation after its DB connection dies; those effects still need their own idempotency or fencing.
85Linearizability vs eventual consistency: how do you serve read-your-writes on top of async replicas?
senior
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 |
|---|---|---|
| balances, uniqueness | choose the needed invariant/isolation and read guarantee | a documented protocol, not simply any leader/quorum read |
| feed, catalog, counters | eventual | any replica |
| 'I saved it — I see it' | read-your-writes | primary for the author / wait on LSN |
- Primary reads — route the session to a valid primary after writing. A fixed N-second stickiness window is only a heuristic unless replica lag is bounded; expire it after verified catch-up when read-your-writes is required.
- Track progress — after the write has successfully committed, record a WAL position at or beyond that commit on the primary. Read from a compatible standby only after pg_last_wal_replay_lsn reaches it, with a deadline/primary fallback. A pre-commit pg_current_wal_lsn is insufficient, and failover/timeline changes require care.
- Quorum — R + W > N provides overlap under assumptions, not linearizability on its own. Use the database’s documented linearizable read protocol rather than inferring it from majority counts.
⚠️ 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.
86Event sourcing and CQRS: when are they justified and what is the price?
concept
Short answer: Event sourcing: the event log is authoritative; current state is derived as state = fold(events). 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, potential projection lag, 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
- 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.
- Price #1: versioning — an event lives forever; when the schema changes you must read every old version (upcasters) or migrate the whole log.
- Price #2: reads — if read models update asynchronously, a projection can lag after a command; the UI, tests, and support must handle that consistency model.
- 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.
The event log is authoritative, but implementations can store snapshots and materialized state. CQRS separates read and write models; it need not use separate databases, and asynchronous projection lag is a design choice rather than a requirement of every CQRS implementation.
87Why can't you rely on wall-clock time across services, and what breaks with last-write-wins?
senior
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
- 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'.
- 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). - 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.
88Kafka topic anatomy: partitions, replicas, leader, ISR — and which settings make a write durable?
middle
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}
- acks=all — the leader replies to the producer only after the write is replicated to every ISR member.
- min.insync.replicas=2 — with fewer than two live ISR replicas the producer gets NotEnoughReplicas instead of a silent write to a single copy.
- RF=3 — if all three replicas are in sync, losing one can leave two available for writes after election/recovery. Transient failures are still possible, and losing one when ISR was already only two can stop writes. Durability also depends on election policy, replica/storage survival and correctly configured producer retries.
⚠️ Common mistake: 'Kafka guarantees message ordering.' Only within one partition; there is no global order in a topic.
89How does a consumer group get partitions assigned, and what happens when there are more consumers than partitions?
junior
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)
- Within a group — each assigned partition has one consumer owner. Processing preserves that order only if the application does not reorder work or commit beyond unfinished records.
- Across groups — independent reads: each group commits its own offsets to
__consumer_offsets, so the same data serves both billing and analytics. - 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.
This describes ordinary Kafka consumer groups; share groups have different assignment/ack semantics. A single partition owner preserves log order at polling, but an application worker pool can still reorder completion and effects.
90What triggers a consumer-group rebalance, and why can it be dangerous?
senior
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:
- Eager (classic) — stop-the-world: every consumer revokes every partition, and the group's processing freezes until the rebalance ends.
- Cooperative / incremental (KIP-429) —
CooperativeStickyAssignor: only the partitions actually moving are revoked, the rest keep working. - Static membership — group.instance.id can avoid unnecessary reassignment when the same member returns before its session expires with compatible assignment. It does not eliminate every rebalance. Classic eager/cooperative assignors and Kafka’s newer consumer group protocol have different configuration rules.
- Rebalance storm — heavy work inside the poll loop:
max.poll.interval.msexceeded → 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.
91How do you choose a partition key, and what goes wrong with a bad one?
middle
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 |
- 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.
- 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.
- 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.
92Offset commit strategies: auto vs manual — and how do you get at-least-once right?
middle
Short answer: Commit the next offset after the last safely completed contiguous record in each partition. A commit before the effect risks skipping work after a crash; a commit after it permits redelivery and requires idempotency.
In the Java consumer, auto-commit can provide at-least-once when every record returned by poll is fully processed before the next poll or close. It becomes unsafe when processing is handed to asynchronous workers while polling/committing advances.
With manual commits:
- Poll and process with enable.auto.commit=false.
- Track completion separately per partition; never commit past an unfinished earlier record, even if later work finished.
- Handle commitAsync errors. A final commitSync may be useful during a valid shutdown/revocation callback but can fail or time out; it is not a delivery guarantee.
- Stop/revoke work safely during rebalances. Retried records must be idempotent.
⚠️ Gotcha: committing offset 42 means resume at 42, so records through 41 must already be safe. A synchronous commit cannot make an external business effect atomic with the Kafka offset.
93A consumer processed a message but crashed before committing the offset. What happens?
junior
Short answer: if an effect completed but its next offset was not committed, reassignment can replay the record and repeat the effect. This is the expected duplicate window of process-then-commit at-least-once; Kafka configuration and application ordering determine the guarantee, not a universal default.
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
- A contract, not a bug — the broker cannot know processing finished; the only signal is a committed offset.
- The defense — an idempotent consumer: insert the stable message id into an inbox table with a
UNIQUEconstraint in the same database transaction as the business effect. A redelivery hits the constraint and cannot apply the effect twice. - 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.'
94Consumer lag keeps growing. How do you diagnose and fix it?
senior
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
- Lag on one partition — a hot key or a 'stuck' message: scaling will not help, fix the key or the processing.
- Lag spread evenly — scale consumers up to the partition count; beyond that it is pointless.
- Slow processing — move I/O out of the poll loop into a worker pool (carefully: per-key ordering), batch the DB writes.
- Systemically — increase the partition count (planned: it remaps keys) or negotiate backpressure with the producers.
- 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.
A worker pool must be bounded and preserve the required per-key order. Keep KafkaConsumer access on its owning thread, pause partitions under pressure while continuing timely polling, and commit only contiguous completed offsets per partition. Moving work out of poll without those rules can lose records.
95How do you build retries in Kafka without blocking a partition?
senior
Short answer: Use bounded retries with a durable handoff and an explicit ordering policy. Retry topics and a DLQ are one option; Kafka does not automatically delay a record because its topic name says 5m.
- On a retryable failure, atomically publish to the retry topic and commit the input offset in one Kafka transaction. Without that boundary, publish-then-commit permits duplicates and commit-then-publish permits loss.
- Store attempt count, original event identity, next eligible time and a sanitized failure reason. Retry consumers enforce the due time without blocking polling past max.poll.interval.ms; pause/resume and bounded scheduling need careful offset handling.
- After the attempt/age budget, durably hand off to a monitored DLQ. Provide an owned inspection/redrive process that preserves the original identity.
- Retry topics let later messages overtake the failed one. For ordered commands or deltas, block/buffer that entity until its missing sequence is resolved. Discarding older events is only safe when a later complete snapshot supersedes them.
⚠️ Gotcha: in-place retries can be legitimate when order matters, provided they are bounded and maintain group liveness. Neither infinite retries nor silently dropping an old payment delta is a correct default.
96Idempotent producer and Kafka transactions: what does each mechanism actually guarantee?
senior
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 |
- Idempotence — protection against the client's own network retries; a repeated
send()from application code is a brand-new message. - Transactions — offsets are committed inside the same transaction (
sendOffsetsToTransaction): the read-process-write cycle happens entirely or not at all. - 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.
⚠️ Gotcha: within a correctly configured Kafka transaction boundary, output and offsets can be exactly-once without a separate inbox. Business duplicates and effects outside that boundary still require their own identities, transactions or receiver-side idempotency.
97Kafka vs RabbitMQ, for real: how do you pick per use case?
middle
Short answer: Compare the messaging model and workload, not fixed throughput rankings. Kafka is a partitioned retained log. RabbitMQ offers classic/quorum task queues as well as retained streams.
| Need | Kafka | RabbitMQ |
|---|---|---|
| Replay and independent readers | Retention plus group offsets | Streams support retention/replay; acknowledged classic/quorum queue messages are normally removed |
| Routing | Topics, keys and application/stream processing | Exchanges and bindings provide flexible routing |
| Task acknowledgement | Usually offsets per partition | Individual acknowledgements/requeue in task queues |
| Delay/priority | Application scheduling/retry-topic design | Depends on queue type; TTL/dead-letter routing or a delay plugin are patterns, not universal arbitrary native delay |
| Ordering | Log order per partition; application must preserve effects | Queue type, redelivery and consumer concurrency affect order |
Choose Kafka for replayable event pipelines and independent processing groups. Choose RabbitMQ task queues when per-message work distribution and routing fit better; evaluate RabbitMQ streams for log workloads too.
⚠️ Gotcha: benchmark with your durability, message size, batching and topology. Neither product promises an intrinsic messages-per-second or latency advantage for every deployment.
98How do you guarantee processing order for one order's events end-to-end?
middle
Short answer: Preserve order through the producer, log, worker scheduling and retry policy. A common key only places messages together; it does not by itself define the business order.
- Assign a sequence/version atomically with each entity change (often in the DB/outbox). Publish in that order with a stable partition key and compatible partitioner; concurrent producers need coordination.
- Keep partition count changes and migrations from splitting the required ordering domain. Kafka preserves append order inside a partition, not an automatic global order across topics.
- Process each entity serially; a worker pool must not let a later operation overtake an unfinished one. Commit only contiguous completed offsets.
- For commands/deltas, require the next expected sequence. Deduplicate already applied events; buffer/retry a future sequence and resolve missing/poison events deliberately.
- A later full snapshot can sometimes supersede an older snapshot. A later payment delta cannot simply replace an earlier delta. Retry topics require this distinction.
⚠️ Gotcha: cross-topic business coordination is possible with a state machine/sequence protocol, but Kafka supplies no cross-topic order automatically. Test duplicates, gaps, retries and rebalances, not only the happy path.
99Why put a queue between services when HTTP with retries exists?
concept
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 |
| Completion | caller can wait for a result | acceptance and completion are often separate |
- Queue — events, background jobs, spiky load, several independent consumers of one event.
- HTTP — fits when the caller needs an immediate result. Synchronous transport alone does not provide stronger transaction isolation or replica consistency.
- 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.
100Cache-aside vs write-through vs write-behind: how does each work and when do you choose it?
middle
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 |
| Freshness | requires invalidation/fill protocol | depends on coordinated writes and failure handling | source lags until flush |
| Write latency | same as the DB | DB + cache: slower | cache only: fast |
| Durability | DB contract after its commit | DB contract after its commit | unflushed state depends on durable buffer |
- 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.
- Write-through — acknowledge success only after the backing write is durable under its contract. Separate DB/cache writes still need rules for partial failure, racing writers/fills and bypass writers; consistency is not automatic.
- 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
Source links provide further reading. Where a recorded technical review is available, the answer shows its date and scope. Tool selections use direct provider links and contain no affiliate placements. Sources, features, and hiring formats can change.
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.