InterviewHack.ai
Start free
Blog/Go (Golang) Interview Questions and How to Answer Them (40+ Questions)

Go (Golang) Interview Questions and How to Answer Them (40+ Questions)

September 16, 2026

golangbackend

A comprehensive reference article covering 45 Go (Golang) interview questions with detailed answers, real code examples, and interviewer rubrics — covering fundamentals, concurrency, memory management, the runtime, and idiomatic patterns.

Go (Golang) Interview Questions and How to Answer Them (45 Questions)

Go has become the lingua franca of cloud infrastructure, microservices, and performance-sensitive backend systems. Companies like Google, Uber, Cloudflare, Dropbox, Docker, and Kubernetes all run on Go. If you are interviewing for a Go role in 2025 or 2026, you will face questions that test not just syntax knowledge but whether you think in Go — concurrency by design, composition over inheritance, explicit error handling, and zero-cost abstractions.

This guide covers 45 questions organized by topic, from fundamentals to advanced runtime internals. Every answer includes real code, the reasoning behind correct patterns, and common wrong answers that interviewers specifically watch for.


Section 1 — Language Fundamentals

1. What is the difference between `var a []int` and `a := []int{}`?

Short answer: The first declares a nil slice; the second declares an empty (non-nil) slice. They behave identically for most operations but differ in JSON serialization and nil checks.

go
var a []int        // nil slice — len 0, cap 0, a == nil is true
b := []int{}       // empty slice — len 0, cap 0, b == nil is false

fmt.Println(a == nil)  // true
fmt.Println(b == nil)  // false

// JSON marshaling difference:
// json.Marshal(a) → "null"
// json.Marshal(b) → "[]"

When to use which: Prefer var a []int when the slice might stay empty; this avoids a zero-length heap allocation. Use []int{} (or make([]int, 0)) when you need a non-nil slice — for example, when you will JSON-encode it and null would be semantically wrong.

Why interviewers ask this: It tests whether you understand Go's nil semantics and the internal slice header (pointer | length | capacity). A nil slice has a zero pointer; an empty slice has a valid (but unreachable) pointer.


2. Explain the internal structure of a slice.

A slice is not an array. It is a three-field struct in the runtime:

go
type slice struct {
    array unsafe.Pointer  // pointer to underlying array
    len   int             // number of elements currently in the slice
    cap   int             // total capacity of the underlying array
}
go
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s))  // 3 5

// Appending within capacity does not allocate
s = append(s, 10)
fmt.Println(len(s), cap(s))  // 4 5

// Appending beyond capacity triggers a new allocation + copy
s = append(s, 20, 30)
fmt.Println(len(s), cap(s))  // 6 10 (capacity doubles, roughly)

Slicing a slice shares the backing array:

go
a := []int{1, 2, 3, 4, 5}
b := a[1:3]      // b = [2, 3], shares a's array
b[0] = 99        // mutates a too!
fmt.Println(a)   // [1 99 3 4 5]

Use copy() or the three-index slice a[1:3:3] (limits capacity, prevents accidental writes through the tail) to avoid this.


3. What is the difference between `make` and `new`?

| | new(T) | make(T, ...) |

|---|---|---|

| Returns | *T (pointer to zeroed memory) | T itself (initialized, not a pointer) |

| Works on | Any type | Only slice, map, channel |

| Initialization | Zero value only | Full internal structure ready to use |

go
p := new(int)         // *int pointing to 0
fmt.Println(*p)       // 0

s := make([]int, 5)   // initialized slice, len=5, cap=5
m := make(map[string]int)  // initialized map, ready for writes
ch := make(chan int, 10)   // buffered channel with cap 10

Common mistake: new([]int) gives you a *[]int pointing to a nil slice — that nil slice is not usable. Always use make for slices, maps, and channels.


4. How do maps work internally, and what are their gotchas?

Go maps are hash tables. Key operations:

go
// Zero value of a map is nil — reading is safe, writing panics
var m map[string]int
_ = m["key"]  // ok, returns 0
m["key"] = 1  // PANIC: assignment to entry in nil map

// Always initialize before writing
m = make(map[string]int)
m["key"] = 1

// Two-value lookup to distinguish missing vs zero
val, ok := m["missing"]
fmt.Println(val, ok)  // 0 false

// Maps are not safe for concurrent use
// Use sync.Map or protect with sync.RWMutex for concurrent access

// Iteration order is randomized intentionally
for k, v := range m {
    fmt.Println(k, v)  // order is not guaranteed
}

Gotcha — deleting during range: Deleting a key you have not visited yet during range is safe in Go. The deleted key simply will not appear.

Gotcha — structs as values are not addressable:

go
type Point struct{ X, Y int }
m := map[string]Point{"a": {1, 2}}
m["a"].X = 10  // COMPILE ERROR: cannot assign to struct field in map
// Fix: read, modify, write back
p := m["a"]
p.X = 10
m["a"] = p

5. What are the zero values in Go, and why do they matter?

Every type has a well-defined zero value when declared without initialization:

| Type | Zero value |

|---|---|

| bool | false |

| int, float64, etc. | 0 |

| string | "" |

| pointer, slice, map, channel, function | nil |

| struct | all fields zeroed |

go
var mu sync.Mutex    // zero value is an unlocked mutex — immediately usable
var wg sync.WaitGroup // zero value is usable
var b bytes.Buffer   // zero value is an empty buffer — write to it directly

