A Go interview is rarely about syntax alone.

Yes, you should know how slices work, how goroutines and channels behave, how interfaces compose, and how errors propagate. But for mid and senior roles, interviewers are looking for something deeper:

  • Can you build correct software under real constraints?
  • Can you reason about trade-offs and explain them in plain language?
  • Can you debug production issues, prevent goroutine leaks, design simple APIs, write tests, and explain why you chose one approach over another?

That is the real interview.

Go is intentionally small as a language, but that does not make Go interviews easy. The simplicity removes places to hide. You cannot lean on a heavyweight framework. You have to understand memory, concurrency, cancellation, errors, profiling, and service design.

This guide is not "100 random Go questions". It is a preparation map.

It is structured so you can use it both as a study path before an interview and as a quick reference between rounds. It targets mid-level engineers who want to step up and senior engineers who want to be sharp on the parts that get tested most.


How To Use This Guide

  • Mid-level focus: language fundamentals, error handling, basic concurrency, testing, the standard library, common mistakes, the practical-tasks section, and the FAQ.
  • Senior focus: everything above, plus the runtime/scheduler, memory model, profiling, system design, "beyond Go" knowledge, and the deeper trade-off discussions in each chapter.
  • The "Common Interview Questions" section near the end works as a final-day flashcard pass.

What Go Interviews Actually Test

A strong Go interview tests several layers at once.

The lower layers are necessary; the upper layers are what separates senior from mid. The goal of preparation is not memorizing answers — it is being able to explain behavior, write correct code, and discuss trade-offs clearly.


Part 1 — Language Fundamentals

Visibility And Packages

In Go there is no public/private/protected. Visibility is decided by capitalization, and the unit of visibility is the package.

Go
package payment

// Exported: visible to other packages.
type ChargeRequest struct {
    AmountCents int64
    Currency    string
}

// Unexported: only the payment package can use it.
type processor struct {
    gateway Gateway
}

A name starting with a capital letter is exported. Lowercase is package-private. This shapes how you design APIs: you do the access-control work by deciding what to put in which package.

Zero Values

Every variable in Go has a useful zero value.

Go
var count int       // 0
var enabled bool    // false
var name string     // ""
var items []string  // nil  (but len(items) == 0 and you can append to it)
var lookup map[string]int // nil (read-only safe; writing panics)
var user *User      // nil

Idiomatic Go often designs types so that the zero value is immediately usable.

Go
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()

sync.Mutex, bytes.Buffer, strings.Builder, and sync.WaitGroup are all useful at their zero value. When you design your own types, ask: does the zero value mean something useful? Often it should.

Constants And iota

Go
type Status int

const (
    StatusPending Status = iota // 0
    StatusActive                // 1
    StatusClosed                // 2
)

iota resets to 0 in each const block and increments by 1 per line. Useful for enumerations, but Go does not have true enums — Status(99) is still a valid Status. Add validation if it matters.

Functions, Multiple Returns, Named Returns

Go commonly returns (value, error).

Go
func FindUser(ctx context.Context, id int64) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("invalid user id: %d", id)
    }
    // ... query database
    return &User{ID: id}, nil
}

Don't silently discard the error to make code shorter:

Go
// Bad
user, _ := FindUser(ctx, id)

// Good
user, err := FindUser(ctx, id)
if err != nil {
    return fmt.Errorf("find user: %w", err)
}

Named returns can simplify code that uses defer to mutate the result, but they easily hurt readability if overused:

Go
// Useful: defer can modify err on the way out.
func loadConfig() (cfg *Config, err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("load config: %w", err)
        }
    }()

    f, err := os.Open("config.json")
    if err != nil {
        return nil, err
    }
    defer f.Close()

    cfg = &Config{}
    return cfg, json.NewDecoder(f).Decode(cfg)
}

A naked return (with no values) returns the named values. Avoid it in long functions — it makes the result less obvious.

Go 1.22+ Loop Variable Semantics

Before Go 1.22, the loop variable in for ... range was reused across iterations. This caused a famous bug:

Go
// Pre-Go 1.22 footgun
for _, v := range []int{1, 2, 3} {
    go func() {
        fmt.Println(v) // often prints 3 3 3
    }()
}

Since Go 1.22, when your go.mod declares go 1.22 or later, each iteration gets a fresh variable, so the program prints 1, 2, 3 in some order. (See the official blog post "Fixing For Loops in Go 1.22".)

This is good news, but interviewers still ask about it because:

  1. Older codebases will run for a long time and the bug is still latent.
  2. The classic v := v workaround is everywhere in real code.
  3. Understanding why it was a bug demonstrates that you understand closures and scope.

Part 2 — Slices, Maps, And Memory Traps

This is one of the most commonly mishandled areas in Go interviews.

Slice Internals

A slice is a small header consisting of three fields: a pointer to an underlying array, a length, and a capacity. It is not the array itself.

Go
items := []int{1, 2, 3, 4}
part := items[:2]

fmt.Println(part)      // [1 2]
fmt.Println(len(part)) // 2
fmt.Println(cap(part)) // 4

cap(part) is exactly 4, not "usually 4". For s[low:high], cap(s[low:high]) == cap(s) - low. This is deterministic and worth knowing.

Sharing The Underlying Array

Multiple slices can point at the same underlying array. Mutations through one are visible through the other when capacity allows it:

Go
a := []int{1, 2, 3}  // len=3, cap=3
b := a[:2]           // len=2, cap=3 (shares storage with a)

b = append(b, 99)    // fits in existing cap → no new array

fmt.Println(a) // [1 2 99]
fmt.Println(b) // [1 2 99]

This output is deterministic, not "usually". Because b had cap=3 and len=2, append wrote in place at index 2. Since a also points to that same backing array, a[2] changed too.

Force a new backing array with the three-index slice expression a[low:high:max], which sets capacity:

Go
a := []int{1, 2, 3}
b := a[:2:2]         // len=2, cap=2 — full

b = append(b, 99)    // cap exceeded → new backing array allocated

fmt.Println(a) // [1 2 3]
fmt.Println(b) // [1 2 99]

Why this matters for interviews: This is the single most common slice gotcha. If you can explain it cleanly, with the three-index expression as a controlled fix, you score well.

Slice Growth

When append exceeds capacity, the runtime allocates a new array and copies the elements. The growth factor is roughly 2x for small slices and around 1.25x for large ones (the exact policy was tuned in Go 1.18+ and may continue to evolve, so don't quote a precise rule). The point for interviews:

  • Growth has cost: allocation + copy.
  • Pre-allocate with make([]T, 0, n) when you know the size.
  • Don't rely on a specific growth factor in code.

Nil vs Empty Slice

Go
var a []int
b := []int{}

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

a = append(a, 1)          // works
b = append(b, 1)          // works

For most operations, they behave the same. Where they differ is JSON encoding:

Go
type Response struct {
    Items []string `json:"items"`
}

json.Marshal(Response{Items: nil})        // {"items":null}
json.Marshal(Response{Items: []string{}}) // {"items":[]}

Public APIs often want [] instead of null. Decide deliberately.

Maps

Map zero value is nil. Reading is safe; writing panics.

Go
var m map[string]int
fmt.Println(m["missing"]) // 0 (safe)
m["x"] = 1                // panic: assignment to entry in nil map

m = make(map[string]int)
m["x"] = 1                // ok

The two-value form distinguishes "not present" from "zero value":

Go
v, ok := m["x"]
if !ok {
    // not present
}

Iteration order is randomized. This is intentional. Never depend on it. If you need ordering, sort the keys:

Go
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Println(k, m[k])
}

Maps are not safe for concurrent use when at least one operation is a write. The runtime detects many such races and panics:

Go
m := map[string]int{}
go func() { m["a"]++ }() // race
go func() { m["b"]++ }() // race

Protect with a mutex (usually preferred) or use sync.Map for specific read-heavy access patterns:

Go
type SafeCounter struct {
    mu sync.Mutex
    m  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[key]++
}

A senior answer is not "always use sync.Map". sync.Map is optimized for two specific patterns: 1) keys written once and read many times, 2) when goroutines access disjoint sets of keys. For general workloads, map + sync.Mutex is usually clearer and faster.

The slices And maps Packages (Go 1.21+)

Since Go 1.21, the standard library has generic helpers for slices and maps. Worth knowing:

Go
import (
    "slices"
    "maps"
)

xs := []int{3, 1, 4, 1, 5}
slices.Sort(xs)                  // [1 1 3 4 5]
slices.Contains(xs, 4)           // true
slices.Index(xs, 4)              // 3
xs = slices.Delete(xs, 0, 2)     // remove [0:2)

a := map[string]int{"x": 1}
b := map[string]int{"y": 2}
maps.Copy(a, b)                  // a now has both
keys := slices.Sorted(maps.Keys(a))

Mid-level answer: know slices.Sort, slices.Contains, slices.Index. Senior answer: also know slices.SortFunc, slices.BinarySearch, maps.Clone, and the cmp package for comparison helpers.


Part 3 — Pointers, Methods, And Interfaces

Everything Is Pass-By-Value

Go always passes arguments by value. The trick is understanding what the value contains.

Go
type User struct {
    Name string
}

func rename(u User) {
    u.Name = "Alex" // mutates the copy
}

u := User{Name: "Nazar"}
rename(u)
fmt.Println(u.Name) // Nazar — original unchanged

To mutate the original, pass a pointer:

Go
func rename(u *User) {
    u.Name = "Alex"
}
rename(&u)
fmt.Println(u.Name) // Alex

Slices, maps, and channels feel like reference types because their value contains an internal pointer. Passing them copies the small descriptor, not the underlying data. This means mutations to the elements/entries through one copy are visible through the other.

Pointer Receivers vs Value Receivers

Go
type Counter struct {
    value int
}

func (c Counter) Value() int { return c.value }
func (c *Counter) Inc()      { c.value++ }

Use pointer receivers when:

  • The method modifies the receiver.
  • Copying would be expensive (large struct).
  • The type contains a sync.Mutex, sync.WaitGroup, or similar — copying these is a bug.
  • You want method-set consistency (mixing pointer and value receivers on the same type is allowed but discouraged).

