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 than t == time.Time{} (and avoids the unrelated Go syntax rule that requires parens around composite literals inside if conditions).
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 shapeSuspect this gotcha
go func() { ...i... }() in a loopLoop variable capture (#1, pre-1.22)
b := a[i:j]; b[0] = ...Slice aliasing (#2)
if err != nil printing on a “nil” returnTyped-nil interface (#5)
defer X(arg) in a functionArgument evaluation timing (#6)
defer inside a loopStacked-up defers (#7)
for _, x := range items { x.Field = ... }Range copies the value (#11)
&i inside a loopLoop variable address (#12, pre-1.22)
Mixed pointer/value receivers on one typeMethod set mismatch (#13)
iota not matching expected valueReset 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 := a doesn’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 %w not %v or %s; errors.Is for sentinels, errors.As for types

If you can recite the cause and fix for each of these, you’ve covered ~90% of Go interview gotcha questions.

Toggle theme (T)