The design philosophy "make the zero value useful" means idiomatic Go rarely needs constructors. This is why embedding sync.Mutex in a struct gives you working concurrency protection without a New() function.


6. Explain value receivers vs pointer receivers. When do you use each?

go
type Counter struct{ count int }

// Value receiver — operates on a copy, cannot mutate
func (c Counter) Value() int {
    return c.count
}

// Pointer receiver — operates on the original, can mutate
func (c *Counter) Increment() {
    c.count++
}

Rules:

  1. 1Use a pointer receiver when the method needs to modify the receiver.
  2. 2Use a pointer receiver when the struct is large (avoids copying).
  3. 3Be consistent: if any method has a pointer receiver, give all methods pointer receivers.

Interface satisfaction gotcha:

go
type Stringer interface{ String() string }

type MyType struct{ name string }

// Only pointer satisfies Stringer if String() has pointer receiver
func (m *MyType) String() string { return m.name }

var s Stringer = &MyType{"hello"}  // ok
var s2 Stringer = MyType{"hello"}  // COMPILE ERROR

7. How does `defer` work, and what are its execution guarantees?

defer schedules a function call to run when the enclosing function returns — regardless of whether it returns normally or via panic.

go
func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()  // guaranteed to run even if the rest panics
    return io.ReadAll(f)
}

LIFO order: Multiple defers run last-in, first-out.

go
func main() {
    defer fmt.Println("first deferred")
    defer fmt.Println("second deferred")
    fmt.Println("main body")
}
// Output:
// main body
// second deferred
// first deferred

Defer captures arguments at scheduling time:

go
func example() {
    x := 10
    defer fmt.Println(x)  // captures 10 now
    x = 20
    fmt.Println(x)        // prints 20
}
// Output: 20, then 10

Named return values can be modified by defer:

go
func double(n int) (result int) {
    defer func() { result *= 2 }()
    result = n
    return  // deferred func runs before actual return, doubling result
}
fmt.Println(double(5))  // 10

8. What is the difference between `panic` and `os.Exit`?

go
// panic unwinds the stack, runs deferred functions, then crashes
func riskyOp() {
    defer fmt.Println("cleanup runs")
    panic("something went wrong")  // deferred functions DO run
}

// os.Exit terminates immediately — deferred functions do NOT run
func main() {
    defer fmt.Println("this will NOT print")
    os.Exit(1)
}

When to use panic:

  • Programmer errors that should never happen (index out of bounds, unreachable branches).
  • Library initialization failures where continuing would be wrong.
  • Never for expected runtime errors (use error return values instead).

Recover: Can only be called inside a deferred function to catch a panic.

go
func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered panic: %v", r)
        }
    }()
    return a / b, nil  // panics if b == 0
}

Section 2 — Interfaces and Type System

9. How do interfaces work in Go? What makes them different from other languages?

Go interfaces are implicitly satisfied — no implements keyword. If a type has all the methods an interface requires, it satisfies the interface.

go
type Writer interface {
    Write(p []byte) (n int, err error)
}

type File struct{ /* ... */ }

// File implicitly implements Writer because it has this method:
func (f *File) Write(p []byte) (int, error) { /* ... */ }

// This works without any declaration:
var w Writer = &File{}

Interface internals: An interface value is a two-word struct: (type, data). The type is a pointer to type metadata; the data is a pointer to the value.

go
var w Writer          // nil interface: (nil, nil)
var f *File = nil
w = f                 // non-nil interface: (*File type, nil pointer)

fmt.Println(w == nil)  // false! The interface has type info even though f is nil

This is one of Go's most common gotchas. Returning a typed nil from a function that returns an interface gives a non-nil interface:

go
func getWriter() Writer {
    var f *File = nil
    return f  // returns a non-nil Writer wrapping a nil *File
}

w := getWriter()
fmt.Println(w == nil)  // false — surprising!

Fix: Return nil explicitly, not a typed nil.


10. What are type assertions and type switches?

go
// Type assertion — panics if wrong type
f := w.(*os.File)

// Safe type assertion — ok is false instead of panic
f, ok := w.(*os.File)
if !ok {
    // w does not hold *os.File
}

// Type switch
switch v := w.(type) {
case *os.File:
    fmt.Println("file:", v.Name())
case *bytes.Buffer:
    fmt.Println("buffer len:", v.Len())
default:
    fmt.Printf("unknown type: %T\n", v)
}

When to use: Type switches are the idiomatic way to dispatch on interface types. Excessive type assertions often signal a design problem — the interface may be too broad or the wrong abstraction.


11. What is embedding in Go, and how does it differ from inheritance?

go
type Animal struct{ Name string }
func (a Animal) Speak() string { return a.Name + " speaks" }

type Dog struct {
    Animal        // embedding — not inheritance
    Breed string
}

d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Husky"}
fmt.Println(d.Speak())   // "Rex speaks" — method promoted from Animal
fmt.Println(d.Name)      // "Rex" — field promoted

Embedding promotes fields and methods to the outer struct, but there is no subtype relationship. A Dog is not an Animal — you cannot pass Dog where Animal is expected. This is composition, not inheritance.

Interface embedding:

go
type ReadWriter interface {
    io.Reader  // embeds Reader interface
    io.Writer  // embeds Writer interface
}

