Go (Golang) Interview Questions: Concurrency, Interfaces, and Performance

Go is the language of cloud infrastructure — Kubernetes, Docker, etcd, Terraform, and Prometheus are all written in Go. This guide covers the Go interview questions you will face at companies using Go for backend services and infrastructure.

Goroutines and the Go Scheduler

package main

import (
    "fmt"
    "sync"
    "time"
    "runtime"
)

// Goroutine: lightweight thread managed by the Go runtime.
// Go scheduler: M:N threading — multiplexes G goroutines onto M OS threads.
// Default: GOMAXPROCS = number of CPUs (can be changed at runtime).

func goroutineBasics() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {  // Capture id by value — avoid closure over loop var
            defer wg.Done()
            fmt.Printf("Worker %d runningn", id)
        }(i)
    }

    wg.Wait()  // Block until all goroutines finish
}

// Goroutine leak: goroutine blocked forever on channel with no sender
// Always pair goroutine creation with a way to stop it

func withTimeout(ctx context.Context, work func() error) error {
    done := make(chan error, 1)  // Buffered: goroutine can always send
    go func() {
        done <- work()
    }()
    select {
    case err := <-done:
        return err
    case <-ctx.Done():
        return ctx.Err()
    }
}

Channels — Communication and Synchronization

package main

import (
    "fmt"
    "time"
)

// Channel: typed conduit between goroutines.
// Unbuffered: send blocks until receiver is ready (synchronous)
// Buffered:   send blocks only when buffer is full

func channelPatterns() {
    // Fan-out: one sender, multiple receivers
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // Start 3 workers
    for w := 0; w < 3; w++ {
        go func() {
            for job := range jobs {  // Range over channel until closed
                results <- job * job
            }
        }()
    }

    // Send jobs
    for j := 0; j < 9; j++ {
        jobs <- j
    }
    close(jobs)  // Signal workers: no more jobs

    // Collect results
    for a := 0; a < 9; a++ {
        fmt.Println(<-results)
    }
}

// Pipeline: chain of goroutines connected by channels
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

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

// select: multiplex across multiple channel operations
func rateLimiter() {
    requests := make(chan int, 5)
    ticker := time.NewTicker(200 * time.Millisecond)
    defer ticker.Stop()
    quit := make(chan struct{})

    for {
        select {
        case req := <-requests:
            <-ticker.C  // Wait for next tick before processing
            fmt.Println("Processed:", req)
        case <-quit:
            return
        }
    }
}

Interfaces and Composition

package main

import (
    "fmt"
    "io"
    "strings"
)

// Interfaces: satisfied implicitly (structural typing, not nominal)
// Go encourages small, focused interfaces

type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type ReadWriter interface { Reader; Writer }  // Interface embedding

// Empty interface vs any:
// interface{} / any: holds any value; requires type assertion to use
func printType(v any) {
    switch val := v.(type) {  // Type switch
    case int:    fmt.Printf("int: %dn", val)
    case string: fmt.Printf("string: %qn", val)
    case []byte: fmt.Printf("bytes: %d bytesn", len(val))
    default:     fmt.Printf("unknown: %Tn", val)
    }
}

// Practical interface design: dependency injection + testability
type Storage interface {
    Get(key string) ([]byte, error)
    Set(key string, val []byte) error
    Delete(key string) error
}

type UserService struct {
    store Storage
    cache Storage
}

func NewUserService(store, cache Storage) *UserService {
    return &UserService{store: store, cache: cache}
}

func (s *UserService) GetUser(id string) ([]byte, error) {
    // Try cache first
    if data, err := s.cache.Get(id); err == nil {
        return data, nil
    }
    // Fall back to store
    data, err := s.store.Get(id)
    if err != nil {
        return nil, err
    }
    _ = s.cache.Set(id, data)  // Populate cache
    return data, nil
}

// In tests: swap in an in-memory Storage implementation
type MemStore struct{ m map[string][]byte }
func (ms *MemStore) Get(k string) ([]byte, error) {
    v, ok := ms.m[k]
    if !ok { return nil, fmt.Errorf("not found") }
    return v, nil
}
func (ms *MemStore) Set(k string, v []byte) error { ms.m[k] = v; return nil }
func (ms *MemStore) Delete(k string) error         { delete(ms.m, k); return nil }

Error Handling and Defer

package main

import (
    "errors"
    "fmt"
)

// Sentinel errors: package-level for comparison
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

// Custom error types for structured error data
type ValidationError struct {
    Field   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: %s — %s", e.Field, e.Message)
}

// Error wrapping: %w for Is/As unwrapping
func fetchUser(id string) error {
    err := dbQuery(id)
    if err != nil {
        return fmt.Errorf("fetchUser %s: %w", id, err)  // Wrap with context
    }
    return nil
}