A common senior answer: "If any method needs a pointer receiver, I usually make all of them pointer receivers for consistency." Stdlib types like bytes.Buffer and strings.Builder follow this rule.

Interface Internals And The Nil Trap

An interface value internally holds two words: a type descriptor and a data pointer.

This produces a famous gotcha:

Go
type NotFoundError struct{ ID int64 }

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("user %d not found", e.ID)
}

func find(id int64) error {
    var err *NotFoundError = nil
    return err            // returns interface with type=*NotFoundError, data=nil
}

func main() {
    err := find(10)
    fmt.Println(err == nil) // false  ← surprises many candidates
}

The interface is non-nil because the type slot is set, even though the data pointer is nil. The fix is to return an explicit nil:

Go
func find(id int64) error {
    var err *NotFoundError
    if err != nil {
        return err
    }
    return nil // returns truly nil interface
}

Rule of thumb: never declare a typed-nil error variable and return it. Either return nil or return a real error.

Small, Implicit, Consumer-Side Interfaces

Go interfaces are implicit — a type satisfies an interface by having the right methods, with no implements keyword.

Three rules of thumb that come up in senior interviews:

1) Keep interfaces small.

Go
// Good
type UserFinder interface {
    FindByID(ctx context.Context, id int64) (*User, error)
}

// Probably too big
type UserService interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, u User) (*User, error)
    Update(ctx context.Context, u User) error
    Delete(ctx context.Context, id int64) error
    Search(ctx context.Context, f Filters) ([]User, error)
    Export(ctx context.Context) ([]byte, error)
}

The classic stdlib examples are io.Reader and io.Writer — one method each.

2) Define interfaces at the consumer, not the producer.

The package that uses a dependency should declare the small interface it needs. The package that provides the implementation just exposes a concrete type. This keeps imports flowing in one direction and tests easy to write.

Go
package report

// report needs only FindByID, so it declares only that.
type userFinder interface {
    FindByID(ctx context.Context, id int64) (*User, error)
}

type Generator struct{ users userFinder }

3) Don't create an interface until you have a real second use case. Premature interfaces add indirection without clarifying anything. Refactor to an interface when you actually need it for testing or alternate implementations.

interface{} vs any

any is an alias for interface{}. They are identical. Modern Go (1.18+) uses any. Old code uses interface{}. Read both, write any.

Type Assertions And Type Switches

Go
func describe(v any) string {
    switch x := v.(type) {
    case string:
        return "string: " + x
    case int:
        return fmt.Sprintf("int: %d", x)
    case fmt.Stringer:
        return "stringer: " + x.String()
    default:
        return "unknown"
    }
}

The single-value form panics on mismatch; the two-value form does not:

Go
s := v.(string)       // panics if v is not a string
s, ok := v.(string)   // ok == false if v is not a string

If your codebase has many type switches across business logic, the design is probably too dynamic — Go usually prefers explicit types.


Part 4 — Error Handling

Go treats errors as values. There are no exceptions for control flow.

Wrapping With %w

Go
func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read %s: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parse %s: %w", path, err)
    }
    return &cfg, nil
}

%w wraps the underlying error so callers can still inspect it. Use %v (not %w) only when you do not want the original error to be programmatically reachable.

errors.Is vs errors.As

errors.Is checks whether a known sentinel error is in the chain:

Go
var ErrUserNotFound = errors.New("user not found")

if errors.Is(err, ErrUserNotFound) {
    // handle 404
}

errors.As extracts a specific error type from the chain:

Go
type ValidationError struct {
    Field, Msg string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }

var v *ValidationError
if errors.As(err, &v) {
    return fmt.Errorf("bad request on field %q: %s", v.Field, v.Msg)
}

Mid-level mistake: comparing errors with ==. That only works for sentinel errors that have not been wrapped. Use errors.Is instead.

errors.Join (Go 1.20)

You can wrap multiple errors into one. errors.Is and errors.As traverse all branches.

Go
func validate(in Input) error {
    var errs []error
    if in.Email == "" {
        errs = append(errs, errors.New("email is required"))
    }
    if in.Age < 0 {
        errs = append(errs, errors.New("age must be non-negative"))
    }
    return errors.Join(errs...) // returns nil if errs is empty
}

fmt.Errorf also accepts multiple %w verbs since Go 1.20:

Go
return fmt.Errorf("save user: %w; %w", dbErr, cacheErr)

Note: errors.Unwrap returns nil for joined errors because the joined error implements Unwrap() []error, not Unwrap() error. Use errors.Is/errors.As for inspection instead of Unwrap directly.

Custom Error Types

Define a type when callers need to inspect structured fields, not just match a sentinel:

Go
type RetryableError struct {
    After time.Duration
    Cause error
}

func (e *RetryableError) Error() string {
    return fmt.Sprintf("retryable in %v: %v", e.After, e.Cause)
}
func (e *RetryableError) Unwrap() error { return e.Cause }

When Is panic Acceptable?

Not for normal business errors. Not for invalid input, not for "not found", not for "payment declined". Use errors.

panic is for truly unrecoverable programmer mistakes or initialization failures where the program cannot safely continue:

Go
var tmpl = template.Must(template.ParseFiles("layout.html"))

In handlers, panics are typically caught by middleware so one bad request does not kill the server — but a recovered panic does not magically fix corrupted state, so log it loudly and surface it as a 500.


Part 5 — defer, panic, recover

Order

Deferred calls execute in LIFO order when the surrounding function returns:

Go
func demo() {
    defer fmt.Println("first")
    defer fmt.Println("second")
}
// Output:
// second
// first

Argument Evaluation

Deferred arguments are evaluated at the point of defer, not when the call runs:

Go
i := 0
defer fmt.Println(i) // captures i = 0
i = 42
// On return: prints 0

Method receivers and arguments are captured immediately. If you want late binding, wrap in a closure:

Go
defer func() { fmt.Println(i) }() // late: prints 42

Defer With Loops

A defer runs at function exit, not loop-iteration exit. This is a common bug:

Go
// Bad — file handles accumulate until function returns
for _, name := range names {
    f, err := os.Open(name)
    if err != nil { return err }
    defer f.Close() // ALL files held open
    // ...
}

Fix by wrapping the body in a function:

Go
for _, name := range names {
    if err := process(name); err != nil {
        return err
    }
}

func process(name string) error {
    f, err := os.Open(name)
    if err != nil { return err }
    defer f.Close()
    // ...
    return nil
}

recover Works Only Inside A Deferred Function

Go
func safeRun() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()
    dangerousOp()
    return nil
}

The HTTP server's default mux already recovers panics in handlers, but custom servers and worker goroutines need their own recovery. A goroutine that panics without a deferred recover crashes the entire process. This is one of the most common production outages with Go.

Go
// Always recover at goroutine boundaries
go func() {
    defer func() {
        if r := recover(); r != nil {
            logger.Error("worker panic", "panic", r, "stack", string(debug.Stack()))
        }
    }()
    runWorker()
}()

Part 6 — Goroutines, Channels, And The Scheduler

The G-M-P Scheduler Model

Go's runtime multiplexes many goroutines onto a small number of OS threads using three abstractions:

  • G — a goroutine (the work).
  • M — an OS thread ("machine").
  • P — a logical processor with a local run-queue. The number of Ps is set by GOMAXPROCS (defaults to the number of logical CPUs).

Key facts senior interviewers like to hear:

  • A goroutine starts with a small stack (a few KB) that grows on demand. That is why you can have hundreds of thousands of them.
  • An idle P will steal work from another P's local queue ("work stealing"), keeping cores busy without a global lock.
  • When a goroutine makes a blocking syscall, the M blocks with it, but the P is detached and picked up by another M so other goroutines keep running.
  • runtime.GOMAXPROCS(n) controls parallelism. The default is fine for almost all workloads.

Goroutines Are Not Free

Cheap, but not free. A goroutine that blocks forever leaks memory and resources.

Go
// Leak: the receiver waits forever if the sender never sends.
func waitFor(ch <-chan Result) Result { return <-ch }

// Safer: pair with a context.
func waitFor(ctx context.Context, ch <-chan Result) (Result, error) {
    select {
    case <-ctx.Done():
        return Result{}, ctx.Err()
    case r := <-ch:
        return r, nil
    }
}

Channels: Unbuffered vs Buffered

An unbuffered channel synchronizes the sender and receiver — both must be ready.

A buffered channel can hold up to cap values without blocking the sender.

Go
ch := make(chan int)    // unbuffered
ch := make(chan int, 8) // buffered, capacity 8

Closing Channels — The Rules

  1. Only the sender closes. The receiver does not know whether more senders exist.
  2. With multiple senders, none of them should close. Use a separate "done" signal (typically context.Done()) instead, or have a coordinator close after all senders finish.
  3. Sending on a closed channel panics.
  4. Closing an already-closed channel panics.
  5. Receiving from a closed channel returns the zero value immediately, and the two-value receive form tells you it is closed:
Go
v, ok := <-ch
if !ok {
    // channel is closed and drained
}

for v := range ch exits cleanly when the channel is closed.

Worker Pool — A Canonical Pattern

Go
type Job struct{ ID int }
type Result struct {
    JobID int
    Err   error
}

func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
    for {
        select {
        case <-ctx.Done():
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            err := process(ctx, job)
            select {
            case <-ctx.Done():
                return
            case results <- Result{JobID: job.ID, Err: err}:
            }
        }
    }
}

func RunWorkers(ctx context.Context, jobsList []Job, n int) []Result {
    jobs := make(chan Job)
    results := make(chan Result)

    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            worker(ctx, jobs, results)
        }()
    }

    // Single producer: safe to close jobs when done.
    go func() {
        defer close(jobs)
        for _, j := range jobsList {
            select {
            case <-ctx.Done():
                return
            case jobs <- j:
            }
        }
    }()

    // Close results once all workers finish.
    go func() {
        wg.Wait()
        close(results)
    }()

    var out []Result
    for r := range results {
        out = append(out, r)
    }
    return out
}

func process(ctx context.Context, j Job) error { return nil }