12. How do generics work in Go (1.18+)?

Generics allow type-parameterized functions and types:

go
// Generic function
func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

doubled := Map([]int{1, 2, 3}, func(x int) int { return x * 2 })
// [2, 4, 6]

// Type constraint using interface
type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](s []T) T {
    var total T
    for _, v := range s {
        total += v
    }
    return total
}

When to use generics: Container types (trees, stacks, queues), collection utilities (Map, Filter, Reduce), algorithm implementations. Do not overuse — Go's simplicity is a virtue, and generics add cognitive overhead.


Section 3 — Error Handling

13. Why does Go use explicit error returns instead of exceptions?

Go returns errors as values. This has several advantages:

  • Errors appear in function signatures — callers know a function can fail
  • The compiler enforces handling (unused return values trigger warnings in some linters)
  • Error handling integrates with normal control flow — no invisible exception paths
  • Performance: no stack unwinding overhead
go
// Idiomatic Go error handling
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

result, err := divide(10, 0)
if err != nil {
    log.Printf("error: %v", err)
    return
}
fmt.Println(result)

14. How does error wrapping work with `fmt.Errorf` and the `errors` package?

go
// Wrapping adds context as errors propagate up the call stack
func queryUser(id int) (*User, error) {
    u, err := db.Find(id)
    if err != nil {
        return nil, fmt.Errorf("queryUser %d: %w", id, err)
    }
    return u, nil
}

// Unwrapping: errors.Is traverses the chain
if errors.Is(err, sql.ErrNoRows) {
    // handle not found
}

// errors.As extracts a specific type from the chain
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
    fmt.Println("postgres error code:", pgErr.Code)
}

Custom error types:

go
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
}

15. What is the `errors.Is` vs `errors.As` distinction?

  • errors.Is(err, target): checks if err or any error in its chain equals target (by value or via an Is(error) bool method). Use for sentinel errors.
  • errors.As(err, &target): checks if err or any error in its chain can be assigned to target's type. Use for extracting typed errors.
go
var ErrNotFound = errors.New("not found")  // sentinel

// errors.Is for sentinel values
if errors.Is(err, ErrNotFound) { /* ... */ }

// errors.As for typed errors
var netErr *net.OpError
if errors.As(err, &netErr) {
    fmt.Println("network op:", netErr.Op)
}

Section 4 — Concurrency

16. What are goroutines, and how do they differ from OS threads?

| | Goroutine | OS Thread |

|---|---|---|

| Initial stack | ~2 KB (grows dynamically) | ~1–8 MB (fixed) |

| Scheduling | Go runtime (user space) | OS kernel |

| Creation cost | Microseconds | Milliseconds |

| Context switch | ~100 ns | ~1–10 µs |

| Typical count | Millions | Thousands |

go
// Starting a goroutine
go func() {
    fmt.Println("running concurrently")
}()

// The goroutine runs concurrently — main may exit before it prints
// Use sync.WaitGroup or channels to synchronize

The Go scheduler uses an M:N model: M goroutines are multiplexed onto N OS threads (where N = GOMAXPROCS, defaulting to CPU count). The scheduler has three entities: G (goroutine), M (OS thread), P (logical processor/scheduling context).


17. What is a goroutine leak, and how do you prevent it?

A goroutine leak occurs when a goroutine is started but never terminates — it stays blocked, wasting memory and CPU.

go
// LEAK: channel has no sender, goroutine blocks forever
func leaky() {
    ch := make(chan int)
    go func() {
        val := <-ch  // blocks forever if nobody sends
        fmt.Println(val)
    }()
    // ch goes out of scope but goroutine is still alive
}

// FIX: use a done channel or context for cancellation
func notLeaky(ctx context.Context) {
    ch := make(chan int)
    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-ctx.Done():
            return  // context cancelled, goroutine exits cleanly
        }
    }()
}

Detection: Use runtime.NumGoroutine() in tests, or goleak (uber-go/goleak) to assert no goroutines leak after a test.


18. What is the difference between buffered and unbuffered channels?

go
// Unbuffered: send blocks until receiver is ready (synchronization point)
ch := make(chan int)
go func() { ch <- 42 }()
val := <-ch  // synchronizes here

// Buffered: send blocks only when buffer is full
ch := make(chan int, 3)
ch <- 1  // does not block
ch <- 2  // does not block
ch <- 3  // does not block
ch <- 4  // BLOCKS — buffer full

Rule of thumb: Use unbuffered channels for synchronization ("handoff"). Use buffered channels to decouple producers and consumers, or as semaphores.

go
// Semaphore pattern — limit to 5 concurrent operations
sem := make(chan struct{}, 5)
for _, url := range urls {
    sem <- struct{}{}  // acquire
    go func(u string) {
        defer func() { <-sem }()  // release
        fetch(u)
    }(url)
}

19. What happens when you send on a closed channel? What about receive?

go
ch := make(chan int, 1)
ch <- 42
close(ch)

// Receiving from closed channel:
val, ok := <-ch
fmt.Println(val, ok)  // 42 true (buffered value still there)

val, ok = <-ch
fmt.Println(val, ok)  // 0 false (channel drained and closed)

// Ranging over a closed channel drains it, then stops
for v := range ch { fmt.Println(v) }

// Sending on a closed channel: PANIC
ch <- 1  // panic: send on closed channel

