This is the cheatsheet to read before a Go interview. Every gotcha here is something interviewers ask about — either as a “spot the bug” question or as a “what does this print?” puzzle. Most also bite real-world code, so they’re worth knowing for actual work too.
The format is the same for each: the trap, the fix, and why.
1. Loop variable capture in goroutines
The trap (pre-Go 1.22)
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}
// Prints: 3 3 3 (or some mix, usually all 3s)
Why
Before Go 1.22, the i in the loop was a single variable reused across iterations. By the time the goroutines actually ran, the loop had finished and i was 3.
The fix (pre-1.22)
Pass i as an argument so each goroutine gets its own copy:
for i := 0; i < 3; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
// Prints: 0 1 2 (in some order)
Go 1.22+
The language changed: each iteration now gets a fresh i. The original buggy code now works correctly. Interviewers may still ask about the pre-1.22 behavior — know both.
2. Slice aliasing — the hidden share
The trap
a := []int{1, 2, 3, 4, 5}
b := a[1:4] // b = [2, 3, 4]
b[0] = 99
fmt.Println(a) // [1, 99, 3, 4, 5] — a changed!
Why
Slicing does not copy. a and b point into the same underlying array. Writes through b are visible through a.
The fix
If you need an independent copy:
b := slices.Clone(a[1:4]) // Go 1.21+
// or
b := make([]int, 3)
copy(b, a[1:4])
3. append may or may not reallocate
The trap
a := make([]int, 3, 5) // len=3, cap=5
a[0], a[1], a[2] = 1, 2, 3
b := append(a, 4) // fits in capacity — same backing array
b[0] = 99
fmt.Println(a) // [99, 2, 3] — a changed!
c := append(a, 4, 5, 6) // exceeds capacity — new array
c[0] = 42
fmt.Println(a) // unchanged
Why
append reuses the backing array if there’s spare capacity. When it has to grow, it allocates a new one. So whether append mutates the original depends on capacity — unpredictable.
The fix
If you need to be sure you’re not mutating the source, clone before appending:
b := append(slices.Clone(a), 4)
Or always preserve the original by reading from a fresh slice header.
4. Map iteration order is random
The trap
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v)
}
// Order is different every run
Why
Go intentionally randomizes map iteration order — even within a single program — to prevent code from accidentally depending on it.
The fix
If you need a stable order, sort the keys:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
5. nil interface ≠ nil concrete type
The trap
func mayFail() error {
var e *MyError = nil
return e // returning a typed nil
}
err := mayFail()
if err != nil {
fmt.Println("got error:", err) // PRINTS! err is not nil!
}
Why
An interface in Go has two parts: a type and a value. An interface is nil only when both are nil. *MyError is a type. Even though the value is nil, the interface holds (type=*MyError, value=nil) — not equal to nil.
The fix
Return a bare nil when there’s no error:
func mayFail() error {
if somethingWentWrong {
return &MyError{...}
}
return nil // bare nil, not a typed nil
}
Or check explicitly:
if err == nil || reflect.ValueOf(err).IsNil() {
// handle nil case
}
The first fix is the right one. The typed-nil trap is the most-asked Go interview gotcha — memorize it.
6. defer argument evaluation
The trap
x := 1
defer fmt.Println("deferred x:", x)
x = 99
fmt.Println("now x:", x)
// Output:
// now x: 99
// deferred x: 1
Why
Arguments to a deferred call are evaluated at the moment defer runs, not when the function actually executes. The x was captured as 1.
The fix
If you want to see the current value, wrap in a closure:
x := 1
defer func() { fmt.Println("deferred x:", x) }()
x = 99
// Output:
// now x: 99
// deferred x: 99
7. defer in a loop
The trap
func processFiles(paths []string) {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
continue
}
defer f.Close() // ❌ all closes happen at function exit, not loop iteration
// ... use f
}
}
Why
defer runs when the function returns, not when the loop iteration ends. With 1000 files, you’ve stacked up 1000 deferred closes — all the file descriptors stay open until the function exits.
The fix
Wrap each iteration in an inline function so defer runs after each one:
func processFiles(paths []string) {
for _, p := range paths {
func() {
f, err := os.Open(p)
if err != nil {
return
}
defer f.Close()
// ... use f
}()
}
}
8. Embedded method promotion — and how it can surprise you
The trap
type Animal struct{}
func (a Animal) Speak() { fmt.Println("generic animal noise") }
type Dog struct {
Animal // embedded
}
func (d Dog) Speak() { fmt.Println("woof") } // override
var a Animal = Dog{}.Animal // explicitly accessing the embedded field
a.Speak() // "generic animal noise" — not "woof"
Why
Embedding is composition, not inheritance. Calling a.Speak() on the Animal value doesn’t know anything about Dog. The override only kicks in when you call the method on the outer (Dog) value.
The fix
If you actually want polymorphism, use an interface, not embedding:
type Speaker interface {
Speak()
}
func makeNoise(s Speaker) {
s.Speak()
}
makeNoise(Dog{}) // "woof"
9. Integer division truncates
The trap
result := 5 / 2 // 2, not 2.5
fmt.Println(result) // 2
Why
Both operands are int, so the result is int. No implicit conversion to float. The fractional part is discarded.
The fix
Convert one operand to a float first:
result := float64(5) / 2 // 2.5
result := 5.0 / 2 // 2.5
Coming from Python? 5 / 2 == 2.5 there because Python auto-promotes. Go doesn’t.
10. Range over a string — runes vs bytes
The trap
s := "héllo"
fmt.Println(len(s)) // 6 (bytes — é is 2 bytes in UTF-8)
for i := 0; i < len(s); i++ {
fmt.Printf("%c ", s[i]) // garbled output around é
}
Why
len(s) is in bytes. s[i] is a byte. Multi-byte UTF-8 characters (é, emoji, non-Latin scripts) get split.
The fix
Use range, which iterates runes:
for i, r := range s {
fmt.Printf("byte=%d rune=%c\n", i, r)
}
For counting characters:
utf8.RuneCountInString(s) // 5
11. range copies the value
The trap
type Item struct{ Count int }
items := []Item{{1}, {2}, {3}}
for _, item := range items {
item.Count *= 10 // modifies the local copy
}
fmt.Println(items) // [{1} {2} {3}] — unchanged!
Why
range returns a copy of each element. Modifying item modifies the copy, not the original.
The fix
Use the index to modify in place:
for i := range items {
items[i].Count *= 10
}
Or use a slice of pointers:
items := []*Item{{1}, {2}, {3}}
for _, item := range items {
item.Count *= 10 // now item is *Item — mutates the original
}
12. Returning the address of a loop variable
The trap (pre-Go 1.22)
var ptrs []*int
for i := 0; i < 3; i++ {
ptrs = append(ptrs, &i)
}
for _, p := range ptrs {
fmt.Println(*p)
}
// Prints: 3 3 3
Why
Same root cause as #1 (loop variable capture). Before 1.22, all iterations shared the same i, so all the pointers point to the same address. Go 1.22+ fixes this — each iteration has its own i.
The fix (pre-1.22)
Copy the loop variable to a fresh one inside the loop:
for i := 0; i < 3; i++ {
i := i // shadow with a fresh variable
ptrs = append(ptrs, &i)
}
13. Pointer vs value receivers — pick one and stick with it
The trap
type Counter struct{ n int }
func (c Counter) Get() int { return c.n } // value receiver
func (c *Counter) Inc() { c.n++ } // pointer receiver
c := Counter{}
c.Inc()
fmt.Println(c.Get()) // 1
// But now in an interface:
type Incrementer interface{ Inc() }
var i Incrementer = c // ❌ compile error: Counter doesn't implement Incrementer
// (Inc is defined on *Counter, not Counter)
var i Incrementer = &c // ✅ works
Why
The method set of T includes only value-receiver methods. The method set of *T includes both. Interface satisfaction depends on which method set is checked.
The fix
Pick one receiver style per type. If any method needs a pointer receiver (because it mutates), use pointer receivers everywhere. Don’t mix.
// Consistent — all pointer receivers
func (c *Counter) Get() int { return c.n }
func (c *Counter) Inc() { c.n++ }
14. iota resets in each const block
The trap
const (
A = iota // 0
B // 1
C // 2
)
const (
D = iota // 0 — not 3!
E // 1
)
Why
iota is reset to 0 at the start of every const block. Each block has its own counter.
The fix
Keep related constants in one block:
const (
A = iota // 0
B // 1
C // 2
D // 3
E // 4
)
Bonus iota traps
const (
_ = iota // skip 0
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20 = 1048576
GB // 1 << 30
)
iota increments by one per line in the block, even on lines that don’t use it. The expression repeats automatically.
15. time.Time — == doesn’t mean “same instant”
The trap
t1 := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
t2 := t1.In(time.FixedZone("EST", -5*3600))
t1.Equal(t2) // true — same point in time
t1 == t2 // false — different Location pointers
The same gotcha bites when comparing a time.Now() value to one that’s been stripped of its monotonic clock reading:
a := time.Now()
b := a.Round(0) // strips the monotonic reading
a.Equal(b) // true
a == b // false
Why
time.Time is a comparable struct — == compiles fine and compares every field. But the fields include a *Location pointer and (sometimes) a monotonic clock reading. Two Time values can represent the same instant while differing in these fields, and == returns false.
The fix
- To compare two times:
t1.Equal(t2)— compares the actual instant, ignoring timezone and monotonic reading. - To check the zero value:
t.IsZero()— clearer intent thant == time.Time{}(and avoids the unrelated Go syntax rule that requires parens around composite literals insideifconditions).
var t time.Time
t.IsZero() // true
t1.Equal(t2) // correct "same instant" check
16. Multiple return values can’t be inlined
The trap
func split(s string) (string, string) { /* ... */ }
fmt.Println(split("hello,world")) // works — multi-arg call
fmt.Println("got: " + split("hello,world")) // ❌ compile error
Why
Multi-return values can only be used in a few contexts: the right side of an assignment, or as the sole argument to a function call. They can’t be mixed with other expressions.
The fix
Capture in variables first:
a, b := split("hello,world")
fmt.Println("got: " + a + " " + b)
17. Empty struct as a zero-byte marker
This is more of an idiom than a gotcha — but it shows up in interview code and confuses people who haven’t seen it.
done := make(chan struct{}) // signal channel, no data
done <- struct{}{} // send a "value" that takes zero bytes
set := make(map[string]struct{}) // map-as-set
set["apple"] = struct{}{}
struct{} takes zero bytes in memory. Use it when you only care about presence/absence, not value.
Quick reference — when you see this, think…
| Code shape | Suspect this gotcha |
|---|---|
go func() { ...i... }() in a loop | Loop variable capture (#1, pre-1.22) |
b := a[i:j]; b[0] = ... | Slice aliasing (#2) |
if err != nil printing on a “nil” return | Typed-nil interface (#5) |
defer X(arg) in a function | Argument evaluation timing (#6) |
defer inside a loop | Stacked-up defers (#7) |
for _, x := range items { x.Field = ... } | Range copies the value (#11) |
&i inside a loop | Loop variable address (#12, pre-1.22) |
| Mixed pointer/value receivers on one type | Method set mismatch (#13) |
iota not matching expected value | Reset per const block (#14) |
Common mistakes (the meta-list)
Memorize these by category:
- Loops + closures: loop variable capture; loop variable address; defer in loops
- Slices: aliasing on slicing; append reallocation;
b := adoesn’t copy - Maps: random iteration order; concurrent access panics; nil-map writes panic
- Interfaces: typed-nil trap; method set mismatch with pointer vs value receivers
- Strings: bytes vs runes; immutable so
+=is quadratic - Defer: arguments evaluated at defer; runs at function exit, not block exit
- Concurrency: sender closes; range ends on close; nil channels block forever; map writes need a mutex
- Errors: wrap with
%wnot%vor%s;errors.Isfor sentinels,errors.Asfor types
If you can recite the cause and fix for each of these, you’ve covered ~90% of Go interview gotcha questions.