func handleError(err error) {
    if errors.Is(err, ErrNotFound) {  // Works through wrapping chains
        fmt.Println("User not found")
    }
    var ve *ValidationError
    if errors.As(err, &ve) {  // Type assertion through wrapping
        fmt.Println("Bad field:", ve.Field)
    }
}

// defer: runs when function returns (LIFO order)
func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer f.Close()  // Always runs — no need to close in every return path

    // defer for cleanup pairs well with acquire/release patterns
    mu.Lock()
    defer mu.Unlock()  // Always unlocked, even on panic

    return doWork(f)
}

Memory Model and Garbage Collection

/*
Go GC: concurrent tri-color mark-and-sweep.
  Tri-color: white (unreachable), gray (reachable, children not scanned), black (done)
  Concurrent: GC runs alongside user goroutines (not stop-the-world)
  STW pauses: < 1ms in Go 1.17+ (just for root marking and termination)

Memory layout:
  Stack: per-goroutine, grows/shrinks dynamically (starts 2KB, max 1GB)
  Heap:  shared; GC managed
  Escape analysis: compiler decides stack vs heap allocation
    go build -gcflags="-m" shows escape decisions

Reducing GC pressure:
  - sync.Pool: reuse temporary objects (byte buffers, scratch space)
  - Avoid excessive small allocations (use slices with pre-allocated capacity)
  - Struct embedding over pointer-to-struct for small, non-interface types
*/

package main

import (
    "sync"
    "bytes"
)

// sync.Pool: reuse buffers across goroutines, reducing allocation
var bufPool = sync.Pool{
    New: func() interface{} { return new(bytes.Buffer) },
}

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

    buf.Write(data)
    // ... process ...
    return buf.Bytes()
}

// Make vs New:
// make: creates slices, maps, channels (initializes internal structure)
// new:  allocates zeroed memory, returns pointer
s := make([]int, 0, 100)   // slice with len=0, cap=100
m := make(map[string]int)   // initialized map
p := new(int)               // *int pointing to zero int

Go Interview Quick Reference

Topic Key Points
Goroutine vs thread Goroutines: ~2KB stack, 100k+ concurrent; threads: ~2MB, OS-scheduled
Channel directions chan<- T send-only; <-chan T receive-only; enforces data flow
Data race detection go test -race or go run -race — use in CI always
context.Context Pass cancellation, deadline, request-scoped values; always first param
Mutex vs RWMutex RWMutex: concurrent reads allowed; use when reads >> writes
init() functions Run before main(), once per package; order within package is top-to-bottom
Panic vs error Error: expected failure (return); panic: programmer error or unrecoverable
Module proxy GOPROXY=proxy.golang.org fetches modules; GONOSUMCHECK for private

Frequently Asked Questions

What is a goroutine and how does it differ from a thread?

A goroutine is a lightweight function managed by the Go runtime. Goroutines start with ~2KB stack (vs ~2MB for OS threads) and are multiplexed onto OS threads by the Go scheduler (M:N threading). You can run hundreds of thousands of goroutines concurrently. Threads are managed by the OS; goroutines are managed by Go runtime.

What is a channel in Go and when would you use it?

Channels are typed conduits for communication between goroutines. Unbuffered channels block the sender until a receiver is ready (synchronous handoff). Buffered channels allow sending without blocking until the buffer fills. Use channels to signal completion, distribute work (fan-out), collect results (fan-in), or implement pipelines between goroutines.

How does Go handle errors compared to exceptions?

Go returns errors as values (multiple return values: result, error). There are no exceptions for expected failures. This makes error handling explicit — callers must check errors. Use fmt.Errorf with %w to wrap errors with context while preserving the ability to unwrap with errors.Is/errors.As. Panic is reserved for programmer errors and unrecoverable states.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is a goroutine and how does it differ from a thread?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A goroutine is a lightweight function managed by the Go runtime. Goroutines start with ~2KB stack (vs ~2MB for OS threads) and are multiplexed onto OS threads by the Go scheduler (M:N threading). You can run hundreds of thousands of goroutines concurrently. Threads are managed by the OS; goroutines are managed by Go runtime.”
}
},
{
“@type”: “Question”,
“name”: “What is a channel in Go and when would you use it?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Channels are typed conduits for communication between goroutines. Unbuffered channels block the sender until a receiver is ready (synchronous handoff). Buffered channels allow sending without blocking until the buffer fills. Use channels to signal completion, distribute work (fan-out), collect results (fan-in), or implement pipelines between goroutines.”
}
},
{
“@type”: “Question”,
“name”: “How does Go handle errors compared to exceptions?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Go returns errors as values (multiple return values: result, error). There are no exceptions for expected failures. This makes error handling explicit — callers must check errors. Use fmt.Errorf with %w to wrap errors with context while preserving the ability to unwrap with errors.Is/errors.As. Panic is reserved for programmer errors and unrecoverable states.”
}
}
]
}

Scroll to Top