Golden rule: Only the sender should close a channel. Never close from the receiver. If multiple goroutines send, coordinate closure (e.g., via sync.WaitGroup + a single closer).


20. Explain the `select` statement and how it handles multiple channels.

go
select {
case v := <-ch1:
    fmt.Println("received from ch1:", v)
case ch2 <- data:
    fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("no channel ready — non-blocking")
}

Behavior:

  • Blocks until at least one case is ready.
  • If multiple cases are ready simultaneously, Go picks one at random (prevents starvation).
  • default makes it non-blocking.
  • time.After implements timeouts.

Done channel pattern (clean goroutine cancellation):

go
done := make(chan struct{})

go func() {
    for {
        select {
        case <-done:
            return
        case work := <-workCh:
            process(work)
        }
    }
}()

close(done)  // signals all goroutines listening on done to stop

21. What are common concurrency patterns in Go?

Fan-out / Fan-in:

go
func fanOut(in <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := range workers {
        channels[i] = worker(in)
    }
    return channels
}

func fanIn(channels ...<-chan int) <-chan int {
    merged := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                merged <- v
            }
        }(ch)
    }
    go func() { wg.Wait(); close(merged) }()
    return merged
}

Worker Pool:

go
func workerPool(jobs <-chan Job, results chan<- Result, workers int) {
    var wg sync.WaitGroup
    for range workers {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    go func() { wg.Wait(); close(results) }()
}

Pipeline:

go
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums { out <- n }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in { out <- n * n }
    }()
    return out
}

// Usage: for v := range square(generate(1,2,3)) { fmt.Println(v) }

22. When should you use `sync.Mutex` vs channels?

The Go proverb: "Do not communicate by sharing memory; share memory by communicating."

Use channels when:

  • Passing ownership of data from one goroutine to another.
  • Coordinating goroutine start/stop.
  • Building pipelines.

Use sync.Mutex when:

  • Protecting a small, shared piece of state accessed by many goroutines.
  • A channel would require awkward ownership gymnastics.
  • Performance profiling shows channel overhead is a bottleneck.
go
// Mutex example: thread-safe counter
type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.RLock()  // use RWMutex for read-heavy workloads
    defer c.mu.RUnlock()
    return c.count
}

sync.RWMutex: Allows multiple concurrent readers or one writer. Use when reads vastly outnumber writes.


23. What is `sync.WaitGroup` and how do you use it correctly?

go
var wg sync.WaitGroup

for i := range 5 {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id)
    }(i)
}

wg.Wait()  // blocks until all goroutines call Done()

Common mistake — calling Add inside the goroutine:

go
// WRONG: Add may be called after Wait returns if goroutines are slow to start
go func() {
    wg.Add(1)  // race condition with Wait
    defer wg.Done()
}()

// CORRECT: always Add before starting the goroutine
wg.Add(1)
go func() {
    defer wg.Done()
}()

24. What is `sync.Once`, and when is it useful?

sync.Once ensures a function is executed exactly once, regardless of how many goroutines call it concurrently. It is the idiomatic way to implement lazy initialization.

go
var (
    instance *Database
    once     sync.Once
)

func GetDatabase() *Database {
    once.Do(func() {
        instance = &Database{conn: newConn()}
    })
    return instance
}

vs init(): init() runs at package load time (eager). sync.Once is lazy — initialization happens at first use. Prefer sync.Once for expensive resources (DB connections, parsers) to keep startup fast.


25. Explain `sync.Pool` and when to use it.

sync.Pool is a concurrent-safe pool of temporary objects that reduces GC pressure by reusing allocations:

go
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func processRequest(data []byte) string {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    buf.Write(data)
    return buf.String()
}

Important caveats:

  • Objects in a Pool can be reclaimed by the GC at any time.
  • Only use for temporary, short-lived objects.
  • Always profile before reaching for Pool — premature optimization is harmful.
  • Do not store state that must survive across calls.

26. What is the Go memory model, and what is the "happens-before" relationship?

The Go memory model defines when writes to a variable in one goroutine are guaranteed to be observable by another goroutine. The key concept is "happens-before":

  • Operations within a single goroutine have sequential happens-before.
  • go statement happens-before the goroutine's start.
  • Channel send happens-before the corresponding receive completes.
  • sync.Mutex.Unlock() happens-before the next Lock().
  • sync.WaitGroup.Done() happens-before Wait() returns.
go
var x int
var wg sync.WaitGroup

wg.Add(1)
go func() {
    x = 42        // write
    wg.Done()     // happens-before wg.Wait() returns
}()
wg.Wait()
fmt.Println(x)    // guaranteed to see 42

Without this synchronization, reading x from another goroutine after the goroutine writes it is a data race — even if it seems to work in practice.


Section 5 — Memory Management and the Runtime

27. How does Go's garbage collector work?

Go uses a concurrent tri-color mark-and-sweep collector:

  1. 1Mark phase: Starting from roots (globals, stack variables), the GC marks all reachable objects. Objects are white (unvisited), gray (reachable but children not yet scanned), or black (fully scanned).
  2. 2Sweep phase: All remaining white objects are unreachable and are freed.