This single example exercises the patterns interviewers care about: cancellation propagation, channel direction (<-chan / chan<-), single-producer-closes rule, and bounded concurrency.

errgroup — When You Want Errors From Concurrent Tasks

golang.org/x/sync/errgroup is the standard pattern for "do N things concurrently, fail fast on any error".

Go
import "golang.org/x/sync/errgroup"

func loadAll(ctx context.Context, ids []int64) ([]*User, error) {
    g, ctx := errgroup.WithContext(ctx)
    out := make([]*User, len(ids))

    for i, id := range ids {
        i, id := i, id // pre-Go 1.22; not needed if go.mod is >=1.22
        g.Go(func() error {
            u, err := fetchUser(ctx, id)
            if err != nil {
                return err
            }
            out[i] = u
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return out, nil
}

g.SetLimit(n) (Go 1.18+) caps concurrency. errgroup.WithContext cancels the context when the first error occurs, so siblings can stop early.


Part 7 — context.Context And Cancellation

context.Context is essential to production Go services. It carries cancellation signals, deadlines, and request-scoped values across API boundaries.

Idioms

Go
func SendReceipt(ctx context.Context, userID int64) error { ... }

Context is the first parameter, by convention. This makes cancellation visible at the call site.

Go
// Don't store context on a struct.
type Service struct {
    ctx context.Context // bad
}

// Pass per call.
type Service struct{ db *sql.DB }
func (s *Service) Find(ctx context.Context, id int64) (*User, error) { ... }

The WithCancel / WithTimeout / WithDeadline Trio

Go
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()                       // always defer cancel — releases resources
result, err := slowOp(ctx)

Always defer cancel() even when the context expires on its own. Forgetting cancel() leaks the timer until the parent context is done.

WithValue — Use Sparingly And Correctly

context.WithValue is for request-scoped data that must cross API boundaries (request IDs, trace IDs, auth claims, locale). It is not a dependency-injection container.

Go
type ctxKey struct{ name string }
var requestIDKey = ctxKey{"request-id"}

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func RequestID(ctx context.Context) string {
    v, _ := ctx.Value(requestIDKey).(string)
    return v
}

Use a private, unexported, struct key type so other packages cannot collide with your key.

Honoring Cancellation Inside A Loop

Go
for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    if err := process(ctx, item); err != nil {
        return err
    }
}

Even better: pass ctx into process so the operation itself stops promptly.


Part 8 — Mutexes, Atomics, And The Race Detector

Concurrency in Go is not only about channels. A mutex is often the simplest and best tool.

sync.Mutex And sync.RWMutex

Go
type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[k]
    return v, ok
}

func (c *Cache) Set(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[k] = v
}

RWMutex allows many concurrent readers when there is no writer. It is not automatically faster than Mutex. For short critical sections under high contention, plain Mutex is often faster because of lower overhead. Measure if it matters.

Senior answer for "channel vs mutex":

"Channels are great for communication, coordination, and ownership transfer. Mutexes are great for protecting shared state. I do not use channels just because the code is in Go."

sync.WaitGroup

Go
var wg sync.WaitGroup
for _, j := range jobs {
    wg.Add(1)
    go func(j Job) {
        defer wg.Done()
        process(j)
    }(j)
}
wg.Wait()

Common mistake: calling wg.Add(1) inside the goroutine. The race-free pattern is to Add before go, or to Add(n) once before the loop.

sync.Once — Lazy Initialization

Go
var (
    cfg     *Config
    cfgOnce sync.Once
    cfgErr  error
)

func GetConfig() (*Config, error) {
    cfgOnce.Do(func() {
        cfg, cfgErr = loadConfig()
    })
    return cfg, cfgErr
}

Go 1.21 added sync.OnceFunc, sync.OnceValue, and sync.OnceValues for nicer ergonomics:

Go
var loadConfig = sync.OnceValue(func() *Config {
    // executed once, lazily, on first call
    return &Config{ /* ... */ }
})

Atomics

i++ is not atomic. It is read-modify-write. Use sync/atomic:

Go
var n atomic.Int64    // Go 1.19+ typed atomic
n.Add(1)
fmt.Println(n.Load())

For older code or APIs, the function form still works:

Go
var n int64
atomic.AddInt64(&n, 1)

Use atomics for simple counters and flags. For anything compound, a mutex is clearer.

Don't Copy Structs With Mutexes

Go
type Store struct {
    mu sync.Mutex
    m  map[string]int
}

s := Store{m: map[string]int{}}
s2 := s // BUG — copies the mutex

go vet flags this. Use a pointer (*Store) to pass around.

The Race Detector

Bash
go test -race ./...
go run -race ./cmd/api

The race detector instruments memory accesses and reports goroutines racing on the same address. It is one of the most valuable tools in Go. Caveats:

  • It only finds races on code paths that actually run during the test/run.
  • It has runtime overhead (5–10x memory, 2–20x CPU). Don't run it in production by default, but do run it on a staging cluster periodically.
  • Write concurrent tests so the race detector has races to find.

The Go Memory Model In One Paragraph

The Go memory model defines when a write in one goroutine is guaranteed to be visible in another. The short version: synchronization primitives establish a "happens-before" relationship. Channel sends/receives, sync.Mutex lock/unlock, sync.Once.Do, sync.WaitGroup.Wait, and atomic operations provide synchronization. Without one of those, you have no guarantee about the order or visibility of writes across goroutines — the compiler and CPU can reorder. If you need to share mutable state, you need synchronization.


Part 9 — Generics

Generics were added in Go 1.18. They are useful when they remove real duplication while keeping code obvious.

Go
func Contains[T comparable](items []T, target T) bool {
    for _, v := range items {
        if v == target { return true }
    }
    return false
}

func Map[T, U any](in []T, f func(T) U) []U {
    out := make([]U, len(in))
    for i, v := range in {
        out[i] = f(v)
    }
    return out
}

Common Constraints

  • any — any type. Same as interface{}.
  • comparable — supports == and !=. (Note: as of Go 1.20, this includes interface types too.)
  • Custom constraints with ~ for the underlying type:
Go
type Number interface {
    ~int | ~int64 | ~float64
}

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

The ~ operator means "this type or any type whose underlying type is this".

When Not To Use Generics

A generic repository is the classic anti-pattern:

Go
// Often a weak abstraction
type Repository[T any] interface {
    FindByID(ctx context.Context, id int64) (*T, error)
    Save(ctx context.Context, v *T) error
}

Real repositories differ in queries, validation, transactions, and joined loads. Forcing them through one generic interface usually leads to leaky escape hatches.

Senior answer: "I use generics for reusable algorithms and data structures. I avoid them when the abstraction hides domain differences I would rather keep explicit."


Part 10 — The Standard Library Tour

Go's standard library is a major reason it works so well for backend services.

net/http

Go
func main() {
    mux := http.NewServeMux()

    // Go 1.22+ method-aware routing
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte("ok"))
    })

    server := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      15 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)
    }
}

For senior interviews, always mention timeouts. A production HTTP server without ReadHeaderTimeout is vulnerable to slowloris-style attacks; without WriteTimeout, slow consumers can starve goroutines.

For HTTP clients, never use http.DefaultClient directly in production. It has no timeout. Build your own:

Go
client := &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
    },
}

encoding/json

Go
type CreateUserRequest struct {
    Email string `json:"email"`
    Name  string `json:"name"`
}

func decode(r *http.Request) (CreateUserRequest, error) {
    defer r.Body.Close()
    var in CreateUserRequest
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // optional, recommended for strict APIs
    if err := dec.Decode(&in); err != nil {
        return CreateUserRequest{}, fmt.Errorf("decode: %w", err)
    }
    return in, nil
}

Useful tag features:

  • json:"name,omitempty" — omit empty values.
  • json:"-" — never serialize.
  • json:"-," — actually use the literal name "-". (Worth knowing as a trivia question.)

log/slog (Go 1.21+)

Structured logging matters in production.

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

logger.Info("payment processed",
    "payment_id", paymentID,
    "amount_cents", amountCents,
    "user_id", userID,
)

A senior log line carries: request ID, user ID (when safe), operation, latency, outcome, and error. Avoid logging secrets, PII without need, or unbounded payloads.

time

A few traps:

  • time.Now() returns wall-clock + monotonic time. For measuring durations always use time.Since(start) or time.Until(deadline) — these use the monotonic component and are immune to wall-clock jumps.
  • Time zones matter. Store and transmit UTC; render in local time only at the edges.
  • time.Time zero value is 0001-01-01 00:00:00 UTC. Use t.IsZero(), not t == time.Time{}.

io, bufio, os

Know io.Reader, io.Writer, io.Closer, io.Copy, io.MultiReader. Know bufio.Scanner for line-by-line reads (it has a max token size — bufio.MaxScanTokenSize defaults to 64 KB, which trips many candidates on large inputs).


Part 11 — Production-Grade HTTP Middleware

Middleware is a function that wraps an http.Handler to add cross-cutting behavior — logging, timing, request IDs, panic recovery, authentication, rate limiting, CORS, compression. The standard signature in Go is:

Go
type Middleware func(http.Handler) http.Handler

Each middleware takes a handler and returns a new handler that does something before and/or after delegating to the wrapped one.

Composing Middleware

You don't need a framework. A small chain helper works:

Go
func Chain(h http.Handler, mws ...Middleware) http.Handler {
    // Apply in reverse so the first listed runs outermost.
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// Usage
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)

handler := Chain(mux,
    RequestID,
    Recovery(logger),
    Logging(logger),
    Timeout(30*time.Second),
)

server := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 5 * time.Second,
}

1) Request ID

Assigns or propagates an X-Request-ID so logs and traces tie together.

Go
type ctxKey struct{ name string }
var requestIDKey = ctxKey{"request-id"}

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func RequestIDFrom(ctx context.Context) string {
    v, _ := ctx.Value(requestIDKey).(string)
    return v
}

2) Panic Recovery

A panic in a handler escapes to net/http, which logs it and closes the connection — but you lose context (request ID, user, what was happening). For production, catch it explicitly:

Go
func Recovery(logger *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                rec := recover()
                if rec == nil {
                    return
                }
                // http.ErrAbortHandler is a sentinel — re-panic so net/http handles it.
                if rec == http.ErrAbortHandler {
                    panic(rec)
                }
                logger.Error("panic in handler",
                    "panic", fmt.Sprintf("%v", rec),
                    "stack", string(debug.Stack()),
                    "method", r.Method,
                    "path", r.URL.Path,
                    "request_id", RequestIDFrom(r.Context()),
                )
                // Best-effort: only writes if no headers have been sent yet.
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }()
            next.ServeHTTP(w, r)
        })
    }
}

Senior gotcha: http.ErrAbortHandler is a documented sentinel that the stdlib uses to abort handlers without logging (e.g. the http2 server uses it on stream resets). Don't swallow it — re-panic so the stdlib does its thing.

3) Structured Logging With Timing

The most useful middleware in production debugging:

Go
type statusRecorder struct {
    http.ResponseWriter
    status int
    bytes  int64
    wrote  bool
}

func (r *statusRecorder) WriteHeader(code int) {
    if !r.wrote {
        r.status = code
        r.wrote = true
    }
    r.ResponseWriter.WriteHeader(code)
}

func (r *statusRecorder) Write(b []byte) (int, error) {
    if !r.wrote {
        r.status = http.StatusOK // implicit 200 if no WriteHeader call
        r.wrote = true
    }
    n, err := r.ResponseWriter.Write(b)
    r.bytes += int64(n)
    return n, err
}

func Logging(logger *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            rec := &statusRecorder{ResponseWriter: w}

            next.ServeHTTP(rec, r)

            logger.Info("http request",
                "method", r.Method,
                "path", r.URL.Path,
                "status", rec.status,
                "bytes", rec.bytes,
                "duration_ms", time.Since(start).Milliseconds(),
                "remote", r.RemoteAddr,
                "request_id", RequestIDFrom(r.Context()),
                "user_agent", r.UserAgent(),
            )
        })
    }
}

Senior trap: wrapping http.ResponseWriter is harder than it looks. The stdlib types implement optional interfaces — http.Flusher, http.Hijacker, http.Pusher. If your wrapper hides them, streaming responses, WebSockets, and SSE break. Two solutions:

  1. Use http.ResponseController(w) (Go 1.20+) inside handlers, which finds the underlying capabilities through wrappers.
  2. Use the small library github.com/felixge/httpsnoop, which preserves all optional interfaces automatically.

4) Timeout

Don't blindly use http.TimeoutHandler — it cancels the context but doesn't actually stop the handler from running. The handler must honor r.Context(). Most production code uses an explicit context timeout:

Go
func Timeout(d time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

This is only effective if downstream code (DB queries, outbound HTTP) honors ctx. Without that, the handler keeps running even after the response is sent. Never start work that ignores r.Context().

5) Authentication

Go
type ctxUserKey struct{}

func RequireBearerToken(verify func(token string) (User, error)) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authz := r.Header.Get("Authorization")
            const prefix = "Bearer "
            if !strings.HasPrefix(authz, prefix) {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }
            user, err := verify(strings.TrimPrefix(authz, prefix))
            if err != nil {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), ctxUserKey{}, user)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func UserFrom(ctx context.Context) (User, bool) {
    u, ok := ctx.Value(ctxUserKey{}).(User)
    return u, ok
}

Apply this only to routes that need it; not as a global. Public health checks and login endpoints should not require auth.

6) Per-IP Rate Limiting

Using golang.org/x/time/rate:

Go
import "golang.org/x/time/rate"

type IPLimiter struct {
    mu       sync.Mutex
    buckets  map[string]*entry
    rate     rate.Limit
    burst    int
    ttl      time.Duration
}

type entry struct {
    lim      *rate.Limiter
    lastSeen time.Time
}

func NewIPLimiter(r rate.Limit, burst int, ttl time.Duration) *IPLimiter {
    l := &IPLimiter{
        buckets: map[string]*entry{},
        rate:    r,
        burst:   burst,
        ttl:     ttl,
    }
    go l.cleanupLoop()
    return l
}

func (l *IPLimiter) get(ip string) *rate.Limiter {
    l.mu.Lock()
    defer l.mu.Unlock()
    e, ok := l.buckets[ip]
    if !ok {
        e = &entry{lim: rate.NewLimiter(l.rate, l.burst)}
        l.buckets[ip] = e
    }
    e.lastSeen = time.Now()
    return e.lim
}

func (l *IPLimiter) cleanupLoop() {
    t := time.NewTicker(time.Minute)
    defer t.Stop()
    for range t.C {
        cutoff := time.Now().Add(-l.ttl)
        l.mu.Lock()
        for ip, e := range l.buckets {
            if e.lastSeen.Before(cutoff) {
                delete(l.buckets, ip)
            }
        }
        l.mu.Unlock()
    }
}

func (l *IPLimiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip, _, err := net.SplitHostPort(r.RemoteAddr)
        if err != nil {
            ip = r.RemoteAddr
        }
        if !l.get(ip).Allow() {
            w.Header().Set("Retry-After", "1")
            http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Notes:

  • Without TTL cleanup, the map grows unbounded — a memory-exhaustion vector.
  • Behind a load balancer, r.RemoteAddr is the LB's address. Use X-Forwarded-For only if your LB is trusted to set it correctly; never trust client-supplied values.
  • For multi-instance deployments, move state to Redis (INCR key EX seconds, or a Lua script for token-bucket math).

7) CORS

For anything non-trivial, use github.com/rs/cors. For a focused minimal implementation:

Go
func CORS(allowOrigin string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
            w.Header().Set("Vary", "Origin")
            if r.Method == http.MethodOptions {
                w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH")
                w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
                w.Header().Set("Access-Control-Max-Age", "600")
                w.WriteHeader(http.StatusNoContent)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Senior gotcha: for credentialed CORS requests (cookies, Authorization headers across origins), the browser rejects Access-Control-Allow-Origin: *. You must echo back the specific origin you trust, and add Access-Control-Allow-Credentials: true.

Order Matters — Common Senior Question

What is the right order for these middleware? Outermost to innermost:

  1. RequestID — every later log line should include it.
  2. Recovery — must be outside Logging so panics get logged with context.
  3. Logging — wraps the rest so it measures the full request including auth.
  4. Timeout — bounds the total duration including auth and rate limiting.
  5. CORS — early so preflight OPTIONS short-circuits.
  6. RateLimit — before auth, so unauthenticated traffic doesn't burn DB lookups verifying tokens.
  7. Auth — last, closest to your handler.

Wrong order causes real bugs: Logging outside Recovery means panics aren't logged with request context; Auth outside RateLimit means an attacker can DoS your token verifier.

What Not To Do In Middleware

  • Don't read or buffer the entire request body unless you need to. It can be unbounded.
  • Don't block the goroutine on external services without a context-aware timeout.
  • Don't store per-request mutable state in package-level variables. Use the request context.
  • Don't mutate r.Header after passing to the next handler — handler ordering becomes invisible.
  • Don't issue redirects in middleware that runs after the body has potentially started writing.

Part 12 — gRPC With Go

gRPC is a binary RPC protocol over HTTP/2. Compared to JSON-over-HTTP it gives you typed schemas (Protocol Buffers), code generation for clients and servers, four call patterns including streaming, smaller wire size, and lower CPU cost on (de)serialization. The trade-offs: harder to debug from a browser, different load-balancing story, stricter coupling to the schema.

When To Reach For gRPC vs REST

Concern REST (JSON over HTTP) gRPC
Public API, browser clients Good fit Needs gRPC-Web or a proxy
Internal service-to-service OK but verbose Strong fit
Streaming (long-lived) Via SSE / WebSocket First-class
Schema-driven contract OpenAPI is optional Enforced by codegen
Easy debugging with curl Yes Need grpcurl
Low latency, high throughput Decent Strong fit
Tooling outside Go ecosystem Universal Available, heavier

A common senior answer: "REST at the edge, gRPC between internal services."

A Minimal .proto

Protobuf
syntax = "proto3";

package users.v1;
option go_package = "example.com/myservice/gen/users/v1;userspb";

service UserService {
  rpc GetUser   (GetUserRequest)   returns (GetUserResponse);
  rpc ListUsers (ListUsersRequest) returns (stream User);   // server streaming
}

message GetUserRequest  { int64 id = 1; }
message GetUserResponse { User user = 1; }

message ListUsersRequest { int32 page_size = 1; }

message User {
  int64  id    = 1;
  string email = 2;
  string name  = 3;
}

Generate code with:

Bash
protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       proto/users/v1/users.proto

In real projects use buf instead of raw protoc — it's the modern standard tool: schema linting, breaking-change detection, dependency management, and a simpler config (buf.yaml, buf.gen.yaml).

Server (Unary RPC)

Go
import (
    "context"
    "errors"
    "log"
    "net"

    userspb "example.com/myservice/gen/users/v1"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type userServer struct {
    userspb.UnimplementedUserServiceServer // forward-compatible default
    store UserStore
}

func (s *userServer) GetUser(ctx context.Context, req *userspb.GetUserRequest) (*userspb.GetUserResponse, error) {
    if req.GetId() <= 0 {
        return nil, status.Error(codes.InvalidArgument, "id must be positive")
    }
    user, err := s.store.FindByID(ctx, req.GetId())
    if errors.Is(err, ErrUserNotFound) {
        return nil, status.Errorf(codes.NotFound, "user %d not found", req.GetId())
    }
    if err != nil {
        return nil, status.Errorf(codes.Internal, "lookup failed: %v", err)
    }
    return &userspb.GetUserResponse{User: &userspb.User{
        Id:    user.ID,
        Email: user.Email,
        Name:  user.Name,
    }}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("listen: %v", err)
    }

    s := grpc.NewServer()
    userspb.RegisterUserServiceServer(s, &userServer{ /* ... */ })

    log.Printf("grpc listening on %s", lis.Addr())
    if err := s.Serve(lis); err != nil {
        log.Fatalf("serve: %v", err)
    }
}

Senior details to call out:

  • Always embed UnimplementedUserServiceServer. This makes adding a new RPC to the proto a non-breaking change — your existing servers still compile against the regenerated code; unimplemented RPCs return Unimplemented automatically.
  • Always return status.Error / status.Errorf with a proper code. Returning a plain Go error gives the client codes.Unknown, which is useless for retry policies and observability.
  • Codes have semantic meaning. Pick correctly — middleware, retry policies, and clients react to them.
Code When to use
InvalidArgument Client sent something the server can't process
NotFound Resource doesn't exist
AlreadyExists Resource already exists (idempotency-friendly)
PermissionDenied Authenticated but not authorized
Unauthenticated No or invalid credentials
DeadlineExceeded Caller's deadline passed
Unavailable Transient — clients should retry
Internal Server bug; not the client's fault
ResourceExhausted Quota/rate limit
FailedPrecondition System state doesn't allow the operation

Client

Go
import (
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/status"
)

conn, err := grpc.NewClient("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()), // TLS in real life
)
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

client := userspb.NewUserServiceClient(conn)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

resp, err := client.GetUser(ctx, &userspb.GetUserRequest{Id: 42})
if err != nil {
    if st, ok := status.FromError(err); ok {
        log.Printf("rpc failed: code=%s msg=%s", st.Code(), st.Message())
    }
    return err
}
log.Printf("user email: %s", resp.GetUser().GetEmail())

grpc.NewClient (v1.63+) is the modern API. The older grpc.Dial is being phased out for new code — NewClient is non-blocking and lazy by default, which fits how connections actually behave with HTTP/2.

Server Streaming

ListUsers streams User messages back. Server side:

Go
func (s *userServer) ListUsers(req *userspb.ListUsersRequest, stream userspb.UserService_ListUsersServer) error {
    rows, err := s.store.IterateUsers(stream.Context(), int(req.GetPageSize()))
    if err != nil {
        return status.Errorf(codes.Internal, "iterate: %v", err)
    }
    defer rows.Close()

    for rows.Next() {
        // Honor cancellation cooperatively.
        select {
        case <-stream.Context().Done():
            return stream.Context().Err()
        default:
        }

        u := rows.Current()
        if err := stream.Send(&userspb.User{Id: u.ID, Email: u.Email, Name: u.Name}); err != nil {
            return err // client disconnected
        }
    }
    return rows.Err()
}

Client side:

Go
stream, err := client.ListUsers(ctx, &userspb.ListUsersRequest{PageSize: 100})
if err != nil {
    return err
}
for {
    u, err := stream.Recv()
    if errors.Is(err, io.EOF) {
        break
    }
    if err != nil {
        return err
    }
    fmt.Println(u.GetEmail())
}

There are also client streaming (client sends many, server replies once) and bidirectional streaming (both stream concurrently — useful for chat, live updates, real-time dashboards).

Interceptors — gRPC's Middleware

Interceptors come in two flavours: unary and stream.

Go
func loggingUnary(logger *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        start := time.Now()
        resp, err := handler(ctx, req)
        logger.Info("grpc",
            "method", info.FullMethod,
            "code", status.Code(err).String(),
            "duration_ms", time.Since(start).Milliseconds(),
        )
        return resp, err
    }
}

func recoveryUnary(logger *slog.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
        defer func() {
            if r := recover(); r != nil {
                logger.Error("panic",
                    "method", info.FullMethod,
                    "panic", fmt.Sprintf("%v", r),
                    "stack", string(debug.Stack()),
                )
                err = status.Errorf(codes.Internal, "internal error")
            }
        }()
        return handler(ctx, req)
    }
}

func authUnary(verify func(string) (User, error)) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        // Skip auth for health checks.
        if strings.HasPrefix(info.FullMethod, "/grpc.health.v1.") {
            return handler(ctx, req)
        }
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return nil, status.Error(codes.Unauthenticated, "missing metadata")
        }
        tokens := md.Get("authorization")
        if len(tokens) == 0 || !strings.HasPrefix(tokens[0], "Bearer ") {
            return nil, status.Error(codes.Unauthenticated, "bearer token required")
        }
        user, err := verify(strings.TrimPrefix(tokens[0], "Bearer "))
        if err != nil {
            return nil, status.Error(codes.Unauthenticated, "invalid token")
        }
        ctx = context.WithValue(ctx, ctxUserKey{}, user)
        return handler(ctx, req)
    }
}

s := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        recoveryUnary(logger),  // outermost
        loggingUnary(logger),
        authUnary(verify),      // innermost
    ),
)

For most production use, reach for github.com/grpc-ecosystem/go-grpc-middleware — it provides battle-tested logging, recovery, retry, ratelimit, validator, and OpenTelemetry tracing interceptors.

Deadlines Propagate Across Hops

A gRPC deadline is just context.WithTimeout on the client. The deadline travels in the request metadata; the server reads it back into ctx.Deadline() on its side. Always set a deadline on the client — gRPC will not invent one for you, and a missing deadline is a frequent cause of cascading hangs.

Go
ctx, cancel := context.WithTimeout(parent, 500*time.Millisecond)
defer cancel()
resp, err := client.GetUser(ctx, req)

If the server then calls another gRPC service, it should pass through the same ctx. The deadline shrinks naturally as time passes, giving end-to-end timeouts for free across many hops.

TLS And Authentication

In production you almost never use insecure.NewCredentials():

Go
creds, err := credentials.NewServerTLSFromFile("server.crt", "server.key")
if err != nil { log.Fatal(err) }
s := grpc.NewServer(grpc.Creds(creds))

For per-call auth, set metadata on the client:

Go
md := metadata.Pairs("authorization", "Bearer "+token)
ctx = metadata.NewOutgoingContext(ctx, md)
resp, err := client.GetUser(ctx, req)

And read it on the server inside an interceptor (see the auth interceptor above).

Health Checks And Reflection

Two standards every production gRPC server should expose:

Go
import (
    "google.golang.org/grpc/health"
    healthpb "google.golang.org/grpc/health/grpc_health_v1"
    "google.golang.org/grpc/reflection"
)

healthSrv := health.NewServer()
healthpb.RegisterHealthServer(s, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)

reflection.Register(s) // lets grpcurl/Postman discover your services

Kubernetes' grpc_health_probe and most service meshes expect the standard health-check service. Reflection is invaluable in development and on internal-only ports — in untrusted environments, gate it behind authn.

Common gRPC Interview Questions

Q: Which HTTP/2 features does gRPC depend on? A: Stream multiplexing (many concurrent RPCs over one TCP connection), binary framing, header compression (HPACK), bidirectional streaming.

Q: Why protobuf over JSON? A: Smaller wire size, faster (de)serialization, and a strict schema with enforced compatibility rules — you cannot change a field's number or type without breaking clients, which catches mistakes at build time.

Q: How do you maintain backwards/forwards compatibility? A: Add new fields with new numbers; never reuse a number; never change a field's type; mark removed fields with reserved. Old clients ignore unknown fields; new clients see zero values for fields old servers don't send.

Q: How is gRPC error handling different from HTTP? A: Errors are status.Status values with a codes.Code, a message, and optional details for richer payloads. Codes have well-defined retry semantics (e.g. Unavailable is retryable, InvalidArgument is not).

Q: How do you load-balance gRPC? A: Because HTTP/2 multiplexes many RPCs over one TCP connection, L4 load balancers don't spread load well — they pin a client to one backend. Use client-side load balancing (grpc.WithDefaultServiceConfig with round_robin, DNS resolver, or xDS) or a service mesh (Linkerd, Istio).

Q: What is gRPC-Web? A: A spec for calling gRPC services from browsers via a small proxy (Envoy or grpcweb). It restricts streaming to server-streaming because browsers can't speak HTTP/2 framing directly to your server.

Q: Unary vs streaming — when do you choose streaming? A: When the natural shape of the data is a sequence whose total size is unknown or large (export, tail-of-log, live updates), or when latency matters per-element. For a small bounded list, unary with a paginated response is simpler.

Q: How do you test a gRPC server? A: google.golang.org/grpc/test/bufconn — an in-memory listener that lets you start a real grpc.Server, dial it without a network, and exercise interceptors and handlers end-to-end. Faster and more deterministic than spinning up a TCP port.


Part 13 — Database Access With database/sql

*sql.DB is not a single connection. It is a handle around an internal connection pool.

You usually create one *sql.DB and reuse it across the application.

Go
db, err := sql.Open("pgx", dsn) // sql.Open does NOT actually connect
if err != nil { return err }

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

if err := db.PingContext(ctx); err != nil {
    return fmt.Errorf("ping: %w", err)
}

Pool tuning intuition: MaxOpenConns should consider what the database can handle. A common starting point is (num_app_instances) * MaxOpenConns ≤ db_max_connections * 0.8. Setting MaxIdleConns equal to MaxOpenConns reduces churn.

Querying With Context

Go
func GetUser(ctx context.Context, db *sql.DB, id int64) (*User, error) {
    row := db.QueryRowContext(ctx,
        `SELECT id, email, created_at FROM users WHERE id = $1`, id)

    var u User
    if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("scan user: %w", err)
    }
    return &u, nil
}

Placeholders differ by driver: PostgreSQL uses $1, $2; MySQL/SQLite use ?. Never build SQL with string concatenation (SQL injection).

Iterating Rows — Easy To Get Wrong

Go
rows, err := db.QueryContext(ctx, `SELECT id, email FROM users WHERE active = true`)
if err != nil {
    return nil, err
}
defer rows.Close() // always

var users []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Email); err != nil {
        return nil, err
    }
    users = append(users, u)
}
if err := rows.Err(); err != nil { // check after the loop!
    return nil, err
}
return users, nil

Two senior-level details: defer rows.Close() to release the connection back to the pool, and rows.Err() after the loop because errors during iteration are silent inside rows.Next().

Transactions

