Go Gotchas & Code Reading¶
Tricky-output and code-reading puzzles that probe a senior Go engineer's grasp of loop-variable semantics, defer, typed nil, slice aliasing, channels, and other subtle runtime behaviors.
35 questions across 14 topics · Level: senior
Topics¶
- Loop Variable Capture (3)
- Defer Semantics (5)
- Typed Nil & Nil Receivers (2)
- Slice Aliasing & Growth (4)
- Map Behaviors (4)
- Range Quirks (2)
- Channels (4)
- Goroutines & Synchronization (2)
- Integers, Runes & Iota (2)
- Structs & Methods (3)
- Interfaces & Receivers (1)
- Initialization Order (1)
- Time Pitfalls (1)
- Variable Shadowing (1)
Loop Variable Capture¶
1. What does this program print under Go 1.21 (pre-1.22) versus Go 1.22+, and why?¶
Difficulty: 🟡 medium · Tags: closures, loop-var, go1.22, goroutines
Go 1.21 and earlier: almost always prints 3 3 3 (in some order, but all 3s). There is a single i shared by every iteration; the goroutines run after the loop has advanced/finished, so they all read the final value 3.
Go 1.22 and later: prints 0 1 2 in some order (the three values, possibly interleaved differently each run). Go 1.22 made the for loop variable per-iteration: each iteration gets a fresh i, so each closure captures a distinct variable holding that iteration's value.
The mechanism: closures in Go capture variables by reference, not by value. Before 1.22 there was one variable to capture; after 1.22 there are three. The output order is still nondeterministic because goroutine scheduling is unordered.
Key points - Closures capture the variable, not a snapshot of its value - Pre-1.22: one shared loop variable -> all goroutines see the last value - Go 1.22 made loop variables per-iteration (scoped to the loop body) - The fix changed observed behavior of a very common bug pattern
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Print(i, " ")
}()
}
wg.Wait()
}
Follow-ups - How did you fix this before 1.22 (e.g. i := i shadow, or passing i as an argument)? - Does the 1.22 change also apply to range loops?
2. Pre-1.22, what is the classic one-line fix inside the loop body, and what does this version print on ALL Go versions?¶
Difficulty: 🟢 warm-up · Tags: closures, loop-var, fix
Prints 0 1 2 (in some nondeterministic order) on every Go version.
The line i := i declares a new variable i scoped to the loop body, initialized to the current iteration's value. Each closure now captures its own copy. This idiom was the canonical pre-1.22 fix and remains harmless (a no-op-ish redundancy) under 1.22+. Passing the value as a function argument — go func(i int){...}(i) — is the other classic fix.
Key points - i := i creates a fresh variable per iteration via shadowing - Works on all Go versions; redundant but not harmful under 1.22+ - Equivalent alternative: pass i as a goroutine argument
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
i := i // shadow with a fresh per-iteration variable
wg.Add(1)
go func() {
defer wg.Done()
fmt.Print(i, " ")
}()
}
wg.Wait()
}
Follow-ups - Which fix do you prefer and why — shadow vs argument passing?
3. What does this print, and does the Go 1.22 loop-var change affect it?¶
Difficulty: 🟡 medium · Tags: closures, loop-var, range, go1.22
Pre-1.22: prints ccc. The range variable v is reused across iterations, so all three closures capture the same variable holding the final value "c".
Go 1.22+: prints abc. The 1.22 per-iteration semantics apply to range loops too: v is fresh each iteration, so each closure sees its own value.
Unlike the goroutine cases, the order here is deterministic because we call the closures sequentially — only the captured values differ between versions.
Key points - The 1.22 change covers both 3-clause and range for-loops - Deterministic order here (sequential calls), only values differ by version - ccc pre-1.22, abc on 1.22+
package main
import "fmt"
func main() {
funcs := make([]func(), 0, 3)
for _, v := range []string{"a", "b", "c"} {
funcs = append(funcs, func() { fmt.Print(v) })
}
for _, f := range funcs {
f()
}
}
Follow-ups - What go.mod go directive controls whether you get the new semantics?
Defer Semantics¶
4. What does this print, and why is the deferred value not 10?¶
Difficulty: 🟢 warm-up · Tags: defer, evaluation-time
Prints:
Deferred function arguments are evaluated immediately when the defer statement executes, not when the deferred call runs. At the moment defer fmt.Println("deferred:", i) is reached, i is 1, so 1 is captured as the argument. The later i = 10 does not affect the already-evaluated argument. The call itself runs at function return — hence printed last.
Key points - Defer captures argument VALUES at the point of the defer statement - The deferred call executes at function return (LIFO) - Contrast with closures, which capture variables, not values
package main
import "fmt"
func main() {
i := 1
defer fmt.Println("deferred:", i)
i = 10
fmt.Println("current:", i)
}
Follow-ups - How would you make it print 10 instead — wrap it in a closure?
5. Now the deferred call is a closure. What does it print?¶
Difficulty: 🟢 warm-up · Tags: defer, closures, evaluation-time
Prints:
Here the deferred entity is a closure over i. Only the call to the anonymous function is deferred; the body — which reads i — runs at return time, by which point i is 10. The closure captures the variable i by reference, so it observes the latest value. This is the key contrast with the previous puzzle, where i was a directly-evaluated argument.
Key points - A deferred closure reads variables at execution time (return) - Closure captures the variable; argument-style defer captures the value - Same i, opposite result vs gotcha-004
package main
import "fmt"
func main() {
i := 1
defer func() { fmt.Println("deferred:", i) }()
i = 10
fmt.Println("current:", i)
}
Follow-ups - When is the argument-evaluation timing actually useful (e.g. defer mu.Unlock())?
6. What order are these defers executed in, and what is printed?¶
Difficulty: 🟢 warm-up · Tags: defer, lifo, ordering
Prints start 2 1 0.
start prints first because the deferred calls only fire when main returns. Defers run in LIFO (last-in, first-out) order: the last registered defer (i=2) runs first. Each call's argument was evaluated when its defer ran, so the captured values are 0, 1, 2 — printed in reverse registration order as 2 1 0.
Key points - Defers execute LIFO at function return - Each deferred call's args were evaluated at registration time (0,1,2) - Body of the function completes before any defer fires
package main
import "fmt"
func main() {
for i := 0; i < 3; i++ {
defer fmt.Print(i, " ")
}
fmt.Print("start ")
}
Follow-ups - Why is LIFO the right choice for paired acquire/release (locks, files)?
7. What does this function return, and how does the defer change it?¶
Difficulty: 🟡 medium · Tags: defer, named-returns
Prints 12.
With a named return value result, the return result + 1 statement does two things: it assigns 6 to result, then begins the return. The deferred closure runs after this assignment but before control actually leaves the function, and it can mutate the named return value: result *= 2 turns 6 into 12. So the caller sees 12, not 6.
If the return were unnamed (func compute() int), the deferred closure would have no name to mutate and the returned value would be 6.
Key points - return x assigns to the named result, THEN defers run, THEN the function exits - Deferred closures can mutate named return values - Common idiom for wrapping errors: defer func(){ err = wrap(err) }()
package main
import "fmt"
func compute() (result int) {
defer func() {
result *= 2
}()
result = 5
return result + 1
}
func main() {
fmt.Println(compute())
}
Follow-ups - Rewrite with an unnamed return — what does it print and why?
8. This loop opens files. What is the resource bug, and when do the files actually close?¶
Difficulty: 🟡 medium · Tags: defer, resource-leak, loop
Behaviorally it prints each path as it processes, but the bug is resource accumulation: defer f.Close() is bound to the function scope, not the loop iteration. None of the files close until processAll returns. If paths has thousands of entries, you hold thousands of open file descriptors simultaneously and can hit EMFILE (too many open files).
Fix options: (1) wrap each iteration in a closure/helper function so the defer fires per-file; or (2) call f.Close() explicitly at the end of the loop body (handling the error). Pattern (1):
for _, p := range paths {
func() {
f, err := os.Open(p)
if err != nil { return }
defer f.Close()
fmt.Println("processing", f.Name())
}()
}
Key points - defer scope is the enclosing function, not the loop body - Defers in a loop accumulate and run only at function return - Risk: file-descriptor / connection / lock exhaustion - Fix: extract a per-iteration function or close explicitly
package main
import (
"fmt"
"os"
)
func processAll(paths []string) {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
continue
}
defer f.Close() // BUG: defers accumulate
fmt.Println("processing", f.Name())
}
}
Follow-ups - How would you propagate Close() errors while still releasing every fd?
Typed Nil & Nil Receivers¶
9. This function returns a nil pointer through an error interface. What does the caller print, and why is the bug so insidious?¶
Difficulty: 🟠 hard · Tags: typed-nil, interfaces, error-handling
Prints error occurred: boom (it then calls Error() on a nil receiver, which is fine here since the method doesn't dereference e).
This is the typed nil interface trap. An interface value is a pair (type, value). err != nil is true only when both halves are nil. doWork returns a *MyError that is itself nil, but the moment it's assigned to the error interface, the interface holds (type=*MyError, value=nil) — the type half is non-nil, so err != nil is true.
The fix is to return nil literally on the success path, not a typed nil pointer: if fail { return &MyError{} }; return nil. Never declare a concrete error pointer and return it across a success path.
Key points - Interfaces are (type, value) pairs; nil only when BOTH are nil - A nil *T assigned to an interface yields a non-nil interface - Always return the literal nil for the no-error path - Calling a method on a nil receiver is legal unless it dereferences
package main
import "fmt"
type MyError struct{}
func (e *MyError) Error() string { return "boom" }
func doWork(fail bool) error {
var e *MyError // nil pointer
if fail {
e = &MyError{}
}
return e // returns a non-nil interface wrapping a (possibly nil) *MyError
}
func main() {
err := doWork(false)
if err != nil {
fmt.Println("error occurred:", err)
} else {
fmt.Println("no error")
}
}
Follow-ups - How does errors.Is / a linter (e.g. nilness, staticcheck) catch this?
10. Can you call a method on a nil pointer here? What does this print?¶
Difficulty: 🟡 medium · Tags: typed-nil, nil-receiver, methods
Prints:
Calling a method on a nil pointer is perfectly legal in Go as long as the method body doesn't dereference the nil receiver. The receiver l is just a pointer argument; the call doesn't dereference it until you access a field. Here Sum checks if l == nil first, so the nil case returns 0 safely. The recursion l.next.Sum() relies on this: the base case is a nil next. For the two-node list, 1 + (2 + 0) = 3.
This is an idiomatic Go pattern for recursive/linked structures — nil receivers as a natural base case.
Key points - Method calls on nil pointers are legal; only dereferencing nil panics - Nil receiver is a clean base case for recursive structures - The method must explicitly guard against the nil receiver
package main
import "fmt"
type List struct {
val int
next *List
}
func (l *List) Sum() int {
if l == nil {
return 0
}
return l.val + l.next.Sum()
}
func main() {
var l *List // nil
fmt.Println(l.Sum())
l = &List{val: 1, next: &List{val: 2}}
fmt.Println(l.Sum())
}
Follow-ups - What happens if Sum forgets the nil check — what's the panic?
Slice Aliasing & Growth¶
11. What does this print? Pay attention to the shared backing array.¶
Difficulty: 🟠 hard · Tags: slices, append, aliasing, backing-array
Prints:
b := a[1:3] shares a's backing array, starting at index 1, with len(b)==2 and cap(b)==4 (from index 1 to the end of a). Because there's spare capacity, append(b, 99) writes into the existing backing array at the slot after b — that's a[3], overwriting the original 4 with 99. So a is mutated to [1 2 3 99 5] even though we only appended to b.
This surprises people who assume append always copies. It only reallocates when capacity is exceeded. To avoid clobbering, use a full slice expression a[1:3:3] to cap the slice, forcing the next append to reallocate.
Key points - Re-slicing shares the backing array - append writes in place when spare capacity exists - This silently mutates the original slice - a[low:high:max] (full slice expr) limits cap to force copy-on-append
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // [2 3], len 2, cap 4
b = append(b, 99)
fmt.Println(a)
fmt.Println(b)
}
Follow-ups - Rewrite using a[1:3:3] — what changes in the output?
12. Same scenario but with a full slice expression. What does it print now?¶
Difficulty: 🟡 medium · Tags: slices, full-slice-expression, append
Prints:
The full slice expression a[1:3:3] sets len(b)==2 and cap(b)==2 (the third index 3 is the max bound, so cap = max - low = 3 - 1 = 2). Now append(b, 99) exceeds capacity, so Go allocates a new backing array, copies [2 3] into it, and appends 99. a is untouched and remains [1 2 3 4 5].
This is the standard defense against accidental aliasing when handing out sub-slices.
Key points - Full slice expression a[low:high:max] caps capacity to max-low - When append exceeds cap, a fresh backing array is allocated - Protects the original slice from being overwritten
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:3:3] // [2 3], len 2, cap 2
b = append(b, 99)
fmt.Println(a)
fmt.Println(b)
}
Follow-ups - Why is this important when returning slices from a library API?
13. A function appends to a slice it received. Does the caller see the change? What prints?¶
Difficulty: 🟠 hard · Tags: slices, append, pass-by-value, function-args
Prints [0 0 0].
Slices are passed by value: the function gets a copy of the slice header (pointer, len, cap). Inside grow, cap(s)==3 and len(s)==3, so append(s, 100) exceeds capacity and allocates a new backing array for the local s. The subsequent s[0] = -1 writes into that new array, which the caller never sees. The caller's slice still points to the original, untouched backing array [0 0 0].
If the slice had spare capacity (e.g. make([]int, 3, 4)), the append would write in place and s[0] = -1 would mutate the shared array — but even then the caller's len would stay 3, so the appended 100 would be invisible while the -1 would show. The robust pattern is to return the slice: s = grow(s).
Key points - Slice headers are passed by value (pointer/len/cap copied) - append that reallocates detaches the callee from the caller's array - The caller's len/cap never change from inside the callee - Idiom: s = append(s, ...) and return the slice
package main
import "fmt"
func grow(s []int) {
s = append(s, 100)
s[0] = -1
}
func main() {
s := make([]int, 3, 3) // [0 0 0], len 3 cap 3
grow(s)
fmt.Println(s)
}
Follow-ups - What changes if you make the slice with cap 4 instead of 3?
14. Is there a difference between these two slices? What does this print?¶
Difficulty: 🟡 medium · Tags: slices, nil-vs-empty, json
Prints:
A nil slice and an empty slice behave identically for len, cap, range, and append — but they are not equal: nilSlice == nil is true, emptySlice == nil is false (the latter has a non-nil backing pointer, even if zero-length).
The difference becomes visible at boundaries like JSON: a nil slice marshals to null, while an empty slice marshals to []. This often matters in API contracts where clients distinguish 'no field' from 'empty list'. For internal logic, prefer treating them the same and don't rely on the distinction unless serialization demands it.
Key points - nil slice == nil is true; empty slice == nil is false - Both have len 0 and are safe to range/append - json.Marshal: nil -> null, empty -> [] - Distinction matters at serialization / API boundaries
package main
import (
"encoding/json"
"fmt"
)
func main() {
var nilSlice []int
emptySlice := []int{}
fmt.Println(nilSlice == nil, emptySlice == nil)
fmt.Println(len(nilSlice), len(emptySlice))
a, _ := json.Marshal(nilSlice)
b, _ := json.Marshal(emptySlice)
fmt.Println(string(a), string(b))
}
Follow-ups - How do you force a nil slice to marshal as [] in an API response?
Map Behaviors¶
15. Will this reliably print keys in insertion order? What is guaranteed?¶
Difficulty: 🟢 warm-up · Tags: maps, iteration-order, nondeterminism
It prints the three keys a, b, c but in a randomized, unspecified order that varies between runs (e.g. c a b). Go deliberately randomizes map iteration order — the runtime starts iteration at a random bucket/offset — specifically to stop programmers from depending on any order.
There is no insertion-order or sorted guarantee for maps. If you need a stable order, collect the keys into a slice and sort.Strings(keys), then iterate the slice.
Key points - Map iteration order is intentionally randomized per run - No insertion-order or sort-order guarantee exists - For deterministic output: extract keys to a slice and sort
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k := range m {
fmt.Print(k, " ")
}
}
Follow-ups - Why did the Go team make this random rather than just unspecified?
16. What does this print? Focus on the missing-key reads.¶
Difficulty: 🟢 warm-up · Tags: maps, comma-ok, zero-value
Prints:
Reading a missing key returns the zero value of the value type (0 for int), never panics. The comma-ok form v2, ok := m[key] distinguishes 'present with zero value' from 'absent': ok is false for a missing key.
The count["a"]++ idiom works precisely because the first read of the absent key yields 0, increments to 1, and stores it — so two increments give 2. This is why you can build counters without pre-initializing keys.
Key points - Missing-key read returns the value type's zero value, no panic - Comma-ok form reveals presence vs absence - m[k]++ relies on the zero-value-on-missing behavior
package main
import "fmt"
func main() {
m := map[string]int{"x": 10}
v := m["missing"]
fmt.Println(v)
v2, ok := m["missing"]
fmt.Println(v2, ok)
count := map[string]int{}
count["a"]++
count["a"]++
fmt.Println(count["a"])
}
Follow-ups - How would you distinguish a stored 0 from an absent key in a counter?
17. What happens at runtime here — does it print, or panic?¶
Difficulty: 🟡 medium · Tags: maps, nil-map, panic
It prints 0 0 first, then panics at the write:
A nil map is readable: lookups return the zero value and len is 0. But you cannot write to a nil map — there's no underlying hash table allocated, so the runtime panics with assignment to entry in nil map. You must initialize it first with m = make(map[string]int) or a composite literal.
This bites people when a struct has a map field that they forgot to initialize before assigning to it.
Key points - Reading and len() on a nil map are safe (zero value, 0) - Writing to a nil map panics: 'assignment to entry in nil map' - Initialize with make() or a literal before writing - Common with uninitialized struct map fields
package main
import "fmt"
func main() {
var m map[string]int // nil map
fmt.Println(m["a"], len(m)) // reads are fine
m["a"] = 1 // write panics
fmt.Println(m)
}
Follow-ups - Does deleting from a nil map panic?
18. Will this compile? If not, why?¶
Difficulty: 🟡 medium · Tags: maps, addressability, compile-error, structs
It does not compile. The error is:
Map elements are not addressable in Go. m["a"] returns a copy of the value, not a reference to the slot, so you cannot mutate a field of it in place (you'd be mutating a temporary). To update, replace the whole value:
map[string]*Point, where m["a"].X = 5 does work because you're dereferencing a pointer, not addressing the map slot. Key points - Map values are not addressable; you can't take &m[k] or m[k].field = - m[k] yields a copy, so in-place field mutation is rejected at compile time - Fixes: read-modify-write the whole value, or store *Point
package main
import "fmt"
type Point struct{ X, Y int }
func main() {
m := map[string]Point{"a": {1, 2}}
m["a"].X = 5 // ???
fmt.Println(m)
}
Follow-ups - Why are map elements non-addressable (hint: rehashing/relocation)?
Range Quirks¶
19. Ranging over an array — does modifying the array mid-loop affect the iteration? What prints?¶
Difficulty: 🟠 hard · Tags: range, array-vs-slice, copy-semantics
Prints 1 2 3.
When you range over an array (not a slice), Go evaluates the range expression once and iterates over a copy of the array. So arr[1] = 99 mutates the original arr, but the loop is reading from the copy made at loop start, which still has 2 at index 1. Hence 2, not 99.
If arr were a slice (arr := []int{1,2,3}), range would share the backing array, and you'd see 1 99 3 because the modification is visible. This array-copy behavior is a classic surprise.
Key points - range over an array copies the array first - Mutations to the original array don't affect the iteration - range over a slice shares the backing array -> mutations ARE visible - Large arrays copied by range can also be a performance footgun
package main
import "fmt"
func main() {
arr := [3]int{1, 2, 3}
for i, v := range arr {
if i == 0 {
arr[1] = 99 // modify upcoming element
}
fmt.Print(v, " ")
}
}
Follow-ups - How would you range over a large array without copying it?
20. What does ranging over this string produce for index and value?¶
Difficulty: 🟡 medium · Tags: range, strings, runes, utf-8
Prints:
Ranging over a string yields (byteOffset, rune) pairs, decoding UTF-8 as it goes. The index is the byte offset of the start of each rune, not a sequential 0,1,2 counter. é (U+00E9) occupies 2 bytes, so after the rune at byte 1 the next index jumps to 3.
Meanwhile len(s) returns the number of bytes (6), not runes (5). To count runes use utf8.RuneCountInString(s) or len([]rune(s)). Indexing s[1] would give a single byte (the first byte of é), not the character.
Key points - range over a string decodes UTF-8: index is the byte offset, value is a rune - Multi-byte runes cause the index to skip - len(string) counts bytes, not runes - s[i] indexes bytes; use []rune or utf8 package for code points
package main
import "fmt"
func main() {
s := "héllo" // é is U+00E9, 2 bytes in UTF-8
for i, r := range s {
fmt.Printf("%d:%c ", i, r)
}
fmt.Println()
fmt.Println("len:", len(s))
}
Follow-ups - What does s[1] evaluate to here, and what type is it?
Channels¶
21. What does receiving from this closed channel print?¶
Difficulty: 🟡 medium · Tags: channels, closed-channel, comma-ok
Prints:
Receiving from a closed channel never blocks and never panics. First it drains any buffered values: the buffered 7 comes out with ok == true. Once drained, every further receive returns the element type's zero value (0) with ok == false. The ok flag is the canonical way to detect channel closure.
This is exactly why for v := range ch terminates cleanly when the channel is closed — range stops at the first ok == false.
Key points - Receiving from a closed channel drains buffered values first - After draining: zero value with ok == false, never blocks - comma-ok on receive detects closure - Underlies how range over a channel terminates
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 7
close(ch)
a, ok1 := <-ch
b, ok2 := <-ch
c, ok3 := <-ch
fmt.Println(a, ok1)
fmt.Println(b, ok2)
fmt.Println(c, ok3)
}
Follow-ups - What's the difference between a closed channel and a nil channel on receive?
22. What happens when you send on a closed channel?¶
Difficulty: 🟢 warm-up · Tags: channels, closed-channel, panic
Prints about to send and then panics:
Sending on a closed channel always panics with send on closed channel, even if the channel is buffered with free space. Closing signals 'no more values will ever be sent', so a subsequent send is a programming error. Likewise, closing an already-closed channel panics, and closing a nil channel panics.
The convention is that the sender (or a single coordinating owner) closes the channel, never the receiver — precisely to avoid sending after close.
Key points - Send on a closed channel panics: 'send on closed channel' - Double-close and close(nil) also panic - Only the sender/owner should close a channel - Receivers detect closure via comma-ok or range
package main
import "fmt"
func main() {
ch := make(chan int, 1)
close(ch)
fmt.Println("about to send")
ch <- 1 // ???
fmt.Println("sent")
}
Follow-ups - How do you coordinate close with multiple senders safely?
23. What does this program do at runtime?¶
Difficulty: 🟠 hard · Tags: channels, nil-channel, deadlock, select
Prints start, then deadlocks:
A nil channel blocks forever on both send and receive. Since ch was never made, the receive <-ch blocks the only goroutine permanently. The Go runtime's deadlock detector notices that all goroutines are blocked and aborts with a fatal error (this is a runtime fatal error, not a recoverable panic).
Nil-channel blocking is actually useful in select: setting a channel variable to nil dynamically disables that case, since a nil channel is never ready.
Key points - Send and receive on a nil channel block forever - A single blocked goroutine triggers the deadlock detector (fatal error) - Deadlock is a fatal error, not a recoverable panic - Useful trick: nil out a channel in select to disable a case
package main
import "fmt"
func main() {
var ch chan int // nil channel
fmt.Println("start")
<-ch // ???
fmt.Println("done")
}
Follow-ups - Show how nil-ing a channel in a select loop disables a branch.
24. What does this select print, and is it deterministic?¶
Difficulty: 🟡 medium · Tags: channels, select, default, non-blocking
Prints (deterministically):
A select with a default is non-blocking: if no case is ready, it takes the default immediately. In the first select, the buffered 42 is available, so the receive case fires. After it's consumed, the buffer is empty; in the second select no receive is ready, so default runs. This is the standard 'try-receive' idiom.
(When multiple cases are ready simultaneously, select chooses one at random — but here only one case is ever ready at a time, so the output is deterministic.)
Key points - select with default is non-blocking (try-send / try-receive) - default fires only when no other case is ready - Among multiple ready cases, select picks pseudo-randomly - Here only one case is ready each time -> deterministic
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 42
select {
case v := <-ch:
fmt.Println("received", v)
default:
fmt.Println("no value ready")
}
select {
case v := <-ch:
fmt.Println("received", v)
default:
fmt.Println("no value ready")
}
}
Follow-ups - When is the random selection among ready cases important for fairness?
Goroutines & Synchronization¶
25. Will this reliably print the goroutine's message? What is the bug?¶
Difficulty: 🟢 warm-up · Tags: goroutines, main-exit, synchronization
It reliably prints main done and usually does NOT print the goroutine message at all.
main returns immediately after launching the goroutine. When main (the main goroutine) returns, the entire program exits — the Go runtime does not wait for other goroutines to finish. The spawned goroutine may not even get scheduled before the process tears down. The output is therefore typically just:
The fix is explicit synchronization: a sync.WaitGroup, a channel to signal completion, etc. Never rely on timing or time.Sleep for correctness.
Key points - Program exits when main returns; it does not wait for goroutines - Output is nondeterministic but usually just 'main done' - Synchronize with WaitGroup or a done channel, not Sleep
package main
import "fmt"
func main() {
go func() {
fmt.Println("hello from goroutine")
}()
fmt.Println("main done")
}
Follow-ups - Why is time.Sleep a poor synchronization mechanism here?
26. There's a concurrency bug in how this WaitGroup is used. What is it, and what can happen?¶
Difficulty: 🟠 hard · Tags: goroutines, waitgroup, race-condition, synchronization
The bug is calling wg.Add(1) inside the goroutine instead of before launching it. This creates a race between wg.Add and wg.Wait. The main goroutine may reach wg.Wait() before any goroutine has run wg.Add(1) — at which point the counter is 0, Wait returns immediately, and the program prints all done while possibly skipping some or all working lines. In the worst case Wait returns before any work happens.
Add must be called in the goroutine that creates the worker, before the go statement (or at least before any Wait), so the counter is guaranteed nonzero when Wait is reached:
-race and this surfaces as a data race / nondeterministic behavior. Key points - wg.Add must happen before the goroutine starts, not inside it - Otherwise Wait can observe a zero counter and return early - Result: missing output and a race between Add and Wait - Add(n) before the go statement is the correct pattern
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // BUG: Add inside the goroutine
defer wg.Done()
fmt.Println("working")
}()
}
wg.Wait()
fmt.Println("all done")
}
Follow-ups - What does go run -race report for this program?
Integers, Runes & Iota¶
27. What does this integer arithmetic print?¶
Difficulty: 🟢 warm-up · Tags: integers, division, rune-byte, truncation
Prints:
7 / 2 is integer division — both operands are untyped integer constants, so the result truncates toward zero to 3. 7.0 / 2 makes one operand a float, so the whole expression is float division: 3.5. Integer division truncates toward zero (not floor), so -7 / 2 == -3 (not -4), and % follows: -7 % 2 == -1 (the result of % takes the sign of the dividend).
'A' is a rune constant with value 65; assigned to a byte it prints as the number 65, while string(x) interprets it as the character "A".
Key points - Integer division truncates toward zero, not floor - Float division requires a float operand (7.0) - % result takes the sign of the dividend: -7 % 2 == -1 - A byte/rune prints numerically; string(b) gives the character
package main
import "fmt"
func main() {
fmt.Println(7 / 2)
fmt.Println(7.0 / 2)
fmt.Println(-7 / 2)
fmt.Println(-7 % 2)
var x byte = 'A'
fmt.Println(x, string(x))
}
Follow-ups - How would you compute a floor division for negative numbers?
28. What values do these iota constants have?¶
Difficulty: 🟡 medium · Tags: iota, constants, enums
Prints:
iota is the index of the const spec within the const block, starting at 0 and incrementing by one per line (whether or not iota is mentioned). In the first block, line 0 is discarded with _. KB is on line 1: 1 << (10*1) = 1024. MB and GB repeat the same expression (constants without an explicit value reuse the previous spec's expression), so they evaluate with iota = 2 and 3: 1<<20 and 1<<30. Because the type is ByteSize float64, they print in float form.
In the second block, iota is 0,1,2,3 per line; the _ on the third line skips value 2, so D (line 3) is 3, not 2. This is the standard iota enum/skip pattern.
Key points - iota is the line index in the const block, incrementing each spec - Omitted expressions repeat the previous spec's expression - _ consumes an iota value (used to skip) - iota resets to 0 at each new const block
package main
import "fmt"
type ByteSize float64
const (
_ = iota // 0, skipped
KB ByteSize = 1 << (10 * iota)
MB
GB
)
const (
A = iota // 0
B // 1
_ // 2, skipped
D // 3
)
func main() {
fmt.Println(KB, MB, GB)
fmt.Println(A, B, D)
}
Follow-ups - How would you build a bit-flag enum with iota (1 << iota)?
Structs & Methods¶
29. Does the mutation stick? What does this print?¶
Difficulty: 🟡 medium · Tags: structs, methods, value-vs-pointer-receiver
Prints:
IncValue has a value receiver: it operates on a copy of c, so incrementing c.n inside it mutates the copy and is lost when the method returns. Two calls leave the original at 0.
IncPtr has a pointer receiver: c.IncPtr() is shorthand for (&c).IncPtr() (Go auto-takes the address because c is addressable), so it mutates the original. Two calls bring c.n to 2.
Rule of thumb: if a method needs to modify the receiver (or the struct is large), use a pointer receiver.
Key points - Value receiver gets a copy; mutations don't persist - Pointer receiver mutates the original; Go auto-takes &c for addressable values - Mixing receiver kinds on one type is a common smell - Use pointer receivers to mutate or avoid large copies
package main
import "fmt"
type Counter struct{ n int }
func (c Counter) IncValue() { c.n++ } // value receiver
func (c *Counter) IncPtr() { c.n++ } // pointer receiver
func main() {
c := Counter{}
c.IncValue()
c.IncValue()
fmt.Println(c.n)
c.IncPtr()
c.IncPtr()
fmt.Println(c.n)
}
Follow-ups - What happens if c is not addressable (e.g. a map value) and you call IncPtr?
30. Which method gets called via the embedded field, and what prints?¶
Difficulty: 🟠 hard · Tags: structs, embedding, method-shadowing, no-virtual-dispatch
Prints:
Dog.Speak shadows the embedded Animal.Speak, so d.Speak() calls the Dog version and returns "Woof".
But Describe is defined on Animal and is promoted to Dog. When you call d.Describe(), it runs with an Animal receiver, and inside it a.Speak() resolves statically to Animal.Speak — there is no virtual dispatch in Go. Embedding is composition, not inheritance: the promoted method has no knowledge of Dog's override, so it returns "I say ...", not "I say Woof".
To get polymorphic behavior you'd need an interface and explicit indirection, not embedding.
Key points - Embedding promotes methods but is composition, not inheritance - Outer-type methods shadow same-named embedded methods on direct calls - Promoted methods run with the embedded receiver -> no virtual dispatch - Use interfaces for polymorphism, not method override expectations
package main
import "fmt"
type Animal struct{}
func (a Animal) Speak() string { return "..." }
func (a Animal) Describe() string { return "I say " + a.Speak() }
type Dog struct{ Animal }
func (d Dog) Speak() string { return "Woof" }
func main() {
d := Dog{}
fmt.Println(d.Speak())
fmt.Println(d.Describe())
}
Follow-ups - How would you restructure with an interface to get Describe to print 'Woof'?
31. Will these struct comparisons compile and run? What is the outcome?¶
Difficulty: 🟡 medium · Tags: structs, comparison, compile-error, comparable
This does not compile. The error is on the second comparison:
Structs are comparable with == only if all their fields are comparable. A has only int and string fields, both comparable, so a1 == a2 is fine and would print true (field-by-field equality).
But B contains a []int slice, and slices are not comparable (you can only compare a slice to nil). Therefore any == on B is a compile-time error, not a runtime one. To compare such structs use reflect.DeepEqual or a hand-written equality function.
Key points - Structs are comparable iff every field is comparable - Slices, maps, and functions are not comparable (compile error) - A{int,string} compares field-by-field and would print true - Use reflect.DeepEqual for structs with non-comparable fields
package main
import "fmt"
type A struct {
X int
Y string
}
type B struct {
Items []int
}
func main() {
a1 := A{1, "hi"}
a2 := A{1, "hi"}
fmt.Println(a1 == a2)
b1 := B{[]int{1}}
b2 := B{[]int{1}}
fmt.Println(b1 == b2)
}
Follow-ups - Why are slices not comparable while arrays are?
Interfaces & Receivers¶
32. Will this compile? It assigns a value to an interface. Explain.¶
Difficulty: 🟠 hard · Tags: interfaces, method-set, pointer-receiver, compile-error
It does not compile. The error is on s = t:
cannot use t (variable of type T) as Stringer value in assignment: T does not implement Stringer (method String has pointer receiver)
When a method is defined with a pointer receiver (*T), only *T is in the method set of the type — T (the value type) does not satisfy the interface. So s = &t works, but s = t fails to compile.
The asymmetry: if String had a value receiver (t T), then both T and *T would satisfy Stringer. Pointer-receiver methods are not in the value type's method set because Go can't always take the address of an interface-stored value. This is why a forgotten & is a frequent 'does not implement' error.
Key points - Pointer-receiver methods are only in T's method set, not T's - Value-receiver methods are in both T's and T's method sets - So *T satisfies the interface but T does not (here) - Common 'does not implement interface' cause: missing &
package main
import "fmt"
type Stringer interface{ String() string }
type T struct{ name string }
func (t *T) String() string { return t.name } // pointer receiver
func main() {
var s Stringer
t := T{"hello"}
s = &t // OK: *T implements Stringer
fmt.Println(s.String())
s = t // ??? does T implement Stringer?
fmt.Println(s.String())
}
Follow-ups - Why can't Go just take the address of the value to make T satisfy it?
Initialization Order¶
33. In what order do these run, and what is printed?¶
Difficulty: 🟡 medium · Tags: init, package-initialization, ordering
Prints:
Package-level variable initialization does not follow source order — Go performs a dependency-ordered initialization. a depends on b, which depends on c, so the runtime initializes c=1 first, then b=c+1=2, then a=b+1=3, regardless of the declaration order in the file.
After all package variables are initialized, init() functions run (in source order within a file, and in file order). Only then does main run. So both lines print 3 2 1. (If there were a true initialization cycle, the compiler would reject it.)
Key points - Package vars initialize in dependency order, not source order - init() runs after all package-level var initialization - main runs after all init() functions complete - Initialization cycles are a compile error
package main
import "fmt"
var a = b + 1
var b = c + 1
var c = 1
func init() {
fmt.Println("init:", a, b, c)
}
func main() {
fmt.Println("main:", a, b, c)
}
Follow-ups - What is the init order across multiple files / multiple packages?
Time Pitfalls¶
34. Why might this time format produce unexpected output? What is the reference layout?¶
Difficulty: 🟡 medium · Tags: time, formatting, reference-layout
Prints:
Go's time.Format uses a reference layout rather than %Y-%m-%d-style tokens. The reference time is Mon Jan 2 15:04:05 MST 2006 (mnemonic: 1 2 3 4 5 6 7 — month, day, hour, minute, second, year-2006, and 7=UTC offset). You describe the shape of the output by formatting that one specific timestamp. So "2006-01-02 15:04:05" yields the actual value formatted that way.
The third line is the classic bug: a developer writes "2023-03-04" thinking it's a placeholder for the date. But Go does not see a year — it greedily matches reference components and copies everything else verbatim. The layout "2023-03-04" actually tokenizes as: 2 = day-of-month (4), 0 = literal 0, 2 = day-of-month again (4), 3 = 12-hour clock (9), then literal -, 03 = zero-padded 12-hour (09), literal -, 04 = minute (05). The result for this timestamp is therefore 4049-09-05 — pure nonsense, and it changes with every different time.
Lesson: never put arbitrary numbers in a layout. Only the reference components are magic (2006 year, 01/1 month, 02/2 day, 15/03/3 hour, 04/4 minute, 05/5 second). Prefer the named constants time.DateOnly ("2006-01-02"), time.RFC3339, etc. to avoid this entirely.
Key points - Go formats by example using the reference time Mon Jan 2 15:04:05 MST 2006 - Layout components are specific magic numbers (01=month, 02=day, 15=hour...) - Using arbitrary numbers like 2023 in a layout silently misbehaves - Use time constants (time.RFC3339, time.DateOnly) to avoid mistakes
package main
import (
"fmt"
"time"
)
func main() {
t := time.Date(2023, time.March, 4, 9, 5, 0, 0, time.UTC)
fmt.Println(t.Format("2006-01-02 15:04:05"))
fmt.Println(t.Format("01/02/2006"))
fmt.Println(t.Format("2023-03-04")) // common mistake
}
Follow-ups - What's the difference between 15 and 03 in the hour position?
Variable Shadowing¶
35. Why does err appear to be nil after a failure? Spot the shadowing bug.¶
Difficulty: 🟠 hard · Tags: shadowing, short-var-declaration, error-handling
Prints:
The bug is on the line val, err := find(). Because at least one new variable would be introduced and := is used inside the if block, Go declares brand-new block-scoped val and err that shadow the outer ones. The error from find() is captured in the inner err, which goes out of scope at the closing brace. The outer err was never assigned, so it stays nil.
This is a very common production bug: the error is checked/printed inside the block but the outer code believes there was no error. The fix is to use = (plain assignment) since both variables already exist: val, err = find(). go vet's shadow analysis and tools like govet -vettool=shadow flag this.
Key points - := inside a block creates new variables if it can, shadowing outer ones - Mixed := where some vars exist still shadows if the scope differs - The outer err is never assigned and stays nil - Use = when all variables already exist; enable shadow linting
package main
import (
"errors"
"fmt"
)
func find() (int, error) {
return 0, errors.New("not found")
}
func main() {
var err error
val := 0
if true {
val, err := find() // := shadows both val and err in this block
fmt.Println("inside:", val, err)
}
fmt.Println("outside err:", err)
_ = val
}
Follow-ups - How does := decide between declaring new vars and reusing existing ones?