Key design properties:

  • Runs mostly concurrently with the application — goroutines are not stopped for the full collection.
  • Short "stop-the-world" pauses (sub-millisecond since Go 1.14) for stack scanning.
  • The GC is triggered when heap size reaches GOGC% growth over the last collection (default GOGC=100, meaning heap doubles).

Go 1.24+ (Green Tea collector): Reorganizes marking around memory locality, reducing GC overhead 10–40% on typical workloads.

Tuning:

bash
GOGC=200   # collect less frequently (higher memory usage, less CPU overhead)
GOGC=off   # disable GC (for batch processing)
GOMEMLIMIT=512MiB  # cap total memory, triggers GC to stay under limit (Go 1.19+)

28. What is escape analysis? How does it affect performance?

The Go compiler performs escape analysis at compile time to decide whether a variable lives on the stack or the heap:

  • Stack: Fast allocation/deallocation (just move stack pointer). No GC involvement.
  • Heap: Slower, involves GC.

A variable "escapes" to the heap when:

  • A pointer to it is returned from the function.
  • It is captured by a closure that outlives the function.
  • It is assigned to an interface (type information must be stored).
  • Its size is not known at compile time.
go
// inspect escape analysis:
// go build -gcflags="-m" ./...

func stackAlloc() int {
    x := 42     // stays on stack
    return x
}

func heapAlloc() *int {
    x := 42
    return &x   // x escapes to heap because pointer is returned
}

Performance implication: Reducing heap allocations reduces GC pressure and improves throughput. Use sync.Pool, pre-allocate slices with make([]T, 0, n), and pass pointers rather than returning them where possible.


29. What is `GOMAXPROCS` and how does it affect concurrency?

GOMAXPROCS sets the maximum number of OS threads that can execute Go code simultaneously (the number of logical processors, P, in the scheduler):

go
import "runtime"

runtime.GOMAXPROCS(4)          // use 4 logical processors
fmt.Println(runtime.GOMAXPROCS(0))  // query current value (0 = query only)

Default: Since Go 1.5, defaults to runtime.NumCPU().

Effect:

  • GOMAXPROCS=1: Goroutines are multiplexed onto a single thread — true concurrency is impossible, but race conditions can still occur (cooperative multitasking on preemption points).
  • GOMAXPROCS=N (N > 1): Up to N goroutines truly execute in parallel.

For I/O-bound workloads: Thousands of goroutines can be efficient even with GOMAXPROCS=1 because goroutines blocked on I/O do not consume a P.


30. What is `pprof` and how do you profile a Go application?

go
import _ "net/http/pprof"  // side-effect import registers HTTP handlers

func main() {
    go http.ListenAndServe(":6060", nil)
    // ... application code
}

Access profiles:

bash
# CPU profile (30-second sample)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Heap allocation profile
go tool pprof http://localhost:6060/debug/pprof/heap

# Goroutine dump
curl http://localhost:6060/debug/pprof/goroutine?debug=2

Benchmark-driven profiling:

bash
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.out

Common hotspots to look for: excessive allocations (strings, interfaces), lock contention, channel overhead in tight loops, regex compilation in hot paths.


Section 6 — Error Handling, Context, and Patterns

31. How does the `context` package work, and how should you use it?

Context carries deadlines, cancellation signals, and request-scoped values across API boundaries. It is always the first parameter by convention.

go
// Creating contexts
ctx := context.Background()           // root, never cancelled
ctx, cancel := context.WithCancel(ctx) // manually cancellable
defer cancel()                         // always call cancel to release resources

ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()

ctx, cancel = context.WithDeadline(ctx, time.Now().Add(5*time.Second))
defer cancel()

// Passing values (use sparingly — only for request-scoped data)
type keyType struct{}
ctx = context.WithValue(ctx, keyType{}, "request-id-123")
val := ctx.Value(keyType{}).(string)

Checking for cancellation:

go
func doWork(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()  // context.Canceled or context.DeadlineExceeded
        default:
            // do a unit of work
        }
    }
}

Rules:

  1. 1Never store context in a struct — pass it as a function argument.
  2. 2Never pass nil context — use context.TODO() if unsure.
  3. 3Always call the cancel function returned by WithCancel/WithTimeout/WithDeadline.

32. What is the options pattern, and why is it idiomatic Go?

The options pattern uses variadic functional options to configure a struct without requiring constructor explosion:

go
type Server struct {
    host    string
    port    int
    timeout time.Duration
}

type Option func(*Server)

func WithHost(host string) Option {
    return func(s *Server) { s.host = host }
}

func WithPort(port int) Option {
    return func(s *Server) { s.port = port }
}

func WithTimeout(d time.Duration) Option {
    return func(s *Server) { s.timeout = d }
}