Go
func Transfer(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    defer func() {
        if err != nil {
            _ = tx.Rollback() // best-effort
        }
    }()

    if _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, from); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if _, err = tx.ExecContext(ctx,
        `UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, to); err != nil {
        return fmt.Errorf("credit: %w", err)
    }
    return tx.Commit()
}

Senior trade-off conversations:

  • Isolation levels: read-committed vs repeatable-read vs serializable; what each prevents.
  • Idempotency: what if the request retries because the response was lost? Use idempotency keys.
  • What if commit succeeds but the response fails? Especially in payment systems, the client may retry; without idempotency you double-charge.

Part 14 — Performance, Profiling, And The GC

The first line of the senior answer to any performance question is measure.

"First I would measure. Then I would identify whether the bottleneck is CPU, allocation, lock contention, I/O, or downstream latency. Then I would change one thing and measure again."

Benchmarks

Go
func BenchmarkBuildReport(b *testing.B) {
    users := generateUsers(1000)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = BuildReport(users)
    }
}
Bash
go test -bench=. -benchmem ./...

-benchmem reports allocations per op — usually the most actionable number.

Use b.ResetTimer() after setup so setup cost does not pollute the measurement. Use b.StopTimer() / b.StartTimer() around per-iteration setup.

Stack vs Heap And Escape Analysis

Go decides whether to allocate a value on the stack (cheap, freed on return) or the heap (GC-managed). This is escape analysis. Inspect it:

Bash
go build -gcflags='-m=2' ./pkg/...

Things that commonly cause a value to escape:

  • Returning a pointer to a local variable.
  • Storing a pointer in an interface or a slice/map.
  • Calling a method through an interface where the receiver outlives the call.
  • Variable size known only at runtime (make([]T, n) with non-constant n).

You don't need to optimize every escape. But for hot paths, reducing allocations often matters more than micro-optimizing the algorithm.

pprof — The Production Profiler

Go
import _ "net/http/pprof"

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Bash
# CPU profile, 30 seconds of sampling
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Heap profile (allocations live now)
go tool pprof http://localhost:6060/debug/pprof/heap

# Goroutine snapshot — invaluable for finding leaks
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2

# Block profile (time spent blocked on sync primitives)
runtime.SetBlockProfileRate(1)

# Mutex profile (contention)
runtime.SetMutexProfileFraction(1)

Never expose pprof publicly. Bind to localhost or behind authenticated network policy. Anyone hitting /debug/pprof/profile can DoS your service.

Investigating High Memory

A practical script:

  1. Capture heap profile under steady load.
  2. Wait, capture again, compare with pprof -base.
  3. Look at top and list for the biggest growth contributors.
  4. Check goroutine count; a slow goroutine leak shows up here before the heap.
  5. Look at unbounded structures: slices that only grow, caches without TTL, queues without back-pressure.
  6. Add limits/cleanup and verify with another profile.

The GC In One Paragraph

Go has a concurrent, tri-color, mark-and-sweep collector with a target pause goal in the low milliseconds. The main knob is GOGC (default 100, meaning the heap is allowed to grow to 2x live size between collections). Lowering GOGC reduces memory at the cost of CPU; raising it does the opposite. Since Go 1.19, GOMEMLIMIT lets you set a soft heap-memory ceiling — useful in containerized environments where the OOM killer is harsh. For most services, leave defaults alone and focus on reducing allocations in hot paths.


Part 15 — Testing

Basic Test

Go
func TestAdd(t *testing.T) {
    if got, want := Add(2, 3), 5; got != want {
        t.Fatalf("Add(2,3) = %d, want %d", got, want)
    }
}

Table-Driven Tests

Go
func TestNormalizeEmail(t *testing.T) {
    tests := []struct {
        name, in, want string
    }{
        {"lowercase", "USER@example.com", "user@example.com"},
        {"trim", "  user@example.com  ", "user@example.com"},
        {"empty", "", ""},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := NormalizeEmail(tt.in); got != tt.want {
                t.Fatalf("got %q, want %q", got, tt.want)
            }
        })
    }
}

t.Helper, t.Cleanup, t.Parallel

Go
func mustOpenDB(t *testing.T) *sql.DB {
    t.Helper() // failures point to the caller, not this line
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatalf("open: %v", err)
    }
    t.Cleanup(func() { db.Close() }) // runs at end of test, even on failure
    return db
}

func TestSomething(t *testing.T) {
    t.Parallel() // run in parallel with other Parallel tests
    // ...
}

httptest

Go
func TestHealth(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    rr := httptest.NewRecorder()
    Health(rr, req)
    if rr.Code != http.StatusOK {
        t.Fatalf("status = %d", rr.Code)
    }
}

For testing a real round-trip including middleware, use httptest.NewServer.

Fakes vs Mocks

A fake is a minimal real-behavior implementation. A mock verifies specific interactions.

Go
// Fake — easier to evolve
type FakeUserStore struct {
    Users map[int64]*User
}
func (s *FakeUserStore) FindByID(_ context.Context, id int64) (*User, error) {
    u, ok := s.Users[id]
    if !ok { return nil, ErrUserNotFound }
    return u, nil
}

For most Go services, fakes are easier to maintain than mocks because they test behavior, not implementation. Reach for mocks (e.g. gomock) when you specifically need to assert on call counts/order — for example, verifying that an audit log was emitted.

Fuzz Tests (Go 1.18+)

Go
func FuzzParseUserID(f *testing.F) {
    f.Add("123")
    f.Add("abc")
    f.Fuzz(func(t *testing.T, in string) {
        _, _ = ParseUserID(in) // should never panic
    })
}
Bash
go test -fuzz=FuzzParseUserID -fuzztime=30s

Fuzzing is excellent for parsers, decoders, and validators of untrusted input.

Testing With The Race Detector

Run go test -race ./... regularly, especially before merging anything that touches concurrency. CI should run with -race on critical packages.


Part 16 — Project Structure

There is no single official Go layout. Avoid blindly following the golang-standards/project-layout repo — it is community-maintained, not official. What interviewers want is evidence that you can organize a service clearly.

A pragmatic structure:

Text
myservice/
├── cmd/
│   ├── api/main.go         # HTTP service entry
│   └── worker/main.go      # background worker entry
├── internal/
│   ├── config/             # env loading
│   ├── httpapi/            # HTTP handlers, middleware
│   ├── users/              # users domain
│   ├── payments/           # payments domain
│   ├── database/           # *sql.DB construction, migrations runner
│   └── observability/      # logger, metrics, tracing
├── migrations/
├── api/openapi.yaml
├── Dockerfile
├── go.mod
└── go.sum

cmd/

Each binary has its own subdirectory whose main.go is small and only wires dependencies and starts the process. Business logic lives in internal/.

internal/

Anything under internal/ can only be imported from within the same module. This is enforced by the compiler. It is the right default for a service: it keeps your packages from accidentally being imported by other projects.

Naming

Short, lowercase, single-word package names. The package name is part of every reference to its symbols (users.NewService), so verbose names produce noise.

Avoid utils, common, helpers, manager, base. They become junk drawers and have no clear ownership. Prefer named-by-purpose: validation, pagination, httperr.

Avoiding Circular Dependencies

Circular imports usually mean two packages share a concept that should live elsewhere. Fix by:

  1. Pulling the shared types into a third, lower-level package.
  2. Defining a small consumer-side interface so one side depends on a method, not a package.

Dependency Injection

Constructor functions wired in main.go are the idiomatic Go DI:

Go
// In main.go
db := mustConnect(cfg.DatabaseURL)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

userStore := users.NewSQLStore(db)
userSvc := users.NewService(userStore, logger)
api := httpapi.New(userSvc, logger)

server := &http.Server{Addr: cfg.Addr, Handler: api}

You don't need a framework. For most services, explicit wiring is more readable than reflection-based DI containers.


Part 17 — System Design For Go Roles

Senior interviews include system design. Go is often used for APIs, workers, gateways, event processors, infra tools, and platform services. Show that you choose Go because it fits the workload (good concurrency, fast cold start, simple deployment), not just because it is the language on offer.

A solid system-design answer covers, in roughly this order:

  1. Clarify the requirements — functional and non-functional. Ask: scale, latency, consistency tolerance, failure tolerance.
  2. Sketch the API boundary first.
  3. Sketch the data model and storage — including indexes.
  4. Sketch the topology — services, queues, caches, where state lives.
  5. Discuss failure modes — what happens on retry, partial success, network partition.
  6. Discuss observability — what you would log/measure to operate this.

Worked Example: Notification Service

Design a service that sends email, push, and in-app notifications.

API:

Http
POST /notifications
Content-Type: application/json
Idempotency-Key: weekly-checkin-123-2026-05-04

{
  "user_id": 123,
  "type": "weekly_check_in",
  "channels": ["email", "push"]
}

Schema (illustrative):

SQL
CREATE TABLE notifications (
    id              BIGSERIAL PRIMARY KEY,
    user_id         BIGINT NOT NULL,
    type            VARCHAR(100) NOT NULL,
    status          VARCHAR(50) NOT NULL,            -- queued, sent, failed
    idempotency_key VARCHAR(255) NOT NULL UNIQUE,
    payload         JSONB NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_notifications_user_status ON notifications(user_id, status);

Worker:

Go
func ProcessNotification(ctx context.Context, job NotificationJob) error {
    n, err := loadNotification(ctx, job.NotificationID)
    if err != nil {
        return fmt.Errorf("load: %w", err)
    }
    if n.Status == "sent" {
        return nil // idempotent retry
    }
    if err := sendToChannels(ctx, n); err != nil {
        return fmt.Errorf("send: %w", err)
    }
    return markSent(ctx, n.ID)
}

Senior trade-off points:

  • Idempotency: the unique idempotency_key plus the "if status=sent, skip" check makes retries safe.
  • At-least-once delivery is the realistic guarantee; design for "at least once" + idempotent consumers.
  • Back-pressure: if the email provider is slow, do you queue indefinitely or shed load? Use bounded queues and a dead-letter queue.
  • Per-user rate limits: prevents bombardment if a producer goes wrong.
  • Observability: per-channel success rate, queue depth, end-to-end latency.

Worked Example: Token Bucket Rate Limiter

Go
type TokenBucket struct {
    mu         sync.Mutex
    capacity   float64
    tokens     float64
    refillRate float64 // tokens per second
    lastRefill time.Time
}

func NewTokenBucket(capacity, refillRate float64) *TokenBucket {
    return &TokenBucket{
        capacity:   capacity,
        tokens:     capacity,
        refillRate: refillRate,
        lastRefill: time.Now(),
    }
}

func (b *TokenBucket) Allow() bool {
    b.mu.Lock()
    defer b.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(b.lastRefill).Seconds()
    b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.refillRate)
    b.lastRefill = now

    if b.tokens < 1 {
        return false
    }
    b.tokens--
    return true
}

This is fine for a single process. For multiple instances, move state to Redis (INCR with expiry, or a Lua script for token-bucket math). Discussing the in-process / distributed split is exactly the trade-off senior interviewers want to hear.


Part 18 — Beyond Go: What Senior Engineers Should Know

A Go interview for a backend role is also a backend interview. You will be evaluated on knowledge that is not in the language spec.

HTTP, REST, And gRPC

  • HTTP methods, status codes (especially 2xx vs 4xx vs 5xx, when each applies, what 422 vs 400 means).
  • Idempotency: which methods are idempotent, why it matters for retries.
  • Caching headers: ETag, If-None-Match, Cache-Control.
  • REST design vs RPC; when JSON-over-HTTP and when gRPC.
  • gRPC basics: protobuf, unary vs streaming, deadlines, status codes.

Databases

  • SQL fundamentals: joins, indexes (B-tree vs hash), EXPLAIN/EXPLAIN ANALYZE, query plans.
  • Why a query is slow: missing index, full scan, function on indexed column, bad statistics, lock contention.
  • N+1 queries and how to spot them.
  • Connection pooling (covered above).
  • Transaction isolation levels: read uncommitted / read committed / repeatable read / serializable; phantom reads, dirty reads, lost updates.
  • Migrations: forward-only migrations, deploying schema changes safely (expand-then-contract).
  • NoSQL when: high write volume, schemaless, key-value access patterns. Know one of: Redis, MongoDB, DynamoDB, Cassandra.

Caching

  • Cache-aside vs read-through vs write-through patterns.
  • TTLs and stampedes (use single-flight or jittered TTLs).
  • Invalidation strategies (the hard one).
  • Hot-key problem.
  • Redis fundamentals: data structures, persistence, eviction policies.

Message Queues

  • At-most-once / at-least-once / exactly-once (and why exactly-once is mostly a myth).
  • Why consumers should be idempotent.
  • Kafka vs RabbitMQ vs NATS — partitioning, ordering, redelivery semantics.
  • Dead-letter queues.

Observability — The Three Pillars

  • Logs: structured, with request/trace IDs.
  • Metrics: counters, gauges, histograms; RED method (Rate, Errors, Duration) for services; USE method (Utilization, Saturation, Errors) for resources.
  • Traces: distributed tracing with OpenTelemetry; spans crossing service boundaries.

A senior production person can answer: "How would you know if this service is unhealthy at 3 AM?"

Containers, Orchestration, CI/CD

  • Docker basics: image layers, multi-stage builds, distroless/scratch images for Go.
  • Kubernetes basics: pod, deployment, service, configmap, secret; readiness vs liveness probes.
  • 12-factor app principles.
  • CI/CD: tests on every PR, lint/vet/race in CI, signed artifacts, gradual rollout.

Distributed Systems Concepts

  • CAP theorem (very loosely): in a partition, you choose consistency or availability. Most real systems are AP with eventual consistency.
  • Consistency models: strong, sequential, causal, eventual.
  • Idempotency keys (again, because they really matter).
  • Backoff and jitter for retries.
  • Circuit breakers for downstream protection.
  • Leader election, consensus (Raft) at least at the level of "what problem they solve".

Security Basics

  • TLS, certificate validation.
  • Authentication vs authorization. JWT structure, when to use sessions instead.
  • OWASP Top 10: SQL injection, XSS, CSRF, broken auth, SSRF.
  • Secrets management — never in code, never in logs.
  • Rate limiting and abuse mitigation.

Git Workflow

  • rebase vs merge, when each is appropriate.
  • Resolving conflicts.
  • Reading a history (git log, git blame, git bisect for finding regressions).

You don't need to be an expert in all of these. You need to be able to have a real conversation about each.


Part 19 — Common Interview Questions With Tight Answers

A condensed Q&A for the day before the interview.

Q: What is the difference between an array and a slice? A: An array has a fixed length that is part of its type. A slice is a header (pointer, length, capacity) over a backing array. Arrays are values; passing one copies all elements. Passing a slice copies only the header — both copies share the backing array.

Q: Why does my appended slice sometimes affect another slice? A: Because they share a backing array. If append fits within capacity, it writes in place. Use s[:n:n] (full slice expression) or slices.Clone to force a fresh array.

Q: Is a map safe for concurrent use? A: No, not when at least one operation is a write. The runtime detects many such races and panics. Protect with a mutex or use sync.Map for the specific patterns it is built for.

Q: Why is map iteration order random? A: Intentional design choice to prevent code from accidentally depending on order. If you need ordering, sort keys explicitly.

Q: Can a non-nil interface hold a nil pointer? A: Yes. The interface has a non-nil type slot but a nil data slot, so iface == nil is false. Avoid this by returning a literal nil instead of a typed-nil variable.

Q: When would you use a pointer receiver vs a value receiver? A: Pointer receivers when you mutate the receiver, when copying is expensive, or when the type embeds a sync.Mutex. Otherwise either works; pick one and be consistent.

Q: When should you close a channel? A: Only the sender should close. With multiple senders, none of them; use a separate signal (often context.Done()) or have a coordinator close after all senders are done.

Q: What does select do? A: Waits on multiple channel operations. Whichever is ready first runs. With default, it becomes non-blocking. Use it to combine cancellation with channel work.

Q: How do you cancel a goroutine? A: You can't from outside. The goroutine must voluntarily check a signal — ctx.Done(), a stop channel, or an atomic flag. Design for cooperative cancellation.

Q: Why is i++ not safe across goroutines? A: It is read-modify-write — three operations. Two goroutines can interleave them and lose increments. Use sync/atomic or a mutex.

Q: What does the race detector do? A: Instruments memory accesses at runtime and reports when two goroutines access the same memory without synchronization, with at least one being a write. Run with go test -race ./....

Q: Difference between buffered and unbuffered channels? A: Unbuffered synchronizes sender and receiver — both must be ready. Buffered allows up to capacity values to be sent without a waiting receiver.

Q: What is context.Context for? A: Carrying cancellation signals, deadlines, and small amounts of request-scoped data across API boundaries. Pass it as the first parameter, don't store it on structs, don't use it as a DI container.

Q: Difference between errors.Is and errors.As? A: Is checks whether a known sentinel value is anywhere in the chain (matches by equality). As extracts a specific error type from the chain into a variable you provide.

Q: When is panic appropriate? A: For unrecoverable programmer errors (impossible state) or initialization failures where the program can't proceed. Not for expected runtime errors.

Q: How does defer interact with named returns? A: A deferred function can read and modify named return values, because they are addressable variables. Useful for unconditionally wrapping errors.

Q: How does Go schedule goroutines? A: G-M-P model. Each P has a local run queue, executed by an M (OS thread). The number of Ps is GOMAXPROCS, default = number of logical CPUs. Idle Ps steal work from other Ps. Blocking syscalls detach M from P so other goroutines keep running.

Q: What is escape analysis? A: The compiler decides whether a value lives on the stack or heap. Heap allocations are GC'd. Returning a pointer to a local, or storing into an interface/slice/map, often forces escape.

Q: How would you find a goroutine leak? A: Hit /debug/pprof/goroutine?debug=2 and inspect the stacks. Look for many goroutines blocked on the same chan receive or select. Fix by adding context-aware cancellation.

Q: How would you reduce GC pressure? A: Pre-allocate slices/maps with known size. Reuse buffers (sync.Pool). Avoid converting []bytestring in hot paths unnecessarily. Pass values, not pointers, when they are small. Use struct fields rather than maps for fixed-shape data.

Q: What is sync.Pool? A: A free-list of reusable objects. Reduces allocation in hot paths. Items can be evicted at any GC, so don't store anything that must survive.

Q: Difference between Mutex and RWMutex? A: RWMutex allows many concurrent readers when no writer holds it. Useful when reads outnumber writes by a large margin. Has more overhead than Mutex — measure before choosing.

Q: Why does time.Now() return a monotonic clock too? A: To make time.Since immune to wall-clock jumps (NTP, DST, manual resets). Use time.Since(start) for measuring durations, not time.Now().Sub(start) after serialization.

Q: How do you handle config? A: Load once at startup from environment variables (12-factor) into a typed struct. Validate. Pass the struct down by constructor injection. Never reach into os.Getenv from deep code.

Q: Why is fmt.Errorf("...: %w", err) better than "...: " + err.Error()? A: %w preserves the error chain so callers can use errors.Is and errors.As. String concatenation throws away the original error type.

Q: When would you reach for generics? A: For reusable algorithms and data structures over types — e.g. Map, Filter, Set[T]. Avoid them for hiding domain differences (the "generic repository" anti-pattern).

Q: What's the difference between a fake and a mock? A: A fake is a working light implementation (e.g. an in-memory user store). A mock asserts on specific calls. For most services, fakes are easier to evolve.

Q: How do you ensure HTTP server graceful shutdown? A: Trap SIGINT/SIGTERM (signal.NotifyContext), call server.Shutdown(ctx) with a timeout, drain in-flight requests, then close worker pools and DB connections.

Q: What's the difference between net/http middleware and a handler? A: A handler responds to a request. Middleware wraps a handler — it pre/post-processes, but eventually calls the next handler. Standard signature: func(http.Handler) http.Handler.

Q: How do you avoid SQL injection? A: Always use parameter placeholders (? or $1); never string-concatenate user input into SQL.

Q: How do you tune *sql.DB? A: SetMaxOpenConns based on what your DB can handle and how many app instances you run. SetMaxIdleConns close to MaxOpenConns to reduce churn. SetConnMaxLifetime to recycle stale connections (often 5–30 min).

Q: What does sql.Open actually do? A: It validates the DSN format and constructs the handle. It does NOT connect. The first real connection happens lazily, or you can force it with db.PingContext(ctx).

Q: How would you implement retries? A: Limited attempts, exponential backoff, jitter, classify retryable vs non-retryable errors, respect context cancellation, give up cleanly with the underlying error wrapped.

Q: What is idempotency and why does it matter? A: An operation is idempotent if applying it twice has the same effect as once. It matters because retries are inevitable in distributed systems. Use idempotency keys, status checks, or upserts to make non-idempotent operations safe to retry.

Q: How would you debug a service consuming too much CPU? A: Capture a CPU profile under load (/debug/pprof/profile). Look at top for hot functions. Look at list <func> for hot lines. Often it's JSON encoding, regex compilation in a loop, or accidental quadratic logic.

Q: How would you handle a 100x traffic spike? A: Auto-scale horizontally. Add a queue and back-pressure for non-critical work. Cache hot reads. Apply per-tenant rate limits. Verify connection pool sizes don't starve. Have load-shed at the edge as a last resort.

Q: What is a goroutine leak and how do you prevent it? A: A goroutine that can never finish — blocked on a channel that no one will close, or a forever loop without a cancellation check. Prevent by always pairing channel ops with context.Done() in a select, and by giving every long-running goroutine a way to stop.

Q: What's the difference between a deadlock and a livelock? A: Deadlock: two or more goroutines block forever waiting on each other. Livelock: they keep changing state but make no progress. Go's scheduler detects all-goroutines-asleep deadlocks at runtime and panics; livelocks are silent and harder to detect.

Q: When do you choose channels over mutexes? A: When the design is about transferring ownership or coordinating sequences of work. Use mutexes when you have a piece of shared state that needs protection. Many "channel" designs would be simpler as a mutex.

Q: How do you test code that uses time.Now? A: Inject a clock interface. Pass a fake clock in tests so you can advance it deterministically. The standard library doesn't ship one; you can use a small interface (type Clock interface { Now() time.Time }) or libraries like github.com/jonboulle/clockwork.

Q: What is iota? A: A counter inside a const block that starts at 0 and increments per line. Used to create enumerations.

Q: What's the order of defers? A: LIFO — last defer runs first.

Q: What does make vs new do? A: new(T) returns a *T pointing to a zero T. make is for slices, maps, and channels — it initializes the internal structure and returns the value (not a pointer).

Q: Are strings mutable? A: No. A string is a read-only []byte view (pointer + length). Convert to []byte to modify. Be aware that []byte(s) copies on conversion — relevant for very hot paths.


Part 20 — Common Mistakes That Make Strong Candidates Look Weaker

These are the patterns that come up over and over in the postmortem of failed Go interviews.

Starting goroutines without a way to stop them.

Go
// Bad
go func() { for { doWork() } }()

// Better
go func() {
    for {
        select {
        case <-ctx.Done():
            return
        default:
        }
        doWork()
    }
}()

But beware: a default branch with doWork() outside any blocking call is a busy loop. Prefer making doWork itself cancellable, or using a timer/channel to throttle.

Closing channels from the receiver side. The receiver doesn't know whether other senders exist. Sending on a closed channel panics.

Calling wg.Add(1) inside the goroutine. It races with wg.Wait(). Always Add before go.

Forgetting defer cancel() after context.WithCancel/Timeout/Deadline. Leaks the timer until the parent context is done. go vet flags this.

Ignoring errors silently.

Go
json.NewEncoder(w).Encode(response) // ignored

// Better
if err := json.NewEncoder(w).Encode(response); err != nil {
    logger.Error("encode response", "error", err)
}

There are exceptions (e.g. the response has already started), but they should be conscious choices, not habit.

Using panic for normal business errors. Validation failure is not a panic. Not-found is not a panic. Payment declined is not a panic. Use errors.

Reaching for channels for everything. A map + sync.Mutex is often clearer than a manager goroutine with a command-channel inbox.

Assuming sync.Map is always faster. It is optimized for specific patterns — read-heavy with stable keys, or disjoint key sets across goroutines. For most workloads, map + mutex is faster and clearer.

Copying structs that contain a sync.Mutex. The copy gets its own mutex, but the data is shared — instant footgun. go vet warns. Use *Store, not Store.

Forgetting that nil maps panic on write. var m map[string]int; m["x"] = 1 panics. Read the zero-value section above.

Forgetting rows.Err() after for rows.Next(). Iteration errors are silent inside the loop.

Forgetting defer rows.Close(). Connection stays out of the pool until the GC catches it — pool starvation.

Using http.DefaultClient in production. No timeout. Build your own.

Building SQL with string concatenation. SQL injection. Use placeholders.

Creating an interface for a single implementation, "in case we need another later". Adds indirection without value. Refactor when you actually need it.

Logging unstructured strings for production debugging. log.Printf("user %d failed: %v", id, err) is hard to filter and aggregate. Use log/slog with key/value pairs.

Not understanding slice capacity. Half of senior Go interviews probe this. If you can explain shared backing arrays and the three-index slice expression cleanly, you ace this category.


Part 21 — Coding Tasks To Practice

Practice with timer in front of you, like a real interview.

  1. Worker pool — fixed number of workers, jobs channel, results channel, context cancellation, graceful shutdown.
  2. Rate limiter — token bucket, sliding window. In-memory first, then describe how to make it distributed.
  3. Streaming CSV parser/validatorencoding/csv, row-level validation, context cancellation, no full-file load.
  4. HTTP middleware — request ID, structured logging, panic recovery, simple auth, timeout.
  5. In-memory cache with TTL — map + mutex, expiration, periodic cleanup, context cancellation. Bonus: single-flight to deduplicate concurrent loads.
  6. Fan-in / merge channels — generic merge of N input channels into one output.
  7. Graceful HTTP shutdownsignal.NotifyContext, server.Shutdown(ctx).
  8. Retry with exponential backoff and jitter — context-aware, classifies retryable errors.
  9. Producer / consumer pipeline — three stages connected by channels, context cancels everything cleanly.
  10. Table-driven tests for everything above.

A reference implementation for #6 (fan-in):

Go
func Merge[T any](ctx context.Context, chans ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup
    for _, ch := range chans {
        wg.Add(1)
        go func(in <-chan T) {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return
                case v, ok := <-in:
                    if !ok {
                        return
                    }
                    select {
                    case <-ctx.Done():
                        return
                    case out <- v:
                    }
                }
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

A reference implementation for #8 (retry):

Go
// Imports: context, errors, fmt, math/rand/v2, time

func Retry(ctx context.Context, maxAttempts int, base time.Duration, fn func() error) error {
    var lastErr error
    for attempt := 0; attempt < maxAttempts; attempt++ {
        if err := fn(); err == nil {
            return nil
        } else {
            lastErr = err
            if !isRetryable(err) {
                return err
            }
        }

        // Exponential backoff with full jitter.
        // base << attempt computes base * 2^attempt; cap to avoid overflow.
        backoff := base << attempt
        if backoff <= 0 || backoff > 30*time.Second {
            backoff = 30 * time.Second
        }
        wait := time.Duration(rand.Int64N(int64(backoff))) // full jitter

        t := time.NewTimer(wait)
        select {
        case <-ctx.Done():
            t.Stop()
            return ctx.Err()
        case <-t.C:
        }
    }
    return fmt.Errorf("after %d attempts: %w", maxAttempts, lastErr)
}

func isRetryable(err error) bool {
    // Classify your errors here: timeouts, 5xx, transient network errors → true.
    return true
}

Notes:

  • base << attempt is type-clean because base is a time.Duration (int64 underneath) — shifting a duration by an integer count is the idiomatic way to compute base * 2^attempt.
  • "Full jitter" (rand.Int64N(backoff)) is what AWS recommends over "exponential + small jitter" — it avoids retry storms better.
  • math/rand/v2 (Go 1.22+) gives you rand.Int64N. Older code used math/rand.Int63n which is the same idea.
  • Always cap backoff to avoid integer overflow on high attempt values.

A reference implementation for #7 (graceful shutdown):

Go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

server := &http.Server{Addr: ":8080", Handler: mux}

go func() {
    if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        log.Fatalf("listen: %v", err)
    }
}()

<-ctx.Done()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
    log.Fatalf("shutdown: %v", err)
}

Part 22 — Final Checklist

Before the interview, you should be able to honestly say "yes" to each:

  • I can explain arrays vs slices and the slice header.
  • I understand slice capacity, append behavior, and shared backing arrays.
  • I know the difference between nil and empty slices, and how they encode to JSON.
  • I understand maps: zero value, iteration order, concurrency rules, when to use sync.Map.
  • I can explain value vs pointer semantics and pick receivers consistently.
  • I understand the interface nil trap and can avoid it.
  • I can design small, consumer-side interfaces.
  • I wrap errors with %w and inspect them with errors.Is / errors.As.
  • I know errors.Join and when it helps.
  • I know when panic is appropriate and when it is not.
  • I understand defer ordering, argument capture, and the loop trap.
  • I can recover panics at goroutine boundaries.
  • I can explain goroutines, channels, select, and the closing rules.
  • I can describe the G-M-P scheduler at a high level.
  • I can prevent goroutine leaks.
  • I write code with context.Context as the first parameter and never store it on structs.
  • I know when to choose mutexes over channels.
  • I understand data races and run go test -race regularly.
  • I can write table-driven tests, fakes, and benchmarks.
  • I can test HTTP handlers with httptest.
  • I can set up *sql.DB pool, use context, run transactions, and avoid SQL injection.
  • I can read pprof output for CPU and heap.
  • I can structure a Go service with cmd/ + internal/ and avoid junk-drawer packages.
  • I can sketch a system (notification service, rate limiter, gateway) and discuss trade-offs.
  • I can talk fluently about HTTP semantics, caching, queues, observability, and security basics.
  • I know what I don't know — and I am okay saying so during the interview.

That is the preparation target.

Not memorized answers. Not exotic syntax. Not pretending every problem needs goroutines.

A senior Go interview rewards calm, practical engineering.

  • Can you write simple code?
  • Can you explain what happens under the hood when asked?
  • Can you avoid concurrency bugs without inventing complexity?
  • Can you debug production behavior with the right tools?
  • Can you design systems that fail safely?

If you can answer yes to those, you are preparing for the right interview.


References For Fact-Checking