func NewServer(opts ...Option) *Server {
    s := &Server{
        host:    "localhost",
        port:    8080,
        timeout: 30 * time.Second,
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

// Usage
srv := NewServer(WithPort(9090), WithTimeout(60*time.Second))

Advantages: Backward-compatible (new options do not break existing callers), readable call sites, self-documenting.


33. How do you implement clean shutdown in a Go HTTP server?

go
func main() {
    srv := &http.Server{Addr: ":8080", Handler: handler}

    // Start server in background goroutine
    go func() {
        if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
            log.Fatal(err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    // Graceful shutdown with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("forced shutdown:", err)
    }
    log.Println("server stopped gracefully")
}

Section 7 — Tooling and Go-Specific Idioms

34. How do Go modules work?

Go modules manage dependencies using go.mod and go.sum files:

module github.com/myorg/myapp

go 1.22

require (
    github.com/gorilla/mux v1.8.0
    github.com/lib/pq v1.10.7
)

Key commands:

bash
go mod init github.com/myorg/myapp  # initialize a module
go get github.com/gorilla/mux@v1.8  # add or upgrade a dependency
go mod tidy                          # remove unused, add missing
go mod vendor                        # copy deps into vendor/ for reproducible builds

Versioning: Go uses semantic versioning. Major version bumps (v2+) require a module path suffix (github.com/foo/bar/v2), allowing multiple major versions in one build.


35. What is the `init()` function and when should you use it?

go
package main

var config *Config

func init() {
    // Runs automatically before main(), after all var declarations
    cfg, err := loadConfig()
    if err != nil {
        log.Fatal(err)
    }
    config = cfg
}

Rules:

  • Multiple init() functions per package are allowed (even in the same file).
  • They run in source order within a file, file order within a package.
  • They run before main().
  • They cannot be called manually.

When to use: Registering database drivers (_ "github.com/lib/pq"), registering codecs, validating configuration. For expensive initialization (DB connections), prefer sync.Once to keep startup fast and make testing easier.


36. What does the blank identifier `_` do?

go
// Discard a return value
_, err := fmt.Println("hello")

// Import for side effects only (triggers init())
import _ "github.com/lib/pq"

// Silence "declared but not used" for loop index
for _, v := range slice { fmt.Println(v) }

// Compile-time interface satisfaction check
var _ io.Writer = (*MyWriter)(nil)  // fails to compile if MyWriter doesn't implement Writer

The last pattern is particularly useful in library code to document and enforce that a type implements an interface.


37. How does Go handle dependency injection?

Go favors explicit constructor injection over service locators or reflection-based frameworks:

go
type UserService struct {
    db     Database
    mailer Mailer
    logger *slog.Logger
}

func NewUserService(db Database, mailer Mailer, logger *slog.Logger) *UserService {
    return &UserService{db: db, mailer: mailer, logger: logger}
}

Why this works: Interfaces are implicit, so Database and Mailer can be mocked for tests without any framework. The dependency graph is explicit and compile-time checked.

Wire / Fx: For large applications, code-generation tools (Google Wire) or runtime DI frameworks (Uber Fx) manage wiring, but they are optional — most Go services wire dependencies manually in main().


38. What is `slog` and why was it added to the standard library?

log/slog (stable since Go 1.21) provides structured, leveled logging:

go
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))

logger.Info("request processed",
    "method", "GET",
    "path", "/api/users",
    "status", 200,
    "latency_ms", 12,
)
// {"time":"...","level":"INFO","msg":"request processed","method":"GET","path":"/api/users","status":200,"latency_ms":12}

Before 1.21, teams had to choose between zap, zerolog, logrus, or log. slog standardizes the interface while remaining extensible via custom handlers.


Section 8 — Advanced and System-Level Questions

39. What is the Go scheduler's work-stealing algorithm?

The Go scheduler assigns goroutines (G) to logical processors (P), which run on OS threads (M). When a P's local run queue is empty, it steals goroutines from other P's run queues or the global queue.

Preemption: Since Go 1.14, the scheduler uses asynchronous preemption — a goroutine running for more than 10ms is preempted via a signal (SIGURG on Unix). This prevents a CPU-bound goroutine from starving others.

go
// This no longer starves other goroutines since Go 1.14:
go func() {
    for {
        // tight CPU loop — scheduler will preempt after ~10ms
    }
}()

Network poller: I/O operations are integrated with the scheduler. When a goroutine blocks on a network syscall, the M is parked, and a background goroutine (netpoller) uses epoll/kqueue to wake up goroutines when I/O is ready.


40. How do you detect and prevent data races?

bash
# Run with race detector (10-20x slowdown, use in tests and staging)
go test -race ./...
go run -race main.go

The race detector uses shadow memory to track all memory accesses and reports when two goroutines access the same location concurrently and at least one is a write.

Common race patterns:

go
// RACE: shared map without synchronization
var cache = make(map[string]string)
go func() { cache["key"] = "value" }()  // write
go func() { _ = cache["key"] }()         // read — DATA RACE

// FIX option 1: sync.RWMutex
var mu sync.RWMutex
go func() {
    mu.Lock()
    cache["key"] = "value"
    mu.Unlock()
}()

// FIX option 2: sync.Map for read-heavy or dynamic key sets
var syncCache sync.Map
go func() { syncCache.Store("key", "value") }()
go func() { syncCache.Load("key") }()

// RACE: goroutine captures loop variable (pre-1.22)
for _, v := range items {
    go func() { fmt.Println(v) }()  // all goroutines see last v
}
// FIX (pre-1.22): capture explicitly
for _, v := range items {
    v := v  // new variable per iteration
    go func() { fmt.Println(v) }()
}
// Go 1.22+: loop variable semantics fixed — no workaround needed

41. What is `unsafe.Pointer` and when is it acceptable to use it?

unsafe.Pointer bypasses Go's type system — it is like void* in C:

go
// Convert between pointer types without allocation
func byteSliceToString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

When it is acceptable:

  • Interfacing with C via cgo.
  • Performance-critical code where profiling shows allocation is a bottleneck and no safe alternative exists.
  • Implementing memory-mapped files or other low-level system interfaces.

Rules for safe use (from the unsafe package docs):

  • A Pointer can be converted to any pointer type.
  • Any pointer type can be converted to Pointer.
  • uintptr can be converted to/from Pointer only in a single expression (not stored in a variable — GC can move objects).

In almost all application code, reaching for unsafe is the wrong answer. If the interviewer asks why, say: "It bypasses the memory safety guarantees that make Go reliable. I would reach for it only after profiling proves a genuine bottleneck and after exhausting safe alternatives."


42. How do you write table-driven tests in Go?

Table-driven tests are idiomatic Go — they reduce duplication and make it easy to add cases:

go
func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"normal division", 10, 2, 5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative divisor", -10, 2, -5, false},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            got, err := divide(tc.a, tc.b)
            if (err != nil) != tc.wantErr {
                t.Errorf("divide() error = %v, wantErr %v", err, tc.wantErr)
                return
            }
            if !tc.wantErr && got != tc.want {
                t.Errorf("divide() = %v, want %v", got, tc.want)
            }
        })
    }
}

t.Run creates subtests, allowing parallel execution (t.Parallel()), individual filtering (go test -run TestDivide/normal), and clearer failure messages.


43. What are build tags and when do you use them?

Build tags conditionally include or exclude files from compilation:

go
//go:build linux && amd64

package main

// This file only compiles on Linux/amd64
func platformSpecific() string { return "linux-amd64" }

Common use cases:

go
//go:build integration  // run only with: go test -tags integration ./...
//go:build !cgo         // include when cgo is disabled
//go:build go1.21       // include only for Go 1.21+

File naming convention (alternative to tags): Files named *_linux.go, *_darwin.go, *_windows.go, *_amd64.go are automatically included for their target platform.


44. How do `range-over-function iterators` work (Go 1.23+)?

Go 1.23 added range-over-function iterators via the iter package:

go
// An iterator is a function that calls yield for each element
// yield returns false when the loop should stop
func Integers(start, end int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := start; i < end; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

// Usage
for v := range Integers(1, 10) {
    fmt.Println(v)
}

// Composable: filter without materializing the full sequence
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
    return func(yield func(T) bool) {
        for v := range seq {
            if pred(v) && !yield(v) {
                return
            }
        }
    }
}

Why it matters for interviews: Shows you understand modern Go. Interviewers at forward-looking companies may ask you to implement a custom iterator or explain how break and return interact with the yield function.


45. What distinguishes a good Go engineer from someone just porting habits from another language?

This is a common closing question. Strong answers include:

  1. 1Concurrency by design, not as an afterthought. Idiomatic Go uses goroutines and channels from the start, not threads and locks bolted on later.
  1. 2Small interfaces. io.Reader has one method. io.Writer has one method. If your interface has 10 methods, it is probably a base class from a Java background. Define interfaces at the point of use, not at the point of definition.
  1. 3Explicit error handling, not exceptions. Never ignore errors. Wrap with context as they propagate. Use sentinel errors or typed errors for callees that need to branch on failure mode.
  1. 4Composition over inheritance. Struct embedding and interface satisfaction give you all the polymorphism you need. No class hierarchies.
  1. 5The zero value is your constructor. Design types so var x MyType is immediately useful.
  1. 6Simple is better than clever. Go code is written once and read many times. goto, unsafe, and deep reflection are red flags in a PR review unless there is a very good reason.
  1. 7Profile before optimizing. Go provides pprof, benchmarks, and the race detector. Use them before reaching for sync.Pool, unsafe, or complex pre-allocation.

Quick Reference Cheat Sheet

| Topic | Key concept | Common pitfall |

|---|---|---|

| Slices | 3-field header (ptr, len, cap) | Shared backing array on sub-slice |

| Maps | Zero value is nil, unordered | Writing to nil map panics |

| Goroutines | 2KB stack, M:N scheduling | Goroutine leaks from blocked channels |

| Channels | Unbuffered = synchronization | Sending on closed channel = panic |

| Interfaces | Implicit, structural typing | Nil interface vs nil value inside interface |

| Errors | Values, wrap with %w | Discarding errors silently |

| defer | LIFO, captures args at schedule | Modifying loop var in deferred closure |

| Context | Carries cancellation and deadlines | Storing context in structs |

| GC | Concurrent tri-color mark-sweep | Premature optimization without profiling |

| Escape analysis | Stack vs heap at compile time | Creating interfaces allocates to heap |

| select | Random when multiple cases ready | Forgetting default makes it blocking |

| sync.Once | Exactly-once initialization | Using for cleanup, not just init |

| Generics (1.18+) | Type parameters with constraints | Overusing — Go values simplicity |

| Loop vars (1.22+) | Fresh var per iteration | Pre-1.22 goroutine closure capture bug |


How Interviewers Actually Score Go Questions

At top tech companies, Go interview rubrics typically measure:

  • Correctness: Does the code compile? Does it handle the zero value, nil, and error cases?
  • Concurrency safety: Does the candidate reach for a mutex or channel without being prompted? Do they mention go test -race?
  • Idiomatic Go: Small interfaces, explicit errors, no exception-style panic propagation, composition over inheritance.
  • Runtime awareness: Can the candidate explain what the GC actually does? Do they know the difference between stack and heap allocation? Can they interpret a pprof flame graph?
  • Production judgment: Does the candidate mention goroutine leaks, graceful shutdown, context cancellation, and connection pool tuning — or only the happy path?

The candidates who stand out do not just answer the question correctly. They say "and there is a subtle issue here with the nil interface check" or "we should also call cancel() on the context to prevent a goroutine leak." That is the signal that you write Go in production, not just in tutorials.

FAQ

What are goroutines and how do they differ from OS threads?+

Goroutines are lightweight concurrent functions managed by the Go runtime. They start with roughly 2 KB of stack space (versus 1–8 MB for OS threads), are scheduled in user space by the Go runtime using an M:N model (M goroutines multiplexed onto N OS threads), and cost microseconds to create versus milliseconds for OS threads. You can run millions of goroutines on a small number of OS threads.

What is the difference between a nil slice and an empty slice in Go?+

var a []int declares a nil slice — a == nil is true, JSON marshal gives null. a := []int{} declares an empty (non-nil) slice — a == nil is false, JSON marshal gives []. Both have length 0 and behave identically for append and range, but the nil distinction matters for JSON APIs and explicit nil checks.

How does error wrapping work in Go and what is the difference between errors.Is and errors.As?+

Wrap errors with fmt.Errorf("context: %w", err) to add context as errors propagate. errors.Is(err, target) checks if err or any error in its chain equals target — used for sentinel errors. errors.As(err, &target) checks if any error in the chain can be assigned to target's concrete type — used to extract typed errors with additional fields like an HTTP status code or database error code.

When should you use channels vs sync.Mutex in Go?+

Use channels to pass ownership of data between goroutines, coordinate start/stop, or build pipelines — this is the idiomatic Go approach (share memory by communicating). Use sync.Mutex when protecting a small piece of shared state accessed from many goroutines, especially when channel ownership semantics would be awkward. Use sync.RWMutex when reads vastly outnumber writes.

What is escape analysis and how does it affect performance?+

Escape analysis is a compile-time pass that determines whether a variable lives on the stack or heap. Stack allocation is faster and has no GC overhead. A variable escapes to the heap when a pointer to it is returned from a function, it is captured by a closure that outlives the function, or it is stored in an interface. Run go build -gcflags=-m to see escape decisions. Reducing heap escapes reduces GC pressure.

How does Go's garbage collector work?+

Go uses a concurrent tri-color mark-and-sweep collector. Reachable objects are colored white (unvisited), gray (reachable but children not scanned), or black (fully scanned). The GC runs mostly concurrently with the application, with sub-millisecond stop-the-world pauses for stack scanning. It is triggered when the heap grows by GOGC percent (default 100%). Go 1.24+ introduced the Green Tea collector, which improves marking locality and reduces GC overhead by 10–40%.

What is a goroutine leak and how do you detect one?+

A goroutine leak occurs when a goroutine is started but never terminates — typically because it is permanently blocked on a channel with no sender or receiver. Leaked goroutines waste memory indefinitely. Detect leaks with runtime.NumGoroutine() in tests, the uber-go/goleak library (asserts no extra goroutines after a test), or the /debug/pprof/goroutine endpoint. Fix by passing a context.Context and selecting on ctx.Done(), or by closing the channel when work is done.

What changed with loop variable semantics in Go 1.22?+

Before Go 1.22, loop variables were reused across iterations. Goroutines started inside a loop would all capture the same variable and typically see the last value. The workaround was v := v inside the loop body. Since Go 1.22, each iteration creates a fresh variable, eliminating this entire class of bugs. The behavior is gated by the go directive in go.mod, so existing modules are not broken.

Related articles

How to Answer Conflict-With-a-Coworker Interview Questions

Learn how to answer conflict-with-a-coworker interview questions with real examples and proven techniques. Stand out in tech and remote job interviews.

How to Answer 'Why Do You Want to Work Here' in Interviews

Discover expert strategies for answering 'why do you want to work here,' tailored for remote tech roles and dollar opportunities. Real, practical interview tips.

Frontend Developer Interview Questions and How to Answer Them (50+)

Complete SEO article covering 54 frontend developer interview questions with detailed answers, real code snippets across HTML, CSS, JavaScript, React, TypeScript, accessibility, security, build tools, and testing.

Full-Stack Developer Interview Questions: How to Answer Like a Pro (45+)

Comprehensive full-stack developer interview guide with 46 numbered questions covering JavaScript/TypeScript, React, CSS, REST APIs, databases, Node.js, system design, security, testing, DevOps, and advanced architecture topics. Each answer includes working code examples and production-level context.

Prepare for your real interview

Paste your job link: we research who's interviewing you and rehearse you live.

Start free →

Have an interview coming up? Install the live copilot →

InterviewHack.ai

Prepare for the exact interview: who's interviewing you, a tailored CV, and a real coach.

Product

JobsFree ATS checkerInterview-English checkSalary checkLATAM salary reportFree coursesBlogTailored CVSpoken practiceIt's free

Remote jobs

ReactPythonFull-StackLATAMArgentinaMexicoSee all →

Prepare

Spoken practiceFrontendBackendAI EngineerBy companySell with your CV

Company

For employersAboutContactPrivacyTerms

© 2026 InterviewHack.ai · Your CV is yours. Never used to train anything. · A product of IA-PTY